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
@@ -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
|
||||
|
||||
@@ -43,19 +43,22 @@ export async function buildWebApp(): Promise<FastifyInstance> {
|
||||
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<FastifyInstance> {
|
||||
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,
|
||||
})
|
||||
|
||||
@@ -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 { 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<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(
|
||||
'/api/v1/notifications/:id',
|
||||
{
|
||||
|
||||
@@ -97,7 +97,11 @@ export function NotificationRow({
|
||||
style={{ backgroundColor: avatarColor.bg }}
|
||||
>
|
||||
{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 }}>
|
||||
{notification.source.slice(0, 2)}
|
||||
|
||||
@@ -110,7 +110,7 @@ export function NotificationDetailPage() {
|
||||
|
||||
{notification.imageUrl && (
|
||||
<img
|
||||
src={notification.imageUrl}
|
||||
src={`/api/v1/notifications/${notification.id}/image`}
|
||||
alt=""
|
||||
className="max-h-96 w-full rounded-xl border border-base-border object-cover"
|
||||
/>
|
||||
|
||||
@@ -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.)
|
||||
|
||||
|
||||
@@ -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