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)
This commit is contained in:
1 parent
a82c29a69d
commit
079013730b
10 files changed
+332
-32
No files matched your search
@@ -85,11 +85,15 @@ full protocol clone (and some of these are explicit non-goals — see
|
||||
into emoji prefixes. If you want an emoji in the title, put it directly in
|
||||
the `Title:` header or JSON `title` field, same as ntfy's own
|
||||
emoji-in-title convention.
|
||||
- **Attachments.** `Attach:` is treated as an image URL the browser loads
|
||||
directly — there's no server-side download, proxying, or file upload.
|
||||
This is a deliberate security choice (see the SSRF section of
|
||||
[docs/phase1/02-threat-model.md](phase1/02-threat-model.md)), not a
|
||||
temporary gap.
|
||||
- **Attachments.** `Attach:` is treated as an image URL. The server fetches
|
||||
and proxies it (`GET /api/v1/notifications/:id/image`) rather than
|
||||
handing the raw URL to the browser — deliberately so 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. Still no arbitrary file
|
||||
upload, and the fetch is narrowly scoped (see the SSRF section of
|
||||
[docs/phase1/02-threat-model.md](phase1/02-threat-model.md)) — only the
|
||||
fixed URL already stored on a notification the caller can see, never a
|
||||
client-supplied one.
|
||||
- **Auth is required by default.** Unlike a bare ntfy instance, publishing
|
||||
always needs a valid `Authorization: Bearer` token — there's no
|
||||
anonymous-publish mode.
|
||||
|
||||
@@ -27,7 +27,7 @@ These are the "major implementation decisions" the process asks to pause on:
|
||||
| 2 | ORM / migrations | **Drizzle ORM + drizzle-kit** | Typesafe, SQL-first, no native binary/engine (unlike Prisma) — simpler in a small Alpine container, plain-SQL migration files you can read and back up. |
|
||||
| 3 | Realtime "instant in UI" delivery | **Server-Sent Events (SSE)**, in-process pub/sub, no Redis | Single-instance app; SSE is one-way (all we need), works over plain HTTP through a reverse proxy, avoids the complexity of a WebSocket + no new infra. |
|
||||
| 4 | VAPID key storage | **Postgres `system_settings` table**, generated on first boot if absent | Already-durable volume; no separate key file to remember to back up — `pg_dump` covers it automatically. |
|
||||
| 5 | Attachments / image handling | **No server-side fetch or proxy.** `image_url`/`external_url` are stored as plain URLs and loaded directly by the browser (`<img src>`), never fetched by the server. | Eliminates SSRF risk entirely for this feature per the security requirements, at the cost of not supporting file uploads in v1 (can be added later as an explicit, scoped feature). |
|
||||
| 5 | Attachments / image handling | `external_url` is stored as a plain URL and rendered as `<a href>`, never fetched server-side. `image_url` **is** fetched server-side and proxied (`GET /api/v1/notifications/:id/image`) so a LAN-only image source still loads for a client off that LAN — narrowly scoped (no client-supplied URL, requires a scoped token to have set it, timeout + size cap), not a general-purpose fetch proxy. See `02-threat-model.md`'s SSRF section. | A homelab client (phone on cellular) still needs to see camera snapshots from LAN-only sources like Frigate; a pure direct-browser-load design can't do that. Doesn't support arbitrary file uploads in v1 (can be added later as an explicit, scoped feature). |
|
||||
| 6 | Webhook mapping | MVP ships a **fixed simple mapping** (`{topic, title, message, url?}` JSON body, or ntfy-style headers), not a configurable templating engine | Spec says "goal is easy migration, not full protocol cloning"; a mapping UI is called out as a later enhancement. |
|
||||
| 7 | Topic access control | Topics are **visible to all authenticated users by default**; an admin can mark a topic `restricted` and grant per-user access | Matches "single/small household" simplicity principle while still satisfying "users can browse allowed topics." |
|
||||
|
||||
|
||||
@@ -72,12 +72,32 @@ is still gated by needing a valid token.
|
||||
**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).
|
||||
**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.
|
||||
|
||||
Reference in new issue
Block a user