From ed87598d7dc54c39d99348ab6e4118b702c1b553 Mon Sep 17 00:00:00 2001 From: Jordan Haine Date: Fri, 10 Dec 2021 02:45:10 -0500 Subject: [PATCH] Add SameSite cookie middleware --- security/middleware.py | 126 +++++++++++++++++++++++++++++++++--- ua_checker.py | 143 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 259 insertions(+), 10 deletions(-) create mode 100644 ua_checker.py diff --git a/security/middleware.py b/security/middleware.py index af50896..f24e484 100644 --- a/security/middleware.py +++ b/security/middleware.py @@ -5,6 +5,7 @@ import json import logging import warnings +from distutils.version import LooseVersion from re import compile import django.conf @@ -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__) @@ -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' @@ -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 @@ -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' @@ -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 diff --git a/ua_checker.py b/ua_checker.py new file mode 100644 index 0000000..239b0bd --- /dev/null +++ b/ua_checker.py @@ -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()