Coverage for /home/runner/work/viur-core/viur-core/viur/src/viur/core/securityheaders.py: 63%

128 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-09-25 15:02 +0000

1""" 

2This module provides configuration for most of the http security headers. The features currently supported are: 

3 - Content security policy (https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP) 

4 - Strict transport security (https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Strict-Transport-Security) 

5 - X-Frame-Options (https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Frame-Options) 

6 - X-XSS-Protection (https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-XSS-Protection) 

7 - X-Content-Type-Options (https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Content-Type-Options) 

8 - X-Permitted-Cross-Domain-Policies (https://www.adobe.com/devnet-docs/acrobatetk/tools/AppSec/xdomain.html) 

9 - Referrer-Policy (https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Referrer-Policy) 

10 - Permissions-Policy (https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Feature-Policy) 

11 - Cross origin isolation (https://web.dev/coop-coep) 

12 - Reporting-Endpoints (https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Reporting-Endpoints) 

13 

14If a feature is not yet supported, you could always set the header directly (e.g. by attaching a request 

15preprocessor). ViUR contains a default configuration for most of these headers where possible, however manual 

16review is mandatory for each project. 

17 

18The content security policy will prevent inline css and javascript by default, but is configured to allow embedding 

19images from cloud-storage and sign-in with google. 

20 

21Strict transport security is enabled by default (with a TTL of one year), but without preload or include-subdomains. 

22 

23X-Frame-Options is limited to the same origin, preventing urls from this project from being embedded in iframes that 

24don't originate from the same origin. 

25 

26X-XSS-Protection is enabled. 

27 

28X-Content-Type-Options is set to nosniff 

29 

30X-Permitted-Cross-Domain-Policies is set to "none", denying embedding resources in pdf files and the like 

31 

32Referrer-Policy is set to strict-origin, preventing leakage of URLs to 3rd-partys. 

33 

34The Permissions-Policy will only allow auto-play by default (thus access to the camera-api etc. is disabled) 

35 

36Cross origin isolation is currently disabled by default (as it's incompatible with many popular services like 

37embedding a map or sign-in with google). 

38 

39CSP violations used to be reported through the ``report-uri`` directive, which carries its url itself. 

40That directive is deprecated since CSP Level 3 and superseded by the Reporting-API: endpoints are declared 

41once in the ``Reporting-Endpoints`` header and referenced by name, by the CSP-directive ``report-to`` as 

42well as by other headers. Browsers that support it ignore ``report-uri`` once both are present, so keeping 

43the old directive around does not keep the reports flowing on its own. 

44 

45No reporting endpoints are configured by default; see :func:`set_reporting_endpoint` on how to receive reports. 

46 

47ViUR also protects it's cookies by default (setting httponly, secure and samesite=lax). This can be changed by 

48setting the corresponding class-level variables on class:`Session<viur.core.session.Session>`. 

49""" 

50 

51from viur.core.config import conf 

52from viur.core import current 

53import logging 

54import re 

55import typing as t 

56 

57# Endpoint names are structured-field keys, see https://www.rfc-editor.org/rfc/rfc8941#section-3.1.2 

58_REPORTING_ENDPOINT_NAME_RE = re.compile(r"^[a-z*][a-z0-9_.*-]*$") 

59 

60 

61def addCspRule(objectType: str, srcOrDirective: str, enforceMode: str = "monitor"): 

62 """ 

63 This function helps configuring and reporting of content security policy rules and violations. 

64 To enable CSP, call addCspRule() from your projects main file before calling server.setup(). 

65 

66 .. code-block:: python 

67 

68 # Example Usage 

69 

70 # Enable CSP for all types and made us the only allowed source 

71 security.addCspRule("default-src","self","enforce") 

72 

73 # Start a new set of rules for stylesheets whitelist us 

74 security.addCspRule("style-src","self","enforce") 

75 

76 # This is currently needed for TextBones! 

77 security.addCspRule("style-src","unsafe-inline","enforce") 

78 

79 If you don't want these rules to be enforced and just getting a report of violations replace "enforce" with 

80 "monitor". To have violations reported, name an endpoint configured via :meth:`set_reporting_endpoint`:: 

81 

82 security.set_reporting_endpoint("csp", "/cspReport") 

83 security.addCspRule("report-to", "csp", "enforce") 

84 

85 and register a function at /cspReport to handle the reports. 

86 

87 The older ``report-uri`` directive does the same without a named endpoint, but is deprecated since 

88 CSP Level 3. It is still worth adding for browsers that do not support the Reporting-API; those that 

89 do ignore it as soon as ``report-to`` is present:: 

90 

91 security.addCspRule("report-uri", "/cspReport", "enforce") 

92 

93 ..note:: 

94 

95 Our tests showed that enabling a report-url on production systems has limited use. There are literally 

96 thousands of browser-extensions out there that inject code into the pages displayed. This causes a whole 

97 flood of violations-spam to your report-url. 

98 

99 

100 :param objectType: For which type of objects should this directive be enforced? (script-src, img-src, ...) 

101 :param srcOrDirective: Either a domain which should be white-listed or a CSP-Keyword like 'self', 'unsafe-inline', etc. 

102 :param enforceMode: Should this directive be enforced or just logged? 

103 """ 

104 assert enforceMode in ["monitor", "enforce"], "enforceMode must be 'monitor' or 'enforce'!" 

105 assert objectType in { 

106 # Fetch directives 

107 "default-src", "child-src", "connect-src", "fenced-frame-src", "font-src", "frame-src", "img-src", 

108 "manifest-src", "media-src", "object-src", "prefetch-src", "script-src", "script-src-elem", 

109 "script-src-attr", "style-src", "style-src-elem", "style-src-attr", "worker-src", 

110 # Document directives 

111 "base-uri", "sandbox", 

112 # Navigation directives 

113 "form-action", "frame-ancestors", 

114 # Reporting directives; "report-uri" is deprecated, prefer "report-to" with set_reporting_endpoint() 

115 "report-uri", "report-to", 

116 # Other directives 

117 "require-trusted-types-for", "trusted-types", "upgrade-insecure-requests", "block-all-mixed-content", 

118 } 

119 assert conf.main_app is None, "You cannot modify CSP rules after server.buildApp() has been run!" 

120 assert not any( 

121 [x in srcOrDirective for x in [";", "'", "\"", "\n", ","]]), "Invalid character in srcOrDirective!" 

122 if conf.security.content_security_policy is None: 122 ↛ 123line 122 didn't jump to line 123 because the condition on line 122 was never true

123 conf.security.content_security_policy = {"_headerCache": {}} 

124 if enforceMode not in conf.security.content_security_policy: 124 ↛ 125line 124 didn't jump to line 125 because the condition on line 124 was never true

125 conf.security.content_security_policy[enforceMode] = {} 

126 if objectType in ("report-uri", "report-to"): 126 ↛ 130line 126 didn't jump to line 130 because the condition on line 126 was always true

127 # Both directives take exactly one value; a second one would be ignored by the browser anyway 

128 conf.security.content_security_policy[enforceMode][objectType] = [srcOrDirective] 

129 else: 

130 if objectType not in conf.security.content_security_policy[enforceMode]: 

131 conf.security.content_security_policy[enforceMode][objectType] = [] 

132 if srcOrDirective not in conf.security.content_security_policy[enforceMode][objectType]: 

133 conf.security.content_security_policy[enforceMode][objectType].append(srcOrDirective) 

134 

135 

136def _rebuildCspHeaderCache(): 

137 """ 

138 Rebuilds the internal conf.security.content_security_policy["_headerCache"] dictionary, ie. it constructs 

139 the Content-Security-Policy-Report-Only and Content-Security-Policy headers based on what has been passed 

140 to 'addRule' earlier on. Should not be called directly. 

141 """ 

142 conf.security.content_security_policy["_headerCache"] = {} 

143 for enforceMode in ["monitor", "enforce"]: 

144 resStr = "" 

145 if enforceMode not in conf.security.content_security_policy: 

146 continue 

147 for key, values in conf.security.content_security_policy[enforceMode].items(): 

148 resStr += key 

149 for value in values: 

150 resStr += " " 

151 if value in {"self", "unsafe-inline", "unsafe-eval", "script", "none"} or \ 

152 any([value.startswith(x) for x in ["sha256-", "sha384-", "sha512-"]]): 

153 # We don't permit nonce- in project wide config as this will be reused on multiple requests 

154 resStr += f"'{value}'" 

155 else: 

156 resStr += value 

157 resStr += "; " 

158 if enforceMode == "monitor": 158 ↛ 159line 158 didn't jump to line 159 because the condition on line 158 was never true

159 conf.security.content_security_policy["_headerCache"][ 

160 "Content-Security-Policy-Report-Only"] = resStr 

161 else: 

162 conf.security.content_security_policy["_headerCache"]["Content-Security-Policy"] = resStr 

163 

164 

165def extendCsp(additionalRules: dict = None, overrideRules: dict = None) -> None: 

166 """ 

167 Adds additional csp rules to the current request. ViUR will emit a default csp-header based on the 

168 project-wide config. For some requests, it's needed to extend or override these rules without having to include 

169 them in the project config. Each dictionary must be in the same format as the 

170 conf.security.content_security_policy. Values in additionalRules will extend the project-specific 

171 configuration, while overrideRules will replace them. 

172 

173 ..Note: This function will only work on CSP-Rules in "enforce" mode, "monitor" is not suppored 

174 

175 :param additionalRules: Dictionary with additional csp-rules to emit 

176 :param overrideRules: Values in this dictionary will override the corresponding default rule 

177 """ 

178 assert additionalRules or overrideRules, "Either additionalRules or overrideRules must be given!" 

179 tmpDict = {} # Copy the project-wide config in 

180 if conf.security.content_security_policy and conf.security.content_security_policy.get("enforce"): 

181 tmpDict.update({k: v[:] for k, v in conf.security.content_security_policy["enforce"].items()}) 

182 if overrideRules: # Merge overrideRules 182 ↛ 183line 182 didn't jump to line 183 because the condition on line 182 was never true

183 for k, v in overrideRules.items(): 

184 if v is None and k in tmpDict: 

185 del tmpDict[k] 

186 else: 

187 tmpDict[k] = v 

188 if additionalRules: # Merge the extension dict 188 ↛ 193line 188 didn't jump to line 193 because the condition on line 188 was always true

189 for k, v in additionalRules.items(): 

190 if k not in tmpDict: 

191 tmpDict[k] = [] 

192 tmpDict[k].extend(v) 

193 resStr = "" # Rebuild the CSP-Header 

194 for key, values in tmpDict.items(): 

195 resStr += key 

196 for value in values: 

197 resStr += " " 

198 if value in {"self", "unsafe-inline", "unsafe-eval", "script", "none"} or \ 198 ↛ 202line 198 didn't jump to line 202 because the condition on line 198 was always true

199 any([value.startswith(x) for x in ["nonce-", "sha256-", "sha384-", "sha512-"]]): 

200 resStr += f"'{value}'" 

201 else: 

202 resStr += value 

203 resStr += "; " 

204 current.request.get().response.headers["Content-Security-Policy"] = resStr 

205 

206 

207def set_reporting_endpoint(name: str, url: str | None) -> None: 

208 """Configure a named endpoint reports are being sent to. 

209 

210 All endpoints configured this way are emitted as ``Reporting-Endpoints`` http-header with each request. 

211 Other headers reference an endpoint by its name, i.e. the CSP-directive ``report-to``: 

212 

213 .. code-block:: python 

214 

215 # Example Usage 

216 

217 security.set_reporting_endpoint("csp", "/cspReport") 

218 security.addCspRule("report-to", "csp", "enforce") 

219 

220 The name ``default`` is special: the browser uses it for reports whose header cannot name an endpoint 

221 on its own, as well as for reports not caused by a header at all (i.e. deprecation reports). 

222 

223 The endpoint receives a POST with the content-type ``application/reports+json``, carrying a *list* of 

224 reports rather than a single one: browsers queue them up and deliver a batch a few seconds later. The 

225 deprecated ``report-uri`` directive behaves differently, it posts one ``application/csp-report`` per 

226 violation right away. Reports of a violation also name the precise directive (``style-src-elem``), 

227 where the legacy format falls back to the broader one (``style-src``). 

228 

229 .. note:: 

230 

231 Reports are only sent from a https origin, and only to a https endpoint. A relative url inherits 

232 the scheme of the document, so a development server on plain http receives nothing -- not even 

233 when the endpoint is given as an absolute https url. Putting a TLS proxy in front of the 

234 development server is enough to make reporting work locally. 

235 

236 .. note:: 

237 

238 Browsers supporting ``report-to`` ignore ``report-uri`` once both directives are present. Keeping 

239 the deprecated one around therefore only serves browsers without Reporting-API support; it is no 

240 way around the https requirement. 

241 

242 .. note:: 

243 

244 Our tests showed that enabling reporting on production systems has limited use. There are literally 

245 thousands of browser-extensions out there that inject code into the pages displayed. This causes a 

246 whole flood of violations-spam to your endpoint. 

247 

248 :param name: The name other headers use to reference this endpoint. 

249 :param url: The url the reports are sent to. Pass None to remove a previously configured endpoint. 

250 :raises ValueError: If either name or url is unsuitable. 

251 """ 

252 if url is None: 

253 conf.security.reporting_endpoints.pop(name, None) 

254 return 

255 _validate_reporting_endpoint(name, url) 

256 conf.security.reporting_endpoints[name] = url 

257 

258 

259def _build_reporting_endpoints_header() -> str: 

260 """Build the value of the ``Reporting-Endpoints`` header. 

261 

262 Uses what has been passed to :func:`set_reporting_endpoint` earlier on. An empty string is returned if no 

263 endpoint is configured, in which case the header must be omitted. Should not be called directly. 

264 """ 

265 return ", ".join(f'{name}="{url}"' for name, url in conf.security.reporting_endpoints.items()) 

266 

267 

268def _validate_reporting_config() -> None: 

269 """Ensure the reporting configuration as a whole is sane. 

270 

271 Every configured endpoint must be emittable and each CSP ``report-to`` directive must name one of them. 

272 Called on startup, should not be called directly. 

273 

274 :raises ValueError: If a configured endpoint is unsuitable. 

275 """ 

276 for name, url in conf.security.reporting_endpoints.items(): 

277 _validate_reporting_endpoint(name, url) 

278 for enforce_mode in ("monitor", "enforce"): 

279 for name in (conf.security.content_security_policy or {}).get(enforce_mode, {}).get("report-to", []): 

280 if name not in conf.security.reporting_endpoints: 

281 logging.warning(f"The CSP directive report-to names the endpoint {name!r} in {enforce_mode!r} mode, " 

282 f"but no such reporting endpoint is configured. The browser will drop the reports.") 

283 if conf.security.reporting_endpoints and conf.instance.is_dev_server: 

284 logging.warning("Reporting endpoints are configured, but browsers drop them unless they are served over " 

285 "https -- expect no reports on a plain http development server.") 

286 

287 

288def _validate_reporting_endpoint(name: str, url: str) -> None: 

289 """Ensure a reporting endpoint can be emitted as ``Reporting-Endpoints`` header without breaking it. 

290 

291 :raises ValueError: If either name or url is unsuitable. 

292 """ 

293 if not _REPORTING_ENDPOINT_NAME_RE.match(name): 

294 raise ValueError(f"Invalid endpoint name {name!r}, must match {_REPORTING_ENDPOINT_NAME_RE.pattern}") 

295 if not url or any(char in url for char in "\"',;\\") or any(char.isspace() for char in url): 

296 raise ValueError(f"Invalid character in url {url!r} of endpoint {name!r}") 

297 if "://" in url and not url.lower().startswith("https://"): 

298 raise ValueError(f"An absolute url must use the https scheme, got {url!r} for endpoint {name!r}") 

299 

300 

301def enableStrictTransportSecurity(maxAge: int = 365 * 24 * 60 * 60, 

302 includeSubDomains: bool = False, 

303 preload: bool = False) -> None: 

304 """ 

305 Enables HTTP strict transport security. 

306 

307 :param maxAge: The time, in seconds, that the browser should remember that this site is only to be accessed using HTTPS. 

308 :param includeSubDomains: If this parameter is set, this rule applies to all of the site's subdomains as well. 

309 :param preload: If set, we'll issue a hint that preloading would be appreciated. 

310 """ 

311 conf.security.strict_transport_security = f"max-age={maxAge}" 

312 if includeSubDomains: 

313 conf.security.strict_transport_security += "; includeSubDomains" 

314 if preload: 

315 conf.security.strict_transport_security += "; preload" 

316 

317 

318def setXFrameOptions(action: str, uri: t.Optional[str] = None) -> None: 

319 """ 

320 Sets X-Frame-Options to prevent click-jacking attacks. 

321 :param action: off | deny | sameorigin | allow-from 

322 :param uri: URL to whitelist 

323 """ 

324 if action == "off": 

325 conf.security.x_frame_options = None 

326 elif action in ["deny", "sameorigin"]: 

327 conf.security.x_frame_options = (action, None) 

328 elif action == "allow-from": 

329 if uri is None or not (uri.lower().startswith("https://") or uri.lower().startswith("http://")): 

330 raise ValueError("If action is allow-from, an uri MUST be given and start with http(s)://") 

331 conf.security.x_frame_options = (action, uri) 

332 

333 

334def setXXssProtection(enable: t.Optional[bool]) -> None: 

335 """ 

336 Sets X-XSS-Protection header. If set, mode will always be block. 

337 :param enable: Enable the protection or not. Set to None to drop this header 

338 """ 

339 if enable is True or enable is False or enable is None: 

340 conf.security.x_xss_protection = enable 

341 else: 

342 raise ValueError("enable must be exactly one of None | True | False") 

343 

344 

345def setXContentTypeNoSniff(enable: bool) -> None: 

346 """ 

347 Sets X-Content-Type-Options if enable is true, otherwise no header is emited. 

348 :param enable: Enable emitting this header or not 

349 """ 

350 if enable is True or enable is False: 

351 conf.security.x_content_type_options = enable 

352 else: 

353 raise ValueError("enable must be one of True | False") 

354 

355 

356def setXPermittedCrossDomainPolicies(value: str) -> None: 

357 if value not in [None, "none", "master-only", "by-content-type", "all"]: 

358 raise ValueError("value [None, \"none\", \"master-only\", \"by-content-type\", \"all\"]") 

359 conf.security.x_permitted_cross_domain_policies = value 

360 

361 

362# Valid values for the referrer-header as per https://www.w3.org/TR/referrer-policy/#referrer-policies 

363validReferrerPolicies = [ 

364 "no-referrer", 

365 "no-referrer-when-downgrade", 

366 "origin", 

367 "origin-when-cross-origin", 

368 "same-origin", 

369 "strict-origin", 

370 "strict-origin-when-cross-origin", 

371 "unsafe-url" 

372] 

373 

374 

375def setReferrerPolicy(policy: str): # fixme: replace str with literal[validreferrerpolicies] when py3.8 gets supported - This is not how Literal works... We can use a Enum for this. 

376 """ 

377 :param policy: The referrer policy to send 

378 """ 

379 assert policy in validReferrerPolicies, f"Policy must be one of {validReferrerPolicies}" 

380 conf.security.referrer_policy = policy 

381 

382 

383def _rebuildPermissionHeaderCache() -> None: 

384 """ 

385 Rebuilds the internal conf.security.permissions_policy["_headerCache"] string, ie. it constructs 

386 the actual header string that's being emitted to the clients. 

387 """ 

388 conf.security.permissions_policy["_headerCache"] = ", ".join([ 

389 "%s=(%s)" % (k, " ".join([("\"%s\"" % x if x != "self" else x) for x in v])) 

390 for k, v in conf.security.permissions_policy.items() if k != "_headerCache" 

391 ]) 

392 

393 

394def setPermissionPolicyDirective(directive: str, allowList: t.Optional[list[str]]) -> None: 

395 """ 

396 Set the permission policy. 

397 :param directive: The directive to set. 

398 Must be one of https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Feature-Policy#directives 

399 :param allowList: 

400 The list of allowed origins. Use "self" to allow the current domain. 

401 Empty list means the feature will be disabled by the browser (it's not accessible by javascript) 

402 """ 

403 conf.security.permissions_policy[directive] = allowList 

404 

405 

406def setCrossOriginIsolation(coep: bool, coop: str, corp: str) -> None: 

407 """ 

408 Configures the cross origin isolation header that ViUR may emit. This is necessary to enable features like 

409 SharedArrayBuffer. See https://web.dev/coop-coep for more information. 

410 

411 :param coep: If set True, we'll emit Cross-Origin-Embedder-Policy: 

412 - require-corp 

413 :param coop: The value for the Cross-Origin-Opener-Policy header. Valid values are 

414 - same-origin 

415 - same-origin-allow-popups 

416 - unsafe-none 

417 :param corp: The value for the Cross-Origin-Resource-Policy header. Valid values are 

418 - same-site 

419 - same-origin 

420 - cross-origin 

421 """ 

422 assert coop in ["same-origin", "same-origin-allow-popups", "unsafe-none"], "Invalid value for the COOP Header" 

423 assert corp in ["same-site", "same-origin", "cross-origin"], "Invalid value for the CORP Header" 

424 conf.security.enable_coep = bool(coep) 

425 conf.security.enable_coop = coop 

426 conf.security.enable_corp = corp