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
This commit is contained in:
Marc-André Bombeck committed 2026-09-10 16:46:13 +02:00
1 parent 6c75c4f39f
commit 984a75d036
17 files changed
+614 -56

No files matched your search

+32 -11
View File
@@ -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
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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에 엔드포인트가 없어요",
+1
View File
@@ -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",
@@ -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<Response>;
function put(path: string, body: unknown): Promise<Response> {
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<Response> {
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/);
}
});
});
+33 -2
View File
@@ -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;
+14
View File
@@ -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);
}
+41 -5
View File
@@ -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;
@@ -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);
}
@@ -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",
+16 -6
View File
@@ -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<ZodOpenApiObject["paths"]> = {
...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<ZodOpenApiObject["paths"]> = {
...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<ZodOpenApiObject["paths"]> = {
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<ZodOpenApiObject["paths"]> = {
},
},
...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<ZodOpenApiObject["paths"]> = {
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<ZodOpenApiObject["paths"]> = {
},
},
...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 } },
},
},
},
},
+10 -1
View File
@@ -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 {
+54 -31
View File
@@ -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;