Initial commit
This commit is contained in:
commit
38c24fc9dc
166 files changed
+22002
No files matched your search
@@ -0,0 +1,130 @@
|
||||
# 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](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)
|
||||
Reference in new issue
Block a user