From 77166408091b7d064e831d343edec2dd3e7efac7 Mon Sep 17 00:00:00 2001 From: Jussi-Pekka Erkkila Date: Sun, 20 Oct 2024 01:17:56 +0300 Subject: [PATCH] reorganize constants --- secheaders/constants.py | 69 ++++++++++++++++++++++++++++++++++ secheaders/securityheaders.py | 70 +++++------------------------------ secheaders/utils.py | 22 ++--------- 3 files changed, 82 insertions(+), 79 deletions(-) diff --git a/secheaders/constants.py b/secheaders/constants.py index 775e412..f34c812 100644 --- a/secheaders/constants.py +++ b/secheaders/constants.py @@ -1,6 +1,75 @@ +from . import utils + # If no URL scheme defined, what to use by default DEFAULT_URL_SCHEME = 'https' DEFAULT_TIMEOUT = 10 +# Let's try to imitate a legit browser to avoid being blocked / flagged as web crawler +REQUEST_HEADERS = { + 'Accept': ('text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,' + 'application/signed-exchange;v=b3;q=0.9'), + 'Accept-Encoding': 'gzip, deflate, br', + 'Accept-Language': 'en-GB,en;q=0.9', + 'Cache-Control': 'max-age=0', + 'User-Agent': ('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko)' + 'Chrome/106.0.0.0 Safari/537.36'), +} + EVAL_WARN = 0 EVAL_OK = 1 + +# There are no universal rules for "safe" and "unsafe" CSP directives, but we apply some common sense here to +# catch some risky configurations +UNSAFE_CSP_RULES = { + "script-src": ["*", "'unsafe-eval'", "data:", "'unsafe-inline'"], + "frame-ancestors": ["*"], + "form-action": ["*"], + "object-src": ["*"], +} + +# Configuring Permission-Policy is very case-specific and it's difficult to define a particular recommendation. +# We apply here a logic, that access to privacy-sensitive features and payments API should be restricted. +RESTRICTED_PERM_POLICY_FEATURES = ['camera', 'geolocation', 'microphone', 'payment'] + +# Headers that we try to sniff for version information +SERVER_VERSION_HEADERS = [ + 'x-powered-by', + 'server', + 'x-aspnet-version', +] + +HEADER_STRUCTURED_LIST = [ # Response headers that define multiple values as comma-sparated list + 'permissions-policy', +] + +SECURITY_HEADERS_DICT = { + 'x-frame-options': { + 'recommended': True, + 'eval_func': utils.eval_x_frame_options, + }, + 'strict-transport-security': { + 'recommended': True, + 'eval_func': utils.eval_sts, + }, + 'content-security-policy': { + 'recommended': True, + 'eval_func': utils.eval_csp, + }, + 'x-content-type-options': { + 'recommended': True, + 'eval_func': utils.eval_content_type_options, + }, + 'x-xss-protection': { + # X-XSS-Protection is deprecated; not supported anymore, and may be even dangerous in older browsers + 'recommended': False, + 'eval_func': utils.eval_x_xss_protection, + }, + 'referrer-policy': { + 'recommended': True, + 'eval_func': utils.eval_referrer_policy, + }, + 'permissions-policy': { + 'recommended': True, + 'eval_func': utils.eval_permissions_policy, + } +} diff --git a/secheaders/securityheaders.py b/secheaders/securityheaders.py index 9029dc8..5a64c88 100644 --- a/secheaders/securityheaders.py +++ b/secheaders/securityheaders.py @@ -7,64 +7,12 @@ from urllib.parse import ParseResult, urlparse from . import utils -from .constants import DEFAULT_TIMEOUT, DEFAULT_URL_SCHEME, EVAL_WARN +from .constants import DEFAULT_TIMEOUT, DEFAULT_URL_SCHEME, EVAL_WARN, REQUEST_HEADERS, HEADER_STRUCTURED_LIST, \ + SECURITY_HEADERS_DICT, SERVER_VERSION_HEADERS from .exceptions import SecurityHeadersException, InvalidTargetURL, UnableToConnect class SecurityHeaders(): - # Let's try to imitate a legit browser to avoid being blocked / flagged as web crawler - REQUEST_HEADERS = { - 'Accept': ('text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,' - 'application/signed-exchange;v=b3;q=0.9'), - 'Accept-Encoding': 'gzip, deflate, br', - 'Accept-Language': 'en-GB,en;q=0.9', - 'Cache-Control': 'max-age=0', - 'User-Agent': ('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko)' - 'Chrome/106.0.0.0 Safari/537.36'), - } - - SECURITY_HEADERS_DICT = { - 'x-frame-options': { - 'recommended': True, - 'eval_func': utils.eval_x_frame_options, - }, - 'strict-transport-security': { - 'recommended': True, - 'eval_func': utils.eval_sts, - }, - 'content-security-policy': { - 'recommended': True, - 'eval_func': utils.eval_csp, - }, - 'x-content-type-options': { - 'recommended': True, - 'eval_func': utils.eval_content_type_options, - }, - 'x-xss-protection': { - # X-XSS-Protection is deprecated; not supported anymore, and may be even dangerous in older browsers - 'recommended': False, - 'eval_func': utils.eval_x_xss_protection, - }, - 'referrer-policy': { - 'recommended': True, - 'eval_func': utils.eval_referrer_policy, - }, - 'permissions-policy': { - 'recommended': True, - 'eval_func': utils.eval_permissions_policy, - } - } - - SERVER_VERSION_HEADERS = [ - 'x-powered-by', - 'server', - 'x-aspnet-version', - ] - - HEADER_STRUCTURED_LIST = [ # Response headers that define multiple values as comma-sparated list - 'permissions-policy', - ] - def __init__(self, url, max_redirects=2, no_check_certificate=False): parsed = urlparse(url) if not parsed.scheme and not parsed.netloc: @@ -106,7 +54,7 @@ def _follow_redirect_until_response(self, url, follow_redirects=5): raise InvalidTargetURL("Unsupported protocol scheme") try: - conn.request('GET', temp_url.path, headers=self.REQUEST_HEADERS) + conn.request('GET', temp_url.path, headers=REQUEST_HEADERS) res = conn.getresponse() except (socket.gaierror, socket.timeout, ConnectionRefusedError) as e: raise UnableToConnect("Connection failed {}".format(temp_url.netloc)) from e @@ -156,7 +104,7 @@ def fetch_headers(self): conn = self.open_connection(self.target_url) try: - conn.request('GET', self.target_url.path, headers=self.REQUEST_HEADERS) + conn.request('GET', self.target_url.path, headers=REQUEST_HEADERS) res = conn.getresponse() except (socket.gaierror, socket.timeout, ConnectionRefusedError, ssl.SSLError) as e: raise UnableToConnect("Connection failed {}".format(self.target_url.hostname)) from e @@ -164,7 +112,7 @@ def fetch_headers(self): headers = res.getheaders() for h in headers: key = h[0].lower() - if key in self.HEADER_STRUCTURED_LIST and key in self.headers: + if key in HEADER_STRUCTURED_LIST and key in self.headers: # Scenario described in RFC 2616 section 4.2 self.headers[key] += ', {}'.format(h[1]) else: @@ -178,9 +126,9 @@ def check_headers(self): raise SecurityHeadersException("Headers not fetched successfully") """ Loop through headers and evaluate the risk """ - for header in self.SECURITY_HEADERS_DICT: + for header in SECURITY_HEADERS_DICT: if header in self.headers: - eval_func = self.SECURITY_HEADERS_DICT[header].get('eval_func') + eval_func = SECURITY_HEADERS_DICT[header].get('eval_func') if not eval_func: raise SecurityHeadersException("No evaluation function found for header: {}".format(header)) res, notes = eval_func(self.headers[header]) @@ -192,10 +140,10 @@ def check_headers(self): } else: - warn = self.SECURITY_HEADERS_DICT[header].get('recommended') + warn = SECURITY_HEADERS_DICT[header].get('recommended') retval[header] = {'defined': False, 'warn': warn, 'contents': None, 'notes': []} - for header in self.SERVER_VERSION_HEADERS: + for header in SERVER_VERSION_HEADERS: if header in self.headers: res, notes = utils.eval_version_info(self.headers[header]) retval[header] = { diff --git a/secheaders/utils.py b/secheaders/utils.py index dee07ca..20d1d1c 100644 --- a/secheaders/utils.py +++ b/secheaders/utils.py @@ -1,8 +1,7 @@ import re from typing import Tuple - -from .constants import EVAL_WARN, EVAL_OK +from .constants import EVAL_WARN, EVAL_OK, UNSAFE_CSP_RULES, RESTRICTED_PERM_POLICY_FEATURES def eval_x_frame_options(contents: str) -> Tuple[int, list]: @@ -38,25 +37,16 @@ def eval_sts(contents: str) -> Tuple[int, list]: def eval_csp(contents: str) -> Tuple[int, list]: - UNSAFE_RULES = { - "script-src": ["*", "'unsafe-eval'", "data:", "'unsafe-inline'"], - "frame-ancestors": ["*"], - "form-action": ["*"], - "object-src": ["*"], - } - - # There are no universal rules for "safe" and "unsafe" CSP directives, but we apply some common sense here to - # catch some obvious lacks or poor configuration csp_unsafe = False csp_notes = [] csp_parsed = csp_parser(contents) - for rule in UNSAFE_RULES: + for rule in UNSAFE_CSP_RULES: if rule not in csp_parsed: if '-src' in rule and 'default-src' in csp_parsed: # fallback to default-src - for unsafe_src in UNSAFE_RULES[rule]: + for unsafe_src in UNSAFE_CSP_RULES[rule]: if unsafe_src in csp_parsed['default-src']: csp_unsafe = True csp_notes.append("Directive {} not defined, and default-src contains unsafe source {}".format( @@ -65,7 +55,7 @@ def eval_csp(contents: str) -> Tuple[int, list]: csp_notes.append("No directive {} nor default-src defined in the Content Security Policy".format(rule)) csp_unsafe = True else: - for unsafe_src in UNSAFE_RULES[rule]: + for unsafe_src in UNSAFE_CSP_RULES[rule]: if unsafe_src in csp_parsed[rule]: csp_notes.append("Unsafe source {} in directive {}".format(unsafe_src, rule)) csp_unsafe = True @@ -85,13 +75,9 @@ def eval_version_info(contents: str) -> Tuple[int, list]: def eval_permissions_policy(contents: str) -> Tuple[int, list]: - # Configuring Permission-Policy is very case-specific and it's difficult to define a particular recommendation. - # We apply here a logic, that access to privacy-sensitive features and payments API should be restricted. - pp_parsed = permissions_policy_parser(contents) notes = [] pp_unsafe = False - RESTRICTED_PERM_POLICY_FEATURES = ['camera', 'geolocation', 'microphone', 'payment'] for feature in RESTRICTED_PERM_POLICY_FEATURES: feat_policy = pp_parsed.get(feature)