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:
siliconsolder committed 2026-09-07 18:49:45 +00:00
1 parent a82c29a69d
commit 079013730b
10 files changed
+332 -32

No files matched your search

+55 -4
View File
@@ -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 scopes) are editable any time from **Admin → Auth** — no further env file
edits or restarts needed for those. 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` All mutating requests on the web listener (`POST`/`PUT`/`PATCH`/`DELETE`
under `/api/`) require a `X-Requested-With: notizarr` header — this is the under `/api/`) require a `X-Requested-With: notizarr` header — this is the
CSRF defense described in the threat model, not optional. The ingestion 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 ## Local development via Docker
```bash ```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 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`: against the old, pre-change `node_modules`:
```bash ```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 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 ```bash
HOST_PORT_WEB=28080 HOST_PORT_INGEST=28081 HOST_PORT_PG=25432 \ 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: 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): rather than regenerated on every build):
```bash ```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) ## Unraid deployment (production)
@@ -242,6 +285,14 @@ work the same way.
## Troubleshooting ## 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.** - **A `docker compose up -d --build` didn't pick up a dependency change.**
Anonymous `node_modules` volumes are reused by default — add `-V Anonymous `node_modules` volumes are reused by default — add `-V
--force-recreate` (see [Local development via --force-recreate` (see [Local development via
+14 -7
View File
@@ -43,19 +43,22 @@ export async function buildWebApp(): Promise<FastifyInstance> {
const app = Fastify({ logger: loggerOptions, bodyLimit: 1024 * 1024 }) const app = Fastify({ logger: loggerOptions, bodyLimit: 1024 * 1024 })
// CSP/HSTS/frame-ancestors per docs/phase1/00-README.md's security // CSP/HSTS/frame-ancestors per docs/phase1/00-README.md's security
// requirements. crossOriginEmbedderPolicy is off because notification // requirements. imgSrc is 'self' only — notification images are proxied
// image_url values are arbitrary third-party https URLs the browser // through GET /api/v1/notifications/:id/image (modules/notifications/routes.ts)
// loads directly (docs/phase1/02-threat-model.md's SSRF mitigation) — // rather than the browser loading notification.imageUrl directly, so the
// COEP would require CORP headers from origins we don't control. HSTS is // browser never needs to reach an arbitrary third-party host itself. That
// only sent in production so a plain-HTTP dev/LAN setup never gets stuck // also closes a minor privacy leak the old direct-load approach had (a
// with a stale HSTS pin from testing. // 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, { await app.register(fastifyHelmet, {
contentSecurityPolicy: { contentSecurityPolicy: {
directives: { directives: {
defaultSrc: ["'self'"], defaultSrc: ["'self'"],
scriptSrc: ["'self'"], scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"], styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", 'https:', 'data:'], imgSrc: ["'self'"],
connectSrc: ["'self'"], connectSrc: ["'self'"],
fontSrc: ["'self'"], fontSrc: ["'self'"],
objectSrc: ["'none'"], objectSrc: ["'none'"],
@@ -64,6 +67,10 @@ export async function buildWebApp(): Promise<FastifyInstance> {
formAction: ["'self'"], 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, crossOriginEmbedderPolicy: false,
hsts: config.isProduction ? { maxAge: 15552000, includeSubDomains: true } : false, hsts: config.isProduction ? { maxAge: 15552000, includeSubDomains: true } : false,
}) })
@@ -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('<html></html>')
} else {
res.writeHead(404)
res.end()
}
})
await new Promise<void>((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<string>('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<void>((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)
})
})
@@ -1,3 +1,5 @@
import { Readable } from 'node:stream'
import type { ReadableStream as NodeWebReadableStream } from 'node:stream/web'
import type { FastifyInstance } from 'fastify' import type { FastifyInstance } from 'fastify'
import { requireAdmin, requireAuth } from '../auth/guards.js' import { requireAdmin, requireAuth } from '../auth/guards.js'
import { writeAuditLog } from '../audit/log.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<Uint8Array>)
} catch (err) {
req.log.warn(err, 'notification image proxy fetch failed')
reply.code(502)
return { error: 'upstream_fetch_failed' }
} finally {
clearTimeout(timeout)
}
},
)
app.patch( app.patch(
'/api/v1/notifications/:id', '/api/v1/notifications/:id',
{ {
@@ -97,7 +97,11 @@ export function NotificationRow({
style={{ backgroundColor: avatarColor.bg }} style={{ backgroundColor: avatarColor.bg }}
> >
{notification.imageUrl ? ( {notification.imageUrl ? (
<img src={notification.imageUrl} alt="" className="h-full w-full object-cover" /> <img
src={`/api/v1/notifications/${notification.id}/image`}
alt=""
className="h-full w-full object-cover"
/>
) : ( ) : (
<span className="text-xs font-semibold uppercase" style={{ color: avatarColor.fg }}> <span className="text-xs font-semibold uppercase" style={{ color: avatarColor.fg }}>
{notification.source.slice(0, 2)} {notification.source.slice(0, 2)}
@@ -110,7 +110,7 @@ export function NotificationDetailPage() {
{notification.imageUrl && ( {notification.imageUrl && (
<img <img
src={notification.imageUrl} src={`/api/v1/notifications/${notification.id}/image`}
alt="" alt=""
className="max-h-96 w-full rounded-xl border border-base-border object-cover" className="max-h-96 w-full rounded-xl border border-base-border object-cover"
/> />
+10 -7
View File
@@ -2,24 +2,27 @@
# machine. NOT what gets deployed to Unraid — see docker-compose.prod.yml # machine. NOT what gets deployed to Unraid — see docker-compose.prod.yml
# and docs/phase1/06-docker-unraid-design.md for that. # and docs/phase1/06-docker-unraid-design.md for that.
# #
# Run from the repo root: # Run from the repo root, ALWAYS with --env-file .env explicitly — Compose's
# docker compose -f docker/docker-compose.yml up -d # 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 # Copy .env.example to .env in the repo root and fill in real values first.
# directory, since that's the CWD the command above is run from) — copy # Every other docker compose invocation against this file (exec, logs,
# .env.example to .env there and fill in real values first. # restart, ...) needs the same --env-file .env flag, not just `up`.
# #
# Host ports are overridable (Unraid boxes commonly already have something # Host ports are overridable (Unraid boxes commonly already have something
# on 8080/5432) — e.g.: # on 8080/5432) — e.g.:
# HOST_PORT_WEB=18080 HOST_PORT_INGEST=18081 HOST_PORT_PG=15432 \ # 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. # 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 # IMPORTANT: whenever a package.json dependency changes, `--build` alone is
# not enough — Compose reuses the OLD container's anonymous node_modules # not enough — Compose reuses the OLD container's anonymous node_modules
# volumes by default instead of repopulating them from the rebuilt image. # volumes by default instead of repopulating them from the rebuilt image.
# Use: # 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 # (-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.) # new image; it does not touch the named notizarr_dev_pgdata volume below.)
+9 -5
View File
@@ -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 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 the `Title:` header or JSON `title` field, same as ntfy's own
emoji-in-title convention. emoji-in-title convention.
- **Attachments.** `Attach:` is treated as an image URL the browser loads - **Attachments.** `Attach:` is treated as an image URL. The server fetches
directly — there's no server-side download, proxying, or file upload. and proxies it (`GET /api/v1/notifications/:id/image`) rather than
This is a deliberate security choice (see the SSRF section of handing the raw URL to the browser — deliberately so a LAN-only image
[docs/phase1/02-threat-model.md](phase1/02-threat-model.md)), not a source (a camera snapshot, say) still loads for a client that's off that
temporary gap. 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 - **Auth is required by default.** Unlike a bare ntfy instance, publishing
always needs a valid `Authorization: Bearer` token — there's no always needs a valid `Authorization: Bearer` token — there's no
anonymous-publish mode. anonymous-publish mode.
+1 -1
View File
@@ -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. | | 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. | | 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. | | 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. | | 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." | | 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." |
+26 -6
View File
@@ -72,12 +72,32 @@ is still gated by needing a valid token.
**Threat:** a malicious/compromised publisher sets `image_url` or `url` to **Threat:** a malicious/compromised publisher sets `image_url` or `url` to
an internal address (`http://169.254.169.254/...`, `http://postgres:5432`, an internal address (`http://169.254.169.254/...`, `http://postgres:5432`,
etc.), hoping the server fetches it. etc.), hoping the server fetches it.
**Mitigation:** the server **never fetches these URLs**. They are stored as **Mitigation, `url`:** never fetched server-side. Stored as an opaque
opaque strings and only ever rendered as `<a href>` / `<img src>` in the string and only ever rendered as `<a href>` in the *browser's* context,
*browser's* context, exactly like a normal webpage. This removes SSRF as a exactly like a normal webpage.
viable vector for this field entirely, at the cost of not offering **Mitigation, `image_url`:** unlike `url`, this **is** fetched server-side
server-side attachment proxying/caching (acceptable per the "attachment — `GET /api/v1/notifications/:id/image` — so that a LAN-only image source
metadata only" requirement). (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 ### XSS via notification content
**Threat:** `title`/`message` containing `<script>` or event handlers. **Threat:** `title`/`message` containing `<script>` or event handlers.