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);
}
+14 -2
View File
@@ -72,8 +72,20 @@ export function NtfyCard({ isAuthenticated }: { isAuthenticated: boolean }) {
}),
});
if (!res.ok) {
const json = await res.json();
throw new Error(json.error || t("common.error"));
const json = (await res.json()) as {
error?: string;
meta?: { errorCode?: string };
};
// A coded refusal (the private-origin policy) translates like the
// test button's; anything else shows the server sentence.
const code = json.meta?.errorCode;
const key = code ? `settings.testConnection.errors.${code}` : null;
const translated = key ? t(key) : null;
throw new Error(
translated && translated !== key
? translated
: json.error || t("common.error"),
);
}
},
onSuccess: () => {
+14 -2
View File
@@ -72,8 +72,20 @@ export function WebhookCard({ isAuthenticated }: { isAuthenticated: boolean }) {
}),
});
if (!res.ok) {
const json = await res.json();
throw new Error(json.error || t("common.error"));
const json = (await res.json()) as {
error?: string;
meta?: { errorCode?: string };
};
// A coded refusal (the private-origin policy) translates like the
// test button's; anything else shows the server sentence.
const code = json.meta?.errorCode;
const key = code ? `settings.testConnection.errors.${code}` : null;
const translated = key ? t(key) : null;
throw new Error(
translated && translated !== key
? translated
: json.error || t("common.error"),
);
}
},
onSuccess: () => {
@@ -0,0 +1,286 @@
/**
* The exact-origin grant grammar shared by every operator allowlist that opens
* a private destination (`NIGHTSCOUT_PRIVATE_ORIGINS`,
* `NOTIFICATION_PRIVATE_ORIGINS`).
*
* Two properties are pinned here and nowhere else:
*
* 1. A grant names one canonical `scheme://host[:port]` and nothing wider —
* no path, credentials, wildcard, suffix, sibling port or sub-host.
* 2. A grant can never name loopback, the unspecified address, link-local or
* the cloud-metadata range. Those are refused at parse time when written
* as a literal, and the pinned dispatcher refuses them again when a
* listed DNS name resolves there (`safe-fetch-dispatcher.test.ts`).
*/
import { describe, expect, it, vi } from "vitest";
import {
canonicalOrigin,
describeGrantRejection,
evaluateOriginGrant,
parsePrivateOrigins,
PRIVATE_ORIGIN_NOT_APPROVED_CODE,
PRIVATE_ORIGIN_NOT_GRANTABLE_CODE,
redactGrantEntry,
} from "../private-origin-policy";
describe("canonicalOrigin", () => {
it("normalises case and drops a default port", () => {
expect(canonicalOrigin("HTTPS://Gotify.Example.COM:443")).toBe(
"https://gotify.example.com",
);
expect(canonicalOrigin("http://ntfy.lan:8080")).toBe(
"http://ntfy.lan:8080",
);
expect(canonicalOrigin("https://[fd00::4]:8443")).toBe(
"https://[fd00::4]:8443",
);
});
it.each([
["credentials", "http://operator:secret@10.0.0.4:1337"],
["non-root path", "https://gotify.example.com/message"],
["query", "http://10.0.0.4:1337?token=secret"],
["fragment", "http://10.0.0.4:1337#admin"],
["wrong scheme", "ftp://10.0.0.4:1337"],
["wildcard", "https://*.gotify.lan"],
["suffix", ".gotify.lan"],
["malformed", "not an origin"],
])("refuses a %s as a grant", (_label, value) => {
expect(canonicalOrigin(value)).toBeNull();
});
it.each([
["IPv4 unspecified", "http://0.0.0.0:8080"],
["link-local / metadata", "http://169.254.169.254"],
["IPv6 unspecified", "http://[::]:8080"],
["IPv6 link-local", "http://[fe80::1]:8080"],
["IPv4-mapped metadata", "http://[::ffff:169.254.169.254]"],
])("refuses %s as a grant", (_label, value) => {
expect(canonicalOrigin(value)).toBeNull();
});
it("keeps loopback grantable: an exact loopback origin is a deliberate host-networking decision", () => {
expect(canonicalOrigin("http://127.0.0.1:8080")).toBe(
"http://127.0.0.1:8080",
);
expect(canonicalOrigin("http://127.10.20.30")).toBe("http://127.10.20.30");
expect(canonicalOrigin("http://[::1]:8080")).toBe("http://[::1]:8080");
expect(canonicalOrigin("http://localhost:1337")).toBe(
"http://localhost:1337",
);
expect(canonicalOrigin("http://gotify.localhost")).toBe(
"http://gotify.localhost",
);
});
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",
);
expect(canonicalOrigin("http://192.168.1.20")).toBe("http://192.168.1.20");
expect(canonicalOrigin("http://172.16.0.9:8080")).toBe(
"http://172.16.0.9:8080",
);
expect(canonicalOrigin("http://100.64.0.7")).toBe("http://100.64.0.7");
expect(canonicalOrigin("http://[fd12:3456::1]:8080")).toBe(
"http://[fd12:3456::1]:8080",
);
});
});
describe("parsePrivateOrigins", () => {
it("parses a comma-separated setting into canonical exact origins", () => {
const onInvalid = vi.fn();
expect(
parsePrivateOrigins(
" http://10.0.0.4:1337, HTTPS://GOTIFY.LAN:443 ,https://[fd00::4]:8443 ",
onInvalid,
),
).toEqual(
new Set([
"http://10.0.0.4:1337",
"https://gotify.lan",
"https://[fd00::4]:8443",
]),
);
expect(onInvalid).not.toHaveBeenCalled();
});
it("grants nothing for an empty setting", () => {
const onInvalid = vi.fn();
expect(parsePrivateOrigins(undefined, onInvalid)).toEqual(new Set());
expect(parsePrivateOrigins(" , ", onInvalid)).toEqual(new Set());
expect(onInvalid).not.toHaveBeenCalled();
});
it("hands every malformed entry to the caller, with a reason, and keeps the valid ones", () => {
const onInvalid = vi.fn();
expect(
parsePrivateOrigins(
"https://gotify.lan, https://*.lan, http://169.254.169.254, not an origin",
onInvalid,
),
).toEqual(new Set(["https://gotify.lan"]));
expect(onInvalid.mock.calls.map(([entry]) => entry)).toEqual([
"https://*.lan",
"http://169.254.169.254",
"not an origin",
]);
expect(onInvalid.mock.calls[1][1]).toMatch(/link-local.*cannot be granted/);
expect(onInvalid.mock.calls[0][1]).toMatch(/one exact http\(s\) origin/);
});
it("never hands a query, fragment or userinfo to the caller (M3)", () => {
const onInvalid = vi.fn();
parsePrivateOrigins(
"http://10.0.0.4/message?token=AbC123, http://user:pw@gotify.lan/, https://ntfy.lan/topic#frag",
onInvalid,
);
const seen = onInvalid.mock.calls.map(([entry]) => entry);
expect(seen).toEqual([
"http://10.0.0.4/message",
"http://gotify.lan/",
"https://ntfy.lan/topic",
]);
expect(JSON.stringify(onInvalid.mock.calls)).not.toMatch(
/AbC123|token=|user:pw|#frag/,
);
});
it("lets the caller turn a malformed entry into a throw", () => {
expect(() =>
parsePrivateOrigins("https://gotify.lan, junk", (entry) => {
throw new Error(`bad entry: ${entry}`);
}),
).toThrow("bad entry: junk");
});
});
describe("evaluateOriginGrant", () => {
const grants = new Set(["http://10.0.0.4:1337", "https://gotify.lan:8443"]);
it("approves exactly the listed scheme, host and port", () => {
expect(evaluateOriginGrant("http://10.0.0.4:1337", grants)).toEqual({
allowed: true,
canonicalOrigin: "http://10.0.0.4:1337",
privateOriginApproved: true,
reasonCode: null,
});
});
it.each([
["scheme", "https://10.0.0.4:1337"],
["port", "http://10.0.0.4:8080"],
["neighbouring address", "http://10.0.0.40:1337"],
["sub-host", "https://sub.gotify.lan:8443"],
["sibling port on a listed name", "https://gotify.lan"],
])("refuses a %s that differs from the grant", (_label, origin) => {
expect(evaluateOriginGrant(origin, grants)).toEqual({
allowed: false,
canonicalOrigin: origin,
privateOriginApproved: false,
reasonCode: PRIVATE_ORIGIN_NOT_APPROVED_CODE,
});
});
it("keeps a public origin functional without any grant", () => {
expect(evaluateOriginGrant("https://ntfy.sh", new Set())).toEqual({
allowed: true,
canonicalOrigin: "https://ntfy.sh",
privateOriginApproved: false,
reasonCode: null,
});
});
it("refuses an unlisted private origin", () => {
expect(evaluateOriginGrant("http://192.168.1.9:8080", grants)).toEqual({
allowed: false,
canonicalOrigin: "http://192.168.1.9:8080",
privateOriginApproved: false,
reasonCode: PRIVATE_ORIGIN_NOT_APPROVED_CODE,
});
});
it("approves a listed loopback origin and refuses an unlisted one", () => {
const loopbackGrants = new Set([
"http://localhost:1337",
"http://[::1]:8080",
]);
expect(
evaluateOriginGrant("http://localhost:1337", loopbackGrants),
).toEqual({
allowed: true,
canonicalOrigin: "http://localhost:1337",
privateOriginApproved: true,
reasonCode: null,
});
expect(
evaluateOriginGrant("http://[::1]:8080", loopbackGrants),
).toMatchObject({ allowed: true, privateOriginApproved: true });
for (const origin of [
"http://localhost:1338",
"http://127.0.0.1:1337",
"http://[::1]:8081",
]) {
expect(evaluateOriginGrant(origin, loopbackGrants)).toEqual({
allowed: false,
canonicalOrigin: origin,
privateOriginApproved: false,
reasonCode: PRIVATE_ORIGIN_NOT_APPROVED_CODE,
});
}
});
it.each([
["link-local / metadata", "http://169.254.169.254"],
["unspecified", "http://0.0.0.0:8080"],
["IPv6 link-local", "http://[fe80::1]:8080"],
["IPv6 unspecified", "http://[::]:8080"],
])(
"answers %s with the not-grantable code, not a request to list it (M1)",
(_label, origin) => {
expect(evaluateOriginGrant(origin, grants)).toEqual({
allowed: false,
canonicalOrigin: origin,
privateOriginApproved: false,
reasonCode: PRIVATE_ORIGIN_NOT_GRANTABLE_CODE,
});
},
);
});
describe("redactGrantEntry / describeGrantRejection", () => {
it("keeps scheme and host, drops userinfo, query and fragment", () => {
expect(redactGrantEntry("http://user:pw@gotify.lan/?token=x#y")).toBe(
"http://gotify.lan/",
);
expect(redactGrantEntry("https://gotify.example.com")).toBe(
"https://gotify.example.com",
);
expect(redactGrantEntry("not an origin?token=x")).toBe("not an origin");
});
it("names the never-grantable class apart from a grammar error", () => {
expect(describeGrantRejection("http://169.254.169.254")).toMatch(
/link-local.*cannot be granted/,
);
expect(describeGrantRejection("http://[fe80::1]:1337")).toMatch(
/link-local/,
);
expect(describeGrantRejection("https://*.lan")).toMatch(/exact http/);
expect(describeGrantRejection("http://10.0.0.4/path")).toMatch(
/exact http/,
);
});
});
+163 -1
View File
@@ -1,8 +1,14 @@
import { describe, it, expect, afterEach, vi } from "vitest";
import dns from "node:dns";
import http from "node:http";
import type { AddressInfo } from "node:net";
import { fetch as undiciFetch } from "undici";
import { isPublicIp } from "@/lib/validations/notifications";
import {
isOperatorGrantableIp,
isPublicIp,
} from "@/lib/validations/notifications";
import {
getPinnedOperatorApprovedDispatcher,
getPinnedPublicDispatcher,
_resetPinnedDispatcherForTests,
} from "../safe-fetch-dispatcher";
@@ -18,6 +24,21 @@ const EMBEDDED_PRIVATE_DNS_ANSWERS = [
["RFC8215 local-use metadata", "64:ff9b:1:a9fe:0:a9fe::"],
] as const;
/** True when the policy marker the dispatcher stamps is anywhere in the chain. */
function hasPrivateHostMarker(error: unknown): boolean {
if (!error || typeof error !== "object") return false;
const value = error as {
safeFetchKind?: unknown;
cause?: unknown;
errors?: unknown[];
};
return (
value.safeFetchKind === "private_host" ||
hasPrivateHostMarker(value.cause) ||
(value.errors ?? []).some(hasPrivateHostMarker)
);
}
function errorCodes(error: unknown): string[] {
if (!error || typeof error !== "object") return [];
const value = error as {
@@ -221,3 +242,144 @@ describe("pinnedPublicDispatcher", () => {
5_000,
);
});
describe("isOperatorGrantableIp", () => {
it("keeps the private ranges an operator may deliberately list", () => {
expect(isOperatorGrantableIp("10.0.0.5")).toBe(true);
// Loopback is grantable: a host-networking deployment lists it exactly.
expect(isOperatorGrantableIp("127.0.0.1")).toBe(true);
expect(isOperatorGrantableIp("127.255.0.1")).toBe(true);
expect(isOperatorGrantableIp("::1")).toBe(true);
expect(isOperatorGrantableIp("::ffff:127.0.0.1")).toBe(true);
expect(isOperatorGrantableIp("172.16.0.9")).toBe(true);
expect(isOperatorGrantableIp("192.168.1.20")).toBe(true);
expect(isOperatorGrantableIp("100.64.0.7")).toBe(true);
expect(isOperatorGrantableIp("fd12:3456:789a::1")).toBe(true);
expect(isOperatorGrantableIp("::ffff:10.0.0.5")).toBe(true);
});
it("keeps public addresses grantable (a listed public origin is harmless)", () => {
expect(isOperatorGrantableIp("203.0.113.5")).toBe(true);
expect(isOperatorGrantableIp("2001:db8::1")).toBe(true);
});
it.each([
["IPv4 unspecified", "0.0.0.0"],
["IPv4 link-local / metadata", "169.254.169.254"],
["IPv6 unspecified", "::"],
["IPv6 link-local", "fe80::1"],
["IPv4-mapped metadata", "::ffff:169.254.169.254"],
["IPv4-mapped unspecified", "::ffff:0.0.0.0"],
["6to4 metadata", "2002:a9fe:a9fe::"],
["NAT64 metadata", "64:ff9b::a9fe:a9fe"],
["not an address", "gotify.lan"],
["empty", ""],
])("never grants %s, listed or not", (_label, ip) => {
expect(isOperatorGrantableIp(ip)).toBe(false);
});
});
describe("pinnedOperatorApprovedDispatcher", () => {
afterEach(() => {
vi.restoreAllMocks();
_resetPinnedDispatcherForTests();
});
function resolveTo(addresses: dns.LookupAddress[]): void {
vi.spyOn(dns, "lookup").mockImplementation(((
_hostname: string,
_opts: dns.LookupAllOptions,
callback: (
err: NodeJS.ErrnoException | null,
addrs: dns.LookupAddress[],
) => void,
) => {
callback(null, addresses);
}) as unknown as typeof dns.lookup);
}
it.each([
["link-local / metadata", { address: "169.254.169.254", family: 4 }],
["unspecified", { address: "0.0.0.0", family: 4 }],
["IPv6 unspecified", { address: "::", family: 6 }],
["IPv6 link-local", { address: "fe80::1", family: 6 }],
["IPv4-mapped metadata", { address: "::ffff:169.254.169.254", family: 6 }],
])(
"refuses a listed name that resolves to %s even under the approved policy",
async (_label, answer) => {
resolveTo([answer]);
const dispatcher = getPinnedOperatorApprovedDispatcher();
let caught: unknown;
try {
await undiciFetch("http://gotify-listed.example.test:8089/message", {
dispatcher,
signal: AbortSignal.timeout(750),
});
} catch (error) {
caught = error;
}
expect(caught).toBeDefined();
expect(errorCodes(caught)).toContain("ENOTFOUND");
// A real NXDOMAIN would also be ENOTFOUND; the policy marker is what
// proves the refusal came from the floor and not from the resolver.
expect(hasPrivateHostMarker(caught)).toBe(true);
},
5_000,
);
it("forwards an RFC1918 answer to the connector (the grant does its job)", async () => {
// 10.255.255.1 is private and, on this harness, unroutable: the pinned
// lookup must ACCEPT it (no ENOTFOUND) and the failure, if any, comes from
// the socket below.
resolveTo([{ address: "10.255.255.1", family: 4 }]);
const dispatcher = getPinnedOperatorApprovedDispatcher();
let caught: unknown;
try {
await undiciFetch("http://gotify-listed.example.test:8089/message", {
dispatcher,
signal: AbortSignal.timeout(500),
});
} catch (error) {
caught = error;
}
expect(errorCodes(caught)).not.toContain("ENOTFOUND");
}, 20_000);
it("drops the metadata answer from a mixed set and dials the loopback one", async () => {
// A real server on loopback proves both halves: the listed name resolves
// to the metadata endpoint AND to loopback; the pinned lookup drops the
// metadata answer and hands undici the loopback survivor, which is
// grantable, so the server receives exactly one request.
let requests = 0;
const server = http.createServer((_req, res) => {
requests += 1;
res.writeHead(200);
res.end();
});
await new Promise<void>((resolve) =>
server.listen(0, "127.0.0.1", () => resolve()),
);
const port = (server.address() as AddressInfo).port;
try {
resolveTo([
{ address: "169.254.169.254", family: 4 },
{ address: "127.0.0.1", family: 4 },
]);
const dispatcher = getPinnedOperatorApprovedDispatcher();
const res = await undiciFetch(
`http://gotify-listed.example.test:${port}/message`,
{ dispatcher, signal: AbortSignal.timeout(5_000) },
);
expect(res.status).toBe(200);
expect(requests).toBe(1);
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
}, 20_000);
});
+24 -8
View File
@@ -58,14 +58,30 @@ describe("SSRF requirePublicHost pin inventory", () => {
);
});
// v1.11.2 product-lead M2 (optional) — lock the PRE-EXISTING user/operator
// webhook pins too, so the full inventory is CI-guarded and a future edit
// that drops one of these reds here rather than shipping silently.
it("ntfy sender pins the user/operator-supplied server outbound", () => {
expect(read("notifications/senders/ntfy.ts")).toMatch(
/requirePublicHost:\s*true/,
);
});
// v1.11.2 locked the ntfy pin as an unconditional `true`. Since #947 both
// user-supplied notification targets carry the same conditional shape as
// Nightscout: the public pin unless the operator listed the exact origin
// in NOTIFICATION_PRIVATE_ORIGINS, in which case the operator-approved pin
// takes over. Neither a bare `true` (the grant would be dead) nor a bare
// `false` (the pin would be gone) may reappear.
it.each([
["webhook", "notifications/senders/webhook.ts"],
["ntfy", "notifications/senders/ntfy.ts"],
])(
"%s sender routes an exact operator-approved private origin through the private pin",
(_channel, rel) => {
const src = read(rel);
expect(src).toMatch(
/requirePublicHost:\s*!policy\.privateOriginApproved/,
);
expect(src).toMatch(
/operatorApprovedPrivateOrigin:\s*policy\.canonicalOrigin/,
);
expect(src).not.toMatch(/requirePublicHost:\s*(?:true|false)\b/);
// The verdict must come from the shared policy, never a local check.
expect(src).toMatch(/evaluateNotificationTarget\(/);
},
);
it("local AI client keeps the pin CONDITIONAL (LAN escape hatch)", () => {
const src = read("ai/local-client.ts");
@@ -1673,6 +1673,69 @@ describe("buildComplianceDisplay — two rows, cadence-scaled windows", () => {
expect(display.currentCycle.hasClosedCycles).toBe(true);
});
it("currentCycle — taking the first of two close doses leaves the second due", () => {
// 19:49 + 23:59 Berlin (CEST): 17:49Z + 21:59Z. The 19:49 take used to
// resolve the 23:59 slot too (flat ±6 h radius), so the open cycle
// pointed at tomorrow's 19:49 while tonight's dose was still due.
const schedules: ComplianceSchedule[] = [
{
windowStart: "19:49",
windowEnd: "23:59",
daysOfWeek: null,
rollingIntervalDays: null,
timesOfDay: ["19:49", "23:59"],
},
];
const slot = new Date("2025-06-15T17:49:00Z");
const takenAt = new Date("2025-06-15T17:49:12Z");
const events = [{ scheduledFor: slot, takenAt, skipped: false }];
const display = buildComplianceDisplay(
events,
schedules,
ctx({ lastIntakeAt: takenAt, timeZone: "Europe/Berlin" }),
{ now: new Date("2025-06-15T18:00:00Z") },
);
expect(display.currentCycle.state).toBe("on_track");
expect(display.currentCycle.nextDueAt?.toISOString()).toBe(
"2025-06-15T21:59:00.000Z",
);
});
it("currentCycle — a row on sibling schedule A's 08:00 leaves sibling B's 12:00 due", () => {
// Two schedule rows on one medication (08:00 daily plus 12:00 daily).
// Berlin (CEST): 06:00Z + 10:00Z. The row carries no schedule identity;
// B's slot must lose to A's nearer occurrence, not read resolved.
const schedules: ComplianceSchedule[] = [
{
windowStart: "08:00",
windowEnd: "08:00",
daysOfWeek: null,
rollingIntervalDays: null,
timesOfDay: ["08:00"],
},
{
windowStart: "12:00",
windowEnd: "12:00",
daysOfWeek: null,
rollingIntervalDays: null,
timesOfDay: ["12:00"],
},
];
const slot = new Date("2025-06-15T06:00:00Z");
const takenAt = new Date("2025-06-15T06:03:00Z");
const events = [{ scheduledFor: slot, takenAt, skipped: false }];
const display = buildComplianceDisplay(
events,
schedules,
ctx({ lastIntakeAt: takenAt, timeZone: "Europe/Berlin" }),
{ now: new Date("2025-06-15T07:00:00Z") },
);
expect(display.currentCycle.state).toBe("on_track");
expect(display.currentCycle.nextDueAt?.toISOString()).toBe(
"2025-06-15T10:00:00.000Z",
);
});
it("currentCycle — rolling weekly overdue past grace → missed", () => {
// v1.13.x Fix 4 — last shot ~10 days ago (cadence 7) → next due was 3
// days ago, well past grace → the open cycle is `missed` (the only red
+49 -24
View File
@@ -3,8 +3,16 @@
import {
nextOccurrenceAfter,
type CanonicalSchedule,
type Occurrence,
type RecurrenceContext,
} from "@/lib/medications/scheduling/recurrence";
import {
ADHOC_RESOLVE_EPSILON_MS,
anchorResolvesOccurrence,
RESOLVE_RADIUS_MS,
type NearestOccurrenceCache,
} from "@/lib/medications/scheduling/slot-resolution";
import {
lastNonSkippedTakenAt,
type ComplianceIntakeInstant,
@@ -64,40 +72,46 @@ export interface CurrentCycle {
* selection: a window holding zero expected (closed) doses means the
* percentage rows are vacuous and the card should show a neutral state.
*/
/**
* v1.15.10 — half-window an intake may sit from a slot's canonical instant
* and still count as "resolving" that slot. The intake write paths snap
* `scheduledFor` to the exact engine slot instant, so an exact (or
* sub-minute) match is the common case; the ±6h tolerance also catches an
* off-time take on a dense intraday cadence (e.g. a 07:00 dose logged 09:13)
* whose snapped slot is the 07:00 row but whose raw instant we compare
* defensively. It is the same ±half-gap radius the cadence pairer uses for a
* 12h-gap (twice-daily) med, floored so a single-dose-a-day cadence still
* matches its one slot.
*/
const OPEN_DOSE_RESOLVE_RADIUS_MS = 6 * 60 * 60 * 1000;
/**
* v1.15.10 — true when an intake event has already resolved the slot at
* `slotAt`. A resolved slot is one the user took, deliberately skipped, or
* the auto-miss cron flagged — any of which means the card must NOT keep
* surfacing that slot as the next dose. Matches on the snapped `scheduledFor`
* first (the canonical, exact path) and falls back to the take/skip instant
* within the resolve radius for rows that predate the snap or drifted.
* surfacing that slot as the next dose. Anchors on the snapped
* `scheduledFor` (the canonical, exact path), falling back to the take
* instant for a legacy row without one. A drifted anchor resolves its
* nearest occurrence only, within the shared drift radius, so a sibling
* slot closer than that radius is never swallowed; an ad-hoc row
* (`scheduledFor === takenAt`) resolves only the slot it sits on. Same rule
* as the list's next-due (`slot-resolution.ts`), so the cycle descriptor
* and the card's due slot cannot disagree.
*/
function slotIsResolved(
slotAt: Date,
intakes: ComplianceIntakeInstant[],
schedule: CanonicalSchedule,
schedules: CanonicalSchedule[],
ctx: RecurrenceContext,
cache: NearestOccurrenceCache,
): boolean {
const slot = slotAt.getTime();
for (const e of intakes) {
const isResolved = e.skipped || e.autoMissed === true || e.takenAt !== null;
if (!isResolved) continue;
const ref = (e.scheduledFor ?? e.takenAt) as Date | undefined;
if (!ref) continue;
if (Math.abs(ref.getTime() - slot) <= OPEN_DOSE_RESOLVE_RADIUS_MS) {
return true;
}
const adHoc =
e.scheduledFor !== undefined &&
e.takenAt !== null &&
e.scheduledFor.getTime() === e.takenAt.getTime();
const resolves = anchorResolvesOccurrence({
anchor: ref,
occurrenceAt: slotAt,
radiusMs: adHoc ? ADHOC_RESOLVE_EPSILON_MS : RESOLVE_RADIUS_MS,
schedule,
schedules,
ctx,
cache,
});
if (resolves) return true;
}
return false;
}
@@ -134,9 +148,11 @@ export function buildCurrentCycle(
}
};
for (let i = 0; i < schedules.length; i++) {
const s = schedules[i];
const canonical = toCanonicalSchedule(s, `compliance-cycle-${i}`);
const canonicals = schedules.map((s, i) =>
toCanonicalSchedule(s, `compliance-cycle-${i}`),
);
const nearestCache: NearestOccurrenceCache = new Map();
for (const canonical of canonicals) {
if (canonical.scheduleType === "PRN") continue;
const n = canonical.rollingIntervalDays;
if (n !== null && n > 0) {
@@ -162,7 +178,16 @@ export function buildCurrentCycle(
for (let step = 0; step < 64; step++) {
const occ = nextOccurrenceAfter(canonical, after, recurrenceCtx);
if (!occ) break;
if (!slotIsResolved(occ.at, allIntakes)) {
if (
!slotIsResolved(
occ.at,
allIntakes,
canonical,
canonicals,
recurrenceCtx,
nearestCache,
)
) {
picked = occ;
break;
}
+10
View File
@@ -150,6 +150,16 @@ export class WideEventBuilder {
return this;
}
/**
* Whether a caller has already named this event's action. A leaf that
* wants to mark the event (the private-origin egress mark in the
* notification senders) checks this first so it never overwrites the
* route's or job's own action name, which dashboards pin on.
*/
hasAction(): boolean {
return this.event.action !== undefined;
}
setBackground(bg: WideEvent["background"]): this {
this.event.background = bg;
return this;
@@ -672,28 +672,275 @@ describe("Mounjaro rolling weekly — canonical overdue band after day change",
scheduleId: "mounjaro-sibling",
});
});
});
it("does not let an action from the archived era suppress the live-era occurrence", () => {
const liveEraStart = d("2026-07-27T12:00:00.000Z");
const display = computeDisplayDue({
/**
* A resolved row stands for exactly ONE occurrence: the one nearest to its
* anchor, and only within the drift radius. The former flat ±6 h radius
* reached the sibling slot on any cadence closer than twelve hours, so the
* first dose's row resolved the second occurrence too, the walk jumped to
* tomorrow, and the second dose could never be logged from the card.
*
* Berlin is UTC+2 in June / September: 19:49 + 23:59 local are 17:49Z +
* 21:59Z; 08:00 + 12:00 local are 06:00Z + 10:00Z.
*/
describe("resolved-slot marks — a row resolves only its nearest occurrence", () => {
const medication = makeMedication();
function closeSlots(timesOfDay: string[]): WorkerScheduleRow {
return {
id: "sched-close",
windowStart: timesOfDay[0],
windowEnd: timesOfDay[timesOfDay.length - 1],
daysOfWeek: null,
timesOfDay,
reminderGraceMinutes: null,
rrule: null,
rollingIntervalDays: null,
scheduleType: "SCHEDULED",
cyclicOnWeeks: null,
cyclicOffWeeks: null,
};
}
it("19:49 taken leaves 23:59 the same day due, not tomorrow's 19:49", () => {
const now = d("2026-09-10T18:00:00Z"); // 20:00 Berlin
const next = computeNextDueAt({
medication,
schedules: [schedule],
now: d("2026-07-29T10:00:00.000Z"),
schedules: [closeSlots(["19:49", "23:59"])],
now,
userTz: BERLIN,
lastIntakeAt: lastTaken,
eraStart: liveEraStart,
lastIntakeAt: d("2026-09-10T17:49:12Z"),
resolvedSlots: [mark(d("2026-09-10T17:49:00Z"))],
});
expect(next).not.toBeNull();
expect(next!.toISOString()).toBe("2026-09-10T21:59:00.000Z");
});
it("the card offers the 23:59 slot after the 19:49 take (display due)", () => {
const now = d("2026-09-10T18:00:00Z");
const due = computeDisplayDue({
medication,
schedules: [closeSlots(["19:49", "23:59"])],
now,
userTz: BERLIN,
lastIntakeAt: d("2026-09-10T17:49:12Z"),
resolvedSlots: [mark(d("2026-09-10T17:49:00Z"))],
});
expect(due).not.toBeNull();
expect(due!.overdue).toBe(false);
expect(due!.at.toISOString()).toBe("2026-09-10T21:59:00.000Z");
});
it("08:00 taken, 12:00 pending → next due is 12:00", () => {
const now = d("2026-06-10T07:00:00Z"); // 09:00 Berlin
const next = computeNextDueAt({
medication,
schedules: [closeSlots(["08:00", "12:00"])],
now,
userTz: BERLIN,
lastIntakeAt: d("2026-06-10T06:03:00Z"),
resolvedSlots: [mark(d("2026-06-10T06:00:00Z"))],
});
expect(next).not.toBeNull();
expect(next!.toISOString()).toBe("2026-06-10T10:00:00.000Z");
});
it("every four hours: the first take leaves the second slot due", () => {
const now = d("2026-06-10T05:00:00Z"); // 07:00 Berlin
const next = computeNextDueAt({
medication,
schedules: [
closeSlots(["02:00", "06:00", "10:00", "14:00", "18:00", "22:00"]),
],
now,
userTz: BERLIN,
lastIntakeAt: d("2026-06-10T04:02:00Z"),
resolvedSlots: [
identifiedMark(missedOccurrence, {
scheduleId: "mounjaro-weekly",
actionAt: d("2026-07-27T11:59:59.000Z"),
}),
mark(d("2026-06-10T00:00:00Z")), // 02:00 Berlin
mark(d("2026-06-10T04:00:00Z")), // 06:00 Berlin
],
});
expect(next).not.toBeNull();
expect(next!.toISOString()).toBe("2026-06-10T08:00:00.000Z"); // 10:00 Berlin
});
expect(display).toMatchObject({
at: missedOccurrence,
overdue: true,
scheduleId: "mounjaro-weekly",
it("a drifted slot-anchored row still resolves its nearest occurrence", () => {
// The case the radius always protected: a row whose anchor sits off
// the canonical instant (a pre-snap row, or an instant that moved
// under it) resolves the occurrence it is nearest to.
const now = d("2026-06-10T08:00:00Z"); // 10:00 Berlin
const next = computeNextDueAt({
medication,
schedules: [closeSlots(["08:00", "20:00"])],
now,
userTz: BERLIN,
lastIntakeAt: d("2026-06-10T07:13:00Z"),
// Anchored 09:13 Berlin, 1 h 13 min off the 08:00 slot.
resolvedSlots: [mark(d("2026-06-10T07:13:00Z"))],
});
expect(next).not.toBeNull();
expect(next!.toISOString()).toBe("2026-06-10T18:00:00.000Z"); // 20:00 Berlin
});
it("a drifted row an hour off across a DST shift still resolves the slot", () => {
// 2026-03-29 Berlin springs forward at 02:00; a 08:00 row computed in
// the old offset sits at 07:00Z where the engine now says 06:00Z.
const now = d("2026-03-29T07:30:00Z"); // 09:30 Berlin (CEST)
const due = computeDisplayDue({
medication,
schedules: [closeSlots(["08:00", "20:00"])],
now,
userTz: BERLIN,
lastIntakeAt: d("2026-03-29T07:00:00Z"),
resolvedSlots: [mark(d("2026-03-29T07:00:00Z"))],
});
expect(due).not.toBeNull();
expect(due!.overdue).toBe(false);
expect(due!.at.toISOString()).toBe("2026-03-29T18:00:00.000Z"); // 20:00 CEST
});
it("a drifted row nearer the sibling resolves the sibling, not both", () => {
// 08:00 + 12:00 Berlin. A skip anchored at 10:30 is nearer 12:00 than
// 08:00, so 08:00 stays open overdue at 09:30 (its band runs to 12:00).
const now = d("2026-06-10T07:30:00Z"); // 09:30 Berlin
const due = computeDisplayDue({
medication,
schedules: [closeSlots(["08:00", "12:00"])],
now,
userTz: BERLIN,
lastIntakeAt: null,
resolvedSlots: [mark(d("2026-06-10T08:30:00Z"))], // 10:30 Berlin
});
expect(due).not.toBeNull();
expect(due!.overdue).toBe(true);
expect(due!.at.toISOString()).toBe("2026-06-10T06:00:00.000Z"); // 08:00 Berlin
});
it("an ad-hoc row between two close slots resolves neither", () => {
const adhoc = d("2026-06-10T08:30:00Z"); // 10:30 Berlin
const now = d("2026-06-10T09:00:00Z"); // 11:00 Berlin
const next = computeNextDueAt({
medication,
schedules: [closeSlots(["08:00", "12:00"])],
now,
userTz: BERLIN,
lastIntakeAt: adhoc,
resolvedSlots: [
mark(d("2026-06-10T06:00:00Z")),
toResolvedSlotMark({ scheduledFor: adhoc, takenAt: adhoc }),
],
});
expect(next).not.toBeNull();
expect(next!.toISOString()).toBe("2026-06-10T10:00:00.000Z"); // 12:00 Berlin
});
it("an ad-hoc row seconds off a close slot still resolves that slot only", () => {
// Only the ad-hoc row is present: at 12:30 the 12:00 band is still open,
// so the slot reads resolved through the epsilon path alone.
const adhoc = d("2026-06-10T09:59:30Z"); // 11:59:30 Berlin
const now = d("2026-06-10T10:30:00Z"); // 12:30 Berlin
const due = computeDisplayDue({
medication,
schedules: [closeSlots(["08:00", "12:00"])],
now,
userTz: BERLIN,
lastIntakeAt: adhoc,
resolvedSlots: [
toResolvedSlotMark({ scheduledFor: adhoc, takenAt: adhoc }),
],
});
expect(due).not.toBeNull();
expect(due!.overdue).toBe(false);
expect(due!.at.toISOString()).toBe("2026-06-11T06:00:00.000Z"); // tomorrow 08:00
});
it("a row exactly midway between two slots resolves the earlier one", () => {
// 08:00 + 12:00 Berlin, a row anchored at 10:00: the tie goes to 08:00,
// so 12:00 stays due.
const now = d("2026-06-10T07:00:00Z"); // 09:00 Berlin
const next = computeNextDueAt({
medication,
schedules: [closeSlots(["08:00", "12:00"])],
now,
userTz: BERLIN,
lastIntakeAt: d("2026-06-10T08:00:00Z"),
resolvedSlots: [mark(d("2026-06-10T08:00:00Z"))], // 10:00 Berlin
});
expect(next).not.toBeNull();
expect(next!.toISOString()).toBe("2026-06-10T10:00:00.000Z"); // 12:00 Berlin
});
it("a row on sibling schedule A's 08:00 does not resolve sibling B's 12:00", () => {
// Two schedule rows on one medication (08:00 daily plus 12:00 daily), the
// wizard's compose shape. Rows carry no schedule identity, so B's slot
// must lose to A's nearer occurrence, not be tested against B alone.
const a: WorkerScheduleRow = { ...closeSlots(["08:00"]), id: "sched-a" };
const b: WorkerScheduleRow = { ...closeSlots(["12:00"]), id: "sched-b" };
const now = d("2026-06-10T07:00:00Z"); // 09:00 Berlin
const input = {
medication,
schedules: [a, b],
now,
userTz: BERLIN,
lastIntakeAt: d("2026-06-10T06:00:00Z"),
resolvedSlots: [mark(d("2026-06-10T06:00:00Z"))], // A's 08:00 exactly
};
expect(computeNextDueAt(input)?.toISOString()).toBe(
"2026-06-10T10:00:00.000Z",
);
const due = computeDisplayDue(input);
expect(due).not.toBeNull();
expect(due!.overdue).toBe(false);
expect(due!.at.toISOString()).toBe("2026-06-10T10:00:00.000Z");
expect(due!.scheduleId).toBe("sched-b");
});
it("on the 25-hour fall-back day a slot across the repeated hour stays due", () => {
// 2026-10-25 Berlin falls back at 03:00 CEST to 02:00 CET. 01:00 local
// is 2026-10-24T23:00Z (CEST); 03:00 local is 02:00Z (CET), three hours
// on. Taking the 01:00 dose must leave 03:00 due.
const now = d("2026-10-25T00:00:00Z");
const next = computeNextDueAt({
medication,
schedules: [closeSlots(["01:00", "03:00"])],
now,
userTz: BERLIN,
lastIntakeAt: d("2026-10-24T23:00:00Z"),
resolvedSlots: [mark(d("2026-10-24T23:00:00Z"))],
});
expect(next).not.toBeNull();
expect(next!.toISOString()).toBe("2026-10-25T02:00:00.000Z");
});
it("on the fall-back day a row anchored in the old offset still resolves 08:00", () => {
// 08:00 CET on 2026-10-25 is 07:00Z; a row computed in the CEST offset
// sits at 06:00Z, an hour off, and still stands for that slot.
const now = d("2026-10-25T07:30:00Z"); // 08:30 CET
const due = computeDisplayDue({
medication,
schedules: [closeSlots(["08:00", "20:00"])],
now,
userTz: BERLIN,
lastIntakeAt: d("2026-10-25T06:00:00Z"),
resolvedSlots: [mark(d("2026-10-25T06:00:00Z"))],
});
expect(due).not.toBeNull();
expect(due!.overdue).toBe(false);
expect(due!.at.toISOString()).toBe("2026-10-25T19:00:00.000Z"); // 20:00 CET
});
it("the overdue search agrees: 23:59 stays open overdue after midnight", () => {
const now = d("2026-09-10T22:30:00Z"); // 00:30 Berlin, next day
const due = computeDisplayDue({
medication,
schedules: [closeSlots(["19:49", "23:59"])],
now,
userTz: BERLIN,
lastIntakeAt: d("2026-09-10T17:49:12Z"),
resolvedSlots: [mark(d("2026-09-10T17:49:00Z"))],
});
expect(due).not.toBeNull();
expect(due!.overdue).toBe(true);
expect(due!.at.toISOString()).toBe("2026-09-10T21:59:00.000Z");
});
});
+48 -43
View File
@@ -19,27 +19,17 @@ import {
import {
advanceRollingOccurrence,
nextOccurrenceAfter,
type CanonicalSchedule,
type RecurrenceContext,
} from "@/lib/medications/scheduling/recurrence";
import { buildBandsForMedication } from "@/lib/medications/scheduling/band-minter";
import { DOSE_WINDOW_DEFAULTS } from "@/lib/medications/scheduling/dose-window-defaults";
/**
* v1.15.10 — radius an intake may sit from a slot's canonical instant and
* still count as resolving it. Intake writes snap `scheduledFor` to the exact
* engine slot instant, so the match is normally exact; the ±6h tolerance is
* the same defensive half-gap the compliance open-dose search uses (a 12h-gap
* twice-daily med splits cleanly at ±6h).
*/
const RESOLVE_RADIUS_MS = 6 * 60 * 60 * 1000;
/**
* v1.16.9 — exact-match slop for an AD-HOC row. An ad-hoc take anchors
* `scheduledFor = takenAt` on its own instant, so it can only resolve a
* slot it actually sits on (sub-minute drift absorbed); letting it use
* the ±6h radius hid genuinely-due slots — a 14:30 ad-hoc take resolved
* tonight's 20:00 dose while the ledger still counted that slot missed.
*/
const ADHOC_RESOLVE_EPSILON_MS = 60 * 1000;
import {
ADHOC_RESOLVE_EPSILON_MS,
anchorResolvesOccurrence,
RESOLVE_RADIUS_MS,
type NearestOccurrenceCache,
} from "@/lib/medications/scheduling/slot-resolution";
/**
* A row that resolves a slot (taken / deliberately skipped /
@@ -47,17 +37,16 @@ const ADHOC_RESOLVE_EPSILON_MS = 60 * 1000;
*
* `slotAnchored: false` marks the ad-hoc shape (`scheduledFor ===
* takenAt`): such a row resolves a slot only on a near-exact anchor
* match, never across the ±6h radius. Slot-anchored rows (the write
* match, never across the drift radius. Slot-anchored rows (the write
* paths snap their `scheduledFor` to the canonical slot instant) keep
* the defensive radius.
* the drift radius, and within it resolve their nearest occurrence only
* (`anchorResolvesOccurrence`).
*/
export interface ResolvedSlotMark {
at: Date;
slotAnchored: boolean;
/** Canonical schedule owner when attribution could identify it. */
scheduleId?: string;
/** When the action was made; used to isolate replacement schedule eras. */
actionAt?: Date;
/** Diagnostic state carried by exact occurrence-aware callers. */
status?: "taken" | "skipped" | "autoMissed";
}
@@ -80,27 +69,39 @@ export function toResolvedSlotMark(row: {
};
}
/**
* Predicate over the canonical occurrences of ONE schedule: true when a
* resolved row stands for the occurrence at `slotAt`. Each row resolves
* its nearest occurrence only, measured across every schedule of the
* medication, so a sibling slot closer than the drift radius is never
* swallowed (see `slot-resolution.ts` for the rule). The era floor is
* enforced by the band range in the overdue search and by `now` in the
* forward walk, not here.
*/
function buildIsResolved(
resolved: ResolvedSlotMark[],
identity?: { scheduleId: string; eraStart?: Date | null },
identity: {
schedule: CanonicalSchedule;
schedules: CanonicalSchedule[];
ctx: RecurrenceContext;
},
): (slotAt: Date) => boolean {
const cache: NearestOccurrenceCache = new Map();
return (slotAt: Date): boolean => {
const slot = slotAt.getTime();
for (const r of resolved) {
if (r.scheduleId !== undefined && r.scheduleId !== identity?.scheduleId) {
if (r.scheduleId !== undefined && r.scheduleId !== identity.schedule.id) {
continue;
}
if (
identity?.eraStart &&
r.actionAt &&
r.actionAt.getTime() < identity.eraStart.getTime()
) {
continue;
}
const radius = r.slotAnchored
? RESOLVE_RADIUS_MS
: ADHOC_RESOLVE_EPSILON_MS;
if (Math.abs(r.at.getTime() - slot) <= radius) return true;
const resolves = anchorResolvesOccurrence({
anchor: r.at,
occurrenceAt: slotAt,
radiusMs: r.slotAnchored ? RESOLVE_RADIUS_MS : ADHOC_RESOLVE_EPSILON_MS,
schedule: identity.schedule,
schedules: identity.schedules,
ctx: identity.ctx,
cache,
});
if (resolves) return true;
}
return false;
};
@@ -161,12 +162,13 @@ function computeNextDueCandidate(input: {
if (schedules.length === 0) return null;
const ctx = buildRecurrenceContext({ medication, userTz, lastIntakeAt });
const canonicals = schedules.map(buildCanonicalSchedule);
let earliest: DueCandidate | null = null;
for (const schedule of schedules) {
const canonical = buildCanonicalSchedule(schedule);
for (const canonical of canonicals) {
const isResolved = buildIsResolved(input.resolvedSlots ?? [], {
scheduleId: canonical.id,
eraStart: input.eraStart,
schedule: canonical,
schedules: canonicals,
ctx,
});
// Walk forward past slots the user has already resolved. Bounded so a
// fully-logged-ahead history can't spin.
@@ -272,19 +274,22 @@ function findOpenOverdueSlot(
const ctx = buildRecurrenceContext({ medication, userTz, lastIntakeAt });
const intakeInstants = lastIntakeAt ? [lastIntakeAt] : [];
const canonicals = schedules.map(buildCanonicalSchedule);
let latest: {
at: Date;
availableFrom: Date;
scheduleId: string;
} | null = null;
for (const schedule of schedules) {
const canonical = buildCanonicalSchedule(schedule);
const isResolved = buildIsResolved(input.resolvedSlots ?? [], {
scheduleId: schedule.id,
eraStart: input.eraStart,
schedule: canonical,
schedules: canonicals,
ctx,
});
const { bands } = buildBandsForMedication({
medication,
schedule: buildCanonicalSchedule(schedule),
schedule: canonical,
ctx,
userTz,
range: { from: floor, to: now },
@@ -0,0 +1,141 @@
/**
* Which occurrence a resolved intake row (taken / deliberately skipped /
* cron-auto-missed) stands for.
*
* The intake write paths snap a slot-anchored row's `scheduledFor` to the
* engine's canonical occurrence instant, so row and occurrence normally meet
* exactly. The tolerance beyond an exact match exists for a row whose anchor
* drifted off the canonical instant: a row written before the snap, or an
* instant that moved under it through a DST or schedule-time shift. From
* v1.15.10 that tolerance was a flat ±6 h radius, sized for the twice-daily
* 12 h gap. On any cadence whose neighbouring slots are closer than twelve
* hours (19:49 + 23:59, 08:00 + 12:00, every four hours) the radius reached
* the sibling: the first dose's row resolved the second occurrence too, the
* next-due walk jumped to tomorrow, the card offered tomorrow's slot, and the
* second dose could never be logged from the card.
*
* Rule: a row resolves exactly ONE occurrence, the one nearest to its anchor,
* and only when that occurrence lies within the row's radius. An exact match
* short-circuits; a drifted row is measured against every occurrence the
* schedule emits within the radius around the anchor, and resolves the slot
* under test only when no other occurrence is nearer (an exact tie goes to
* the earlier occurrence). So the effective reach is `min(radius, half the
* gap to the neighbouring occurrence)`, and a sibling can never be swallowed.
* The neighbours are every schedule's occurrences, not only the schedule
* under test: a medication composed of sibling schedules (08:00 daily plus
* 12:00 daily) is one cadence to the person taking it. Rolling cadences emit
* at most one occurrence per interval day, which is farther than any radius
* here, so the nearest test is skipped for them.
*/
import {
occurrencesBetween,
type CanonicalSchedule,
type RecurrenceContext,
} from "@/lib/medications/scheduling/recurrence";
/**
* Drift a slot-anchored row may sit from a slot's canonical instant and
* still count as resolving it, provided the slot is the row's nearest
* occurrence.
*/
export const RESOLVE_RADIUS_MS = 6 * 60 * 60 * 1000;
/**
* v1.16.9 — exact-match slop for an AD-HOC row. An ad-hoc take anchors
* `scheduledFor = takenAt` on its own instant, so it can only resolve a
* slot it actually sits on (sub-minute drift absorbed); letting it use
* the drift radius hid genuinely-due slots — a 14:30 ad-hoc take resolved
* tonight's 20:00 dose while the ledger still counted that slot missed.
*/
export const ADHOC_RESOLVE_EPSILON_MS = 60 * 1000;
/**
* Nearest occurrence to `anchor` within `radiusMs`, across EVERY schedule
* of the medication: `at` and its distance, or null when no schedule emits
* inside the window. On an exact tie the earlier occurrence wins. Memoised
* per anchor in `cache` when one is passed, because the set depends on the
* row alone and a predicate asks about many slots per row.
*/
function nearestOccurrenceWithin(input: {
anchor: number;
radiusMs: number;
schedules: CanonicalSchedule[];
ctx: RecurrenceContext;
cache?: NearestOccurrenceCache;
}): { at: number; distance: number } | null {
const key = `${input.anchor}:${input.radiusMs}`;
const cached = input.cache?.get(key);
if (cached !== undefined) return cached;
let nearest: { at: number; distance: number } | null = null;
const from = new Date(input.anchor - input.radiusMs);
const to = new Date(input.anchor + input.radiusMs);
for (const schedule of input.schedules) {
for (const occurrence of occurrencesBetween(
schedule,
from,
to,
input.ctx,
)) {
const at = occurrence.at.getTime();
const distance = Math.abs(input.anchor - at);
if (
nearest === null ||
distance < nearest.distance ||
(distance === nearest.distance && at < nearest.at)
) {
nearest = { at, distance };
}
}
}
input.cache?.set(key, nearest);
return nearest;
}
export type NearestOccurrenceCache = Map<
string,
{ at: number; distance: number } | null
>;
export function anchorResolvesOccurrence(input: {
/** The resolved row's anchor (`scheduledFor`, or the take instant). */
anchor: Date;
/** The canonical occurrence instant under test. */
occurrenceAt: Date;
/** Reach of this row: the drift radius, or the ad-hoc epsilon. */
radiusMs: number;
/** The schedule that emitted `occurrenceAt`. */
schedule: CanonicalSchedule;
/**
* Every schedule of the medication. The nearest test enumerates all of
* them: production rows carry no schedule identity, so a row on sibling
* A's 08:00 is also asked about sibling B's 12:00, and B's own
* occurrences alone would never show that A's 08:00 is nearer.
*/
schedules: CanonicalSchedule[];
ctx: RecurrenceContext;
/** Optional per-predicate memo for the nearest-occurrence enumeration. */
cache?: NearestOccurrenceCache;
}): boolean {
const anchor = input.anchor.getTime();
const slot = input.occurrenceAt.getTime();
const distance = Math.abs(anchor - slot);
if (distance === 0) return true;
if (distance > input.radiusMs) return false;
if (input.schedule.rollingIntervalDays !== null) return true;
// Drifted: the row stands for its nearest occurrence only. Any occurrence
// strictly nearer than the slot under test, or equally near but earlier,
// claims the row instead.
const nearest = nearestOccurrenceWithin({
anchor,
radiusMs: input.radiusMs,
schedules: input.schedules,
ctx: input.ctx,
cache: input.cache,
});
if (nearest === null || nearest.at === slot) return true;
return !(
nearest.distance < distance ||
(nearest.distance === distance && nearest.at < slot)
);
}
@@ -5,7 +5,9 @@
* an operator-approved private origin reaches the pinned Undici connector.
* This test keeps the Nightscout client, SafeFetch, dispatcher, socket, and
* HTTP server real. Only DNS is deterministic: the approved test hostname is
* resolved to the loopback server from inside the connector.
* resolved to the loopback server from inside the connector. Loopback is a
* grantable answer under the operator-approved policy (a host-networking
* deployment lists it), so the floor runs for real here.
*/
import dns from "node:dns";
import http from "node:http";
@@ -0,0 +1,255 @@
/**
* `NOTIFICATION_PRIVATE_ORIGINS` (#947): the operator grant for a webhook or
* ntfy target on a private network.
*
* What is pinned here: the variable is read from the server environment and
* nowhere else, a malformed entry is logged once and grants nothing, a listed
* origin approves exactly itself, and a target that is not listed keeps the
* public floor it always had. The dial-time behaviour of an approved origin
* (pinned resolver, redirect ban, loopback floor) is proven in
* `safe-fetch.test.ts` and `safe-fetch-dispatcher.test.ts`; the senders'
* use of the verdict in their own suites.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { eventStorage } from "@/lib/logging/context";
import { WideEventBuilder } from "@/lib/logging/event-builder";
import {
annotatePrivateOriginEgress,
configuredNotificationPrivateOrigins,
evaluateNotificationTarget,
isAllowedNotificationTarget,
} from "../egress-policy";
const ORIGINAL = process.env.NOTIFICATION_PRIVATE_ORIGINS;
beforeEach(() => {
delete process.env.NOTIFICATION_PRIVATE_ORIGINS;
});
afterEach(() => {
vi.restoreAllMocks();
if (ORIGINAL === undefined) {
delete process.env.NOTIFICATION_PRIVATE_ORIGINS;
} else {
process.env.NOTIFICATION_PRIVATE_ORIGINS = ORIGINAL;
}
});
describe("configuredNotificationPrivateOrigins", () => {
it("grants nothing when the variable is unset or blank", () => {
expect(configuredNotificationPrivateOrigins()).toEqual(new Set());
process.env.NOTIFICATION_PRIVATE_ORIGINS = " , ";
expect(configuredNotificationPrivateOrigins()).toEqual(new Set());
});
it("parses exact origins and follows a changed value", () => {
process.env.NOTIFICATION_PRIVATE_ORIGINS =
"https://gotify.example.com, http://ntfy.lan:8080";
expect(configuredNotificationPrivateOrigins()).toEqual(
new Set(["https://gotify.example.com", "http://ntfy.lan:8080"]),
);
process.env.NOTIFICATION_PRIVATE_ORIGINS = "http://10.0.0.5:8080";
expect(configuredNotificationPrivateOrigins()).toEqual(
new Set(["http://10.0.0.5:8080"]),
);
});
it("logs a malformed entry once, skips it, and keeps the valid ones", () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
process.env.NOTIFICATION_PRIVATE_ORIGINS =
"https://gotify.lan, https://*.lan, http://169.254.169.254";
expect(configuredNotificationPrivateOrigins()).toEqual(
new Set(["https://gotify.lan"]),
);
expect(configuredNotificationPrivateOrigins()).toEqual(
new Set(["https://gotify.lan"]),
);
// One line per malformed entry, written on the first parse only.
expect(warn).toHaveBeenCalledTimes(2);
const lines = warn.mock.calls.map((call) => String(call[0]));
expect(lines[0]).toContain("NOTIFICATION_PRIVATE_ORIGINS");
expect(lines[0]).toContain("https://*.lan");
expect(lines[1]).toContain("http://169.254.169.254");
expect(lines[1]).toMatch(/link-local.*cannot be granted/);
});
it("warns without echoing a query, fragment or userinfo (M3)", () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
process.env.NOTIFICATION_PRIVATE_ORIGINS =
"http://10.0.0.4/message?token=AbC123, http://user:pw@gotify.lan/, https://ntfy.lan/topic#frag";
expect(configuredNotificationPrivateOrigins()).toEqual(new Set());
const lines = warn.mock.calls.map((call) => String(call[0]));
expect(lines).toHaveLength(3);
expect(lines[0]).toContain('"http://10.0.0.4/message"');
expect(lines[1]).toContain('"http://gotify.lan/"');
expect(lines.join("\n")).not.toMatch(/AbC123|token=|user:pw|#frag/);
});
it("never widens on a wholly malformed value", () => {
vi.spyOn(console, "warn").mockImplementation(() => {});
process.env.NOTIFICATION_PRIVATE_ORIGINS = "10.0.0.0/8, *.lan, true";
expect(configuredNotificationPrivateOrigins()).toEqual(new Set());
});
});
describe("evaluateNotificationTarget", () => {
it("approves a target under a listed origin, path and all", () => {
process.env.NOTIFICATION_PRIVATE_ORIGINS = "https://gotify.example.com";
expect(
evaluateNotificationTarget("https://gotify.example.com/message?token=x"),
).toEqual({
allowed: true,
canonicalOrigin: "https://gotify.example.com",
privateOriginApproved: true,
reasonCode: null,
});
});
it("approves a listed literal private address the input-time floor would refuse", () => {
process.env.NOTIFICATION_PRIVATE_ORIGINS = "http://10.0.0.5:8080";
expect(evaluateNotificationTarget("http://10.0.0.5:8080/message")).toEqual({
allowed: true,
canonicalOrigin: "http://10.0.0.5:8080",
privateOriginApproved: true,
reasonCode: null,
});
expect(isAllowedNotificationTarget("http://10.0.0.5:8080/message")).toBe(
true,
);
});
it.each([
["sibling port", "http://10.0.0.5:9000/message"],
["other scheme", "https://10.0.0.5:8080/message"],
["neighbouring address", "http://10.0.0.6:8080/message"],
["sub-host of a listed name", "https://push.gotify.example.com/message"],
["unlisted private literal", "http://192.168.1.9/message"],
])("refuses a %s: a grant names one origin only", (_label, url) => {
process.env.NOTIFICATION_PRIVATE_ORIGINS =
"http://10.0.0.5:8080, https://gotify.example.com";
expect(evaluateNotificationTarget(url)).toMatchObject({
allowed: false,
privateOriginApproved: false,
reasonCode: "private_origin_not_approved",
});
expect(isAllowedNotificationTarget(url)).toBe(false);
});
it("keeps a public target functional and unapproved (the pin stays on)", () => {
process.env.NOTIFICATION_PRIVATE_ORIGINS = "https://gotify.example.com";
expect(evaluateNotificationTarget("https://ntfy.sh/topic")).toEqual({
allowed: true,
canonicalOrigin: "https://ntfy.sh",
privateOriginApproved: false,
reasonCode: null,
});
});
it("keeps the raw-string floor: an octal loopback spelling is refused, not parsed", () => {
expect(
evaluateNotificationTarget("http://0177.0.0.1/message"),
).toMatchObject({
allowed: false,
reasonCode: "private_origin_not_approved",
});
});
it("approves a listed loopback target and refuses an unlisted one", () => {
process.env.NOTIFICATION_PRIVATE_ORIGINS = "http://localhost:8080";
expect(
evaluateNotificationTarget("http://localhost:8080/message"),
).toMatchObject({ allowed: true, privateOriginApproved: true });
for (const url of [
"http://127.0.0.1:8080/message",
"http://localhost:9090/message",
"http://[::1]:8080/message",
]) {
expect(evaluateNotificationTarget(url)).toMatchObject({
allowed: false,
reasonCode: "private_origin_not_approved",
});
}
});
it.each([
["metadata", "http://169.254.169.254/latest"],
["unspecified", "http://0.0.0.0:8080/message"],
["IPv6 link-local", "http://[fe80::1]:8080/message"],
])("answers a %s target with the not-grantable code (M1)", (_l, url) => {
process.env.NOTIFICATION_PRIVATE_ORIGINS = "https://gotify.example.com";
expect(evaluateNotificationTarget(url)).toMatchObject({
allowed: false,
reasonCode: "private_origin_not_grantable",
});
});
it("refuses an unparseable target as invalid", () => {
expect(evaluateNotificationTarget("not a url")).toEqual({
allowed: false,
canonicalOrigin: null,
privateOriginApproved: false,
reasonCode: "invalid_origin",
});
expect(
evaluateNotificationTarget("ftp://gotify.lan/message"),
).toMatchObject({ allowed: false, reasonCode: "invalid_origin" });
});
it("lets an unlisted private DNS name through to the pinned resolver, as before", () => {
// Nothing widens and nothing narrows for a name the operator did not
// list: input time cannot tell gotify.lan from a public name, so the
// verdict is the same as before #947 and the connect-time pin decides.
expect(evaluateNotificationTarget("https://gotify.lan/message")).toEqual({
allowed: true,
canonicalOrigin: "https://gotify.lan",
privateOriginApproved: false,
reasonCode: null,
});
});
});
describe("annotatePrivateOriginEgress", () => {
it("names the action when the event has none and pins the meta pair", () => {
const event = new WideEventBuilder("background");
eventStorage.run(event, () => {
annotatePrivateOriginEgress("webhook", "https://gotify.example.com");
});
const json = event.toJSON();
expect(json.action).toEqual({ name: "notification.egress.private_origin" });
expect(json.meta).toEqual({
channel: "webhook",
origin: "https://gotify.example.com",
});
});
it("keeps an action the route or job already set, and still lands the meta", () => {
// A settings route or a job names its own action; dashboards pin on it.
// The egress mark must not overwrite that name.
const event = new WideEventBuilder("http");
event.setAction({ name: "settings.webhook.test" });
eventStorage.run(event, () => {
annotatePrivateOriginEgress("ntfy", "http://ntfy.lan:8080");
});
const json = event.toJSON();
expect(json.action).toEqual({ name: "settings.webhook.test" });
expect(json.meta).toEqual({
channel: "ntfy",
origin: "http://ntfy.lan:8080",
});
});
it("is a no-op without an active event", () => {
expect(() =>
annotatePrivateOriginEgress("webhook", "https://gotify.example.com"),
).not.toThrow();
});
});
+113
View File
@@ -0,0 +1,113 @@
import {
evaluateOriginGrant,
INVALID_ORIGIN,
originOfUrl,
parsePrivateOrigins,
PRIVATE_ORIGIN_NOT_APPROVED_CODE,
PRIVATE_ORIGIN_NOT_GRANTABLE_CODE,
type OriginVerdict,
} from "@/lib/private-origin-policy";
import { annotate, getEvent } from "@/lib/logging/context";
/**
* `NOTIFICATION_PRIVATE_ORIGINS` (#947) — the operator grant that lets the
* generic webhook and ntfy senders reach a relay on the operator's own
* network (a Gotify behind a LAN reverse proxy, a self-hosted ntfy on
* Tailscale).
*
* Both senders dial through `safeFetch` with the connect-time DNS pin, which
* refuses any destination that resolves to a private address. That is the
* DNS-rebinding defence (#217) and it is deliberate: a public-looking name
* with a LAN A record is exactly what it stops. The grant is the operator's
* way to say "this one origin is mine": comma-separated exact origins,
*
* NOTIFICATION_PRIVATE_ORIGINS="https://gotify.example.com,http://ntfy.lan:8080"
*
* A listed origin is still dialled through the pinned resolver with redirects
* forbidden; link-local, the metadata range and the unspecified address stay
* refused even when listed (loopback is grantable when listed exactly). Nothing not listed widens, and there is no `=true`, CIDR,
* wildcard or suffix form. The grant lives in the server environment only —
* no request field and no settings toggle can confer it.
*
* The variable is read directly from `process.env` so the compose-whitelist
* guard sees the read. It is parsed once per distinct value: a malformed
* entry is logged once and grants nothing, and the valid entries beside it
* keep working, because a typo in one relay's origin must not take every
* public webhook on the instance down with it.
*/
const ENV_NAME = "NOTIFICATION_PRIVATE_ORIGINS";
let cachedRaw: string | undefined;
let cachedOrigins: ReadonlySet<string> = new Set();
export function configuredNotificationPrivateOrigins(): ReadonlySet<string> {
const raw = process.env.NOTIFICATION_PRIVATE_ORIGINS;
if (raw === cachedRaw) return cachedOrigins;
// The entry arrives reduced to scheme and host: this line goes to stdout,
// not through the wide-event redactors, and a malformed entry is where a
// pasted token or userinfo would sit.
cachedOrigins = parsePrivateOrigins(raw, (redactedEntry, reason) => {
console.warn(`${ENV_NAME}: ignoring entry "${redactedEntry}": ${reason}`);
});
cachedRaw = raw;
return cachedOrigins;
}
/**
* Resolve one webhook or ntfy target URL against the grant and the public
* floor. The target may carry a path (Gotify's `/message`, an ntfy topic);
* only its origin is compared to the grant set. The full URL goes to the
* public check so the raw-string guards in `isPublicUrl` (octal and hex
* address spellings) see what the user typed.
*
* One function for every caller — the settings save, the test button and the
* dispatcher's senders — so the decision cannot differ between the button
* that says "works" and the reminder that then does not arrive.
*/
export function evaluateNotificationTarget(url: string): OriginVerdict {
const origin = originOfUrl(url);
if (!origin) {
return {
allowed: false,
canonicalOrigin: null,
privateOriginApproved: false,
reasonCode: INVALID_ORIGIN,
};
}
return evaluateOriginGrant(
origin,
configuredNotificationPrivateOrigins(),
url,
);
}
/** Predicate form for the Zod refine on the save routes. */
export function isAllowedNotificationTarget(url: string): boolean {
return evaluateNotificationTarget(url).allowed;
}
export { PRIVATE_ORIGIN_NOT_APPROVED_CODE, PRIVATE_ORIGIN_NOT_GRANTABLE_CODE };
/**
* Wide-event mark for a send that used the grant: one row per private
* egress, pinned to `{ channel, origin }` so a dashboard can count them and
* an operator can see which grant is actually in use. The origin is the
* canonical grant string, never the full target URL, which may carry a
* token in its path or query.
*
* The action name is set only when the event has none yet: a settings route
* or a job that already named its action keeps it, and the meta pair still
* lands on the same event, so the mark is never lost and never clobbers.
*/
export function annotatePrivateOriginEgress(
channel: "webhook" | "ntfy",
origin: string,
): void {
const event = getEvent();
if (!event) return;
if (!event.hasAction()) {
annotate({ action: { name: "notification.egress.private_origin" } });
}
annotate({ meta: { channel, origin } });
}
+7
View File
@@ -35,6 +35,13 @@ export interface SendOutcome {
reason?: string;
/** Human-readable error fragment for logs (NOT for the disabled_reason column). */
message?: string;
/**
* Stable machine code an API surface may forward as `meta.errorCode`. Set
* only for a refusal the caller can act on — today the private-origin
* policy (#947), which the test routes answer with 422 and a reason
* instead of a bare 500.
*/
errorCode?: string;
}
/**
@@ -0,0 +1,235 @@
/**
* ntfy sender unit tests.
*
* Covers the outbound shape (topic appended to the server URL, priority and
* tag headers, bearer token), the outcome classification, and (#947) the
* NOTIFICATION_PRIVATE_ORIGINS grant: a listed server rides the
* operator-approved pin, an unlisted one keeps the public pin, and a listed
* origin admits nothing beside itself.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const safeFetchMock = vi.fn();
const recordPushAttemptMock = vi.fn();
const annotateMock = vi.fn();
vi.mock("@/lib/safe-fetch", () => ({
safeFetch: (...args: unknown[]) => safeFetchMock(...args),
SafeFetchError: class SafeFetchError extends Error {
kind: string;
constructor(message: string, kind: string) {
super(message);
this.kind = kind;
}
},
}));
vi.mock("@/lib/logging/context", () => ({
getEvent: () => ({
addWarning: vi.fn(),
addExternalCall: vi.fn(),
hasAction: () => false,
}),
annotate: (...args: unknown[]) => annotateMock(...args),
}));
vi.mock("@/lib/notifications/senders/push-attempt-record", () => ({
recordPushAttempt: (...args: unknown[]) => recordPushAttemptMock(...args),
recordPushAttemptForPayload: (
_payload: unknown,
_recipientUserId: string,
attempt: unknown,
) => recordPushAttemptMock(attempt),
}));
import { sendViaNtfy } from "../ntfy";
import { SafeFetchError } from "@/lib/safe-fetch";
const config = {
serverUrl: "https://ntfy.example.com/",
topic: "health",
authToken: "tk_secret",
};
function payload(over?: Record<string, unknown>) {
return {
eventType: "SYSTEM_ALERT" as const,
userId: "user-1",
title: "Title",
message: "<b>Body</b>",
...over,
};
}
type EgressOpts = {
requirePublicHost?: boolean;
operatorApprovedPrivateOrigin?: string;
};
const ORIGINAL_PRIVATE_ORIGINS = process.env.NOTIFICATION_PRIVATE_ORIGINS;
beforeEach(() => {
safeFetchMock.mockReset();
recordPushAttemptMock.mockReset();
annotateMock.mockReset();
delete process.env.NOTIFICATION_PRIVATE_ORIGINS;
});
afterEach(() => {
vi.clearAllMocks();
if (ORIGINAL_PRIVATE_ORIGINS === undefined) {
delete process.env.NOTIFICATION_PRIVATE_ORIGINS;
} else {
process.env.NOTIFICATION_PRIVATE_ORIGINS = ORIGINAL_PRIVATE_ORIGINS;
}
});
describe("sendViaNtfy", () => {
it("POSTs the topic URL through safeFetch under the public pin and returns ok on 200", async () => {
safeFetchMock.mockResolvedValue({ ok: true, status: 200 });
const result = await sendViaNtfy(config, payload());
expect(result).toEqual({ ok: true, statusCode: 200 });
const [url, init, opts] = safeFetchMock.mock.calls[0] as [
string,
{ method?: string; headers?: Record<string, string>; body?: string },
EgressOpts,
];
expect(url).toBe("https://ntfy.example.com/health");
expect(init.method).toBe("POST");
expect(init.headers?.Authorization).toBe("Bearer tk_secret");
expect(init.body).toBe("Body");
expect(opts.requirePublicHost).toBe(true);
expect(opts.operatorApprovedPrivateOrigin).toBeUndefined();
expect(recordPushAttemptMock).toHaveBeenCalledWith(
expect.objectContaining({ channel: "NTFY", result: "ok" }),
);
});
it("hard-rejects on 410 (topic gone)", async () => {
safeFetchMock.mockResolvedValue({ ok: false, status: 410 });
const result = await sendViaNtfy(config, payload());
expect(result.ok).toBe(false);
expect(result.hardReject).toBe(true);
expect(result.reason).toBe("ntfy_410_gone");
});
it("reports a soft, named refusal when safeFetch blocks a private host (SSRF)", async () => {
safeFetchMock.mockRejectedValue(
new SafeFetchError("refused private host", "private_host"),
);
const result = await sendViaNtfy(config, payload());
expect(result).toMatchObject({
ok: false,
hardReject: false,
reason: "ntfy_private_origin_refused",
errorCode: "private_origin_not_approved",
});
expect(recordPushAttemptMock).toHaveBeenCalledWith(
expect.objectContaining({
channel: "NTFY",
result: "error",
reason: "ntfy_private_origin_refused",
}),
);
});
it("keeps a genuine network fault distinct from a policy refusal", async () => {
safeFetchMock.mockRejectedValue(new SafeFetchError("timeout", "timeout"));
const result = await sendViaNtfy(config, payload());
expect(result.reason).toBe("ntfy_network_error");
expect(result.errorCode).toBeUndefined();
});
describe("NOTIFICATION_PRIVATE_ORIGINS (#947)", () => {
it("dials a listed server through the operator-approved pin and marks the event", async () => {
process.env.NOTIFICATION_PRIVATE_ORIGINS = "http://ntfy.lan:8080";
safeFetchMock.mockResolvedValue({ ok: true, status: 200 });
const result = await sendViaNtfy(
{ serverUrl: "http://ntfy.lan:8080", topic: "health" },
payload(),
);
expect(result.ok).toBe(true);
const [url, , opts] = safeFetchMock.mock.calls[0] as [
string,
unknown,
EgressOpts,
];
expect(url).toBe("http://ntfy.lan:8080/health");
expect(opts.requirePublicHost).toBe(false);
expect(opts.operatorApprovedPrivateOrigin).toBe("http://ntfy.lan:8080");
expect(annotateMock).toHaveBeenCalledWith({
action: { name: "notification.egress.private_origin" },
});
expect(annotateMock).toHaveBeenCalledWith({
meta: { channel: "ntfy", origin: "http://ntfy.lan:8080" },
});
});
it("keeps the public pin for a server the operator did not list", async () => {
process.env.NOTIFICATION_PRIVATE_ORIGINS = "https://gotify.example.com";
safeFetchMock.mockResolvedValue({ ok: true, status: 200 });
await sendViaNtfy(config, payload());
const [, , opts] = safeFetchMock.mock.calls[0] as [
string,
unknown,
EgressOpts,
];
expect(opts.requirePublicHost).toBe(true);
expect(opts.operatorApprovedPrivateOrigin).toBeUndefined();
expect(annotateMock).not.toHaveBeenCalled();
});
it.each([
["sibling port", "http://ntfy.lan:9090"],
["other scheme", "https://ntfy.lan:8080"],
["sub-host", "http://push.ntfy.lan:8080"],
["unlisted private literal", "http://192.168.1.9"],
])(
"refuses a %s without dialling: a listed origin admits only itself",
async (_label, serverUrl) => {
process.env.NOTIFICATION_PRIVATE_ORIGINS = "http://ntfy.lan:8080";
const result = await sendViaNtfy(
{ serverUrl, topic: "health" },
payload(),
);
expect(safeFetchMock).not.toHaveBeenCalled();
expect(result).toMatchObject({
ok: false,
hardReject: false,
reason: "ntfy_private_origin_refused",
errorCode: "private_origin_not_approved",
});
expect(annotateMock).not.toHaveBeenCalled();
},
);
});
it("reports the not-grantable code for a metadata target, without dialling (M1)", async () => {
const result = await sendViaNtfy(
{ serverUrl: "http://169.254.169.254", topic: "health" },
payload(),
);
expect(safeFetchMock).not.toHaveBeenCalled();
expect(result).toMatchObject({
ok: false,
hardReject: false,
reason: "ntfy_private_origin_refused",
errorCode: "private_origin_not_grantable",
});
});
});
@@ -3,7 +3,10 @@
*
* Covers: success, hard-reject classification (404/410/401/403), SSRF block
* (safeFetch throws `private_host`), cooldown-relevant transient classification
* (5xx), and the push_attempts ledger write per outcome.
* (5xx), the push_attempts ledger write per outcome, and (#947) the
* NOTIFICATION_PRIVATE_ORIGINS grant: a listed origin rides the
* operator-approved pin, an unlisted one keeps the public pin, and a listed
* origin admits nothing beside itself.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@@ -21,8 +24,14 @@ vi.mock("@/lib/safe-fetch", () => ({
},
}));
const annotateMock = vi.fn();
vi.mock("@/lib/logging/context", () => ({
getEvent: () => ({ addWarning: vi.fn(), addExternalCall: vi.fn() }),
getEvent: () => ({
addWarning: vi.fn(),
addExternalCall: vi.fn(),
hasAction: () => false,
}),
annotate: (...args: unknown[]) => annotateMock(...args),
}));
vi.mock("@/lib/notifications/senders/push-attempt-record", () => ({
@@ -53,15 +62,29 @@ function payload(over?: Record<string, unknown>) {
};
}
const ORIGINAL_PRIVATE_ORIGINS = process.env.NOTIFICATION_PRIVATE_ORIGINS;
beforeEach(() => {
safeFetchMock.mockReset();
recordPushAttemptMock.mockReset();
annotateMock.mockReset();
delete process.env.NOTIFICATION_PRIVATE_ORIGINS;
});
afterEach(() => {
vi.clearAllMocks();
if (ORIGINAL_PRIVATE_ORIGINS === undefined) {
delete process.env.NOTIFICATION_PRIVATE_ORIGINS;
} else {
process.env.NOTIFICATION_PRIVATE_ORIGINS = ORIGINAL_PRIVATE_ORIGINS;
}
});
type EgressOpts = {
requirePublicHost?: boolean;
operatorApprovedPrivateOrigin?: string;
};
describe("sendViaWebhook", () => {
it("POSTs through safeFetch with requirePublicHost and returns ok on 200", async () => {
safeFetchMock.mockResolvedValue({ ok: true, status: 200 });
@@ -115,7 +138,10 @@ describe("sendViaWebhook", () => {
expect(result.reason).toBe("webhook_503");
});
it("returns a transient network error when safeFetch blocks a private host (SSRF)", async () => {
it("reports a soft, named refusal when safeFetch blocks a private host (SSRF)", async () => {
// Before #947 this collapsed into `webhook_network_error` and the test
// button answered a bare 500. The refusal stays soft (delivery resumes
// the moment the operator lists the origin) but names itself.
safeFetchMock.mockRejectedValue(
new SafeFetchError("refused private host", "private_host"),
);
@@ -124,16 +150,28 @@ describe("sendViaWebhook", () => {
expect(result.ok).toBe(false);
expect(result.hardReject).toBe(false);
expect(result.reason).toBe("webhook_network_error");
expect(result.reason).toBe("webhook_private_origin_refused");
expect(result.errorCode).toBe("private_origin_not_approved");
expect(recordPushAttemptMock).toHaveBeenCalledWith(
expect.objectContaining({
channel: "WEBHOOK",
result: "error",
reason: "webhook_network_error",
reason: "webhook_private_origin_refused",
}),
);
});
it("keeps a genuine network fault distinct from a policy refusal", async () => {
safeFetchMock.mockRejectedValue(
new SafeFetchError("socket hang up", "network"),
);
const result = await sendViaWebhook(config, payload());
expect(result.reason).toBe("webhook_network_error");
expect(result.errorCode).toBeUndefined();
});
it("omits the custom header when not configured", async () => {
safeFetchMock.mockResolvedValue({ ok: true, status: 200 });
await sendViaWebhook({ url: "https://example.com/hook" }, payload());
@@ -142,4 +180,104 @@ describe("sendViaWebhook", () => {
(init as { headers?: Record<string, string> }).headers?.Authorization,
).toBeUndefined();
});
describe("NOTIFICATION_PRIVATE_ORIGINS (#947)", () => {
it("dials a listed origin through the operator-approved pin and marks the event", async () => {
process.env.NOTIFICATION_PRIVATE_ORIGINS =
"https://gotify.example.com, http://ntfy.lan:8080";
safeFetchMock.mockResolvedValue({ ok: true, status: 200 });
const result = await sendViaWebhook(config, payload());
expect(result.ok).toBe(true);
const [url, , opts] = safeFetchMock.mock.calls[0] as [
string,
unknown,
EgressOpts,
];
expect(url).toBe("https://gotify.example.com/message");
expect(opts.requirePublicHost).toBe(false);
expect(opts.operatorApprovedPrivateOrigin).toBe(
"https://gotify.example.com",
);
expect(annotateMock).toHaveBeenCalledWith({
action: { name: "notification.egress.private_origin" },
});
expect(annotateMock).toHaveBeenCalledWith({
meta: { channel: "webhook", origin: "https://gotify.example.com" },
});
});
it("keeps the public pin for a target the operator did not list", async () => {
process.env.NOTIFICATION_PRIVATE_ORIGINS = "http://ntfy.lan:8080";
safeFetchMock.mockResolvedValue({ ok: true, status: 200 });
await sendViaWebhook(config, payload());
const [, , opts] = safeFetchMock.mock.calls[0] as [
string,
unknown,
EgressOpts,
];
expect(opts.requirePublicHost).toBe(true);
expect(opts.operatorApprovedPrivateOrigin).toBeUndefined();
expect(annotateMock).not.toHaveBeenCalled();
});
it.each([
["sibling port", "https://gotify.example.com:8443/message"],
["other scheme", "http://gotify.example.com/message"],
["sub-host", "https://push.gotify.example.com/message"],
["unlisted private literal", "http://10.0.0.9/message"],
])(
"refuses a %s without dialling: a listed origin admits only itself",
async (_label, url) => {
process.env.NOTIFICATION_PRIVATE_ORIGINS = "https://gotify.example.com";
const result = await sendViaWebhook({ url }, payload());
expect(safeFetchMock).not.toHaveBeenCalled();
expect(result).toMatchObject({
ok: false,
hardReject: false,
reason: "webhook_private_origin_refused",
errorCode: "private_origin_not_approved",
});
expect(annotateMock).not.toHaveBeenCalled();
},
);
it("approves a listed literal private address the input floor alone would refuse", async () => {
process.env.NOTIFICATION_PRIVATE_ORIGINS = "http://10.0.0.9:8080";
safeFetchMock.mockResolvedValue({ ok: true, status: 200 });
const result = await sendViaWebhook(
{ url: "http://10.0.0.9:8080/message" },
payload(),
);
expect(result.ok).toBe(true);
const [, , opts] = safeFetchMock.mock.calls[0] as [
string,
unknown,
EgressOpts,
];
expect(opts.operatorApprovedPrivateOrigin).toBe("http://10.0.0.9:8080");
});
});
it("reports the not-grantable code for a metadata target, without dialling (M1)", async () => {
const result = await sendViaWebhook(
{ url: "http://169.254.169.254/latest" },
payload(),
);
expect(safeFetchMock).not.toHaveBeenCalled();
expect(result).toMatchObject({
ok: false,
hardReject: false,
reason: "webhook_private_origin_refused",
errorCode: "private_origin_not_grantable",
});
});
});
+49 -9
View File
@@ -6,10 +6,16 @@ import type { SendOutcome } from "@/lib/notifications/retry-policy";
import { classifyHttpStatus } from "@/lib/notifications/retry-policy";
import { getEvent } from "@/lib/logging/context";
import { recordPushAttemptForPayload } from "@/lib/notifications/senders/push-attempt-record";
import { safeFetch } from "@/lib/safe-fetch";
import { safeFetch, SafeFetchError } from "@/lib/safe-fetch";
import { plainPushText } from "@/lib/notifications/strip-emoji";
import { stripHtml } from "@/lib/notifications/strip-html";
import { isUrgentPayload } from "@/lib/notifications/types";
import {
annotatePrivateOriginEgress,
evaluateNotificationTarget,
PRIVATE_ORIGIN_NOT_APPROVED_CODE,
} from "@/lib/notifications/egress-policy";
import type { OriginReason } from "@/lib/private-origin-policy";
/**
* Send notification via ntfy (simple HTTP POST).
@@ -26,6 +32,10 @@ export async function sendViaNtfy(
): Promise<SendOutcome> {
const userId = payload.recipientUserId ?? payload.userId;
const start = performance.now();
// Set when the policy itself refuses, so the outcome can say which of the
// two refusals it was: an origin the operator could list, or one no grant
// can open.
let policyReason: OriginReason | null = null;
try {
const url = `${config.serverUrl.replace(/\/$/, "")}/${encodeURIComponent(config.topic)}`;
@@ -60,11 +70,25 @@ export async function sendViaNtfy(
// Strip HTML tags for ntfy (plain text only) + emoji on routine reminders
const body = plainPushText(stripHtml(payload.message), payload.eventType);
// requirePublicHost adds the DNS-rebinding pin (issue #217) on top
// of the input-time isPublicUrl guard already enforced when the
// user saved the ntfy serverUrl in their channel config. The
// authToken would otherwise leak on a rebinding flip to a private
// address.
// The DNS-rebinding pin (issue #217) sits on top of the input-time
// guard enforced when the user saved the serverUrl; the authToken would
// otherwise leak on a rebinding flip to a private address. A server the
// operator listed in NOTIFICATION_PRIVATE_ORIGINS (#947) runs under the
// operator-approved pin instead — same resolver pin, redirects
// forbidden, loopback and metadata refused. Same verdict function as
// the settings routes and the webhook sender.
const policy = evaluateNotificationTarget(url);
if (!policy.allowed || !policy.canonicalOrigin) {
policyReason = policy.reasonCode;
throw new SafeFetchError(
"ntfy server refused by the notification origin policy",
"private_host",
);
}
if (policy.privateOriginApproved) {
annotatePrivateOriginEgress("ntfy", policy.canonicalOrigin);
}
const res = await safeFetch(
url,
{
@@ -72,7 +96,13 @@ export async function sendViaNtfy(
headers,
body,
},
{ timeoutMs: 5_000, requirePublicHost: true },
{
timeoutMs: 5_000,
requirePublicHost: !policy.privateOriginApproved,
...(policy.privateOriginApproved
? { operatorApprovedPrivateOrigin: policy.canonicalOrigin }
: {}),
},
);
getEvent()?.addExternalCall({
@@ -107,6 +137,13 @@ export async function sendViaNtfy(
};
} catch (err) {
const message = err instanceof Error ? err.message : "request_failed";
// A policy refusal is not a network fault (see the webhook sender): soft
// classification, its own reason, and a code the test route can forward.
const policyRefused =
err instanceof SafeFetchError && err.kind === "private_host";
const reason = policyRefused
? "ntfy_private_origin_refused"
: "ntfy_network_error";
getEvent()?.addExternalCall({
service: "ntfy",
method: "sendNotification",
@@ -118,13 +155,16 @@ export async function sendViaNtfy(
channel: "NTFY",
eventType: payload.eventType,
result: "error",
reason: "ntfy_network_error",
reason,
});
return {
ok: false,
hardReject: false,
reason: "ntfy_network_error",
reason,
message,
...(policyRefused
? { errorCode: policyReason ?? PRIVATE_ORIGIN_NOT_APPROVED_CODE }
: {}),
};
}
}
+53 -8
View File
@@ -6,9 +6,15 @@ import type { SendOutcome } from "@/lib/notifications/retry-policy";
import { classifyHttpStatus } from "@/lib/notifications/retry-policy";
import { getEvent } from "@/lib/logging/context";
import { recordPushAttemptForPayload } from "@/lib/notifications/senders/push-attempt-record";
import { safeFetch } from "@/lib/safe-fetch";
import { safeFetch, SafeFetchError } from "@/lib/safe-fetch";
import { plainPushText } from "@/lib/notifications/strip-emoji";
import { stripHtml } from "@/lib/notifications/strip-html";
import {
annotatePrivateOriginEgress,
evaluateNotificationTarget,
PRIVATE_ORIGIN_NOT_APPROVED_CODE,
} from "@/lib/notifications/egress-policy";
import type { OriginReason } from "@/lib/private-origin-policy";
/**
* Send a notification via a generic outbound webhook (v1.17.1).
@@ -20,10 +26,15 @@ import { stripHtml } from "@/lib/notifications/strip-html";
* `title`/`message` fields are stripped of HTML + decorative emoji on routine
* reminders exactly like the ntfy sender.
*
* Outbound goes through `safeFetch({ requirePublicHost: true })`: the SSRF
* floor plus the connect-time DNS-rebinding pin apply because the host is
* user-supplied. The optional shared-secret header would otherwise leak on a
* rebinding flip to a private address.
* Outbound goes through `safeFetch` with the connect-time DNS-rebinding pin
* because the host is user-supplied; the optional shared-secret header would
* otherwise leak on a rebinding flip to a private address. A public target
* runs under the public pin. An origin the operator listed in
* `NOTIFICATION_PRIVATE_ORIGINS` (#947) runs under the operator-approved pin
* instead: still resolved and pinned inside the connector, redirects
* forbidden, loopback and metadata answers refused. The verdict comes from
* `evaluateNotificationTarget`, the same function the settings routes use,
* so the test button and the dispatcher cannot disagree.
*
* Returns a `SendOutcome` so the dispatcher can distinguish hard rejects
* (404/410 endpoint gone, 401/403 secret wrong) from soft errors (5xx, 429,
@@ -35,6 +46,10 @@ export async function sendViaWebhook(
): Promise<SendOutcome> {
const userId = payload.recipientUserId ?? payload.userId;
const start = performance.now();
// Set when the policy itself refuses, so the outcome can say which of the
// two refusals it was: an origin the operator could list, or one no grant
// can open.
let policyReason: OriginReason | null = null;
try {
const headers: Record<string, string> = {
"Content-Type": "application/json",
@@ -63,6 +78,18 @@ export async function sendViaWebhook(
: "default",
});
const policy = evaluateNotificationTarget(config.url);
if (!policy.allowed || !policy.canonicalOrigin) {
policyReason = policy.reasonCode;
throw new SafeFetchError(
"webhook target refused by the notification origin policy",
"private_host",
);
}
if (policy.privateOriginApproved) {
annotatePrivateOriginEgress("webhook", policy.canonicalOrigin);
}
const res = await safeFetch(
config.url,
{
@@ -70,7 +97,13 @@ export async function sendViaWebhook(
headers,
body,
},
{ timeoutMs: 5_000, requirePublicHost: true },
{
timeoutMs: 5_000,
requirePublicHost: !policy.privateOriginApproved,
...(policy.privateOriginApproved
? { operatorApprovedPrivateOrigin: policy.canonicalOrigin }
: {}),
},
);
getEvent()?.addExternalCall({
@@ -105,6 +138,15 @@ export async function sendViaWebhook(
};
} catch (err) {
const message = err instanceof Error ? err.message : "request_failed";
// A policy refusal is not a network fault: the target is on a private
// network the operator has not listed. It keeps the soft classification
// (delivery resumes the moment the origin is listed) but carries its own
// reason so the ledger and the test button can say why.
const policyRefused =
err instanceof SafeFetchError && err.kind === "private_host";
const reason = policyRefused
? "webhook_private_origin_refused"
: "webhook_network_error";
getEvent()?.addExternalCall({
service: "webhook",
method: "sendNotification",
@@ -116,13 +158,16 @@ export async function sendViaWebhook(
channel: "WEBHOOK",
eventType: payload.eventType,
result: "error",
reason: "webhook_network_error",
reason,
});
return {
ok: false,
hardReject: false,
reason: "webhook_network_error",
reason,
message,
...(policyRefused
? { errorCode: policyReason ?? PRIVATE_ORIGIN_NOT_APPROVED_CODE }
: {}),
};
}
}
+1
View File
@@ -330,6 +330,7 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
"notification_prefs_conflict",
"oidc_only",
"private_origin_not_approved",
"private_origin_not_grantable",
"rate_limited_self",
"too_many_rows",
"unreadable_json",
+16 -6
View File
@@ -243,7 +243,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({
@@ -255,7 +255,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.",
});
/**
@@ -400,7 +400,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`), or it is a link-local, metadata or unspecified address that no grant can open (`private_origin_not_grantable`). Nothing was stored.",
content: { "application/json": { schema: errorEnvelope } },
},
},
@@ -516,7 +516,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`), or it is a link-local, metadata or unspecified address that no grant can open (`private_origin_not_grantable`). Nothing was stored.",
content: { "application/json": { schema: errorEnvelope } },
},
},
@@ -705,7 +705,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:
@@ -717,6 +717,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`), or is a link-local, metadata or unspecified address that no grant can open (`private_origin_not_grantable`). The resolved address is never echoed.",
content: { "application/json": { schema: errorEnvelope } },
},
},
},
},
@@ -746,7 +751,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:
@@ -758,6 +763,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`), or is a link-local, metadata or unspecified address that no grant can open (`private_origin_not_grantable`). The resolved address is never echoed.",
content: { "application/json": { schema: errorEnvelope } },
},
},
},
},
+232
View File
@@ -0,0 +1,232 @@
import { isIP } from "node:net";
import {
isOperatorGrantableIp,
isPublicUrl,
} from "@/lib/validations/notifications";
/**
* Exact-origin grant grammar shared by every operator allowlist that opens a
* private destination: `NIGHTSCOUT_PRIVATE_ORIGINS` (v1.17.0) and
* `NOTIFICATION_PRIVATE_ORIGINS` (#947). One parser, one verdict, so the two
* lists cannot drift in what they accept.
*
* A grant is one canonical `scheme://host[:port]` trust unit. It is never a
* suffix, wildcard, CIDR, capability URL or prefix: a range grant would let a
* DNS rebinding to any address inside the range pass the connect-time pin,
* which turns the pin into a formality. An operator with several relays lists
* several origins.
*/
export const PRIVATE_ORIGIN_NOT_APPROVED_CODE =
"private_origin_not_approved" as const;
/**
* The target is the unspecified address, link-local or the metadata range:
* no grant can ever open it, so telling the user to ask the operator would
* send them in a circle.
*/
export const PRIVATE_ORIGIN_NOT_GRANTABLE_CODE =
"private_origin_not_grantable" as const;
export const INVALID_ORIGIN = "invalid_origin" as const;
export type OriginReason =
| typeof PRIVATE_ORIGIN_NOT_APPROVED_CODE
| typeof PRIVATE_ORIGIN_NOT_GRANTABLE_CODE
| typeof INVALID_ORIGIN;
/**
* A hostname no grant may name: a literal address outside the grantable
* ranges (unspecified, link-local, metadata). Loopback and `localhost` are
* grantable when listed exactly — a host-networking deployment lists them —
* and DNS names are not judged here: a name that resolves into a refused
* range is dropped at dial time by the pinned dispatcher.
*/
export function isNeverGrantableHost(hostname: string): boolean {
const literal = hostname.toLowerCase().replace(/^\[|\]$/g, "");
return isIP(literal) !== 0 && !isOperatorGrantableIp(literal);
}
/**
* The entry as it may be logged or put in an error message: scheme and host
* only. A malformed entry is malformed precisely when it carries the shapes
* that hold a secret — a query with a token, userinfo, a capability URL
* pasted whole — and this text goes to stdout, not through the wide-event
* redactors.
*/
export function redactGrantEntry(entry: string): string {
return entry
.split(/[?#]/, 1)[0]
.replace(/\/\/[^/@]*@/, "//")
.slice(0, 200);
}
/** Why an entry is not a grant, in operator-readable words. */
export function describeGrantRejection(entry: string): string {
try {
const url = new URL(entry);
if (
(url.protocol === "http:" || url.protocol === "https:") &&
isNeverGrantableHost(url.hostname)
) {
return "the unspecified address, link-local and metadata addresses cannot be granted; list the address the relay actually listens on";
}
} catch {
// fall through to the grammar sentence
}
return "a grant is one exact http(s) origin (scheme://host[:port]) with no path, query, credentials or wildcard";
}
export interface OriginVerdict {
allowed: boolean;
canonicalOrigin: string | null;
privateOriginApproved: boolean;
reasonCode: OriginReason | null;
}
/**
* Parse one exact http(s) origin.
*
* Credentials, paths, queries, and fragments are deliberately forbidden: the
* operator grants a complete canonical scheme/host/port trust unit, never a
* suffix, wildcard, capability URL, or prefix. A literal address in the
* unspecified range, link-local or the metadata range is refused too —
* `isOperatorGrantableIp` says why — and the pinned dispatcher applies the
* same floor when a listed DNS name resolves there. Loopback is grantable
* when listed exactly: an operator on host networking lists it on purpose.
*/
export function canonicalOrigin(value: string): string | null {
try {
const url = new URL(value);
if (url.protocol !== "http:" && url.protocol !== "https:") return 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;
// Unspecified, link-local and metadata literals are refused at parse
// time. Loopback, `localhost` and 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 metadata
// or link-local answer.
if (isNeverGrantableHost(url.hostname)) return null;
return url.origin;
} catch {
return null;
}
}
/**
* The origin of a target URL that may legitimately carry a path (a Gotify
* `/message` endpoint, an ntfy topic), for comparison against the grant set.
*/
export function originOfUrl(value: string): string | null {
try {
const url = new URL(value);
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
if (!url.hostname) return null;
return url.origin;
} catch {
return null;
}
}
function sharesApprovedPrivateHostname(
origin: string,
privateOrigins: ReadonlySet<string>,
): boolean {
const hostname = new URL(origin).hostname;
for (const approvedOrigin of privateOrigins) {
const approvedHostname = new URL(approvedOrigin).hostname;
if (
hostname === approvedHostname ||
hostname.endsWith(`.${approvedHostname}`)
) {
return true;
}
}
return false;
}
/**
* Parse a server-only comma-separated exact-origin allowlist.
*
* Every malformed non-empty entry is handed to `onInvalid` with the entry
* already reduced to scheme and host (`redactGrantEntry`) and the reason in
* words (`describeGrantRejection`); the callback decides the failure mode: the Nightscout list throws (a connection form can afford to
* fail loudly), the notification list logs once and skips the entry (a typo
* must not take every public webhook down with it). Either way a malformed
* entry grants nothing — the set only ever shrinks.
*/
export function parsePrivateOrigins(
raw: string | undefined,
onInvalid: (redactedEntry: string, reason: string) => void,
): ReadonlySet<string> {
const origins = new Set<string>();
for (const entry of (raw ?? "").split(",")) {
const trimmed = entry.trim();
if (!trimmed) continue;
const origin = canonicalOrigin(trimmed);
if (!origin) {
onInvalid(redactGrantEntry(trimmed), describeGrantRejection(trimmed));
continue;
}
origins.add(origin);
}
return origins;
}
/**
* Resolve one canonical origin against the grant set.
*
* Exact operator membership wins before the ordinary public-host verdict so a
* private DNS name can be trusted without granting its suffix or sibling
* ports. Public origins remain supported without configuration. `publicCheck`
* defaults to the origin itself; a caller holding the full URL passes it so
* the raw-string guards in `isPublicUrl` (octal and hex address spellings)
* see the text the user typed.
*/
export function evaluateOriginGrant(
origin: string,
privateOrigins: ReadonlySet<string>,
publicCheck: string = origin,
): OriginVerdict {
if (privateOrigins.has(origin)) {
return {
allowed: true,
canonicalOrigin: origin,
privateOriginApproved: true,
reasonCode: null,
};
}
// An exact grant must not accidentally become a hostname suffix grant.
// Deny related hosts before the generic public-host fallback: private DNS
// names such as gotify.lan may otherwise look syntactically public here.
if (sharesApprovedPrivateHostname(origin, privateOrigins)) {
return {
allowed: false,
canonicalOrigin: origin,
privateOriginApproved: false,
reasonCode: PRIVATE_ORIGIN_NOT_APPROVED_CODE,
};
}
if (isPublicUrl(publicCheck)) {
return {
allowed: true,
canonicalOrigin: origin,
privateOriginApproved: false,
reasonCode: null,
};
}
// Two refusals that read differently to the person who gets them: a
// private address the operator could list, and one no grant can open.
return {
allowed: false,
canonicalOrigin: origin,
privateOriginApproved: false,
reasonCode: isNeverGrantableHost(new URL(origin).hostname)
? PRIVATE_ORIGIN_NOT_GRANTABLE_CODE
: PRIVATE_ORIGIN_NOT_APPROVED_CODE,
};
}
+16 -6
View File
@@ -1,7 +1,9 @@
import dns from "node:dns";
import { isIP } from "node:net";
import { Agent } from "undici";
import { isPublicIp } from "@/lib/validations/notifications";
import {
isOperatorGrantableIp,
isPublicIp,
} from "@/lib/validations/notifications";
/**
* Custom `undici.Agent` that resolves the request hostname literally,
@@ -68,10 +70,15 @@ function pinnedLookupWithPolicy(
callback(err, "");
return;
}
// The operator-approved policy is not "anything that parses": a listed
// name that resolves to link-local, the metadata range or the
// unspecified address is refused here exactly as an unlisted one would
// be. The grant opens the operator's own network (loopback included, on
// host networking), never the cloud host's metadata endpoint.
const allowed = addresses.filter((address) =>
policy === "public"
? isPublicIp(address.address)
: isIP(address.address) !== 0,
: isOperatorGrantableIp(address.address),
);
if (allowed.length === 0) {
const refused = new Error(
@@ -165,10 +172,13 @@ export function getPinnedPublicDispatcher(): Agent {
/**
* Pinned dispatcher for an exact operator-approved origin.
*
* Private addresses are permitted only after the Nightscout origin policy has
* matched the complete canonical scheme/host/port trust grant. Resolution is
* still performed once inside Undici's connector and the vetted answer set is
* Private addresses are permitted only after an exact-origin policy
* (`NIGHTSCOUT_PRIVATE_ORIGINS`, `NOTIFICATION_PRIVATE_ORIGINS`) has matched
* the complete canonical scheme/host/port trust grant. Resolution is still
* performed once inside Undici's connector and the vetted answer set is
* pinned to the socket, so a later DNS answer cannot replace the destination.
* Unspecified, link-local and metadata answers are dropped regardless of the
* grant (`isOperatorGrantableIp`).
*/
export function getPinnedOperatorApprovedDispatcher(): Agent {
cachedOperatorApproved ??= createPinnedDispatcher("operator-approved");
@@ -120,4 +120,41 @@ describe("Nightscout exact-origin policy", () => {
reasonCode: "invalid_origin",
});
});
it("keeps a loopback Nightscout grant working exactly as before", () => {
// A host-networking self-host lists http://localhost:1337; the shared
// floor must not turn that into a failed sync on upgrade.
expect(parseNightscoutPrivateOrigins("http://localhost:1337")).toEqual(
new Set(["http://localhost:1337"]),
);
expect(
evaluateNightscoutOrigin(
"http://localhost:1337",
parseNightscoutPrivateOrigins("http://localhost:1337"),
),
).toMatchObject({ allowed: true, privateOriginApproved: true });
expect(
parseNightscoutPrivateOrigins("http://127.0.0.1:1337, http://[::1]:1337"),
).toEqual(new Set(["http://127.0.0.1:1337", "http://[::1]:1337"]));
});
it("names the entry and the reason when a metadata grant is listed, without its token (M2)", () => {
let caught: unknown;
try {
parseNightscoutPrivateOrigins(
"https://cgm.lan, http://169.254.169.254:1337/?token=SECRET123",
);
} catch (err) {
caught = err;
}
expect(caught).toBeInstanceOf(Error);
const message = (caught as Error).message;
expect((caught as Error).name).toBe("NightscoutOriginConfigError");
expect(message).toContain(
'NIGHTSCOUT_PRIVATE_ORIGINS entry "http://169.254.169.254:1337/"',
);
expect(message).toMatch(/link-local.*cannot be granted/);
expect(message).not.toContain("SECRET123");
expect(message).not.toContain("token=");
});
});
+37 -100
View File
@@ -1,89 +1,62 @@
import { z } from "zod/v4";
import { isPublicUrl } from "@/lib/validations/notifications";
import {
canonicalOrigin,
evaluateOriginGrant,
INVALID_ORIGIN,
parsePrivateOrigins,
PRIVATE_ORIGIN_NOT_APPROVED_CODE,
type OriginReason,
type OriginVerdict,
} from "@/lib/private-origin-policy";
export const NIGHTSCOUT_PRIVATE_ORIGIN_REASON =
"private_origin_not_approved" as const;
export const NIGHTSCOUT_INVALID_ORIGIN_REASON = "invalid_origin" as const;
export type NightscoutOriginReason =
| typeof NIGHTSCOUT_PRIVATE_ORIGIN_REASON
| typeof NIGHTSCOUT_INVALID_ORIGIN_REASON;
export interface NightscoutOriginVerdict {
allowed: boolean;
canonicalOrigin: string | null;
privateOriginApproved: boolean;
reasonCode: NightscoutOriginReason | null;
}
PRIVATE_ORIGIN_NOT_APPROVED_CODE;
export const NIGHTSCOUT_INVALID_ORIGIN_REASON = INVALID_ORIGIN;
/**
* Parse one exact http(s) origin.
*
* Credentials, paths, queries, and fragments are deliberately forbidden: the
* operator grants a complete canonical scheme/host/port trust unit, never a
* suffix, wildcard, capability URL, or prefix.
* Thrown when `NIGHTSCOUT_PRIVATE_ORIGINS` holds an entry that is not a
* grant. The message names the entry (scheme and host only) and the reason,
* so the sync ledger and the connect form show the operator what to edit
* instead of a bare "invalid entry". Loopback and `localhost` entries stay
* valid grants exactly as before #947; the shared floor refuses only the
* unspecified address, link-local and the metadata range.
*/
function canonicalOrigin(value: string): string | null {
try {
const url = new URL(value);
if (url.protocol !== "http:" && url.protocol !== "https:") return 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;
return url.origin;
} catch {
return null;
export class NightscoutOriginConfigError extends Error {
constructor(redactedEntry: string, reason: string) {
super(
`Invalid NIGHTSCOUT_PRIVATE_ORIGINS entry "${redactedEntry}": ${reason}`,
);
this.name = "NightscoutOriginConfigError";
}
}
function sharesApprovedPrivateHostname(
origin: string,
privateOrigins: ReadonlySet<string>,
): boolean {
const hostname = new URL(origin).hostname;
export type NightscoutOriginReason = OriginReason;
for (const approvedOrigin of privateOrigins) {
const approvedHostname = new URL(approvedOrigin).hostname;
if (
hostname === approvedHostname ||
hostname.endsWith(`.${approvedHostname}`)
) {
return true;
}
}
return false;
}
export type NightscoutOriginVerdict = OriginVerdict;
/**
* Parse the server-only comma-separated exact-origin allowlist.
*
* Invalid non-empty entries fail startup/call-site evaluation loudly rather
* than being skipped: partial acceptance makes an operator believe a private
* integration is protected while silently changing which origin is trusted.
* The grammar lives in `@/lib/private-origin-policy` and is shared with
* `NOTIFICATION_PRIVATE_ORIGINS`. Invalid non-empty entries fail
* startup/call-site evaluation loudly rather than being skipped: partial
* acceptance makes an operator believe a private integration is protected
* while silently changing which origin is trusted.
*/
export function parseNightscoutPrivateOrigins(
raw: string | undefined,
): ReadonlySet<string> {
const origins = new Set<string>();
for (const entry of (raw ?? "").split(",")) {
const trimmed = entry.trim();
if (!trimmed) continue;
const origin = canonicalOrigin(trimmed);
if (!origin) {
throw new Error("Invalid NIGHTSCOUT_PRIVATE_ORIGINS entry");
}
origins.add(origin);
}
return origins;
return parsePrivateOrigins(raw, (redactedEntry, reason) => {
throw new NightscoutOriginConfigError(redactedEntry, reason);
});
}
/**
* Resolve one user-supplied Nightscout base URL against server policy.
*
* Exact operator membership wins before the ordinary public-host verdict so a
* private DNS name can be trusted without granting its suffix or sibling
* ports. Public origins remain supported without configuration.
* A Nightscout base URL is itself a bare origin, so the strict grant grammar
* applies to the input as well: a path, query, fragment or credential part
* is `invalid_origin`, not merely unapproved.
*/
export function evaluateNightscoutOrigin(
value: string,
@@ -98,43 +71,7 @@ export function evaluateNightscoutOrigin(
reasonCode: NIGHTSCOUT_INVALID_ORIGIN_REASON,
};
}
if (privateOrigins.has(origin)) {
return {
allowed: true,
canonicalOrigin: origin,
privateOriginApproved: true,
reasonCode: null,
};
}
// An exact grant must not accidentally become a hostname suffix grant.
// Deny related hosts before the generic public-host fallback: private DNS
// names such as cgm.lan may otherwise look syntactically public here.
if (sharesApprovedPrivateHostname(origin, privateOrigins)) {
return {
allowed: false,
canonicalOrigin: origin,
privateOriginApproved: false,
reasonCode: NIGHTSCOUT_PRIVATE_ORIGIN_REASON,
};
}
if (isPublicUrl(origin)) {
return {
allowed: true,
canonicalOrigin: origin,
privateOriginApproved: false,
reasonCode: null,
};
}
return {
allowed: false,
canonicalOrigin: origin,
privateOriginApproved: false,
reasonCode: NIGHTSCOUT_PRIVATE_ORIGIN_REASON,
};
return evaluateOriginGrant(origin, privateOrigins);
}
export function configuredNightscoutPrivateOrigins(): ReadonlySet<string> {
+111 -31
View File
@@ -228,6 +228,63 @@ export function isPublicIp(ip: string): boolean {
return false;
}
/**
* Decide whether a resolved address may be dialled under an operator's
* exact-origin grant (`NOTIFICATION_PRIVATE_ORIGINS`,
* `NIGHTSCOUT_PRIVATE_ORIGINS`).
*
* A grant exists so a relay on the operator's own network can be reached, so
* RFC1918, CGNAT (a Tailscale address), IPv6 unique-local and loopback stay
* grantable: an operator on host networking who lists an exact loopback
* origin with its port has made the same deliberate decision as one who
* lists a LAN origin. Two classes are refused even when the operator lists a
* name that resolves there, because no relay lives on them: the unspecified
* address is a resolver misfire, and link-local is the cloud-metadata
* endpoint. The transition formats (`::ffff:`, 6to4, NAT64) are unwrapped
* first so an IPv6 spelling of those does not slip past the IPv4 verdict.
*/
export function isOperatorGrantableIp(ip: string): boolean {
if (!ip) return false;
const lower = ip.toLowerCase();
const family = isIP(lower);
if (family === 4) {
const parsed = parseIpv4Strict(lower);
if (!parsed) return false;
return !isNeverGrantableIpv4(parsed);
}
if (family === 6) {
const bytes = parseIpv6Bytes(lower);
if (!bytes) return false;
// Unspecified and link-local IPv6. Unique-local (fc00::/7) and loopback
// (::1) deliberately stay out of this list: they are what an operator
// on a private or host network lists. `::1` is answered before the
// embedded-IPv4 unwrap, which would otherwise read it as the
// IPv4-compatible spelling of 0.0.0.1 and refuse it as unspecified.
if (bytesEqual(bytes, 0, Array(16).fill(0))) return false;
if (bytesEqual(bytes, 0, Array(15).fill(0)) && bytes[15] === 1) {
return true;
}
if (bytes[0] === 0xfe && (bytes[1] & 0xc0) === 0x80) return false;
const embedded = embeddedIpv4(bytes);
if (embedded && isNeverGrantableIpv4(embedded)) return false;
return true;
}
return false;
}
/** 0/8 and 169.254/16: the ranges no operator grant may open. */
function isNeverGrantableIpv4(ip: [number, number, number, number]): boolean {
const [a, b] = ip;
if (a === 0) return true;
if (a === 169 && b === 254) return true;
return false;
}
export function isPublicUrl(url: string): boolean {
try {
// Pre-URL guard #1: the WHATWG URL parser interprets leading-zero IPv4
@@ -281,41 +338,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;