Support configurable CSP reporting percentage

This commit is contained in:
Rob Hudson 2024-07-11 15:38:33 -07:00 коммит произвёл Rob Hudson
Родитель fe9144dc0e
Коммит 6da5de9219
5 изменённых файлов: 87 добавлений и 19 удалений

Просмотреть файл

@ -24,7 +24,7 @@ from django.utils.deprecation import MiddlewareMixin
from django.utils.translation import trans_real
from commonware.middleware import FrameOptionsHeader as OldFrameOptionsHeader
from csp.middleware import CSPMiddleware
from csp.contrib.rate_limiting import RateLimitedCSPMiddleware
from bedrock.base import metrics
from bedrock.base.i18n import (
@ -299,7 +299,7 @@ class MetricsViewTimingMiddleware(MiddlewareMixin):
self._record_timing(request, 500)
class CSPMiddlewareByPathPrefix(CSPMiddleware):
class CSPMiddlewareByPathPrefix(RateLimitedCSPMiddleware):
"""
A subclass of CSPMiddleware that allows for different CSP policies based path prefix.

Просмотреть файл

@ -10,6 +10,7 @@ from django.test import Client, RequestFactory, TestCase
from django.test.utils import override_settings
from django.urls import reverse
import csp.constants
import pytest
from jinja2.exceptions import UndefinedError
from markus.testing import MetricsMock
@ -275,20 +276,24 @@ def csp_middleware():
return CSPMiddlewareByPathPrefix(lambda req: HttpResponse())
@override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"default-src": ["default.com"]}})
@override_settings(
CONTENT_SECURITY_POLICY={"DIRECTIVES": {"default-src": ["default.com"]}},
CONTENT_SECURITY_POLICY_REPORT_ONLY=None,
)
def test_no_csp_path_overrides(csp_middleware):
rf = RequestFactory()
request = rf.get("/u/thedude/")
response = csp_middleware.process_response(request, HttpResponse())
assert not hasattr(response, "_csp_config")
assert not hasattr(response, "_csp_config_ro")
assert "Content-Security-Policy" in response.headers
assert "Content-Security-Policy-Report-Only" not in response.headers
assert response.headers["Content-Security-Policy"] == "default-src default.com"
assert csp.constants.HEADER in response.headers
assert csp.constants.HEADER_REPORT_ONLY not in response.headers
assert response.headers[csp.constants.HEADER] == "default-src default.com"
@override_settings(
CONTENT_SECURITY_POLICY={"DIRECTIVES": {"default-src": ["default.com"]}},
CONTENT_SECURITY_POLICY_REPORT_ONLY=None,
CSP_PATH_OVERRIDES={"/u/thedude": {"DIRECTIVES": {"default-src": ["override.com"]}}},
)
def test_csp_path_overrides(csp_middleware):
@ -297,20 +302,24 @@ def test_csp_path_overrides(csp_middleware):
response = csp_middleware.process_response(request, HttpResponse())
assert response._csp_config == {"default-src": ["override.com"]}
assert not hasattr(response, "_csp_config_ro")
assert "Content-Security-Policy" in response.headers
assert "Content-Security-Policy-Report-Only" not in response.headers
assert response.headers["Content-Security-Policy"] == "default-src override.com"
assert csp.constants.HEADER in response.headers
assert csp.constants.HEADER_REPORT_ONLY not in response.headers
assert response.headers[csp.constants.HEADER] == "default-src override.com"
@override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"default-src": ["default.com"]}}, CSP_PATH_OVERRIDES={"/u/thedude": {"DIRECTIVES": {}}})
@override_settings(
CONTENT_SECURITY_POLICY={"DIRECTIVES": {"default-src": ["default.com"]}},
CONTENT_SECURITY_POLICY_REPORT_ONLY=None,
CSP_PATH_OVERRIDES={"/u/thedude": {"DIRECTIVES": {}}},
)
def test_csp_path_overrides_nullify(csp_middleware):
rf = RequestFactory()
request = rf.get("/u/thedude/")
response = csp_middleware.process_response(request, HttpResponse())
assert response._csp_config == {}
assert not hasattr(response, "_csp_config_ro")
assert "Content-Security-Policy" not in response.headers
assert "Content-Security-Policy-Report-Only" not in response.headers
assert csp.constants.HEADER not in response.headers
assert csp.constants.HEADER_REPORT_ONLY not in response.headers
@override_settings(
@ -324,7 +333,21 @@ def test_csp_path_overrides_report_only(csp_middleware):
response = csp_middleware.process_response(request, HttpResponse())
assert response._csp_config_ro == {"default-src": ["override.com"]}
assert not hasattr(response, "_csp_config")
assert "Content-Security-Policy" in response.headers
assert "Content-Security-Policy-Report-Only" in response.headers
assert response.headers["Content-Security-Policy"] == "default-src default.com"
assert response.headers["Content-Security-Policy-Report-Only"] == "default-src override.com"
assert csp.constants.HEADER in response.headers
assert csp.constants.HEADER_REPORT_ONLY in response.headers
assert response.headers[csp.constants.HEADER] == "default-src default.com"
assert response.headers[csp.constants.HEADER_REPORT_ONLY] == "default-src override.com"
@override_settings(
CONTENT_SECURITY_POLICY={"REPORT_PERCENTAGE": 0, "DIRECTIVES": {"default-src": ["default.com"]}},
CONTENT_SECURITY_POLICY_REPORT_ONLY={"REPORT_PERCENTAGE": 100, "DIRECTIVES": {"default-src": ["default.com"], "report-uri": ["report.com"]}},
)
def test_csp_report_percentage_zero(csp_middleware):
rf = RequestFactory()
request = rf.get("/u/thedude/")
response = csp_middleware.process_response(request, HttpResponse())
assert csp.constants.HEADER in response.headers
assert csp.constants.HEADER_REPORT_ONLY in response.headers
assert "report-uri" not in response.headers[csp.constants.HEADER]
assert "report-uri" in response.headers[csp.constants.HEADER_REPORT_ONLY]

Просмотреть файл

@ -4,6 +4,7 @@
import logging.config
import sys
from copy import deepcopy
from csp.constants import SELF, UNSAFE_EVAL, UNSAFE_INLINE
@ -258,15 +259,21 @@ CONTENT_SECURITY_POLICY = {
"connect-src": list(set(_csp_default_src + _csp_connect_src)),
# support older browsers (mainly Safari)
"frame-src": _csp_child_src,
"report-uri": config("CSP_REPORT_URI", default="") or None,
},
}
# Start report-only CSP as a copy. We'll modify it later if needed.
# Only set up report-only CSP if we have a report-uri set.
if csp_report_uri := config("CSP_REPORT_URI", default="") or None:
CONTENT_SECURITY_POLICY_REPORT_ONLY = deepcopy(CONTENT_SECURITY_POLICY)
CONTENT_SECURITY_POLICY_REPORT_ONLY["REPORT_PERCENTAGE"] = config("CSP_REPORT_PERCENTAGE", default="100", parser=int)
CONTENT_SECURITY_POLICY_REPORT_ONLY["DIRECTIVES"]["report-uri"] = csp_report_uri
# Mainly for overriding CSP settings for CMS admin.
# Works in conjunction with the `bedrock.base.middleware.CSPMiddlewareByPathPrefix` middleware.
# /cms-admin/images/ loads just-uploaded images as blobs.
CMS_ADMIN_IMAGES_CSP = CONTENT_SECURITY_POLICY.copy()
CMS_ADMIN_IMAGES_CSP = deepcopy(CONTENT_SECURITY_POLICY)
CMS_ADMIN_IMAGES_CSP["DIRECTIVES"]["img-src"] += ["blob:"]
CSP_PATH_OVERRIDES = {

39
bin/qa/check_csp_report.py Executable file
Просмотреть файл

@ -0,0 +1,39 @@
#!/usr/bin/env python
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
from collections import defaultdict
import click
import requests
@click.command()
@click.option("-n", type=click.IntRange(0, 100, clamp=True), default=100, help="Number of requests to make")
@click.argument("url", type=str, required=True)
def check_for_report_uri(url, n=100):
"""
Check if a URL has a Content-Security-Policy header with a report-uri directive.
"""
print(f"Checking {url}")
results = defaultdict(int)
for _ in range(n):
resp = requests.get(url)
if "Content-Security-Policy" in resp.headers:
header = resp.headers["Content-Security-Policy-Report-Only"]
if "report-uri" in header:
results["report-uri present"] += 1
else:
results["report-uri not present"] += 1
else:
results["No CSP header"] += 1
print(f"{n} requests made")
print(results)
if __name__ == "__main__":
check_for_report_uri()

Просмотреть файл

@ -11,7 +11,6 @@ ALLOWED_HOSTS: .allizom.org,.moz.works,.run.app
CONTENT_CARDS_URL: https://www-dev.allizom.org/media/
CSP_DEFAULT_SRC: "*.allizom.org"
CSP_EXTRA_FRAME_SRC: "*.mozaws.net,o1069899.sentry.io"
CSP_REPORT_ENABLE: "True"
DB_DOWNLOAD_IGNORE_GIT: "True"
DEBUG: "False"
DEV: "True"