Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions app/api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
49 changes: 49 additions & 0 deletions app/api/middleware.py
Original file line number Diff line number Diff line change
@@ -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
4 changes: 4 additions & 0 deletions docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
16 changes: 16 additions & 0 deletions static/confirm.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// Confirmation dialogs without inline handlers (so the CSP can forbid inline JS).
// Any <form data-confirm="message"> 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,
);
})();
3 changes: 2 additions & 1 deletion templates/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script>
<script nonce="{{ request.state.csp_nonce }}">
// Apply the theme before CSS resolves so the page never flashes the wrong
// palette. Stored choice wins; otherwise follow the OS preference, falling
// back to dark (which is also what :root defaults to if JS is disabled).
Expand Down Expand Up @@ -58,5 +58,6 @@

{% block scripts %}{% endblock %}
<script src="/static/theme.js?v={{ v }}" defer></script>
<script src="/static/confirm.js?v={{ v }}" defer></script>
</body>
</html>
2 changes: 1 addition & 1 deletion templates/channels/form.html
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ <h1 class="hero-title">

{% if channel %}
<form method="post" action="/channels/{{ channel.id }}/delete" class="form-inline form-danger"
onsubmit="return confirm('Delete channel {{ channel.name }}?');">
data-confirm="Delete channel {{ channel.name }}?">
<button type="submit" class="btn-danger">Delete channel</button>
</form>
{% endif %}
Expand Down
2 changes: 1 addition & 1 deletion templates/channels/list.html
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ <h2>Channels<span class="count">{{ channels|length }} total</span></h2>
<button type="submit" class="btn-new btn-muted">Test</button>
</form>
<form method="post" action="/channels/{{ c.id }}/delete" class="form-inline"
onsubmit="return confirm('Delete channel {{ c.name }}?');">
data-confirm="Delete channel {{ c.name }}?">
<button type="submit" class="btn-danger">Delete</button>
</form>
</td>
Expand Down
2 changes: 1 addition & 1 deletion templates/escalation/list.html
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ <h2>Policies <span class="count">{{ policies|length }}</span></h2>
</td>
<td class="row-actions">
<form method="post" action="/escalation/{{ p.id }}/delete" class="form-inline"
onsubmit="return confirm('Delete policy {{ p.name }}?');">
data-confirm="Delete policy {{ p.name }}?">
<button type="submit" class="btn-new btn-muted">Delete</button>
</form>
</td>
Expand Down
4 changes: 2 additions & 2 deletions templates/hosts/detail.html
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ <h2>Settings</h2>
</div>
</form>
<form method="post" action="/hosts/{{ host.id }}/delete" class="form-inline form-danger"
onsubmit="return confirm('Delete host {{ host.name }} and all its items?');">
data-confirm="Delete host {{ host.name }} and all its items?">
<button type="submit" class="btn-danger">Delete host</button>
</form>
</section>
Expand Down Expand Up @@ -150,7 +150,7 @@ <h2>Items <span class="count">{{ item_views|length }}</span></h2>
</td>
<td class="row-actions">
<form method="post" action="/hosts/{{ host.id }}/items/{{ iv.item.id }}/delete" class="form-inline"
onsubmit="return confirm('Delete item {{ iv.item.key }}?');">
data-confirm="Delete item {{ iv.item.key }}?">
<button type="submit" class="btn-new btn-muted">Delete</button>
</form>
</td>
Expand Down
2 changes: 1 addition & 1 deletion templates/hosts/item_detail.html
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ <h2>Triggers <span class="count">{{ triggers|length }}</span></h2>
<td><span class="tstate tstate-{{ t.state.value }}">{{ t.state.value }}</span></td>
<td class="row-actions">
<form method="post" action="/hosts/{{ host.id }}/items/{{ item.id }}/triggers/{{ t.id }}/delete" class="form-inline"
onsubmit="return confirm('Delete this trigger?');">
data-confirm="Delete this trigger?">
<button type="submit" class="btn-new btn-muted">Delete</button>
</form>
</td>
Expand Down
2 changes: 1 addition & 1 deletion templates/maintenances/detail.html
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ <h2>Settings</h2>
</div>
</form>
<form method="post" action="/maintenances/{{ maintenance.id }}/delete" class="form-inline form-danger"
onsubmit="return confirm('Delete maintenance {{ maintenance.title }}?');">
data-confirm="Delete maintenance {{ maintenance.title }}?">
<button type="submit" class="btn-danger">Delete window</button>
</form>
</section>
Expand Down
2 changes: 1 addition & 1 deletion templates/maintenances/list.html
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ <h2>Maintenance windows<span class="count">{{ maintenances|length }} total</span
<td>{{ 'yes' if m.is_active else 'no' }}</td>
<td class="row-actions">
<form method="post" action="/maintenances/{{ m.id }}/delete" class="form-inline"
onsubmit="return confirm('Delete maintenance {{ m.title }}?');">
data-confirm="Delete maintenance {{ m.title }}?">
<button type="submit" class="btn-danger">Delete</button>
</form>
</td>
Expand Down
4 changes: 2 additions & 2 deletions templates/monitors/detail.html
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ <h2>Settings</h2>
</div>
</form>
<form method="post" action="/monitors/{{ monitor.id }}/delete" class="form-inline form-danger"
onsubmit="return confirm('Delete this monitor?');">
data-confirm="Delete this monitor?">
<button type="submit" class="btn-danger">Delete monitor</button>
</form>
</section>
Expand Down Expand Up @@ -132,7 +132,7 @@ <h2>Triggers <span class="count">{{ triggers|length }}</span></h2>
<td><span class="tstate tstate-{{ t.state.value }}">{{ t.state.value }}</span></td>
<td class="row-actions">
<form method="post" action="/monitors/{{ monitor.id }}/triggers/{{ t.id }}/delete" class="form-inline"
onsubmit="return confirm('Delete this trigger?');">
data-confirm="Delete this trigger?">
<button type="submit" class="btn-new btn-muted">Delete</button>
</form>
</td>
Expand Down
42 changes: 42 additions & 0 deletions tests/test_security.py
Original file line number Diff line number Diff line change
@@ -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"
Loading