From 984a75d036533be37b593df3a7f7800d58e5d261 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 10 Sep 2026 16:46:13 +0200 Subject: [PATCH] fix(api): name the private-origin refusal on webhook and ntfy routes The save routes evaluate the same policy the senders do. A target on a private network the operator has not listed is a 422 with meta.errorCode = private_origin_not_approved and a message that names NOTIFICATION_PRIVATE_ORIGINS, instead of the generic "Invalid data"; a listed private address saves although the plain public floor would have refused it. The settings schemas take the target predicate as a parameter so the validator module never reads the environment; the exported constants keep the public floor for the OpenAPI contract. The test routes forward the sender's refusal as the same 422 and code instead of a bare 500, so the card can say why the test failed rather than sending the operator to the wide-event log. A delivery fault is still a 500. Both test routes and the dispatcher reach the same sender function, and a structural test holds them there. The OpenAPI descriptions name the code on all four routes; the test button's error map gains the code in every locale. Refs #947 --- docs/api/openapi.yaml | 43 +- messages/de.json | 1 + messages/en.json | 1 + messages/es.json | 1 + messages/fr.json | 1 + messages/it.json | 1 + messages/ko.json | 1 + messages/pl.json | 1 + .../__tests__/private-origin-routes.test.ts | 381 ++++++++++++++++++ src/app/api/settings/ntfy/route.ts | 35 +- src/app/api/settings/ntfy/test/route.ts | 14 + src/app/api/settings/webhook/route.ts | 46 ++- src/app/api/settings/webhook/test/route.ts | 14 + .../__tests__/private-origin-policy.test.ts | 12 + src/lib/openapi/routes/settings.ts | 22 +- src/lib/private-origin-policy.ts | 11 +- src/lib/validations/notifications.ts | 85 ++-- 17 files changed, 614 insertions(+), 56 deletions(-) create mode 100644 src/app/api/settings/__tests__/private-origin-routes.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 0acb08f3d..e468f3627 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -11969,7 +11969,8 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": description: Body matched neither accepted shape, or `enabled` is true while the stored config has no server URL and - topic. + topic; or the server URL is on a private network the operator has not listed in + `NOTIFICATION_PRIVATE_ORIGINS` (`meta.errorCode` = `private_origin_not_approved`). Nothing was stored. content: application/json: schema: @@ -12094,7 +12095,9 @@ paths: "413": *a16 "415": *a17 "422": - description: Body matched neither accepted shape, or `enabled` is true while no webhook URL is stored. + description: Body matched neither accepted shape, or `enabled` is true while no webhook URL is stored; or the URL is on + a private network the operator has not listed in `NOTIFICATION_PRIVATE_ORIGINS` (`meta.errorCode` = + `private_origin_not_approved`). Nothing was stored. content: application/json: schema: @@ -12311,7 +12314,9 @@ paths: summary: Send a test message to the saved ntfy topic description: Publishes one message to the account's configured ntfy server and topic. Takes no body. Rate-limited 5 per 5 minutes per user. Refuses with 400 when no ntfy channel is saved or when the saved config is missing a server - URL or topic; a publish failure is a 500. + URL or topic; a publish failure is a 500. A server on a private network is refused with 422 and `meta.errorCode` + = `private_origin_not_approved` unless the operator listed its exact origin in `NOTIFICATION_PRIVATE_ORIGINS`; + the test and the scheduled delivery take the same decision. responses: "200": description: The provider accepted the message. Not proof it was delivered. @@ -12320,7 +12325,13 @@ paths: schema: $ref: "#/components/schemas/NtfyTestEnvelope" "401": *a1 - "422": *a4 + "422": + description: The saved server is on a private network the operator has not approved (`meta.errorCode` = + `private_origin_not_approved`). The resolved address is never echoed. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" "429": *a5 /api/settings/telegram/test: post: @@ -12353,7 +12364,9 @@ paths: description: Posts one payload to the account's configured webhook URL. Takes no body. Rate-limited 5 per 5 minutes per user. Refuses with 400 when no webhook channel is saved or the saved config has no URL; a delivery failure is a 500. The URL was checked against the SSRF floor when it was saved, so a test cannot be used to reach an internal - host. + host. A URL on a private network is refused with 422 and `meta.errorCode` = `private_origin_not_approved` unless + the operator listed its exact origin in `NOTIFICATION_PRIVATE_ORIGINS`; the test and the scheduled delivery take + the same decision. responses: "200": description: The provider accepted the message. Not proof it was delivered. @@ -12362,7 +12375,13 @@ paths: schema: $ref: "#/components/schemas/WebhookTestEnvelope" "401": *a1 - "422": *a4 + "422": + description: The saved URL is on a private network the operator has not approved (`meta.errorCode` = + `private_origin_not_approved`). The resolved address is never echoed. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" "429": *a5 /api/settings/data: delete: @@ -25600,9 +25619,10 @@ components: required: - enabled description: "ntfy channel config. `serverUrl` must pass the SSRF floor — a URL resolving into a private range is - refused at input time. An EMPTY OR OMITTED `authToken` preserves the stored one rather than clearing it: the GET - never returns the secret, so a client round-tripping the form would otherwise wipe it on every unrelated save. - Sending a non-empty value replaces it. There is no way to clear a stored auth token through this endpoint." + refused at input time unless the operator listed its exact origin in `NOTIFICATION_PRIVATE_ORIGINS`. An EMPTY OR + OMITTED `authToken` preserves the stored one rather than clearing it: the GET never returns the secret, so a + client round-tripping the form would otherwise wipe it on every unrelated save. Sending a non-empty value + replaces it. There is no way to clear a stored auth token through this endpoint." TelegramSettingsPutRequest: anyOf: - $ref: "#/components/schemas/NotificationChannelEnabledRequest" @@ -25660,8 +25680,9 @@ components: - enabled description: "Generic-webhook channel config — one channel covering Gotify, Discord, Slack, a Matrix bridge, Home Assistant, or any relay accepting an inbound JSON POST. `url` must pass the SSRF floor at input time and is - re-checked at dispatch time. An EMPTY OR OMITTED `headerValue` preserves the stored one, for the same reason as - ntfy's `authToken`: the GET never returns it. There is no way to clear a stored header value through this + re-checked at dispatch time; a private origin passes both only when the operator listed it in + `NOTIFICATION_PRIVATE_ORIGINS`. An EMPTY OR OMITTED `headerValue` preserves the stored one, for the same reason + as ntfy's `authToken`: the GET never returns it. There is no way to clear a stored header value through this endpoint." DeleteAccountRequest: type: object diff --git a/messages/de.json b/messages/de.json index fca6667b9..bfb2c3f93 100644 --- a/messages/de.json +++ b/messages/de.json @@ -5990,6 +5990,7 @@ "connection_failed": "Verbindung fehlgeschlagen", "not_configured": "Nicht konfiguriert", "url_not_public": "URL ist kein öffentlicher Endpunkt", + "private_origin_not_approved": "Ziel im privaten Netz. Der Betreiber muss diesen Origin in NOTIFICATION_PRIVATE_ORIGINS eintragen", "url_invalid": "URL ist ungültig", "redirected": "Endpunkt leitet weiter — URL prüfen", "endpoint_not_found": "Endpunkt unter der URL nicht gefunden", diff --git a/messages/en.json b/messages/en.json index 5891d7ddf..5ad731168 100644 --- a/messages/en.json +++ b/messages/en.json @@ -5990,6 +5990,7 @@ "connection_failed": "Connection failed", "not_configured": "Not configured", "url_not_public": "URL is not a public endpoint", + "private_origin_not_approved": "Private network target. The operator has to list this origin in NOTIFICATION_PRIVATE_ORIGINS", "url_invalid": "URL is invalid", "redirected": "Endpoint redirects — check the URL", "endpoint_not_found": "Endpoint not found at the URL", diff --git a/messages/es.json b/messages/es.json index 990f5c414..cf5293105 100644 --- a/messages/es.json +++ b/messages/es.json @@ -5990,6 +5990,7 @@ "connection_failed": "Falló la conexión", "not_configured": "No configurado", "url_not_public": "La URL no es un endpoint público", + "private_origin_not_approved": "Destino en una red privada. El operador debe añadir este origen a NOTIFICATION_PRIVATE_ORIGINS", "url_invalid": "La URL no es válida", "redirected": "El endpoint redirige — comprueba la URL", "endpoint_not_found": "No se encontró el endpoint en la URL", diff --git a/messages/fr.json b/messages/fr.json index eee2b3cbd..b58b49ab8 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -5990,6 +5990,7 @@ "connection_failed": "Échec de la connexion", "not_configured": "Non configuré", "url_not_public": "L’URL n’est pas un point de terminaison public", + "private_origin_not_approved": "Cible sur un réseau privé. L’opérateur doit ajouter cette origine à NOTIFICATION_PRIVATE_ORIGINS", "url_invalid": "L’URL n’est pas valide", "redirected": "Le point de terminaison redirige — vérifiez l’URL", "endpoint_not_found": "Point de terminaison introuvable à l’URL", diff --git a/messages/it.json b/messages/it.json index f550c93a0..c390dddc1 100644 --- a/messages/it.json +++ b/messages/it.json @@ -5990,6 +5990,7 @@ "connection_failed": "Connessione non riuscita", "not_configured": "Non configurato", "url_not_public": "L’URL non è un endpoint pubblico", + "private_origin_not_approved": "Destinazione su una rete privata. L’operatore deve aggiungere questa origine a NOTIFICATION_PRIVATE_ORIGINS", "url_invalid": "L’URL non è valido", "redirected": "L’endpoint reindirizza — controlla l’URL", "endpoint_not_found": "Endpoint non trovato all’URL", diff --git a/messages/ko.json b/messages/ko.json index 641082b67..9f00cb7e2 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -5990,6 +5990,7 @@ "connection_failed": "연결에 실패했어요", "not_configured": "설정되지 않았어요", "url_not_public": "URL이 공개 엔드포인트가 아니에요", + "private_origin_not_approved": "비공개 네트워크 대상이에요. 운영자가 NOTIFICATION_PRIVATE_ORIGINS에 이 출처를 등록해야 해요", "url_invalid": "URL이 올바르지 않아요", "redirected": "엔드포인트가 리디렉션돼요 — URL을 확인해 주세요", "endpoint_not_found": "해당 URL에 엔드포인트가 없어요", diff --git a/messages/pl.json b/messages/pl.json index 1caaad0a3..270131987 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -5990,6 +5990,7 @@ "connection_failed": "Połączenie nie powiodło się", "not_configured": "Nieskonfigurowano", "url_not_public": "Adres URL nie jest publicznym punktem końcowym", + "private_origin_not_approved": "Cel w sieci prywatnej. Operator musi dodać to źródło do NOTIFICATION_PRIVATE_ORIGINS", "url_invalid": "Adres URL jest nieprawidłowy", "redirected": "Punkt końcowy przekierowuje — sprawdź adres URL", "endpoint_not_found": "Nie znaleziono punktu końcowego pod adresem URL", diff --git a/src/app/api/settings/__tests__/private-origin-routes.test.ts b/src/app/api/settings/__tests__/private-origin-routes.test.ts new file mode 100644 index 000000000..7ad276f50 --- /dev/null +++ b/src/app/api/settings/__tests__/private-origin-routes.test.ts @@ -0,0 +1,381 @@ +/** + * #947 — a webhook or ntfy target on a private network, at the four routes + * that meet it. + * + * The save routes evaluate the same policy the senders do, so a private + * target that is not listed in NOTIFICATION_PRIVATE_ORIGINS is a 422 that + * names the reason (`meta.errorCode = private_origin_not_approved`) instead + * of a generic "Invalid data", and a listed literal address saves although + * the plain public floor would refuse it. The test routes forward the + * sender's own refusal as a 422 with the same code instead of the bare 500 + * that made the reporter read the wide-event log to learn it was policy. + * + * The last block is the structural half: the test button and the dispatcher + * must reach the SAME sender function, so the decision cannot differ between + * the button that says "works" and the reminder that then does not arrive. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + notificationChannel: { + findUnique: vi.fn(), + updateMany: vi.fn(), + upsert: vi.fn(), + }, + }, +})); + +vi.mock("@/lib/crypto", () => ({ + encrypt: vi.fn((value: string) => `encrypted:${value}`), + decrypt: vi.fn((value: string) => value.replace(/^encrypted:/, "")), +})); + +vi.mock("@/lib/app-settings", () => ({ + isChannelGloballyEnabled: vi.fn().mockResolvedValue(true), +})); + +const sendViaWebhookMock = vi.fn(); +const sendViaNtfyMock = vi.fn(); +vi.mock("@/lib/notifications/senders/webhook", () => ({ + sendViaWebhook: (...args: unknown[]) => sendViaWebhookMock(...args), +})); +vi.mock("@/lib/notifications/senders/ntfy", () => ({ + sendViaNtfy: (...args: unknown[]) => sendViaNtfyMock(...args), +})); + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn().mockResolvedValue({ + allowed: true, + remaining: 4, + resetAt: Date.now() + 60_000, + }), + rateLimitHeaders: () => ({}), +})); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { PUT as putWebhook } from "../webhook/route"; +import { PUT as putNtfy } from "../ntfy/route"; +import { POST as testWebhook } from "../webhook/test/route"; +import { POST as testNtfy } from "../ntfy/test/route"; +import { prisma } from "@/lib/db"; +import { getSession } from "@/lib/auth/session"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "user-1", username: "testuser", role: "USER" as const }, +}; + +type Route = (request: Request) => Promise; + +function put(path: string, body: unknown): Promise { + const route = (path === "webhook" ? putWebhook : putNtfy) as Route; + return route( + new Request(`http://localhost/api/settings/${path}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }), + ); +} + +function post(path: string): Promise { + const route = (path === "webhook" ? testWebhook : testNtfy) as Route; + return route( + new Request(`http://localhost/api/settings/${path}/test`, { + method: "POST", + }), + ); +} + +const ORIGINAL_PRIVATE_ORIGINS = process.env.NOTIFICATION_PRIVATE_ORIGINS; + +beforeEach(() => { + vi.clearAllMocks(); + delete process.env.NOTIFICATION_PRIVATE_ORIGINS; + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.notificationChannel.findUnique).mockResolvedValue( + null as never, + ); + vi.mocked(prisma.notificationChannel.upsert).mockResolvedValue({} as never); +}); + +afterEach(() => { + if (ORIGINAL_PRIVATE_ORIGINS === undefined) { + delete process.env.NOTIFICATION_PRIVATE_ORIGINS; + } else { + process.env.NOTIFICATION_PRIVATE_ORIGINS = ORIGINAL_PRIVATE_ORIGINS; + } +}); + +describe("PUT /api/settings/webhook — private targets", () => { + it("refuses an unlisted private address with the reason, and stores nothing", async () => { + const response = await put("webhook", { + url: "http://10.0.0.5:8080/message", + enabled: true, + }); + + expect(response.status).toBe(422); + const json = await response.json(); + expect(json.data).toBeNull(); + expect(json.meta).toEqual({ errorCode: "private_origin_not_approved" }); + expect(json.error).toContain("NOTIFICATION_PRIVATE_ORIGINS"); + expect(prisma.notificationChannel.upsert).not.toHaveBeenCalled(); + }); + + it("refuses a sub-host of a listed origin: a grant names one origin only", async () => { + process.env.NOTIFICATION_PRIVATE_ORIGINS = "https://gotify.example.com"; + + const response = await put("webhook", { + url: "https://push.gotify.example.com/message", + enabled: true, + }); + + expect(response.status).toBe(422); + const json = await response.json(); + expect(json.meta).toEqual({ errorCode: "private_origin_not_approved" }); + expect(prisma.notificationChannel.upsert).not.toHaveBeenCalled(); + }); + + it("saves a listed private address the public floor alone would refuse", async () => { + process.env.NOTIFICATION_PRIVATE_ORIGINS = "http://10.0.0.5:8080"; + + const response = await put("webhook", { + url: "http://10.0.0.5:8080/message", + enabled: true, + }); + + expect(response.status).toBe(200); + expect(prisma.notificationChannel.upsert).toHaveBeenCalledTimes(1); + const call = vi.mocked(prisma.notificationChannel.upsert).mock.calls[0][0]; + expect(call.create.config).toBe( + 'encrypted:{"url":"http://10.0.0.5:8080/message"}', + ); + }); + + it("keeps the plain shape refusal for a body that is not a private-origin case", async () => { + const response = await put("webhook", { url: 42, enabled: true }); + + expect(response.status).toBe(422); + const json = await response.json(); + expect(json.error).toBe("Invalid data"); + expect(json.meta).toBeUndefined(); + }); + + it("refuses a loopback grant at save time: listing it does not open it", async () => { + // The parser drops a loopback or localhost grant with a warning, so the + // save is refused with the reason and the operator learns that the list + // is not the way to reach the container itself. + vi.spyOn(console, "warn").mockImplementation(() => {}); + process.env.NOTIFICATION_PRIVATE_ORIGINS = + "http://127.0.0.1:8080, http://localhost:8080"; + + for (const url of [ + "http://127.0.0.1:8080/message", + "http://localhost:8080/message", + ]) { + const response = await put("webhook", { url, enabled: true }); + expect(response.status).toBe(422); + const json = await response.json(); + expect(json.meta).toEqual({ errorCode: "private_origin_not_approved" }); + } + expect(prisma.notificationChannel.upsert).not.toHaveBeenCalled(); + }); + + it("saves an mDNS name the input floor refuses, once the operator lists it", async () => { + process.env.NOTIFICATION_PRIVATE_ORIGINS = "http://gotify.local"; + + const refused = await put("webhook", { + url: "http://ntfy.local/message", + enabled: true, + }); + expect(refused.status).toBe(422); + await expect(refused.json()).resolves.toMatchObject({ + meta: { errorCode: "private_origin_not_approved" }, + }); + + const saved = await put("webhook", { + url: "http://gotify.local/message", + enabled: true, + }); + expect(saved.status).toBe(200); + expect(prisma.notificationChannel.upsert).toHaveBeenCalledTimes(1); + }); +}); + +describe("PUT /api/settings/ntfy — private servers", () => { + it("refuses an unlisted private server with the reason, and stores nothing", async () => { + const response = await put("ntfy", { + serverUrl: "http://192.168.1.20:8080", + topic: "health", + enabled: true, + }); + + expect(response.status).toBe(422); + const json = await response.json(); + expect(json.meta).toEqual({ errorCode: "private_origin_not_approved" }); + expect(json.error).toContain("NOTIFICATION_PRIVATE_ORIGINS"); + expect(prisma.notificationChannel.upsert).not.toHaveBeenCalled(); + }); + + it("saves a listed private server", async () => { + process.env.NOTIFICATION_PRIVATE_ORIGINS = "http://192.168.1.20:8080"; + + const response = await put("ntfy", { + serverUrl: "http://192.168.1.20:8080", + topic: "health", + enabled: true, + }); + + expect(response.status).toBe(200); + expect(prisma.notificationChannel.upsert).toHaveBeenCalledTimes(1); + }); + + it("refuses a sibling port of a listed server", async () => { + process.env.NOTIFICATION_PRIVATE_ORIGINS = "http://192.168.1.20:8080"; + + const response = await put("ntfy", { + serverUrl: "http://192.168.1.20:9090", + topic: "health", + enabled: true, + }); + + expect(response.status).toBe(422); + const json = await response.json(); + expect(json.meta).toEqual({ errorCode: "private_origin_not_approved" }); + }); +}); + +describe("POST /api/settings/{webhook,ntfy}/test — the refusal names itself", () => { + beforeEach(() => { + vi.mocked(prisma.notificationChannel.findUnique).mockImplementation( + (async (args: { where: { userId_type: { type: string } } }) => + args.where.userId_type.type === "WEBHOOK" + ? { config: 'encrypted:{"url":"https://gotify.lan/message"}' } + : { + config: + 'encrypted:{"serverUrl":"https://ntfy.lan","topic":"health"}', + }) as never, + ); + }); + + it.each([ + ["webhook", sendViaWebhookMock], + ["ntfy", sendViaNtfyMock], + ])( + "%s: answers a private-origin refusal with 422 and the code, not a 500", + async (path, sender) => { + sender.mockResolvedValue({ + ok: false, + hardReject: false, + reason: `${path}_private_origin_refused`, + errorCode: "private_origin_not_approved", + }); + + const response = await post(path); + + expect(response.status).toBe(422); + const json = await response.json(); + expect(json.meta).toEqual({ errorCode: "private_origin_not_approved" }); + expect(json.error).toContain("NOTIFICATION_PRIVATE_ORIGINS"); + }, + ); + + it.each([ + ["webhook", sendViaWebhookMock], + ["ntfy", sendViaNtfyMock], + ])("%s: a plain delivery failure is still a 500", async (path, sender) => { + sender.mockResolvedValue({ + ok: false, + hardReject: false, + reason: `${path}_503`, + statusCode: 503, + }); + + const response = await post(path); + + expect(response.status).toBe(500); + const json = await response.json(); + expect(json.meta).toBeUndefined(); + }); + + it.each([ + ["webhook", sendViaWebhookMock], + ["ntfy", sendViaNtfyMock], + ])("%s: a delivered test is a 200", async (path, sender) => { + sender.mockResolvedValue({ ok: true, statusCode: 200 }); + + const response = await post(path); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ + data: { sent: true }, + error: null, + }); + }); +}); + +describe("the test button and the dispatcher share one sender", () => { + const SRC = join(process.cwd(), "src"); + const read = (rel: string) => readFileSync(join(SRC, rel), "utf8"); + + it.each([ + [ + "webhook", + "app/api/settings/webhook/test/route.ts", + /import \{ sendViaWebhook \} from "@\/lib\/notifications\/senders\/webhook"/, + /sendViaWebhook\(config, payload\)/, + ], + [ + "ntfy", + "app/api/settings/ntfy/test/route.ts", + /import \{ sendViaNtfy \} from "@\/lib\/notifications\/senders\/ntfy"/, + /sendViaNtfy\(config, payload\)/, + ], + ])( + "%s: the route and the dispatcher import the same sender module", + (_channel, routeFile, importPattern, dispatcherCall) => { + expect(read(routeFile)).toMatch(importPattern); + const dispatcher = read("lib/notifications/dispatcher.ts"); + expect(dispatcher).toMatch(importPattern); + expect(dispatcher).toMatch(dispatcherCall); + }, + ); + + it("neither sender decides the private-origin verdict on its own", () => { + // The senders take the verdict from `evaluateNotificationTarget`, the + // function the save routes use too. No sender may carry a second policy. + for (const rel of [ + "lib/notifications/senders/webhook.ts", + "lib/notifications/senders/ntfy.ts", + ]) { + const src = read(rel); + expect(src).toMatch(/evaluateNotificationTarget\(/); + expect(src).not.toMatch(/process\.env\.NOTIFICATION_PRIVATE_ORIGINS/); + expect(src).not.toMatch(/isPublicUrl\(/); + } + for (const rel of [ + "app/api/settings/webhook/route.ts", + "app/api/settings/ntfy/route.ts", + ]) { + expect(read(rel)).toMatch(/isAllowedNotificationTarget/); + } + }); +}); diff --git a/src/app/api/settings/ntfy/route.ts b/src/app/api/settings/ntfy/route.ts index ea54e4c0f..d0fff0ead 100644 --- a/src/app/api/settings/ntfy/route.ts +++ b/src/app/api/settings/ntfy/route.ts @@ -2,14 +2,29 @@ import { prisma } from "@/lib/db"; import { apiSuccess, apiError, safeJson } from "@/lib/api-response"; import { notificationChannelEnabledSchema, - ntfySettingsSchema, + ntfySettingsSchemaWith, } from "@/lib/validations/notifications"; +import { + evaluateNotificationTarget, + isAllowedNotificationTarget, + PRIVATE_ORIGIN_NOT_APPROVED, +} from "@/lib/notifications/egress-policy"; import { encrypt, decrypt } from "@/lib/crypto"; import { NextRequest } from "next/server"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { isChannelGloballyEnabled } from "@/lib/app-settings"; import { annotate } from "@/lib/logging/context"; +/** + * The save-time schema evaluates the same policy the sender does: the public + * floor, plus the operator's exact-origin grant (#947). A listed private + * server therefore saves; an unlisted one is refused here with the reason. + */ +const ntfySettingsSchema = ntfySettingsSchemaWith(isAllowedNotificationTarget); + +const PRIVATE_ORIGIN_REFUSAL = + "This server is on a private network. The operator has to list its exact origin (scheme://host:port) in NOTIFICATION_PRIVATE_ORIGINS before HealthLog can send to it."; + /** * Refusal for an enable attempt on a channel the operator switched off * instance-wide (`AppSettings.ntfyGlobal`). A toggle that appeared to work @@ -119,7 +134,23 @@ export const PUT = apiHandler(async (request: NextRequest) => { } const parsed = ntfySettingsSchema.safeParse(body); - if (!parsed.success) return apiError("Invalid data", 422); + if (!parsed.success) { + const candidate = (body as { serverUrl?: unknown } | null)?.serverUrl; + if ( + typeof candidate === "string" && + evaluateNotificationTarget(candidate).reasonCode === + PRIVATE_ORIGIN_NOT_APPROVED + ) { + annotate({ + action: { name: "settings.ntfy.update" }, + meta: { refused: PRIVATE_ORIGIN_NOT_APPROVED }, + }); + return apiError(PRIVATE_ORIGIN_REFUSAL, 422, { + errorCode: PRIVATE_ORIGIN_NOT_APPROVED, + }); + } + return apiError("Invalid data", 422); + } const { serverUrl, topic, authToken, enabled } = parsed.data; diff --git a/src/app/api/settings/ntfy/test/route.ts b/src/app/api/settings/ntfy/test/route.ts index 0d6fa08d9..3805f9f8f 100644 --- a/src/app/api/settings/ntfy/test/route.ts +++ b/src/app/api/settings/ntfy/test/route.ts @@ -42,6 +42,20 @@ export const POST = apiHandler(async () => { }); if (!result.ok) { + // A private-origin refusal is a policy decision, not a delivery fault: + // say so, with the code the card translates, instead of a bare 500 that + // sends the operator to the wide-event log to learn why (#947). + if (result.errorCode) { + annotate({ + action: { name: "settings.ntfy.test" }, + meta: { success: false, refused: result.errorCode }, + }); + return apiError( + "The server is on a private network the operator has not approved. List its exact origin (scheme://host:port) in NOTIFICATION_PRIVATE_ORIGINS on the server.", + 422, + { errorCode: result.errorCode }, + ); + } return apiError("Failed to send test message", 500); } diff --git a/src/app/api/settings/webhook/route.ts b/src/app/api/settings/webhook/route.ts index 8129a2745..d8f1c2280 100644 --- a/src/app/api/settings/webhook/route.ts +++ b/src/app/api/settings/webhook/route.ts @@ -2,22 +2,42 @@ import { prisma } from "@/lib/db"; import { apiSuccess, apiError, safeJson } from "@/lib/api-response"; import { notificationChannelEnabledSchema, - webhookSettingsSchema, + webhookSettingsSchemaWith, } from "@/lib/validations/notifications"; +import { + evaluateNotificationTarget, + isAllowedNotificationTarget, + PRIVATE_ORIGIN_NOT_APPROVED, +} from "@/lib/notifications/egress-policy"; import { encrypt, decrypt } from "@/lib/crypto"; import { NextRequest } from "next/server"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; +/** + * The save-time schema evaluates the same policy the sender does: the public + * floor, plus the operator's exact-origin grant (#947). A listed private + * address therefore saves; an unlisted one is refused here with the reason, + * before anything is encrypted onto the row. + */ +const webhookSettingsSchema = webhookSettingsSchemaWith( + isAllowedNotificationTarget, +); + +const PRIVATE_ORIGIN_REFUSAL = + "This address is on a private network. The operator has to list its exact origin (scheme://host:port) in NOTIFICATION_PRIVATE_ORIGINS before HealthLog can send to it."; + /** * Generic-webhook channel config (v1.17.1). * GET: current config (header value redacted — only presence flagged). * PUT: upsert config. * * Covers Gotify / Discord / Slack / Matrix-bridge / Home Assistant in one - * channel: the user supplies a public URL and an optional shared-secret - * header. SSRF is enforced at input time (`isPublicUrl` in the schema) and - * again at dispatch time (`safeFetch({ requirePublicHost: true })`). + * channel: the user supplies a URL and an optional shared-secret header. SSRF + * is enforced at input time (the schema's target predicate) and again at + * dispatch time (`safeFetch` with the connect-time pin); a private origin + * passes both only when the operator listed it in + * `NOTIFICATION_PRIVATE_ORIGINS`. */ export const GET = apiHandler(async () => { const { user } = await requireAuth(); @@ -108,7 +128,23 @@ export const PUT = apiHandler(async (request: NextRequest) => { } const parsed = webhookSettingsSchema.safeParse(body); - if (!parsed.success) return apiError("Invalid data", 422); + if (!parsed.success) { + const candidate = (body as { url?: unknown } | null)?.url; + if ( + typeof candidate === "string" && + evaluateNotificationTarget(candidate).reasonCode === + PRIVATE_ORIGIN_NOT_APPROVED + ) { + annotate({ + action: { name: "settings.webhook.update" }, + meta: { refused: PRIVATE_ORIGIN_NOT_APPROVED }, + }); + return apiError(PRIVATE_ORIGIN_REFUSAL, 422, { + errorCode: PRIVATE_ORIGIN_NOT_APPROVED, + }); + } + return apiError("Invalid data", 422); + } const { url, headerName, headerValue, enabled } = parsed.data; diff --git a/src/app/api/settings/webhook/test/route.ts b/src/app/api/settings/webhook/test/route.ts index 96fce255b..267345482 100644 --- a/src/app/api/settings/webhook/test/route.ts +++ b/src/app/api/settings/webhook/test/route.ts @@ -41,6 +41,20 @@ export const POST = apiHandler(async () => { }); if (!result.ok) { + // A private-origin refusal is a policy decision, not a delivery fault: + // say so, with the code the card translates, instead of a bare 500 that + // sends the operator to the wide-event log to learn why (#947). + if (result.errorCode) { + annotate({ + action: { name: "settings.webhook.test" }, + meta: { success: false, refused: result.errorCode }, + }); + return apiError( + "The target is on a private network the operator has not approved. List its exact origin (scheme://host:port) in NOTIFICATION_PRIVATE_ORIGINS on the server.", + 422, + { errorCode: result.errorCode }, + ); + } return apiError("Failed to send test message", 500); } diff --git a/src/lib/__tests__/private-origin-policy.test.ts b/src/lib/__tests__/private-origin-policy.test.ts index 88540e7fd..7cd57b174 100644 --- a/src/lib/__tests__/private-origin-policy.test.ts +++ b/src/lib/__tests__/private-origin-policy.test.ts @@ -57,10 +57,22 @@ describe("canonicalOrigin", () => { ["IPv6 link-local", "http://[fe80::1]:8080"], ["IPv4-mapped loopback", "http://[::ffff:127.0.0.1]:8080"], ["IPv4-mapped metadata", "http://[::ffff:169.254.169.254]"], + ["localhost", "http://localhost:8080"], + ["a *.localhost name", "http://gotify.localhost"], ])("refuses %s as a grant", (_label, value) => { expect(canonicalOrigin(value)).toBeNull(); }); + it("keeps mDNS and other reserved-looking names grantable when listed exactly", () => { + // The input-time floor refuses `.local` and `.internal` unlisted; an + // operator naming the exact origin is the override, and a loopback or + // metadata answer is still dropped at dial time. + expect(canonicalOrigin("http://gotify.local")).toBe("http://gotify.local"); + expect(canonicalOrigin("http://ntfy.internal:8080")).toBe( + "http://ntfy.internal:8080", + ); + }); + it("keeps RFC1918, CGNAT and ULA grantable — that is the point of the list", () => { expect(canonicalOrigin("http://10.0.0.4:1337")).toBe( "http://10.0.0.4:1337", diff --git a/src/lib/openapi/routes/settings.ts b/src/lib/openapi/routes/settings.ts index 5edde619c..746714f56 100644 --- a/src/lib/openapi/routes/settings.ts +++ b/src/lib/openapi/routes/settings.ts @@ -242,7 +242,7 @@ const channelEnabledOnlyRequest = notificationChannelEnabledSchema.meta({ const ntfySettingsRequest = ntfySettingsSchema.meta({ id: "NtfySettingsRequest", description: - "ntfy channel config. `serverUrl` must pass the SSRF floor — a URL resolving into a private range is refused at input time. An EMPTY OR OMITTED `authToken` preserves the stored one rather than clearing it: the GET never returns the secret, so a client round-tripping the form would otherwise wipe it on every unrelated save. Sending a non-empty value replaces it. There is no way to clear a stored auth token through this endpoint.", + "ntfy channel config. `serverUrl` must pass the SSRF floor — a URL resolving into a private range is refused at input time unless the operator listed its exact origin in `NOTIFICATION_PRIVATE_ORIGINS`. An EMPTY OR OMITTED `authToken` preserves the stored one rather than clearing it: the GET never returns the secret, so a client round-tripping the form would otherwise wipe it on every unrelated save. Sending a non-empty value replaces it. There is no way to clear a stored auth token through this endpoint.", }); const telegramSettingsRequest = telegramSettingsSchema.meta({ @@ -254,7 +254,7 @@ const telegramSettingsRequest = telegramSettingsSchema.meta({ const webhookSettingsRequest = webhookSettingsSchema.meta({ id: "WebhookSettingsRequest", description: - "Generic-webhook channel config — one channel covering Gotify, Discord, Slack, a Matrix bridge, Home Assistant, or any relay accepting an inbound JSON POST. `url` must pass the SSRF floor at input time and is re-checked at dispatch time. An EMPTY OR OMITTED `headerValue` preserves the stored one, for the same reason as ntfy's `authToken`: the GET never returns it. There is no way to clear a stored header value through this endpoint.", + "Generic-webhook channel config — one channel covering Gotify, Discord, Slack, a Matrix bridge, Home Assistant, or any relay accepting an inbound JSON POST. `url` must pass the SSRF floor at input time and is re-checked at dispatch time; a private origin passes both only when the operator listed it in `NOTIFICATION_PRIVATE_ORIGINS`. An EMPTY OR OMITTED `headerValue` preserves the stored one, for the same reason as ntfy's `authToken`: the GET never returns it. There is no way to clear a stored header value through this endpoint.", }); /** @@ -399,7 +399,7 @@ export const settingsPaths: NonNullable = { ...stdResponses, "422": { description: - "Body matched neither accepted shape, or `enabled` is true while the stored config has no server URL and topic.", + "Body matched neither accepted shape, or `enabled` is true while the stored config has no server URL and topic; or the server URL is on a private network the operator has not listed in `NOTIFICATION_PRIVATE_ORIGINS` (`meta.errorCode` = `private_origin_not_approved`). Nothing was stored.", content: { "application/json": { schema: errorEnvelope } }, }, }, @@ -515,7 +515,7 @@ export const settingsPaths: NonNullable = { ...stdResponses, "422": { description: - "Body matched neither accepted shape, or `enabled` is true while no webhook URL is stored.", + "Body matched neither accepted shape, or `enabled` is true while no webhook URL is stored; or the URL is on a private network the operator has not listed in `NOTIFICATION_PRIVATE_ORIGINS` (`meta.errorCode` = `private_origin_not_approved`). Nothing was stored.", content: { "application/json": { schema: errorEnvelope } }, }, }, @@ -703,7 +703,7 @@ export const settingsPaths: NonNullable = { tags: ["Notifications"], summary: "Send a test message to the saved ntfy topic", description: - "Publishes one message to the account's configured ntfy server and topic. Takes no body. Rate-limited 5 per 5 minutes per user. Refuses with 400 when no ntfy channel is saved or when the saved config is missing a server URL or topic; a publish failure is a 500.", + "Publishes one message to the account's configured ntfy server and topic. Takes no body. Rate-limited 5 per 5 minutes per user. Refuses with 400 when no ntfy channel is saved or when the saved config is missing a server URL or topic; a publish failure is a 500. A server on a private network is refused with 422 and `meta.errorCode` = `private_origin_not_approved` unless the operator listed its exact origin in `NOTIFICATION_PRIVATE_ORIGINS`; the test and the scheduled delivery take the same decision.", responses: { "200": { description: @@ -715,6 +715,11 @@ export const settingsPaths: NonNullable = { }, }, ...stdResponses, + "422": { + description: + "The saved server is on a private network the operator has not approved (`meta.errorCode` = `private_origin_not_approved`). The resolved address is never echoed.", + content: { "application/json": { schema: errorEnvelope } }, + }, }, }, }, @@ -744,7 +749,7 @@ export const settingsPaths: NonNullable = { tags: ["Notifications"], summary: "Send a test payload to the saved webhook", description: - "Posts one payload to the account's configured webhook URL. Takes no body. Rate-limited 5 per 5 minutes per user. Refuses with 400 when no webhook channel is saved or the saved config has no URL; a delivery failure is a 500. The URL was checked against the SSRF floor when it was saved, so a test cannot be used to reach an internal host.", + "Posts one payload to the account's configured webhook URL. Takes no body. Rate-limited 5 per 5 minutes per user. Refuses with 400 when no webhook channel is saved or the saved config has no URL; a delivery failure is a 500. The URL was checked against the SSRF floor when it was saved, so a test cannot be used to reach an internal host. A URL on a private network is refused with 422 and `meta.errorCode` = `private_origin_not_approved` unless the operator listed its exact origin in `NOTIFICATION_PRIVATE_ORIGINS`; the test and the scheduled delivery take the same decision.", responses: { "200": { description: @@ -756,6 +761,11 @@ export const settingsPaths: NonNullable = { }, }, ...stdResponses, + "422": { + description: + "The saved URL is on a private network the operator has not approved (`meta.errorCode` = `private_origin_not_approved`). The resolved address is never echoed.", + content: { "application/json": { schema: errorEnvelope } }, + }, }, }, }, diff --git a/src/lib/private-origin-policy.ts b/src/lib/private-origin-policy.ts index 794d08a34..df27715c6 100644 --- a/src/lib/private-origin-policy.ts +++ b/src/lib/private-origin-policy.ts @@ -48,7 +48,16 @@ export function canonicalOrigin(value: string): string | null { if (url.username || url.password) return null; if (url.pathname !== "/" || url.search || url.hash) return null; if (!url.hostname || url.hostname.includes("*")) return null; - const literal = url.hostname.replace(/^\[|\]$/g, ""); + // `localhost` and `*.localhost` are loopback by definition (RFC 6761); + // refuse them at parse time like a literal loopback address. Other + // reserved-looking names (`.local` mDNS, `.internal`, `.lan`) stay + // grantable: an explicitly listed origin is what the list is for, and + // the dial-time floor still drops a loopback or metadata answer. + const hostname = url.hostname.toLowerCase(); + if (hostname === "localhost" || hostname.endsWith(".localhost")) { + return null; + } + const literal = hostname.replace(/^\[|\]$/g, ""); if (isIP(literal) !== 0 && !isOperatorGrantableIp(literal)) return null; return url.origin; } catch { diff --git a/src/lib/validations/notifications.ts b/src/lib/validations/notifications.ts index 1cc8e12e6..98d960e01 100644 --- a/src/lib/validations/notifications.ts +++ b/src/lib/validations/notifications.ts @@ -337,41 +337,64 @@ export function isPublicUrl(url: string): boolean { } } -export const ntfySettingsSchema = z.object({ - serverUrl: z - .url("Ungültige Server-URL") - .max(200) - .refine( - (url) => isPublicUrl(url), - "Server-URL darf nicht auf interne Netzwerke zeigen", - ) - .optional() - .or(z.literal("")), - topic: z.string().max(100).optional().or(z.literal("")), - authToken: z.string().max(200).optional().or(z.literal("")), - enabled: z.boolean(), -}); +/** + * ntfy channel settings. The server URL is user-supplied and must pass the + * target predicate at input time. The exported constant carries the plain + * public floor (`isPublicUrl`) for the OpenAPI contract; the save route + * builds the same shape with `isAllowedNotificationTarget`, which adds the + * operator's `NOTIFICATION_PRIVATE_ORIGINS` grant (#947) on top of the floor + * without this module having to read the environment. + */ +export function ntfySettingsSchemaWith( + isAllowedTarget: (url: string) => boolean, +) { + return z.object({ + serverUrl: z + .url("Ungültige Server-URL") + .max(200) + .refine( + isAllowedTarget, + "Server-URL darf nicht auf interne Netzwerke zeigen", + ) + .optional() + .or(z.literal("")), + topic: z.string().max(100).optional().or(z.literal("")), + authToken: z.string().max(200).optional().or(z.literal("")), + enabled: z.boolean(), + }); +} + +export const ntfySettingsSchema = ntfySettingsSchemaWith(isPublicUrl); /** * Generic-webhook channel settings (v1.17.1). The URL is user-supplied and - * must pass the SSRF floor (`isPublicUrl`) at input time; the dispatcher - * re-checks it at fetch time via `safeFetch({ requirePublicHost: true })`. The - * optional header name/value carry a shared secret (e.g. Gotify token). + * must pass the target predicate at input time; the sender re-checks it at + * fetch time through `safeFetch` with the connect-time pin. The exported + * constant carries the plain public floor for the OpenAPI contract; the save + * route passes `isAllowedNotificationTarget` so an origin the operator listed + * in `NOTIFICATION_PRIVATE_ORIGINS` (#947) saves. The optional header + * name/value carry a shared secret (e.g. Gotify token). */ -export const webhookSettingsSchema = z.object({ - url: z - .url("Ungültige Webhook-URL") - .max(500) - .refine( - (url) => isPublicUrl(url), - "Webhook-URL darf nicht auf interne Netzwerke zeigen", - ) - .optional() - .or(z.literal("")), - headerName: z.string().max(100).optional().or(z.literal("")), - headerValue: z.string().max(500).optional().or(z.literal("")), - enabled: z.boolean(), -}); +export function webhookSettingsSchemaWith( + isAllowedTarget: (url: string) => boolean, +) { + return z.object({ + url: z + .url("Ungültige Webhook-URL") + .max(500) + .refine( + isAllowedTarget, + "Webhook-URL darf nicht auf interne Netzwerke zeigen", + ) + .optional() + .or(z.literal("")), + headerName: z.string().max(100).optional().or(z.literal("")), + headerValue: z.string().max(500).optional().or(z.literal("")), + enabled: z.boolean(), + }); +} + +export const webhookSettingsSchema = webhookSettingsSchemaWith(isPublicUrl); /** * Email channel settings (v1.17.1). The SMTP transport is operator-configured;