mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
Merge remote-tracking branch 'origin/main' into feat/onboarding-wizard
This commit is contained in:
commit
8ed46eef58
49 files changed
+3199
-307
No files matched your search
@@ -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/);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user