1
1
# Copyright: 2019, NLnet Labs and the Internet.nl contributors
2
2
# SPDX-License-Identifier: Apache-2.0
3
+ from collections import namedtuple , defaultdict
3
4
import http .client
5
+ import re
4
6
import socket
5
7
6
8
from .tls_connection import NoIpError , http_fetch , MAX_REDIRECT_DEPTH
@@ -56,12 +58,186 @@ class HeaderCheckerContentSecurityPolicy(object):
56
58
Class for checking the Content-Security-Policy HTTP header.
57
59
58
60
"""
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
+
59
218
def __init__ (self ):
60
219
self .name = "Content-Security-Policy"
61
220
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
+
62
233
def check (self , value , results ):
63
234
"""
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.
65
241
66
242
"""
67
243
if not value :
@@ -72,6 +248,66 @@ def check(self, value, results):
72
248
values = get_multiple_values_from_header (value )
73
249
results ['content_security_policy_values' ].extend (values )
74
250
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
+
75
311
def get_positive_values (self ):
76
312
score = scoring .WEB_APPSECPRIV_CONTENT_SECURITY_POLICY_GOOD
77
313
return {
0 commit comments