Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WIP: Add SameSite cookie middleware #93

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 116 additions & 10 deletions security/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import json
import logging
import warnings
from distutils.version import LooseVersion
from re import compile

import django.conf
Expand All @@ -15,9 +16,10 @@
from django.test.signals import setting_changed
from django.utils import timezone
from django.utils.deprecation import MiddlewareMixin
from django.utils.encoding import smart_str
import django.views.static

from ua_parser import user_agent_parser
from ua_checker import UserAgentChecker


logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -899,13 +901,14 @@ def process_response(self, request, response):
enforcement or report-only headers in all currently used variants.
"""
# choose headers based enforcement mode
is_ie = False
if 'HTTP_USER_AGENT' in request.META:
parsed_ua = user_agent_parser.ParseUserAgent(request.META['HTTP_USER_AGENT'])
is_ie = parsed_ua['family'] == 'IE'

http_user_agent = smart_str(
request.META.get("HTTP_USER_AGENT") or " ",
encoding="ascii",
errors="ignore",
)
user_agent_checker = UserAgentChecker(http_user_agent)
csp_header = 'Content-Security-Policy'
if is_ie:
if user_agent_checker.is_ie:
csp_header = 'X-Content-Security-Policy'
report_only_header = 'Content-Security-Policy-Report-Only'

Expand Down Expand Up @@ -1316,6 +1319,7 @@ def process_request(self, request):

return HttpResponseRedirect(login_url)


class ReferrerPolicyMiddleware(BaseMiddleware):
"""
Sends Referrer-Policy HTTP header that controls when the browser will set
Expand All @@ -1339,9 +1343,17 @@ class ReferrerPolicyMiddleware(BaseMiddleware):

OPTIONAL_SETTINGS = ("REFERRER_POLICY",)

OPTIONS = [ 'no-referrer', 'no-referrer-when-downgrade', 'origin',
'origin-when-cross-origin', 'same-origin', 'strict-origin',
'strict-origin-when-cross-origin', 'unsafe-url', 'off' ]
OPTIONS = [
'no-referrer',
'no-referrer-when-downgrade',
'origin',
'origin-when-cross-origin',
'same-origin',
'strict-origin',
'strict-origin-when-cross-origin',
'unsafe-url',
'off'
]

DEFAULT = 'same-origin'

Expand All @@ -1368,3 +1380,97 @@ def process_response(self, request, response):
header = self.option
response['Referrer-Policy'] = header
return response


class SameSiteCookieMiddleware(BaseMiddleware):
"""
Sets SameSite attribute for session and CSRF cookies in legacy versions of Django.

Django 3.1 introduces full support for the SameSite flag on session and CSRF cookies.
"""

def get_config_setting(setting_name, default_value=None):
"""
Load the Django setting with DCS_ prefix and fallback to the legacy name if not found.
"""
return getattr(
django.conf.settings,
"DCS_{}".format(setting_name),
getattr(django.conf.settings, setting_name, default_value),
)

def __init__(self, *args, **kwargs):
self.protected_cookies = self.get_config_setting(
"SESSION_COOKIE_SAMESITE_KEYS", set()
)

if not isinstance(self.protected_cookies, (list, set, tuple)):
raise ValueError(
"SESSION_COOKIE_SAMESITE_KEYS should be a list, set or tuple."
)

self.protected_cookies = set(self.protected_cookies)
if self.get_config_setting("SESSION_COOKIE_SAMESITE_FORCE_CORE", True):
self.protected_cookies |= {
django.conf.settings.SESSION_COOKIE_NAME,
django.conf.settings.CSRF_COOKIE_NAME,
}

samesite_flag = self.get_config_setting("SESSION_COOKIE_SAMESITE", "")
self.samesite_flag = (
str(samesite_flag).capitalize() if samesite_flag is not None else ""
)
self.samesite_force_all = self.get_config_setting(
"SESSION_COOKIE_SAMESITE_FORCE_ALL"
)
# SAMESITE_DEVMODE=True means, use Lax if http request.
self.devmode = bool(self.get_config_setting("SAMESITE_DEVMODE"))

return super(CookiesSameSiteMiddleware, self).__init__(*args, **kwargs)

def update_cookie(self, cookie, request, response):
https = request.is_secure()
if self.devmode and not https:
flag = "Lax"
else:
flag = self.samesite_flag
response.cookies[cookie]["samesite"] = flag
if https:
response.cookies[cookie]["secure"] = True

def process_response(self, request, response):
# SameSite = None introduced in Chrome 80 breaks Chrome 51-66
# https://www.chromium.org/updates/same-site/incompatible-clients
# Some HTTP Clients have non-ascii characters in their User Agents; should ignore all non-ascii characters.
# Related: https://stackoverflow.com/questions/4400678/what-character-encoding-should-i-use-for-a-http-header
http_user_agent = smart_str(
request.META.get("HTTP_USER_AGENT") or " ",
encoding="ascii",
errors="ignore",
)
user_agent_checker = UserAgentChecker(http_user_agent)

if user_agent_checker.do_not_send_same_site_policy:
return response

if LooseVersion(django.get_version()) >= LooseVersion("3.1.0"):
raise DeprecationWarning(
"Your version of Django supports SameSite flag in the cookies mechanism."
"Consider removing CookiesSameSiteMiddleware and using the built-in method."
)

if not self.samesite_flag:
return response

if self.samesite_flag not in {"Lax", "None", "Strict"}:
raise ValueError('samesite must be "Lax", "None", or "Strict".')

if self.samesite_force_all:
for cookie in response.cookies:
self.update_cookie(cookie, request, response)
else:
for cookie in self.protected_cookies:
if cookie in response.cookies:
self.update_cookie(cookie, request, response)

return response
143 changes: 143 additions & 0 deletions ua_checker.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
from ua_parser import user_agent_parser
import re


class UserAgentChecker:
UC_BROWSER = "UC Browser"
SAFARI_REGX = "Safari"
CHROME_REGX = "Chrom(e|ium)"
MAC_OSX = "Mac OS X"
IOS = "iOS"
MIN_UC_BROWSER_VER_MAJOR = 12
MIN_UC_BROWSER_VER_MINOR = 13
MIN_UC_BROWSER_VER_BUILD = 2
BUGGY_CHROME_VERSION_MAJOR_MIN = 51
BUGGY_CHROME_VERSION_MAJOR_MAX = 66
MIN_IOS_VERSION = 12
MIN_MAC_OSX_VERSION_MAJOR = 10
MIN_MAC_OSX_VERSION_MINOR = 14

def __init__(self, user_agent_string=""):
user_agent_parsed = user_agent_parser.Parse(
user_agent_string if user_agent_string else ""
)
self.user_agent = user_agent_parsed.get("user_agent", dict())
self.user_agent_os = user_agent_parsed.get("os", dict())
self.user_agent_device = user_agent_parsed.get("device", dict())
self.user_agent_string = user_agent_parsed.get("string", "")
self.user_agent_family = user_agent_parsed.get("family", "")

@property
def is_ie(self):
return self.user_agent_family == "IE"

@property
def do_not_send_same_site_policy(self):
if not self.user_agent_string:
return False
else:
return not (self.supported_browsers_os() or self.other_browsers())

def supported_browsers_os(self):
return (
self.supported_ios_and_mac_os_browsers()
or self.supported_chrome_and_uc_browsers()
)

def supported_chrome_and_uc_browsers(self):
return (
self.is_chrome_supported_version()
or self.is_uc_browser_in_least_supported_version()
)

def supported_ios_and_mac_os_browsers(self):
return self.is_supported_ios_version() or self.is_supported_mac_osx_safari()

def other_browsers(self):
is_uc_or_chrome = self.is_uc_browser() or self.is_chrome_browser()
is_safari_ios_mac_supported = (
self.is_safari() or self.is_ios() or self.is_supported_mac_osx_safari()
)
return not (is_uc_or_chrome or is_safari_ios_mac_supported)

def is_uc_browser(self):
return self.user_agent.get("family") == "UC Browser"

def is_uc_browser_in_least_supported_version(self):
major = self.get_val_in_int(self.user_agent.get("major"))
minor = self.get_val_in_int(self.user_agent.get("minor"))
build = self.get_val_in_int(self.user_agent.get("patch"))
if self.is_uc_browser():
if self.MIN_UC_BROWSER_VER_MAJOR == major:
if self.MIN_UC_BROWSER_VER_MINOR == minor:
return self.MIN_UC_BROWSER_VER_BUILD <= build
else:
return self.MIN_UC_BROWSER_VER_MINOR < minor
else:
return self.MIN_UC_BROWSER_VER_MAJOR < major
return False

def is_chrome_browser(self):
return (
True
if re.search(self.CHROME_REGX, self.user_agent.get("family", ""))
else False
)

def is_chrome_supported_version(self):
uav = self.get_val_in_int(self.user_agent.get("major"))
return (
True
if self.is_chrome_browser()
and (
self.BUGGY_CHROME_VERSION_MAJOR_MIN > uav
or uav > self.BUGGY_CHROME_VERSION_MAJOR_MAX
)
else False
)

def get_user_agent_os_version(self, version_type):
return self.get_val_in_int(self.user_agent_os.get(version_type))

def get_user_agent_os_major(self):
return self.get_user_agent_os_version("major")

def get_user_agent_os_minor(self):
return self.get_user_agent_os_version("minor")

def get_val_in_int(self, val):
try:
return int(val or "0")
except (TypeError, ValueError):
return 0

def is_ios(self):
return self.user_agent_os.get("family") == self.IOS

def is_supported_ios_version(self):
return self.is_ios() and not (
self.get_user_agent_os_major() == self.MIN_IOS_VERSION
)

def is_mac_osx(self):
return self.user_agent_os.get("family", "") == self.MAC_OSX

def is_supported_mac_osx_version(self):
if self.is_mac_osx():
is_min_mac_maj = (
self.get_user_agent_os_major() == self.MIN_MAC_OSX_VERSION_MAJOR
)
is_min_mac_min = (
self.get_user_agent_os_minor() == self.MIN_MAC_OSX_VERSION_MINOR
)
return not (is_min_mac_maj and is_min_mac_min)
return False

def is_safari(self):
is_safari_reg_res = re.search(
self.SAFARI_REGX, self.user_agent.get("family", "")
)
return True if is_safari_reg_res and not self.is_chrome_browser() else False

def is_supported_mac_osx_safari(self):
return self.is_supported_mac_osx_version() and self.is_safari()