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 django.utils.translation import trans_real
from commonware.middleware import FrameOptionsHeader as OldFrameOptionsHeader 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 import metrics
from bedrock.base.i18n import ( from bedrock.base.i18n import (
@ -299,7 +299,7 @@ class MetricsViewTimingMiddleware(MiddlewareMixin):
self._record_timing(request, 500) self._record_timing(request, 500)
class CSPMiddlewareByPathPrefix(CSPMiddleware): class CSPMiddlewareByPathPrefix(RateLimitedCSPMiddleware):
""" """
A subclass of CSPMiddleware that allows for different CSP policies based path prefix. 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.test.utils import override_settings
from django.urls import reverse from django.urls import reverse
import csp.constants
import pytest import pytest
from jinja2.exceptions import UndefinedError from jinja2.exceptions import UndefinedError
from markus.testing import MetricsMock from markus.testing import MetricsMock
@ -275,20 +276,24 @@ def csp_middleware():
return CSPMiddlewareByPathPrefix(lambda req: HttpResponse()) 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): def test_no_csp_path_overrides(csp_middleware):
rf = RequestFactory() rf = RequestFactory()
request = rf.get("/u/thedude/") request = rf.get("/u/thedude/")
response = csp_middleware.process_response(request, HttpResponse()) response = csp_middleware.process_response(request, HttpResponse())
assert not hasattr(response, "_csp_config") assert not hasattr(response, "_csp_config")
assert not hasattr(response, "_csp_config_ro") assert not hasattr(response, "_csp_config_ro")
assert "Content-Security-Policy" in response.headers assert csp.constants.HEADER in response.headers
assert "Content-Security-Policy-Report-Only" not in response.headers assert csp.constants.HEADER_REPORT_ONLY not in response.headers
assert response.headers["Content-Security-Policy"] == "default-src default.com" assert response.headers[csp.constants.HEADER] == "default-src default.com"
@override_settings( @override_settings(
CONTENT_SECURITY_POLICY={"DIRECTIVES": {"default-src": ["default.com"]}}, CONTENT_SECURITY_POLICY={"DIRECTIVES": {"default-src": ["default.com"]}},
CONTENT_SECURITY_POLICY_REPORT_ONLY=None,
CSP_PATH_OVERRIDES={"/u/thedude": {"DIRECTIVES": {"default-src": ["override.com"]}}}, CSP_PATH_OVERRIDES={"/u/thedude": {"DIRECTIVES": {"default-src": ["override.com"]}}},
) )
def test_csp_path_overrides(csp_middleware): 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()) response = csp_middleware.process_response(request, HttpResponse())
assert response._csp_config == {"default-src": ["override.com"]} assert response._csp_config == {"default-src": ["override.com"]}
assert not hasattr(response, "_csp_config_ro") assert not hasattr(response, "_csp_config_ro")
assert "Content-Security-Policy" in response.headers assert csp.constants.HEADER in response.headers
assert "Content-Security-Policy-Report-Only" not in response.headers assert csp.constants.HEADER_REPORT_ONLY not in response.headers
assert response.headers["Content-Security-Policy"] == "default-src override.com" 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): def test_csp_path_overrides_nullify(csp_middleware):
rf = RequestFactory() rf = RequestFactory()
request = rf.get("/u/thedude/") request = rf.get("/u/thedude/")
response = csp_middleware.process_response(request, HttpResponse()) response = csp_middleware.process_response(request, HttpResponse())
assert response._csp_config == {} assert response._csp_config == {}
assert not hasattr(response, "_csp_config_ro") assert not hasattr(response, "_csp_config_ro")
assert "Content-Security-Policy" not in response.headers assert csp.constants.HEADER not in response.headers
assert "Content-Security-Policy-Report-Only" not in response.headers assert csp.constants.HEADER_REPORT_ONLY not in response.headers
@override_settings( @override_settings(
@ -324,7 +333,21 @@ def test_csp_path_overrides_report_only(csp_middleware):
response = csp_middleware.process_response(request, HttpResponse()) response = csp_middleware.process_response(request, HttpResponse())
assert response._csp_config_ro == {"default-src": ["override.com"]} assert response._csp_config_ro == {"default-src": ["override.com"]}
assert not hasattr(response, "_csp_config") assert not hasattr(response, "_csp_config")
assert "Content-Security-Policy" in response.headers assert csp.constants.HEADER in response.headers
assert "Content-Security-Policy-Report-Only" in response.headers assert csp.constants.HEADER_REPORT_ONLY in response.headers
assert response.headers["Content-Security-Policy"] == "default-src default.com" assert response.headers[csp.constants.HEADER] == "default-src default.com"
assert response.headers["Content-Security-Policy-Report-Only"] == "default-src override.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 logging.config
import sys import sys
from copy import deepcopy
from csp.constants import SELF, UNSAFE_EVAL, UNSAFE_INLINE 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)), "connect-src": list(set(_csp_default_src + _csp_connect_src)),
# support older browsers (mainly Safari) # support older browsers (mainly Safari)
"frame-src": _csp_child_src, "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. # Mainly for overriding CSP settings for CMS admin.
# Works in conjunction with the `bedrock.base.middleware.CSPMiddlewareByPathPrefix` middleware. # Works in conjunction with the `bedrock.base.middleware.CSPMiddlewareByPathPrefix` middleware.
# /cms-admin/images/ loads just-uploaded images as blobs. # /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:"] CMS_ADMIN_IMAGES_CSP["DIRECTIVES"]["img-src"] += ["blob:"]
CSP_PATH_OVERRIDES = { 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/ CONTENT_CARDS_URL: https://www-dev.allizom.org/media/
CSP_DEFAULT_SRC: "*.allizom.org" CSP_DEFAULT_SRC: "*.allizom.org"
CSP_EXTRA_FRAME_SRC: "*.mozaws.net,o1069899.sentry.io" CSP_EXTRA_FRAME_SRC: "*.mozaws.net,o1069899.sentry.io"
CSP_REPORT_ENABLE: "True"
DB_DOWNLOAD_IGNORE_GIT: "True" DB_DOWNLOAD_IGNORE_GIT: "True"
DEBUG: "False" DEBUG: "False"
DEV: "True" DEV: "True"