Merge remote-tracking branch 'origin/main' into feat/onboarding-wizard

This commit is contained in:
Marc-André Bombeck committed 2026-09-10 20:53:38 +02:00
commit 8ed46eef58
49 files changed
+3199 -307

No files matched your search

+17 -4
View File
@@ -26,6 +26,7 @@ import {
configuredNightscoutPrivateOrigins,
evaluateNightscoutOrigin,
nightscoutConnectSchema,
NightscoutOriginConfigError,
} from "@/lib/validations/nightscout";
/**
@@ -67,10 +68,22 @@ export const POST = apiHandler(async (request: NextRequest) => {
}
const { url, token } = result.data;
const policy = evaluateNightscoutOrigin(
url,
configuredNightscoutPrivateOrigins(),
);
// An operator list that holds a non-grant is an operator problem, not a
// user one: say which entry and why, rather than a 500 with no words.
let privateOrigins: ReadonlySet<string>;
try {
privateOrigins = configuredNightscoutPrivateOrigins();
} catch (err) {
if (err instanceof NightscoutOriginConfigError) {
annotate({
action: { name: "nightscout.connect" },
meta: { refused: "operator_origin_list_invalid" },
});
return apiError(err.message, 422);
}
throw err;
}
const policy = evaluateNightscoutOrigin(url, privateOrigins);
// SSRF floor at INPUT time, not merely as a side effect of the live probe
// below. `fetchSgvEntries` does route through `safeFetch` with the same
@@ -0,0 +1,431 @@
/**
* #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("saves a listed loopback target and refuses an unlisted one with the listable code", async () => {
// Host networking: an exact loopback origin with its port is a grant like
// any other. Unlisted, it is refused with the code that says "list it",
// because listing it does open it.
process.env.NOTIFICATION_PRIVATE_ORIGINS = "http://localhost:8080";
const refused = await put("webhook", {
url: "http://127.0.0.1:8080/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://localhost:8080/message",
enabled: true,
});
expect(saved.status).toBe(200);
expect(prisma.notificationChannel.upsert).toHaveBeenCalledTimes(1);
});
it("answers a metadata or unspecified target with the not-grantable code, listed or not (M1)", async () => {
// This used to pin a dead end: the route said "list it", the parser
// dropped the listing, and the next save said "list it" again. A target
// no grant can open gets its own code and a message that says so.
vi.spyOn(console, "warn").mockImplementation(() => {});
process.env.NOTIFICATION_PRIVATE_ORIGINS =
"http://169.254.169.254, http://0.0.0.0:8080";
for (const url of [
"http://169.254.169.254/latest",
"http://0.0.0.0:8080/message",
"http://[fe80::1]: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_grantable" });
expect(json.error).toMatch(/no operator grant can open/);
expect(json.error).not.toContain("NOTIFICATION_PRIVATE_ORIGINS");
expect(json.details.issues.length).toBeGreaterThan(0);
}
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: forwards the not-grantable code with a message that does not say list it (M1)",
async (path, sender) => {
sender.mockResolvedValue({
ok: false,
hardReject: false,
reason: `${path}_private_origin_refused`,
errorCode: "private_origin_not_grantable",
});
const response = await post(path);
expect(response.status).toBe(422);
const json = await response.json();
expect(json.meta).toEqual({ errorCode: "private_origin_not_grantable" });
expect(json.error).toMatch(/no operator grant can open/);
expect(json.error).not.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/);
}
});
});
+47 -7
View File
@@ -8,14 +8,32 @@ import {
} from "@/lib/api-response";
import {
notificationChannelEnabledSchema,
ntfySettingsSchema,
ntfySettingsSchemaWith,
} from "@/lib/validations/notifications";
import {
evaluateNotificationTarget,
isAllowedNotificationTarget,
PRIVATE_ORIGIN_NOT_APPROVED_CODE,
PRIVATE_ORIGIN_NOT_GRANTABLE_CODE,
} 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.";
const NOT_GRANTABLE_REFUSAL =
"This server is a link-local, metadata or unspecified address, which no operator grant can open. Use the address the relay actually listens on.";
/**
* Refusal for an enable attempt on a channel the operator switched off
* instance-wide (`AppSettings.ntfyGlobal`). A toggle that appeared to work
@@ -125,12 +143,34 @@ export const PUT = apiHandler(async (request: NextRequest) => {
}
const parsed = ntfySettingsSchema.safeParse(body);
if (!parsed.success)
return apiValidationError(
"Invalid data",
sanitiseZodIssues(parsed.error.issues),
422,
);
if (!parsed.success) {
// Every shape refusal carries the issue list; the private-origin case
// adds the code and a message naming the operator's lever (#947).
const issues = sanitiseZodIssues(parsed.error.issues);
const candidate = (body as { serverUrl?: unknown } | null)?.serverUrl;
const reason =
typeof candidate === "string"
? evaluateNotificationTarget(candidate).reasonCode
: null;
if (
reason === PRIVATE_ORIGIN_NOT_APPROVED_CODE ||
reason === PRIVATE_ORIGIN_NOT_GRANTABLE_CODE
) {
annotate({
action: { name: "settings.ntfy.update" },
meta: { refused: reason },
});
return apiValidationError(
reason === PRIVATE_ORIGIN_NOT_GRANTABLE_CODE
? NOT_GRANTABLE_REFUSAL
: PRIVATE_ORIGIN_REFUSAL,
issues,
422,
{ errorCode: reason },
);
}
return apiValidationError("Invalid data", issues, 422);
}
const { serverUrl, topic, authToken, enabled } = parsed.data;
+16
View File
@@ -42,6 +42,22 @@ 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(
result.errorCode === "private_origin_not_grantable"
? "The server is a link-local, metadata or unspecified address, which no operator grant can open. Use the address the relay actually listens on."
: "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);
}
+55 -10
View File
@@ -8,22 +8,45 @@ import {
} from "@/lib/api-response";
import {
notificationChannelEnabledSchema,
webhookSettingsSchema,
webhookSettingsSchemaWith,
} from "@/lib/validations/notifications";
import {
evaluateNotificationTarget,
isAllowedNotificationTarget,
PRIVATE_ORIGIN_NOT_APPROVED_CODE,
PRIVATE_ORIGIN_NOT_GRANTABLE_CODE,
} 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.";
const NOT_GRANTABLE_REFUSAL =
"This address is a link-local, metadata or unspecified address, which no operator grant can open. Use the address the relay actually listens on.";
/**
* 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();
@@ -114,12 +137,34 @@ export const PUT = apiHandler(async (request: NextRequest) => {
}
const parsed = webhookSettingsSchema.safeParse(body);
if (!parsed.success)
return apiValidationError(
"Invalid data",
sanitiseZodIssues(parsed.error.issues),
422,
);
if (!parsed.success) {
// Every shape refusal carries the issue list; the private-origin case
// adds the code and a message naming the operator's lever (#947).
const issues = sanitiseZodIssues(parsed.error.issues);
const candidate = (body as { url?: unknown } | null)?.url;
const reason =
typeof candidate === "string"
? evaluateNotificationTarget(candidate).reasonCode
: null;
if (
reason === PRIVATE_ORIGIN_NOT_APPROVED_CODE ||
reason === PRIVATE_ORIGIN_NOT_GRANTABLE_CODE
) {
annotate({
action: { name: "settings.webhook.update" },
meta: { refused: reason },
});
return apiValidationError(
reason === PRIVATE_ORIGIN_NOT_GRANTABLE_CODE
? NOT_GRANTABLE_REFUSAL
: PRIVATE_ORIGIN_REFUSAL,
issues,
422,
{ errorCode: reason },
);
}
return apiValidationError("Invalid data", issues, 422);
}
const { url, headerName, headerValue, enabled } = parsed.data;
@@ -41,6 +41,22 @@ 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(
result.errorCode === "private_origin_not_grantable"
? "The target is a link-local, metadata or unspecified address, which no operator grant can open. Use the address the relay actually listens on."
: "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);
}