Skip to content

Commit

Permalink
[2/2] Fix: Content Security Policy (CSP) Not Implemented (DataBiosphe…
Browse files Browse the repository at this point in the history
  • Loading branch information
hannes-ucsc committed Nov 22, 2024
1 parent b1dc032 commit ed097ad
Show file tree
Hide file tree
Showing 5 changed files with 257 additions and 148 deletions.
8 changes: 6 additions & 2 deletions lambdas/service/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@
from azul.collections import (
OrderedSet,
)
from azul.csp import (
CSP,
)
from azul.drs import (
AccessMethod,
)
Expand Down Expand Up @@ -490,14 +493,15 @@ def manifest_url(self,
def oauth2_redirect():
file_name = 'oauth2-redirect.html.template.mustache'
template = app.load_static_resource('swagger', file_name)
nonce = app.csp_nonce()
nonce = CSP.new_nonce()
html = chevron.render(template, {
'CSP_NONCE': json.dumps(nonce)
})
csp = CSP.for_azul(nonce)
return Response(status_code=200,
headers={
'Content-Type': 'text/html',
'Content-Security-Policy': app.content_security_policy(nonce)
'Content-Security-Policy': str(csp)
},
body=html)

Expand Down
146 changes: 8 additions & 138 deletions src/azul/chalice.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
from abc import (
ABCMeta,
)
import base64
from collections.abc import (
Iterable,
)
Expand All @@ -16,8 +15,6 @@
import mimetypes
import os
import pathlib
import re
import secrets
from typing import (
Any,
Iterator,
Expand Down Expand Up @@ -61,6 +58,9 @@
from azul.collections import (
deep_dict_merge,
)
from azul.csp import (
CSP,
)
from azul.enums import (
auto,
)
Expand All @@ -76,7 +76,6 @@
)
from azul.strings import (
join_words as jw,
single_quote as sq,
)
from azul.types import (
JSON,
Expand Down Expand Up @@ -208,137 +207,6 @@ def _api_gateway_context_middleware(self, event, get_response):
finally:
config.lambda_is_handling_api_gateway_request = False

@classmethod
def csp_nonce(cls) -> str:
"""
Return a randomly generated nonce value for use in a Content Security
Policy header.
"""
return base64.b64encode(secrets.token_bytes(32)).decode('ascii').rstrip('=')

@classmethod
def content_security_policy(cls, nonce: str | None = None) -> str:
"""
>>> from azul.doctests import assert_json
>>> assert_json(AzulChaliceApp.content_security_policy(None).split(';'))
[
"default-src 'self'",
"img-src 'self' data:",
"script-src 'self'",
"style-src 'self'",
"frame-ancestors 'none'"
]
>>> assert_json(AzulChaliceApp.content_security_policy(nonce='foo').split(';'))
[
"default-src 'self'",
"img-src 'self' data:",
"script-src 'self' 'nonce-foo'",
"style-src 'self' 'nonce-foo'",
"frame-ancestors 'none'"
]
"""
self_ = sq('self')
none = sq('none')
nonce = [] if nonce is None else [sq('nonce-' + nonce)]

return ';'.join([
jw('default-src', self_),
jw('img-src', self_, 'data:'),
jw('script-src', self_, *nonce),
jw('style-src', self_, *nonce),
jw('frame-ancestors', none),
])

@classmethod
def validate_csp(cls, csp: str, has_nonce: bool) -> str:
"""
Raise an exception if the CSP is invalid, otherwise return the validated
CSP.
>>> cls = AzulChaliceApp
>>> cls.validate_csp("default-src 'self';img-src 'self' data:;"
... "script-src 'self';"
... "style-src 'self';"
... "frame-ancestors 'none'", has_nonce=False)
"default-src 'self';img-src 'self' data:;script-src 'self';style-src 'self';frame-ancestors 'none'"
Fails if nonce violates the RFC
>>> cls.validate_csp("default-src 'self';img-src 'self' data:;"
... "script-src 'self' 'nonce-1234567890123456789012345678901234567890***';"
... "style-src 'self' 'nonce-1234567890123456789012345678901234567890***';"
... "frame-ancestors 'none'", has_nonce=True)
Traceback (most recent call last):
...
AssertionError: 'nonce-1234567890123456789012345678901234567890***'
Fails if nonce is shorter than expected
>>> cls.validate_csp("default-src 'self';img-src 'self' data:;"
... "script-src 'self' 'nonce-1234567890';"
... "style-src 'self' 'nonce-1234567890';"
... "frame-ancestors 'none'", has_nonce=True)
Traceback (most recent call last):
...
AssertionError: 'nonce-1234567890'
Fails if nonce is longer than expected
>>> cls.validate_csp("default-src 'self';img-src 'self' data:;"
... "script-src 'self' 'nonce-12345678901234567890123456789012345678901234567890';"
... "style-src 'self' 'nonce-12345678901234567890123456789012345678901234567890';"
... "frame-ancestors 'none'", has_nonce=True)
Traceback (most recent call last):
...
AssertionError: 'nonce-12345678901234567890123456789012345678901234567890'
"""
# https://www.w3.org/TR/CSP2/#policy-syntax
directive_re = re.compile(r'[ \t]*([a-zA-Z0-9-]+)'
# Space, tab and any visible character
# (0x21-0xFE) except for comma (0x2C) or
# semicolon (0x3B).
r'(?:[ \t]([ \t\x21-\x2B\x2D-\x3A\x3C-\xFE]*))?')
nonce_re = re.compile(r"'nonce-([a-zA-Z0-9+/]{43})'")
expected_directives = [
'default-src',
'frame-ancestors',
'img-src',
'script-src',
'style-src',
]
expected_expressions = [
sq('none'),
sq('self'),
'data:',
]
directives = list()
expressions = list()
nonces = dict()

for directive in csp.split(';'):
match = directive_re.fullmatch(directive)
assert match is not None
name, value = match.groups()
assert name not in directives, name
directives.append(name)
for expression in value.split(' '):
if expression in expected_expressions:
expressions.append(expression)
else:
match = nonce_re.fullmatch(expression)
assert match is not None, expression
assert name not in nonces, name
nonces[name] = match.group(1)
if has_nonce:
assert ['script-src', 'style-src'] == sorted(nonces.keys()), nonces.keys()
assert len(set(nonces.values())) == 1, sorted(set(nonces.values()))
else:
assert nonces == {}, nonces
assert expected_directives == sorted(directives), sorted(directives)
assert expected_expressions == sorted(set(expressions)), sorted(set(expressions))
return csp

@classmethod
def security_headers(cls) -> dict[str, str]:
"""
Expand All @@ -347,8 +215,9 @@ def security_headers(cls) -> dict[str, str]:
addresses known security vulnerabilities.
"""
hsts_max_age = 60 * 60 * 24 * 365 * 2
csp = CSP.for_azul()
return {
'Content-Security-Policy': cls.content_security_policy(),
'Content-Security-Policy': str(csp),
'Referrer-Policy': 'strict-origin-when-cross-origin',
'Strict-Transport-Security': jw(f'max-age={hsts_max_age};',
'includeSubDomains;',
Expand Down Expand Up @@ -667,7 +536,7 @@ def swagger_ui(self) -> Response:
base_url = self.base_url
redirect_url = furl(base_url).add(path='oauth2_redirect')
deployment_url = furl(base_url).add(path='openapi')
nonce = self.csp_nonce()
nonce = CSP.new_nonce()
html = chevron.render(template, {
'CSP_NONCE': json.dumps(nonce),
'DEPLOYMENT_PATH': json.dumps(str(deployment_url.path)),
Expand All @@ -678,10 +547,11 @@ def swagger_ui(self) -> Response:
for path, method in self.non_interactive_routes
])
})
csp = CSP.for_azul(nonce)
return Response(status_code=200,
headers={
'Content-Type': 'text/html',
'Content-Security-Policy': self.content_security_policy(nonce)
'Content-Security-Policy': str(csp)
},
body=html)

Expand Down
Loading

0 comments on commit ed097ad

Please sign in to comment.