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
|
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
|
||||||
|
|||||||
@@ -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"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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.)
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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." |
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in new issue
Block a user