# 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 `` 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 `