Files
Notizarr/docs/phase1/02-threat-model.md
siliconsolder 079013730b Add notification image proxy, expand README, fix compose env-file handling
- Proxy notification image_url server-side (GET /api/v1/notifications/:id/image)
  instead of loading it directly in the browser, so LAN-only image sources
  (e.g. Frigate snapshots) still load for clients off that network
- Tighten CSP img-src to 'self' now that images are same-origin
- Update threat model / architecture docs to match the new image-fetch design
- Expand README: Home Assistant quick-start, Pocket ID/OIDC walkthrough,
  Pangolin note, backup automation, new troubleshooting entries
- Fix docker-compose.yml: document that --env-file .env must be passed
  explicitly (Compose's implicit .env lookup was unreliable with -f from
  a different directory)
- Fix image-proxy tests to build a real session cookie (AUTH_MODE=oidc
  applies inside the test container too, so requireAuth needs one)
2026-09-07 18:49:45 +00:00

7.9 KiB

02 — Threat Model

Trust domains

Domain Trust level Notes
A. Public internet (browsers) Untrusted until OIDC-authenticated Only reaches listener A, via Pangolin
B. Pangolin reverse proxy Trusted only when its source IP is in TRUSTED_PROXY_CIDRS and AUTH_MODE=proxy Otherwise treated as domain A
C. LAN services (Home Assistant, Seerr, etc.) Semi-trusted network, but the real boundary is the API token Reaches listener B only
D. notizarr app Mediates every access
E. Postgres Fully trusted, but only reachable from the notizarr container on the internal Docker network Never published to host

Assets to protect

  • Notification history (may contain home automation state, personal info)
  • API tokens (plaintext shown once, hash stored)
  • OIDC client secret
  • VAPID private key
  • Push subscription endpoints (identify a user's device to a push service)
  • Session cookies
  • Admin functions (user/topic/token management)

Threats and mitigations

Spoofed identity via proxy headers

Threat: an attacker on the LAN or a compromised container sends X-Auth-Request-Email directly to notizarr, bypassing Pangolin. Mitigation: proxy-header auth is only trusted when the direct TCP peer address (not a forwarded-for chain) is inside TRUSTED_PROXY_CIDRS. If AUTH_MODE=oidc, proxy headers are never read at all. Document that Pangolin must connect directly to notizarr (no additional untrusted hop in between).

Forged/leaked OIDC tokens

Threat: token replay, audience confusion, state/nonce fixation. Mitigation: Authorization Code + PKCE (S256) via openid-client; validate iss, aud, nonce, exp, signature against the issuer's JWKS; state/nonce stored in a short-lived signed httpOnly cookie scoped to the auth flow only.

Session hijacking / CSRF

Threat: stolen session cookie, or a malicious site making authenticated requests on the user's behalf. Mitigation: httpOnly, Secure, SameSite=Lax session cookie. All mutating requests (POST/PATCH/DELETE on listener A) additionally require a custom header (X-Requested-With: notizarr) and are rejected if the Origin header doesn't match PUBLIC_BASE_URL — a cross-site form post can't set custom headers, so this blocks CSRF without needing a token handshake.

API token theft / abuse

Threat: a leaked LAN token is used to flood topics, read data it shouldn't, or pivot into admin functions. Mitigation: tokens are write-only credentials — they can only hit listener B's publish endpoints, never any read/admin endpoint on listener A, even if presented there. Tokens are scoped (topics, source name, source CIDR, expiry). Only a SHA-256 hash is stored; plaintext is shown once at creation/rotation. Per-token rate limiting caps blast radius. Revocation is immediate (checked on every request, no caching of revoked status).

Ingestion listener exposed to the internet by misconfiguration

Threat: admin accidentally publishes 8081 to 0.0.0.0 on a port-forwarded router, or Pangolin is misrouted to it. Mitigation: defense in depth beyond "the admin will firewall it": optional INGESTION_ALLOWED_CIDRS env var enforced in-app (default: RFC1918 ranges) rejects any publish request from outside those CIDRs regardless of token validity. Listener B never serves the SPA or session auth, so even a fully exposed 8081 can't be used to browse notification history or reach admin functions — worst case is unauthorized token-scoped publishing, which is still gated by needing a valid token.

SSRF via notification content

Threat: a malicious/compromised publisher sets image_url or url to an internal address (http://169.254.169.254/..., http://postgres:5432, etc.), hoping the server fetches it. Mitigation, url: never fetched server-side. Stored as an opaque string and only ever rendered as <a href> in the browser's context, exactly like a normal webpage. Mitigation, image_url: unlike url, this is fetched server-side — GET /api/v1/notifications/:id/image — so that a LAN-only image source (a camera snapshot, say) still loads for a client that's off that LAN, since the server sits on it persistently; the browser is deliberately kept from ever needing to reach an arbitrary third-party host itself (also closes a tracking-pixel-style privacy leak the pure client-side-load approach had). This is a narrower, more deliberate SSRF surface than a general-purpose fetch proxy, not the absence of one:

  • No client-supplied URL parameter — only the fixed value already stored on a notification, and only after confirming the caller can see that notification (same topic-visibility check as reading it at all).
  • Setting that value in the first place requires a valid, scoped API token — the threat actor already needs ingestion access, same prerequisite as any other publish-time attack in this model.
  • Request has a 10s timeout, a 20MB response-size cap, and the response is only ever forwarded back to the same authorized viewer as opaque image bytes (never reflected into a response an attacker could read directly, and rejected outright if the upstream Content-Type isn't image/*).
  • Residual risk: a scoped token holder could still use this as a blind probe (timing/success of the fetch) against internal hosts the server can reach. Accepted for a single-tenant homelab deployment where publishing already requires a trusted, scoped credential; revisit if Notizarr is ever exposed to less-trusted publishers.

XSS via notification content

Threat: title/message containing <script> or event handlers. Mitigation: the client renders title/message as plain text only (React's default escaping); a minimal, allowlist-based linkifier turns bare URLs into links but never injects raw HTML (dangerouslySetInnerHTML is not used anywhere in the notification rendering path). No notification field is ever interpreted as HTML/Markdown-with-HTML.

Rate-limit exhaustion of the push quota

Threat: a noisy/misbehaving source (the original ntfy incident: ~1 notification/minute) exhausts Apple/Google's push quota or spams the user. Mitigation: per-token and per-IP rate limits (burst + sustained) on listener B; noisy-source detection surfaced on the admin dashboard; rejections return 429 with a clear body and are logged — never silently dropped. See 04-api-design.md for exact limits.

Denial of service via large payloads

Mitigation: JSON body size cap (e.g. 64 KB) on ingestion endpoints; Fastify's built-in body-limit; oversized requests rejected with 413 before being parsed.

Secret leakage via logs

Mitigation: structured logging (pino) with a redaction list covering Authorization, Cookie, token_hash, OIDC client_secret, VAPID private key. Never log full notification bodies at info level in a way that ends up in metrics labels (metrics use topic id/slug cardinality only, never message content).

Privilege escalation between roles

Mitigation: role is only settable by an existing admin via the admin API; the very first user to log in when the users table is empty is auto-promoted to admin (documented "bootstrap admin" behavior) so there's no chicken-and-egg problem, and this path is disabled once at least one admin exists.

Retention / data minimization

Notifications are deleted per-topic retention policy by an interval job; audit_log is exempt from automatic retention deletion (kept for security review) but is not unbounded — documented in Phase 8 as configurable.

Explicitly out of scope for v1

  • Multi-tenant isolation beyond role + topic access control (this is a household app, not a SaaS)
  • End-to-end encryption of notification content at rest (Postgres-level encryption is the operator's responsibility, e.g. disk encryption on the Unraid array)
  • Protecting against a fully compromised Postgres container (it's inside the same trust boundary as the app by design)