6.6 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: the server never fetches these URLs. They are stored as
opaque strings and only ever rendered as <a href> / <img src> in the
browser's context, exactly like a normal webpage. This removes SSRF as a
viable vector for this field entirely, at the cost of not offering
server-side attachment proxying/caching (acceptable per the "attachment
metadata only" requirement).
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)