Skip to content

Commit b4fec79

Browse files
committed
- Validate CSP directives (#325).
1 parent c3dbd9a commit b4fec79

File tree

3 files changed

+241
-3
lines changed

3 files changed

+241
-3
lines changed

Changelog.md

+3-1
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ Changes
1616
- Minimum max-age for HSTS is now 14 months. [(#421)]
1717
- Accept all 3xx+3xx and 3xx+2xx DANE rollover schemes. [(#341)]
1818
- Certificate Usage Field on TLSA records for email test. [(#329)]
19+
- Validate CSP directives. [(#325)]
1920

2021
Bug Fixes
2122
- Fix indefinite locks in cache (not a current problem).
@@ -41,8 +42,9 @@ Settings
4142
- New SMTP_EHLO_DOMAIN setting in settings.py. [(#483)]
4243

4344
[(#249)]: https://github.com/NLnetLabs/Internet.nl/issues/249
44-
[(#341)]: https://github.com/NLnetLabs/Internet.nl/issues/341
4545
[(#329)]: https://github.com/NLnetLabs/Internet.nl/issues/329
46+
[(#325)]: https://github.com/NLnetLabs/Internet.nl/issues/325
47+
[(#341)]: https://github.com/NLnetLabs/Internet.nl/issues/341
4648
[(#421)]: https://github.com/NLnetLabs/Internet.nl/issues/421
4749
[(#443)]: https://github.com/NLnetLabs/Internet.nl/issues/443
4850
[(#461)]: https://github.com/NLnetLabs/Internet.nl/issues/461

checks/scoring.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,7 @@
196196

197197
WEB_APPSECPRIV_CONTENT_SECURITY_POLICY_GOOD = FULL_WEIGHT_POINTS
198198
WEB_APPSECPRIV_CONTENT_SECURITY_POLICY_BAD = NO_POINTS
199-
WEB_APPSECPRIV_CONTENT_SECURITY_POLICY_WORST_STATUS = STATUS_INFO
199+
WEB_APPSECPRIV_CONTENT_SECURITY_POLICY_WORST_STATUS = STATUS_NOTICE
200200

201201

202202
# --- MAILTEST

checks/tasks/http_headers.py

+237-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
# Copyright: 2019, NLnet Labs and the Internet.nl contributors
22
# SPDX-License-Identifier: Apache-2.0
3+
from collections import namedtuple, defaultdict
34
import http.client
5+
import re
46
import socket
57

68
from .tls_connection import NoIpError, http_fetch, MAX_REDIRECT_DEPTH
@@ -56,12 +58,186 @@ class HeaderCheckerContentSecurityPolicy(object):
5658
Class for checking the Content-Security-Policy HTTP header.
5759
5860
"""
61+
Directive = namedtuple('Directive', [
62+
'default', 'values', 'values_optional', 'values_regex_all'],
63+
defaults=[[], [], False, False])
64+
host_source_regex = re.compile(
65+
r'(?P<scheme>[^:]+://)?(?P<host>[^:]+\.[^:]+)(:(?P<port>\d+))?')
66+
scheme_source_regex = re.compile(
67+
r'(?:https?|data|mediastream|blob|filesystem):')
68+
self_none_regex = re.compile(r"(?:'self'|'none')")
69+
other_source_regex = re.compile(
70+
r"(?:"
71+
r"'self'|'unsafe-eval'|'unsafe-hashes'|'unsafe-inline'|'none'"
72+
r"|'nonce-[+a-zA-Z0-9/]+=*'"
73+
r"|'(?:sha256|sha384|sha512)-[+a-zA-Z0-9/]+=*')")
74+
strict_dynamic_regex = re.compile(r"'strict-dynamic'")
75+
report_sample_regex = re.compile(r"'report-sample'")
76+
plugin_types_regex = re.compile(r'[^/]+/[^/]+')
77+
sandox_values_regex = re.compile(
78+
r'(?:allow-downloads-without-user-activation|allow-forms|allow-modals'
79+
r'|allow-orientation-lock|allow-pointer-lock|allow-popups'
80+
r'|allow-popups-to-escape-sandbox|allow-presentation|allow-same-origin'
81+
r'|allow-scripts|allow-storage-access-by-user-activation'
82+
r'|allow-top-navigation|allow-top-navigation-by-user-activation)')
83+
directives = {
84+
'child-src': Directive(
85+
default=['default-src'],
86+
values=[
87+
host_source_regex, scheme_source_regex, other_source_regex]
88+
),
89+
'connect-src': Directive(
90+
default=['default-src'],
91+
values=[
92+
host_source_regex, scheme_source_regex, other_source_regex],
93+
),
94+
'default-src': Directive(
95+
values=[
96+
host_source_regex, scheme_source_regex, other_source_regex,
97+
strict_dynamic_regex, report_sample_regex],
98+
),
99+
'font-src': Directive(
100+
default=['default-src'],
101+
values=[
102+
host_source_regex, scheme_source_regex, other_source_regex],
103+
),
104+
'frame-src': Directive(
105+
default=['child-src', 'default-src'],
106+
values=[
107+
host_source_regex, scheme_source_regex, other_source_regex],
108+
),
109+
'img-src': Directive(
110+
default=['default-src'],
111+
values=[
112+
host_source_regex, scheme_source_regex, other_source_regex,
113+
strict_dynamic_regex, report_sample_regex],
114+
),
115+
'manifest-src': Directive(
116+
default=['default-src'],
117+
values=[
118+
host_source_regex, scheme_source_regex, other_source_regex],
119+
),
120+
'media-src': Directive(
121+
default=['default-src'],
122+
values=[
123+
host_source_regex, scheme_source_regex, other_source_regex],
124+
),
125+
'object-src': Directive(
126+
default=['default-src'],
127+
values=[
128+
host_source_regex, scheme_source_regex, other_source_regex],
129+
),
130+
'prefetch-src': Directive(
131+
default=['default-src'],
132+
values=[
133+
host_source_regex, scheme_source_regex, other_source_regex],
134+
),
135+
'script-src': Directive(
136+
default=['default-src'],
137+
values=[
138+
host_source_regex, scheme_source_regex, other_source_regex,
139+
strict_dynamic_regex, report_sample_regex],
140+
),
141+
'script-src-elem': Directive(
142+
default=['script-src', 'default-src'],
143+
values=[
144+
host_source_regex, scheme_source_regex, other_source_regex,
145+
strict_dynamic_regex, report_sample_regex],
146+
),
147+
'script-src-attr': Directive(
148+
default=['script-src', 'default-src'],
149+
values=[
150+
host_source_regex, scheme_source_regex, other_source_regex,
151+
strict_dynamic_regex, report_sample_regex],
152+
),
153+
'style-src': Directive(
154+
default=['default-src'],
155+
values=[
156+
host_source_regex, scheme_source_regex, other_source_regex],
157+
),
158+
'style-src-elem': Directive(
159+
default=['style-src', 'default-src'],
160+
values=[
161+
host_source_regex, scheme_source_regex, other_source_regex,
162+
report_sample_regex],
163+
),
164+
'style-src-attr': Directive(
165+
default=['style-src', 'default-src'],
166+
values=[
167+
host_source_regex, scheme_source_regex, other_source_regex,
168+
report_sample_regex],
169+
),
170+
'worker-src': Directive(
171+
default=['child-src', 'script-src', 'default-src'],
172+
values=[
173+
host_source_regex, scheme_source_regex, other_source_regex],
174+
),
175+
'base-uri': Directive(
176+
values=[
177+
host_source_regex, scheme_source_regex, other_source_regex,
178+
strict_dynamic_regex, report_sample_regex],
179+
),
180+
'plugin-types': Directive(
181+
values=[plugin_types_regex],
182+
),
183+
'sandbox': Directive(
184+
values=[sandox_values_regex],
185+
values_optional=True,
186+
),
187+
'form-action': Directive(
188+
values=[
189+
host_source_regex, scheme_source_regex, other_source_regex,
190+
strict_dynamic_regex, report_sample_regex],
191+
),
192+
'frame-ancestors': Directive(
193+
values=[self_none_regex],
194+
),
195+
'navigate-to': Directive(
196+
values=[
197+
host_source_regex, scheme_source_regex, other_source_regex,
198+
strict_dynamic_regex, report_sample_regex],
199+
),
200+
'report-to': Directive(
201+
# It could be anything in the Report-To header.
202+
values=[re.compile(r'.+')],
203+
),
204+
'block-all-mixed-content': Directive(
205+
),
206+
'trusted-types': Directive(
207+
values=[re.compile(
208+
r"^(?:'none'|"
209+
r"(?:\*|[\w\-#=\/@.%]+)"
210+
r"(?:(?: (?:\*|[\w\-#=\/@.%]+))+"
211+
r"(?: 'allow-duplicates')?)?)$")],
212+
values_optional=True,
213+
values_regex_all=True,
214+
),
215+
'upgrade-insecure-requests': Directive(),
216+
}
217+
59218
def __init__(self):
60219
self.name = "Content-Security-Policy"
61220

221+
def _check_parsed_for_self_or_none(self, name):
222+
found = False
223+
if name in self.parsed:
224+
if "'self'" in self.parsed[name] or "'none'" in self.parsed[name]:
225+
found = True
226+
else:
227+
for parent in self.directives[name].default:
228+
found = self._check_parsed_for_self_or_none(parent)
229+
if found:
230+
break
231+
return found
232+
62233
def check(self, value, results):
63234
"""
64-
Check if the header has any value.
235+
Check if the header respects the following:
236+
- No `unsafe-invalid`;
237+
- No `unsafe-eval`;
238+
- `default-src`, `frame-src` and `frame-ancestors` need to defined
239+
and be `'self'` or `'none'`;
240+
- `http:` should not be used as a scheme.
65241
66242
"""
67243
if not value:
@@ -72,6 +248,66 @@ def check(self, value, results):
72248
values = get_multiple_values_from_header(value)
73249
results['content_security_policy_values'].extend(values)
74250

251+
self.parsed = defaultdict(list)
252+
has_unsafe_inline = False
253+
has_unsafe_eval = False
254+
has_http = False
255+
has_default_src = False
256+
has_frame_src = False
257+
has_frame_ancestors = False
258+
259+
for header in values:
260+
dirs = filter(None, header.split(';'))
261+
for content in dirs:
262+
content = content.strip().split()
263+
dir = content[0]
264+
values = content[1:]
265+
# Only care for known directives.
266+
if dir in self.directives:
267+
if (not values and self.directives[dir].values
268+
and not self.directives[dir].values_optional):
269+
continue
270+
271+
if self.directives[dir].values_regex_all:
272+
matched = min(1, len(values))
273+
test_values = [' '.join(values)]
274+
else:
275+
matched = len(values)
276+
test_values = values
277+
278+
for value in test_values:
279+
for exp_value in self.directives[dir].values:
280+
if exp_value.match(value):
281+
if (not has_http and exp_value in (
282+
self.host_source_regex,
283+
self.scheme_source_regex)):
284+
if 'http:' in value:
285+
has_http = True
286+
if (not has_unsafe_inline
287+
and 'unsafe-inline' in value):
288+
has_unsafe_inline = True
289+
if (not has_unsafe_eval
290+
and 'unsafe-eval' in value):
291+
has_unsafe_eval = True
292+
matched -= 1
293+
break
294+
if matched <= 0:
295+
self.parsed[dir].extend(values)
296+
297+
has_default_src = self._check_parsed_for_self_or_none(
298+
'default-src')
299+
has_frame_src = self._check_parsed_for_self_or_none(
300+
'frame-src')
301+
has_frame_ancestors = self._check_parsed_for_self_or_none(
302+
'frame-ancestors')
303+
304+
if (has_unsafe_inline or has_unsafe_eval or has_http or not (
305+
has_default_src and has_frame_src
306+
and has_frame_ancestors)):
307+
results['content_security_policy_enabled'] = False
308+
score = scoring.WEB_APPSECPRIV_CONTENT_SECURITY_POLICY_BAD
309+
results['content_security_policy_score'] = score
310+
75311
def get_positive_values(self):
76312
score = scoring.WEB_APPSECPRIV_CONTENT_SECURITY_POLICY_GOOD
77313
return {

0 commit comments

Comments
 (0)