From 079013730b546709a78c3d395dbd1b7182e94902 Mon Sep 17 00:00:00 2001 From: siliconsolder Date: Mon, 7 Sep 2026 18:49:45 +0000 Subject: [PATCH] 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) --- README.md | 59 ++++++- apps/server/src/listeners/web.ts | 21 ++- .../image-proxy.integration.test.ts | 147 ++++++++++++++++++ .../src/modules/notifications/routes.ts | 64 ++++++++ .../notifications/NotificationRow.tsx | 6 +- apps/web/src/pages/NotificationDetailPage.tsx | 2 +- docker/docker-compose.yml | 17 +- docs/migration-ntfy.md | 14 +- docs/phase1/00-README.md | 2 +- docs/phase1/02-threat-model.md | 32 +++- 10 files changed, 332 insertions(+), 32 deletions(-) create mode 100644 apps/server/src/modules/notifications/image-proxy.integration.test.ts diff --git a/README.md b/README.md index 58c22cb..194f594 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,49 @@ the actual provider details (issuer URL, client ID/secret, redirect URL, scopes) are editable any time from **Admin → Auth** — no further env file edits or restarts needed for those. +### Setting up OIDC with Pocket ID + +Notizarr handles the OIDC login flow itself (Authorization Code + PKCE) — +it does not delegate auth to the reverse proxy via trusted headers, so +Pangolin (or whatever sits in front of it) only needs to forward HTTP, no +auth injection. + +1. In Pocket ID, create a new OIDC client: + - **Name**: `Notizarr` + - **Client's start URL**: your public Notizarr URL (e.g. + `https://notify.example.com`) + - **Callback URLs**: `https://notify.example.com/api/v1/auth/oidc/callback` + — this exact path is hardcoded server-side + ([apps/server/src/modules/auth/routes.ts](apps/server/src/modules/auth/routes.ts)), + it has to match exactly + - **Public Client**: **off** — Notizarr is a confidential/server-side + client and needs a client secret. Leaving this on means Pocket ID + never issues one. + - **PKCE**: **on** — Notizarr always sends a PKCE challenge regardless, + so this just means Pocket ID requires it too + - Everything else (logout callback URLs, skip consent, logo) is + optional/cosmetic +2. Save, then open the client back up and find its **Referenser** / + Credentials tab — client secrets in Pocket ID live there, not on the + creation form, and are only shown once when generated. +3. In Notizarr, go to **Admin → Auth** and fill in the Issuer URL, Client + ID, Client Secret, Redirect URL (same value as the Callback URL above), + and Scopes (`openid profile email`). Save. +4. Set `AUTH_MODE=oidc` in `.env` and restart the container — this one + setting is the only part that still needs a restart, since it changes + which routes the server exposes at boot. Everything from step 3 takes + effect immediately without one. +5. Log in once. If you'd previously been running `AUTH_MODE=disabled`, its + implicit "Local Admin" user already claimed the bootstrap-admin slot + (the very first user *ever created*), so your real login likely comes + back as a normal user — see the "First-ever login should be an admin + but isn't" entry under [Troubleshooting](#troubleshooting) to fix that. + +**Pangolin**: point it at the web listener only (`notizarr:8080` on the +same Docker network, or the host's `PUBLIC_BASE_URL`) — never at `:8081`, +the LAN-only ingestion listener. See [Unraid deployment](#unraid-deployment-production) +below. + All mutating requests on the web listener (`POST`/`PUT`/`PATCH`/`DELETE` under `/api/`) require a `X-Requested-With: notizarr` header — this is the CSRF defense described in the threat model, not optional. The ingestion @@ -74,7 +117,7 @@ use the dev Docker Compose stack below, which includes one. ## Local development via Docker ```bash -docker compose -f docker/docker-compose.yml up -d +docker compose --env-file .env -f docker/docker-compose.yml up -d ``` Brings up `notizarr-postgres-dev` and `notizarr-dev` (hot-reload: the repo @@ -91,7 +134,7 @@ anonymous `node_modules` volumes in one go, or the container keeps running against the old, pre-change `node_modules`: ```bash -docker compose -f docker/docker-compose.yml up -d --build --force-recreate -V +docker compose --env-file .env -f docker/docker-compose.yml up -d --build --force-recreate -V ``` Override host ports if they also collide, either inline or via a @@ -99,7 +142,7 @@ Override host ports if they also collide, either inline or via a ```bash HOST_PORT_WEB=28080 HOST_PORT_INGEST=28081 HOST_PORT_PG=25432 \ - docker compose -f docker/docker-compose.yml up -d + docker compose --env-file .env -f docker/docker-compose.yml up -d ``` Check health: @@ -115,7 +158,7 @@ this writes real PNGs into `apps/web/public/icons/`, committed to the repo rather than regenerated on every build): ```bash -docker compose -f docker/docker-compose.yml exec notizarr pnpm --filter @notizarr/web generate-icons +docker compose --env-file .env -f docker/docker-compose.yml exec notizarr pnpm --filter @notizarr/web generate-icons ``` ## Unraid deployment (production) @@ -242,6 +285,14 @@ work the same way. ## Troubleshooting +- **`docker compose ... exec/logs/etc` fails with `required variable + POSTGRES_PASSWORD is missing a value`.** Compose's implicit `.env` + lookup when the compose file is passed via `-f docker/docker-compose.yml` + from the repo root doesn't reliably resolve to the repo root's `.env` — + it's ambiguous/version-dependent, and got this wrong in practice. Always + pass `--env-file .env` explicitly, on every invocation against this + file, not just `up` (see the comment at the top of + `docker/docker-compose.yml`). - **A `docker compose up -d --build` didn't pick up a dependency change.** Anonymous `node_modules` volumes are reused by default — add `-V --force-recreate` (see [Local development via diff --git a/apps/server/src/listeners/web.ts b/apps/server/src/listeners/web.ts index 6e2ce51..41a3946 100644 --- a/apps/server/src/listeners/web.ts +++ b/apps/server/src/listeners/web.ts @@ -43,19 +43,22 @@ export async function buildWebApp(): Promise { const app = Fastify({ logger: loggerOptions, bodyLimit: 1024 * 1024 }) // CSP/HSTS/frame-ancestors per docs/phase1/00-README.md's security - // requirements. crossOriginEmbedderPolicy is off because notification - // image_url values are arbitrary third-party https URLs the browser - // loads directly (docs/phase1/02-threat-model.md's SSRF mitigation) — - // COEP would require CORP headers from origins we don't control. HSTS is - // only sent in production so a plain-HTTP dev/LAN setup never gets stuck - // with a stale HSTS pin from testing. + // requirements. imgSrc is 'self' only — notification images are proxied + // through GET /api/v1/notifications/:id/image (modules/notifications/routes.ts) + // rather than the browser loading notification.imageUrl directly, so the + // browser never needs to reach an arbitrary third-party host itself. That + // also closes a minor privacy leak the old direct-load approach had (a + // topic's image_url could otherwise act as a read-receipt/tracking pixel + // revealing the viewer's IP to whatever host it pointed at). HSTS is only + // sent in production so a plain-HTTP dev/LAN setup never gets stuck with + // a stale HSTS pin from testing. await app.register(fastifyHelmet, { contentSecurityPolicy: { directives: { defaultSrc: ["'self'"], scriptSrc: ["'self'"], styleSrc: ["'self'", "'unsafe-inline'"], - imgSrc: ["'self'", 'https:', 'data:'], + imgSrc: ["'self'"], connectSrc: ["'self'"], fontSrc: ["'self'"], objectSrc: ["'none'"], @@ -64,6 +67,10 @@ export async function buildWebApp(): Promise { formAction: ["'self'"], }, }, + // Left off rather than flipped on speculatively — everything the app + // loads is same-origin now, so COEP's require-corp default would + // likely be safe, but that's an untested behavior change and not what + // prompted this edit; revisit deliberately if it's ever worth it. crossOriginEmbedderPolicy: false, hsts: config.isProduction ? { maxAge: 15552000, includeSubDomains: true } : false, }) diff --git a/apps/server/src/modules/notifications/image-proxy.integration.test.ts b/apps/server/src/modules/notifications/image-proxy.integration.test.ts new file mode 100644 index 0000000..13e4994 --- /dev/null +++ b/apps/server/src/modules/notifications/image-proxy.integration.test.ts @@ -0,0 +1,147 @@ +import { randomUUID } from 'node:crypto' +import http from 'node:http' +import type { AddressInfo } from 'node:net' +import type { FastifyInstance } from 'fastify' +import { eq } from 'drizzle-orm' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { db, pool } from '../../db/client.js' +import { runMigrations } from '../../db/migrate.js' +import { notifications, topics, users } from '../../db/schema.js' +import { buildWebApp } from '../../listeners/web.js' +import { encryptJson } from '../../lib/crypto-session.js' +import { getSetting } from '../../db/settings.js' +import { createNotification } from './repository.js' + +// Integration test — same requirements/run command as +// modules/tokens/repository.integration.test.ts. Runs against a tiny local +// HTTP server standing in for a LAN camera/service, so it doesn't depend on +// network access or a real Frigate instance being up. +// +// The image endpoint requires a real session (requireAuth calls +// getSessionUser whenever AUTH_MODE=oidc, which is what this test's own +// container runs under) — a plain app.inject() with no cookie gets 401, so +// each request below carries a hand-built session cookie, encrypted the +// same way modules/auth/session.ts does it against the same persisted +// session_secret. +describe('notification image proxy', () => { + let app: FastifyInstance + let upstream: http.Server + let upstreamPort: number + let sessionCookie: string + const testSlug = `test-image-proxy-${randomUUID()}` + const testAuthSubject = `test-image-proxy-user-${randomUUID()}` + let userId: string + let topicId: string + + const pngBytes = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) + + beforeAll(async () => { + await runMigrations() + app = await buildWebApp() + await app.ready() + + upstream = http.createServer((req, res) => { + if (req.url === '/image.png') { + res.writeHead(200, { 'Content-Type': 'image/png' }) + res.end(pngBytes) + } else if (req.url === '/not-an-image') { + res.writeHead(200, { 'Content-Type': 'text/html' }) + res.end('') + } else { + res.writeHead(404) + res.end() + } + }) + await new Promise((resolve) => upstream.listen(0, '127.0.0.1', resolve)) + upstreamPort = (upstream.address() as AddressInfo).port + + const [user] = await db + .insert(users) + .values({ + email: `${testAuthSubject}@example.test`, + displayName: 'Test User', + authSubject: testAuthSubject, + role: 'admin', + }) + .returning() + userId = user!.id + + const [topic] = await db + .insert(topics) + .values({ slug: testSlug, displayName: 'Test Topic', createdBy: userId }) + .returning() + topicId = topic!.id + + // Same key derivation as modules/auth/session.ts's registerSession — + // that ran as part of buildWebApp() above, so the secret already + // exists in system_settings by this point. + const secretHex = await getSetting('session_secret') + const sessionKey = Buffer.from(secretHex!, 'hex') + sessionCookie = encryptJson(sessionKey, { userId }) + }) + + afterAll(async () => { + await db.delete(notifications).where(eq(notifications.topicId, topicId)) + await db.delete(topics).where(eq(topics.id, topicId)) + await db.delete(users).where(eq(users.id, userId)) + await new Promise((resolve) => upstream.close(() => resolve())) + await app.close() + await pool.end() + }) + + it('proxies an image from the stored imageUrl', async () => { + const result = await createNotification({ + topicSlug: testSlug, + source: 'test', + title: 'Has image', + message: 'test', + imageUrl: `http://127.0.0.1:${upstreamPort}/image.png`, + }) + if ('error' in result) throw new Error('unexpected error creating notification') + + const res = await app.inject({ + method: 'GET', + url: `/api/v1/notifications/${result.notification.id}/image`, + headers: { cookie: `notizarr_session=${sessionCookie}` }, + }) + + expect(res.statusCode).toBe(200) + expect(res.headers['content-type']).toBe('image/png') + expect(res.rawPayload.equals(pngBytes)).toBe(true) + }) + + it('returns 404 for a notification with no imageUrl', async () => { + const result = await createNotification({ + topicSlug: testSlug, + source: 'test', + title: 'No image', + message: 'test', + }) + if ('error' in result) throw new Error('unexpected error creating notification') + + const res = await app.inject({ + method: 'GET', + url: `/api/v1/notifications/${result.notification.id}/image`, + headers: { cookie: `notizarr_session=${sessionCookie}` }, + }) + expect(res.statusCode).toBe(404) + }) + + it('returns 502 when the upstream response is not an image', async () => { + const result = await createNotification({ + topicSlug: testSlug, + source: 'test', + title: 'Bad upstream', + message: 'test', + imageUrl: `http://127.0.0.1:${upstreamPort}/not-an-image`, + }) + if ('error' in result) throw new Error('unexpected error creating notification') + + const res = await app.inject({ + method: 'GET', + url: `/api/v1/notifications/${result.notification.id}/image`, + headers: { cookie: `notizarr_session=${sessionCookie}` }, + }) + expect(res.statusCode).toBe(502) + }) +}) diff --git a/apps/server/src/modules/notifications/routes.ts b/apps/server/src/modules/notifications/routes.ts index f0981b1..58d80af 100644 --- a/apps/server/src/modules/notifications/routes.ts +++ b/apps/server/src/modules/notifications/routes.ts @@ -1,3 +1,5 @@ +import { Readable } from 'node:stream' +import type { ReadableStream as NodeWebReadableStream } from 'node:stream/web' import type { FastifyInstance } from 'fastify' import { requireAdmin, requireAuth } from '../auth/guards.js' import { writeAuditLog } from '../audit/log.js' @@ -116,6 +118,68 @@ export async function registerNotificationReadRoutes(app: FastifyInstance) { }, ) + // Proxies notification.imageUrl through the server instead of having the + // client's browser load it directly. Matters because that URL is often a + // LAN-only camera/service address (e.g. Frigate) — the server sits on + // that LAN persistently, but a phone off-network doesn't. Deliberately + // narrower than a general-purpose fetch proxy: there's no client-supplied + // URL parameter, only the fixed value already stored on a notification + // the caller is authorized to see (publishing one at all requires a + // valid, scoped API token), which is what keeps this from being an open + // SSRF endpoint. + app.get( + '/api/v1/notifications/:id/image', + { + preHandler: requireAuth, + schema: { + params: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] }, + }, + }, + async (req, reply) => { + const { id } = req.params as { id: string } + const notification = await getNotificationForUser(req.currentUser!.id, id) + if (!notification?.imageUrl) { + reply.code(404) + return { error: 'not_found' } + } + + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), 10_000) + try { + const upstream = await fetch(notification.imageUrl, { signal: controller.signal }) + if (!upstream.ok || !upstream.body) { + reply.code(502) + return { error: 'upstream_fetch_failed' } + } + + const contentType = upstream.headers.get('content-type') ?? '' + if (!contentType.startsWith('image/')) { + reply.code(502) + return { error: 'not_an_image', message: `upstream returned ${contentType || 'no content-type'}` } + } + + const contentLength = Number(upstream.headers.get('content-length') ?? 0) + if (contentLength > 20 * 1024 * 1024) { + reply.code(502) + return { error: 'image_too_large' } + } + + reply.header('Content-Type', contentType) + reply.header('Cache-Control', 'private, max-age=300') + // DOM lib's fetch() types ReadableStream from lib.dom, Readable.fromWeb + // expects node:stream/web's — structurally identical at runtime, this + // cast just bridges the two type declarations. + return Readable.fromWeb(upstream.body as unknown as NodeWebReadableStream) + } catch (err) { + req.log.warn(err, 'notification image proxy fetch failed') + reply.code(502) + return { error: 'upstream_fetch_failed' } + } finally { + clearTimeout(timeout) + } + }, + ) + app.patch( '/api/v1/notifications/:id', { diff --git a/apps/web/src/components/notifications/NotificationRow.tsx b/apps/web/src/components/notifications/NotificationRow.tsx index 863b10a..b71037e 100644 --- a/apps/web/src/components/notifications/NotificationRow.tsx +++ b/apps/web/src/components/notifications/NotificationRow.tsx @@ -97,7 +97,11 @@ export function NotificationRow({ style={{ backgroundColor: avatarColor.bg }} > {notification.imageUrl ? ( - + ) : ( {notification.source.slice(0, 2)} diff --git a/apps/web/src/pages/NotificationDetailPage.tsx b/apps/web/src/pages/NotificationDetailPage.tsx index 87992b7..da777a2 100644 --- a/apps/web/src/pages/NotificationDetailPage.tsx +++ b/apps/web/src/pages/NotificationDetailPage.tsx @@ -110,7 +110,7 @@ export function NotificationDetailPage() { {notification.imageUrl && ( diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 6c30737..2042fa6 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -2,24 +2,27 @@ # machine. NOT what gets deployed to Unraid — see docker-compose.prod.yml # and docs/phase1/06-docker-unraid-design.md for that. # -# Run from the repo root: -# docker compose -f docker/docker-compose.yml up -d +# Run from the repo root, ALWAYS with --env-file .env explicitly — Compose's +# implicit .env lookup when a compose file is passed via -f from a +# different directory is ambiguous/version-dependent, and got this wrong at +# least once. Don't rely on it: +# docker compose --env-file .env -f docker/docker-compose.yml up -d # -# Reads a .env file from the repo root (Compose's default project -# directory, since that's the CWD the command above is run from) — copy -# .env.example to .env there and fill in real values first. +# Copy .env.example to .env in the repo root and fill in real values first. +# Every other docker compose invocation against this file (exec, logs, +# restart, ...) needs the same --env-file .env flag, not just `up`. # # Host ports are overridable (Unraid boxes commonly already have something # on 8080/5432) — e.g.: # HOST_PORT_WEB=18080 HOST_PORT_INGEST=18081 HOST_PORT_PG=15432 \ -# docker compose -f docker/docker-compose.yml up -d +# docker compose --env-file .env -f docker/docker-compose.yml up -d # or put those in the same .env file so you don't have to repeat them. # # IMPORTANT: whenever a package.json dependency changes, `--build` alone is # not enough — Compose reuses the OLD container's anonymous node_modules # volumes by default instead of repopulating them from the rebuilt image. # Use: -# docker compose -f docker/docker-compose.yml up -d --build --force-recreate -V +# docker compose --env-file .env -f docker/docker-compose.yml up -d --build --force-recreate -V # (-V / --renew-anon-volumes forces those volumes to be recreated from the # new image; it does not touch the named notizarr_dev_pgdata volume below.) diff --git a/docs/migration-ntfy.md b/docs/migration-ntfy.md index b6eb6b5..9516407 100644 --- a/docs/migration-ntfy.md +++ b/docs/migration-ntfy.md @@ -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. diff --git a/docs/phase1/00-README.md b/docs/phase1/00-README.md index 977893f..f226fce 100644 --- a/docs/phase1/00-README.md +++ b/docs/phase1/00-README.md @@ -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 (``), 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 ``, 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." | diff --git a/docs/phase1/02-threat-model.md b/docs/phase1/02-threat-model.md index a416271..d023eb8 100644 --- a/docs/phase1/02-threat-model.md +++ b/docs/phase1/02-threat-model.md @@ -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 `` / `` 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 `` 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 `