7a0da7932b
Backwards Compatibility / Verify Encryption Constants (push) Waiting to run
Backwards Compatibility / PyPI Version Compatibility (push) Waiting to run
Backwards Compatibility / Database Migration Tests (push) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
CodeQL Advanced / Analyze (python) (push) Waiting to run
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Blocked by required conditions
Docker Tests (Consolidated) / detect-changes (push) Waiting to run
Docker Tests (Consolidated) / Build Test Image (push) Waiting to run
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Blocked by required conditions
Docker Tests (Consolidated) / Accessibility Tests (push) Blocked by required conditions
Docker Tests (Consolidated) / LLM Unit Tests (push) Blocked by required conditions
Docker Tests (Consolidated) / LLM Example Tests (push) Blocked by required conditions
Docker Tests (Consolidated) / Production Image Smoke Test (push) Blocked by required conditions
Docker Tests (Consolidated) / Infrastructure Tests (push) Blocked by required conditions
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Waiting to run
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
326 lines
10 KiB
Python
326 lines
10 KiB
Python
"""Tests for uncovered branches in security_headers.py."""
|
|
|
|
import flask
|
|
import pytest
|
|
|
|
from local_deep_research.security.security_headers import SecurityHeaders
|
|
|
|
|
|
def _make_app(**config_overrides):
|
|
"""Create a minimal Flask app with SecurityHeaders."""
|
|
app = flask.Flask(__name__)
|
|
app.config["TESTING"] = True
|
|
for k, v in config_overrides.items():
|
|
app.config[k] = v
|
|
SecurityHeaders(app)
|
|
return app
|
|
|
|
|
|
class TestIsApiRoute:
|
|
def test_api_prefix(self):
|
|
assert SecurityHeaders._is_api_route("/api/research") is True
|
|
|
|
def test_research_api_prefix(self):
|
|
assert SecurityHeaders._is_api_route("/research/api/test") is True
|
|
|
|
def test_history_api_prefix(self):
|
|
assert SecurityHeaders._is_api_route("/history/api") is True
|
|
|
|
def test_static_not_api(self):
|
|
assert SecurityHeaders._is_api_route("/static/file.js") is False
|
|
|
|
def test_root_not_api(self):
|
|
assert SecurityHeaders._is_api_route("/") is False
|
|
|
|
def test_settings_not_api(self):
|
|
assert SecurityHeaders._is_api_route("/settings/") is False
|
|
|
|
|
|
class TestValidateCorsConfig:
|
|
def test_credentials_with_wildcard_raises(self):
|
|
with pytest.raises(
|
|
ValueError, match="Cannot use credentials with wildcard"
|
|
):
|
|
_make_app(
|
|
SECURITY_CORS_ALLOWED_ORIGINS="*",
|
|
SECURITY_CORS_ALLOW_CREDENTIALS=True,
|
|
)
|
|
|
|
def test_credentials_with_specific_origin_ok(self):
|
|
app = _make_app(
|
|
SECURITY_CORS_ALLOWED_ORIGINS="https://example.com",
|
|
SECURITY_CORS_ALLOW_CREDENTIALS=True,
|
|
)
|
|
assert app is not None
|
|
|
|
def test_cors_disabled_skips_validation(self):
|
|
# Should not raise even with bad config when CORS is disabled
|
|
app = _make_app(
|
|
SECURITY_CORS_ENABLED=False,
|
|
SECURITY_CORS_ALLOWED_ORIGINS="*",
|
|
SECURITY_CORS_ALLOW_CREDENTIALS=True,
|
|
)
|
|
assert app is not None
|
|
|
|
def test_multi_origin_with_credentials_logs_info(self):
|
|
app = _make_app(
|
|
SECURITY_CORS_ALLOWED_ORIGINS="https://a.com,https://b.com",
|
|
SECURITY_CORS_ALLOW_CREDENTIALS=True,
|
|
)
|
|
assert app is not None
|
|
|
|
|
|
class TestAddSecurityHeaders:
|
|
def test_basic_headers_present(self):
|
|
app = _make_app()
|
|
with app.test_client() as client:
|
|
|
|
@app.route("/test")
|
|
def test_route():
|
|
return "ok"
|
|
|
|
resp = client.get("/test")
|
|
assert "Content-Security-Policy" in resp.headers
|
|
assert "X-Frame-Options" in resp.headers
|
|
assert "X-Content-Type-Options" in resp.headers
|
|
assert "Permissions-Policy" in resp.headers
|
|
assert "Referrer-Policy" in resp.headers
|
|
|
|
def test_hsts_not_set_on_http(self):
|
|
app = _make_app()
|
|
with app.test_client() as client:
|
|
|
|
@app.route("/test")
|
|
def test_route():
|
|
return "ok"
|
|
|
|
resp = client.get("/test")
|
|
assert "Strict-Transport-Security" not in resp.headers
|
|
|
|
def test_static_path_no_cache_control(self):
|
|
"""Static paths should NOT get no-cache headers."""
|
|
app = _make_app()
|
|
|
|
@app.route("/static/test.js")
|
|
def static_test():
|
|
return "js"
|
|
|
|
with app.test_client() as client:
|
|
resp = client.get("/static/test.js")
|
|
cache = resp.headers.get("Cache-Control", "")
|
|
assert "no-store" not in cache
|
|
|
|
def test_non_static_path_gets_no_cache(self):
|
|
app = _make_app()
|
|
|
|
@app.route("/page")
|
|
def page():
|
|
return "html"
|
|
|
|
with app.test_client() as client:
|
|
resp = client.get("/page")
|
|
assert "no-store" in resp.headers.get("Cache-Control", "")
|
|
|
|
def test_coep_policy_from_config(self):
|
|
app = _make_app(SECURITY_COEP_POLICY="require-corp")
|
|
|
|
@app.route("/test")
|
|
def test_route():
|
|
return "ok"
|
|
|
|
with app.test_client() as client:
|
|
resp = client.get("/test")
|
|
assert (
|
|
resp.headers["Cross-Origin-Embedder-Policy"] == "require-corp"
|
|
)
|
|
|
|
|
|
class TestCorsHeaders:
|
|
def test_wildcard_origin(self):
|
|
app = _make_app(SECURITY_CORS_ALLOWED_ORIGINS="*")
|
|
|
|
@app.route("/api/test")
|
|
def api_test():
|
|
return "ok"
|
|
|
|
with app.test_client() as client:
|
|
resp = client.get("/api/test")
|
|
assert resp.headers["Access-Control-Allow-Origin"] == "*"
|
|
assert "Access-Control-Allow-Credentials" not in resp.headers
|
|
|
|
def test_single_origin(self):
|
|
app = _make_app(
|
|
SECURITY_CORS_ALLOWED_ORIGINS="https://example.com",
|
|
SECURITY_CORS_ALLOW_CREDENTIALS=True,
|
|
)
|
|
|
|
@app.route("/api/test")
|
|
def api_test():
|
|
return "ok"
|
|
|
|
with app.test_client() as client:
|
|
resp = client.get("/api/test")
|
|
assert (
|
|
resp.headers["Access-Control-Allow-Origin"]
|
|
== "https://example.com"
|
|
)
|
|
assert resp.headers["Access-Control-Allow-Credentials"] == "true"
|
|
|
|
def test_multi_origin_reflects_matching(self):
|
|
app = _make_app(
|
|
SECURITY_CORS_ALLOWED_ORIGINS="https://a.com,https://b.com",
|
|
SECURITY_CORS_ALLOW_CREDENTIALS=True,
|
|
)
|
|
|
|
@app.route("/api/test")
|
|
def api_test():
|
|
return "ok"
|
|
|
|
with app.test_client() as client:
|
|
resp = client.get("/api/test", headers={"Origin": "https://b.com"})
|
|
assert (
|
|
resp.headers["Access-Control-Allow-Origin"] == "https://b.com"
|
|
)
|
|
assert resp.headers["Access-Control-Allow-Credentials"] == "true"
|
|
|
|
def test_multi_origin_non_matching_omits_acao(self):
|
|
app = _make_app(
|
|
SECURITY_CORS_ALLOWED_ORIGINS="https://a.com,https://b.com",
|
|
)
|
|
|
|
@app.route("/api/test")
|
|
def api_test():
|
|
return "ok"
|
|
|
|
with app.test_client() as client:
|
|
resp = client.get(
|
|
"/api/test", headers={"Origin": "https://evil.com"}
|
|
)
|
|
assert "Access-Control-Allow-Origin" not in resp.headers
|
|
|
|
def test_non_api_route_no_cors(self):
|
|
app = _make_app(SECURITY_CORS_ALLOWED_ORIGINS="*")
|
|
|
|
@app.route("/page")
|
|
def page():
|
|
return "ok"
|
|
|
|
with app.test_client() as client:
|
|
resp = client.get("/page")
|
|
assert "Access-Control-Allow-Origin" not in resp.headers
|
|
|
|
def test_max_age_header(self):
|
|
app = _make_app(SECURITY_CORS_ALLOWED_ORIGINS="*")
|
|
|
|
@app.route("/api/test")
|
|
def api_test():
|
|
return "ok"
|
|
|
|
with app.test_client() as client:
|
|
resp = client.get("/api/test")
|
|
assert resp.headers["Access-Control-Max-Age"] == "3600"
|
|
|
|
def test_vary_origin_present_when_acao_is_dynamic(self):
|
|
"""A reflected (non-wildcard) ACAO must carry Vary: Origin so shared
|
|
caches don't serve one origin's ACAO to another."""
|
|
app = _make_app(
|
|
SECURITY_CORS_ALLOWED_ORIGINS="https://a.com,https://b.com",
|
|
)
|
|
|
|
@app.route("/api/test")
|
|
def api_test():
|
|
return "ok"
|
|
|
|
with app.test_client() as client:
|
|
resp = client.get("/api/test", headers={"Origin": "https://b.com"})
|
|
assert (
|
|
resp.headers["Access-Control-Allow-Origin"] == "https://b.com"
|
|
)
|
|
vary_tokens = [
|
|
v.strip().lower()
|
|
for v in resp.headers.get("Vary", "").split(",")
|
|
]
|
|
assert "origin" in vary_tokens
|
|
|
|
def test_vary_origin_absent_for_wildcard(self):
|
|
"""A fixed wildcard ACAO is the same for every origin, so it must NOT
|
|
add Vary: Origin (which would needlessly fragment caches)."""
|
|
app = _make_app(SECURITY_CORS_ALLOWED_ORIGINS="*")
|
|
|
|
@app.route("/api/test")
|
|
def api_test():
|
|
return "ok"
|
|
|
|
with app.test_client() as client:
|
|
resp = client.get("/api/test", headers={"Origin": "https://x.com"})
|
|
assert resp.headers["Access-Control-Allow-Origin"] == "*"
|
|
vary_tokens = [
|
|
v.strip().lower()
|
|
for v in resp.headers.get("Vary", "").split(",")
|
|
]
|
|
assert "origin" not in vary_tokens
|
|
|
|
def test_vary_origin_not_duplicated(self):
|
|
"""When the response already carries a Vary header, Origin is appended
|
|
once rather than duplicated."""
|
|
from flask import make_response
|
|
|
|
app = _make_app(
|
|
SECURITY_CORS_ALLOWED_ORIGINS="https://a.com,https://b.com",
|
|
)
|
|
|
|
@app.route("/api/test")
|
|
def api_test():
|
|
resp = make_response("ok")
|
|
resp.headers["Vary"] = "Accept-Encoding"
|
|
return resp
|
|
|
|
with app.test_client() as client:
|
|
resp = client.get("/api/test", headers={"Origin": "https://b.com"})
|
|
vary_tokens = [
|
|
v.strip().lower()
|
|
for v in resp.headers.get("Vary", "").split(",")
|
|
if v.strip()
|
|
]
|
|
assert vary_tokens.count("origin") == 1
|
|
assert "accept-encoding" in vary_tokens
|
|
|
|
|
|
class TestGetCspPolicy:
|
|
def test_default_connect_src(self):
|
|
app = _make_app()
|
|
sh = SecurityHeaders()
|
|
sh.app = app
|
|
csp = sh.get_csp_policy()
|
|
assert "connect-src 'self'" in csp
|
|
|
|
def test_custom_connect_src(self):
|
|
app = _make_app(
|
|
SECURITY_CSP_CONNECT_SRC="'self' https://api.example.com"
|
|
)
|
|
sh = SecurityHeaders()
|
|
sh.app = app
|
|
csp = sh.get_csp_policy()
|
|
assert "connect-src 'self' https://api.example.com" in csp
|
|
|
|
def test_csp_contains_required_directives(self):
|
|
app = _make_app()
|
|
sh = SecurityHeaders()
|
|
sh.app = app
|
|
csp = sh.get_csp_policy()
|
|
for directive in [
|
|
"default-src",
|
|
"script-src",
|
|
"style-src",
|
|
"img-src",
|
|
"object-src 'none'",
|
|
]:
|
|
assert directive in csp
|
|
|
|
|
|
class TestGetPermissionsPolicy:
|
|
def test_disables_dangerous_features(self):
|
|
policy = SecurityHeaders.get_permissions_policy()
|
|
for feature in ["geolocation", "camera", "microphone", "payment"]:
|
|
assert f"{feature}=()" in policy
|