From 60ce82babe022ff548d869638847f4312029f815 Mon Sep 17 00:00:00 2001 From: Clara Vanacker Date: Tue, 30 Jun 2026 22:11:56 +0200 Subject: [PATCH] feat(security): strict Content-Security-Policy and security headers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Harden the browser surface and make the zero-knowledge passphrase mode CSP-safe. - A per-request security-headers middleware sets a strict CSP: no third-party origins, `script-src 'self' 'nonce-…' 'wasm-unsafe-eval'`, `style-src 'self' 'unsafe-inline'`, `object-src 'none'`, `frame-ancestors 'none'`, etc. The `wasm-unsafe-eval` directive lets the vendored hash-wasm Argon2 module run, so the ZK passphrase decryption keeps working under CSP. Also nosniff, Referrer-Policy, X-Frame-Options: DENY and a restrictive Permissions-Policy. `/docs` /`/redoc` are exempt (Swagger needs inline + CDN). - Remove inline JS so the CSP can forbid it: the one inline (theme no-flash) script carries the request nonce, and all `onsubmit="confirm(…)"` handlers become `data-confirm` wired by a small vendored `confirm.js`. Verified in a real browser: the theme applies, delete confirmations work, and an Argon2-passphrase private item decrypts in-page — with zero CSP violations. Tests assert the CSP (wasm-unsafe-eval + per-request nonce matching the inline script) and the docs exemption. Full suite 215 passed; ruff + mypy(strict) green. --- README.md | 2 +- app/api/main.py | 2 ++ app/api/middleware.py | 49 ++++++++++++++++++++++++++++++ docs/roadmap.md | 4 +++ static/confirm.js | 16 ++++++++++ templates/base.html | 3 +- templates/channels/form.html | 2 +- templates/channels/list.html | 2 +- templates/escalation/list.html | 2 +- templates/hosts/detail.html | 4 +-- templates/hosts/item_detail.html | 2 +- templates/maintenances/detail.html | 2 +- templates/maintenances/list.html | 2 +- templates/monitors/detail.html | 4 +-- tests/test_security.py | 42 +++++++++++++++++++++++++ 15 files changed, 126 insertions(+), 12 deletions(-) create mode 100644 app/api/middleware.py create mode 100644 static/confirm.js create mode 100644 tests/test_security.py diff --git a/README.md b/README.md index 60ab309..1cf98e1 100644 --- a/README.md +++ b/README.md @@ -122,7 +122,7 @@ open and unacknowledged: - **Server-side collection** — items have a `source` (trapper / SNMP); the scheduler polls due SNMP items (any OID) from the host's address. - **Agent ingestion** — per-owner ingest tokens and a token-authenticated `POST /api/ingest`; a dependency-free agent (`ghostmon agent run`) reports system metrics from `/proc`. - **Notifications** — email (SMTP) and webhooks, attached per monitor or host, **severity-routed**, and fire-and-forget so a slow SMTP server never stalls probing. -- **Privacy** — encryption at rest for secrets *and* alert targets; zero-knowledge private items; no telemetry, no third-party calls, hashed ingest tokens, bounded retention. +- **Privacy** — encryption at rest for secrets *and* alert targets; zero-knowledge private items; a strict Content-Security-Policy (no third-party origins, nonce'd inline script, `wasm-unsafe-eval` for the in-browser Argon2); no telemetry, no third-party calls, hashed ingest tokens, bounded retention. - **Maintenance windows** — one-shot or recurring (`cron`) alert silencing. - **Auth** — local accounts (argon2, JWT) and **OIDC SSO** (any OpenID Connect provider) on top, sharing the same session; SSO-only accounts need no password. diff --git a/app/api/main.py b/app/api/main.py index 69c51e9..bf608ba 100644 --- a/app/api/main.py +++ b/app/api/main.py @@ -7,6 +7,7 @@ from prometheus_fastapi_instrumentator import Instrumentator from starlette.middleware.sessions import SessionMiddleware +from app.api.middleware import add_security_headers from app.api.routes import api_router, health_router from app.api.routes.web import router as web_router from app.core.config import get_settings @@ -51,6 +52,7 @@ def create_app(*, lifespan: Lifespan = scheduler_lifespan) -> FastAPI: same_site="lax", https_only=settings.app_env == "production", ) + add_security_headers(app) Instrumentator( should_group_status_codes=True, diff --git a/app/api/middleware.py b/app/api/middleware.py new file mode 100644 index 0000000..2900772 --- /dev/null +++ b/app/api/middleware.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +import secrets +from collections.abc import Awaitable, Callable + +from fastapi import FastAPI, Request, Response + +# Swagger/ReDoc need a CDN + inline scripts; don't impose the app CSP on them. +_CSP_EXEMPT_PREFIXES = ("/docs", "/redoc", "/openapi.json") + + +def _content_security_policy(nonce: str) -> str: + # Strict by default. `wasm-unsafe-eval` lets the vendored hash-wasm Argon2 module + # run (zero-knowledge passphrase mode); the per-request nonce admits only our one + # inline (theme) script. Inline styles are pervasive and low-risk, so style keeps + # 'unsafe-inline'. No third-party origins — consistent with the no-telemetry stance. + return "; ".join( + ( + "default-src 'self'", + f"script-src 'self' 'nonce-{nonce}' 'wasm-unsafe-eval'", + "style-src 'self' 'unsafe-inline'", + "img-src 'self' data:", + "connect-src 'self'", + "font-src 'self'", + "object-src 'none'", + "base-uri 'self'", + "form-action 'self'", + "frame-ancestors 'none'", + ) + ) + + +def add_security_headers(app: FastAPI) -> None: + @app.middleware("http") + async def _security_headers( + request: Request, call_next: Callable[[Request], Awaitable[Response]] + ) -> Response: + nonce = secrets.token_urlsafe(16) + request.state.csp_nonce = nonce + response = await call_next(request) + if not request.url.path.startswith(_CSP_EXEMPT_PREFIXES): + response.headers.setdefault("Content-Security-Policy", _content_security_policy(nonce)) + response.headers.setdefault("X-Content-Type-Options", "nosniff") + response.headers.setdefault("Referrer-Policy", "same-origin") + response.headers.setdefault("X-Frame-Options", "DENY") + response.headers.setdefault( + "Permissions-Policy", "geolocation=(), microphone=(), camera=()" + ) + return response diff --git a/docs/roadmap.md b/docs/roadmap.md index 319d2f4..bf903a8 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -79,6 +79,10 @@ GhostMonitor's reason to exist over a plain Zabbix clone is privacy (ghost-suite interoperable across the CLI and the browser). In-browser decryption via Web Crypto + hash-wasm; reference CLI `ghostmon zk genkey|encrypt|decrypt [--key|--password]`. Verified end-to-end in a real browser. +- ✅ **Hardened browser surface**: a strict Content-Security-Policy (no third-party + origins, per-request nonce for the one inline script, inline event handlers removed) + with `wasm-unsafe-eval` so the zero-knowledge Argon2 passphrase mode runs under it, + plus `nosniff` / `X-Frame-Options: DENY` / `Referrer-Policy` / `Permissions-Policy`. - Baseline: no telemetry, no third-party calls, minimal alert payloads, hashed ingest tokens, bounded retention. diff --git a/static/confirm.js b/static/confirm.js new file mode 100644 index 0000000..4806ad6 --- /dev/null +++ b/static/confirm.js @@ -0,0 +1,16 @@ +// Confirmation dialogs without inline handlers (so the CSP can forbid inline JS). +// Any
asks before submitting. +(function () { + document.addEventListener( + "submit", + function (event) { + var form = event.target; + if (!form || !form.getAttribute) return; + var message = form.getAttribute("data-confirm"); + if (message && !window.confirm(message)) { + event.preventDefault(); + } + }, + true, + ); +})(); diff --git a/templates/base.html b/templates/base.html index 1f56c92..95eb6b8 100644 --- a/templates/base.html +++ b/templates/base.html @@ -3,7 +3,7 @@ - + diff --git a/templates/channels/form.html b/templates/channels/form.html index 5bc4d38..88756e0 100644 --- a/templates/channels/form.html +++ b/templates/channels/form.html @@ -75,7 +75,7 @@

{% if channel %} + data-confirm="Delete channel {{ channel.name }}?"> {% endif %} diff --git a/templates/channels/list.html b/templates/channels/list.html index 910b94f..2741e23 100644 --- a/templates/channels/list.html +++ b/templates/channels/list.html @@ -44,7 +44,7 @@

Channels{{ channels|length }} total

+ data-confirm="Delete channel {{ c.name }}?">
diff --git a/templates/escalation/list.html b/templates/escalation/list.html index 85be036..6ff7af4 100644 --- a/templates/escalation/list.html +++ b/templates/escalation/list.html @@ -32,7 +32,7 @@

Policies {{ policies|length }}

+ data-confirm="Delete policy {{ p.name }}?">
diff --git a/templates/hosts/detail.html b/templates/hosts/detail.html index 00a93a1..f202d45 100644 --- a/templates/hosts/detail.html +++ b/templates/hosts/detail.html @@ -55,7 +55,7 @@

Settings

+ data-confirm="Delete host {{ host.name }} and all its items?">
@@ -150,7 +150,7 @@

Items {{ item_views|length }}

+ data-confirm="Delete item {{ iv.item.key }}?">
diff --git a/templates/hosts/item_detail.html b/templates/hosts/item_detail.html index 10d963a..4edcec2 100644 --- a/templates/hosts/item_detail.html +++ b/templates/hosts/item_detail.html @@ -44,7 +44,7 @@

Triggers {{ triggers|length }}

{{ t.state.value }}
+ data-confirm="Delete this trigger?">
diff --git a/templates/maintenances/detail.html b/templates/maintenances/detail.html index 6b0eb75..beb8be5 100644 --- a/templates/maintenances/detail.html +++ b/templates/maintenances/detail.html @@ -76,7 +76,7 @@

Settings

+ data-confirm="Delete maintenance {{ maintenance.title }}?">
diff --git a/templates/maintenances/list.html b/templates/maintenances/list.html index d253db5..03d1f37 100644 --- a/templates/maintenances/list.html +++ b/templates/maintenances/list.html @@ -43,7 +43,7 @@

Maintenance windows{{ maintenances|length }} total{{ 'yes' if m.is_active else 'no' }}
+ data-confirm="Delete maintenance {{ m.title }}?">
diff --git a/templates/monitors/detail.html b/templates/monitors/detail.html index b26b527..0f6ae58 100644 --- a/templates/monitors/detail.html +++ b/templates/monitors/detail.html @@ -67,7 +67,7 @@

Settings

+ data-confirm="Delete this monitor?">
@@ -132,7 +132,7 @@

Triggers {{ triggers|length }}

{{ t.state.value }}
+ data-confirm="Delete this trigger?">
diff --git a/tests/test_security.py b/tests/test_security.py new file mode 100644 index 0000000..468f844 --- /dev/null +++ b/tests/test_security.py @@ -0,0 +1,42 @@ +"""Security headers: a strict CSP (with wasm-unsafe-eval + a per-request nonce).""" + +from __future__ import annotations + +import re + +import httpx + + +async def test_csp_and_security_headers_on_a_page(web_client: httpx.AsyncClient) -> None: + resp = await web_client.get("/login") + assert resp.status_code == 200 + + csp = resp.headers["content-security-policy"] + assert "default-src 'self'" in csp + assert "'wasm-unsafe-eval'" in csp # lets the Argon2 wasm run for ZK passphrase mode + assert "frame-ancestors 'none'" in csp + assert "object-src 'none'" in csp + + # The nonce in the header admits exactly the inline theme script in the page. + match = re.search(r"'nonce-([^']+)'", csp) + assert match is not None + assert f'nonce="{match.group(1)}"' in resp.text + + assert resp.headers["x-content-type-options"] == "nosniff" + assert resp.headers["x-frame-options"] == "DENY" + assert "geolocation=()" in resp.headers["permissions-policy"] + + +async def test_csp_nonce_is_per_request(web_client: httpx.AsyncClient) -> None: + a = (await web_client.get("/login")).headers["content-security-policy"] + b = (await web_client.get("/login")).headers["content-security-policy"] + assert a != b # a fresh nonce each request + + +async def test_docs_are_exempt_from_csp(web_client: httpx.AsyncClient) -> None: + resp = await web_client.get("/docs") + assert resp.status_code == 200 + # Swagger UI needs inline scripts/CDN; the app CSP must not be imposed on it. + assert "content-security-policy" not in resp.headers + # …but the cheap hardening headers still apply. + assert resp.headers["x-content-type-options"] == "nosniff"