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