From 6c75c4f39f5a269a7dde5c1f5caa180ec9e006b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 10 Sep 2026 16:39:24 +0200 Subject: [PATCH] feat(notifications): honour NOTIFICATION_PRIVATE_ORIGINS on send A self-hoster's Gotify or ntfy on a private network was refused at dial time with no operator override: both senders pinned the public resolver, and the two grants that exist for other surfaces were never read here. Add the third grant, NOTIFICATION_PRIVATE_ORIGINS, a comma- separated list of exact origins read from the server environment. A listed origin runs under the operator-approved pin: still resolved and pinned inside the connector, redirects forbidden, loopback and metadata answers refused. An origin not listed keeps the public pin it always had. A malformed entry is logged once and grants nothing, and the valid entries beside it keep working, so a typo in one relay's origin cannot take every public webhook down with it. There is no `=true`, CIDR, wildcard or suffix form. One function, evaluateNotificationTarget, gives the verdict for both senders, so the test button and the scheduled reminder cannot disagree. A refusal now carries its own reason (`*_private_origin_refused`) and an errorCode instead of collapsing into a network error, and a send that used the grant marks the wide event with `notification.egress.private_origin` and the channel plus origin. The pin inventory keeps both senders in the conditional shape and refuses a bare true or false in either. Refs #947 --- src/lib/__tests__/ssrf-pin-inventory.test.ts | 32 ++- .../__tests__/egress-policy.test.ts | 168 ++++++++++++++ src/lib/notifications/egress-policy.ts | 105 +++++++++ src/lib/notifications/retry-policy.ts | 7 + .../senders/__tests__/ntfy.test.ts | 214 ++++++++++++++++++ .../senders/__tests__/webhook.test.ts | 125 +++++++++- src/lib/notifications/senders/ntfy.ts | 50 +++- src/lib/notifications/senders/webhook.ts | 53 ++++- 8 files changed, 725 insertions(+), 29 deletions(-) create mode 100644 src/lib/notifications/__tests__/egress-policy.test.ts create mode 100644 src/lib/notifications/egress-policy.ts create mode 100644 src/lib/notifications/senders/__tests__/ntfy.test.ts diff --git a/src/lib/__tests__/ssrf-pin-inventory.test.ts b/src/lib/__tests__/ssrf-pin-inventory.test.ts index b5b7d67c5..21e4886db 100644 --- a/src/lib/__tests__/ssrf-pin-inventory.test.ts +++ b/src/lib/__tests__/ssrf-pin-inventory.test.ts @@ -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"); diff --git a/src/lib/notifications/__tests__/egress-policy.test.ts b/src/lib/notifications/__tests__/egress-policy.test.ts new file mode 100644 index 000000000..1818abd7b --- /dev/null +++ b/src/lib/notifications/__tests__/egress-policy.test.ts @@ -0,0 +1,168 @@ +/** + * `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 { + 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://127.0.0.1:8080"; + + 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://127.0.0.1:8080"); + }); + + 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("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, + }); + }); +}); diff --git a/src/lib/notifications/egress-policy.ts b/src/lib/notifications/egress-policy.ts new file mode 100644 index 000000000..8afce08ed --- /dev/null +++ b/src/lib/notifications/egress-policy.ts @@ -0,0 +1,105 @@ +import { + evaluateOriginGrant, + INVALID_ORIGIN, + originOfUrl, + parsePrivateOrigins, + PRIVATE_ORIGIN_NOT_APPROVED, + type OriginVerdict, +} from "@/lib/private-origin-policy"; +import { annotate } 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; loopback, link-local and the metadata range stay refused even + * when listed. 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 = new Set(); + +export function configuredNotificationPrivateOrigins(): ReadonlySet { + const raw = process.env.NOTIFICATION_PRIVATE_ORIGINS; + if (raw === cachedRaw) return cachedOrigins; + cachedOrigins = parsePrivateOrigins(raw, (entry) => { + console.warn( + `${ENV_NAME}: ignoring entry "${entry}" — a grant is one exact http(s) origin (scheme://host[:port]) on a private network; loopback, link-local and metadata addresses cannot be granted`, + ); + }); + 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 }; + +/** + * 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. + */ +export function annotatePrivateOriginEgress( + channel: "webhook" | "ntfy", + origin: string, +): void { + annotate({ + action: { name: "notification.egress.private_origin" }, + meta: { channel, origin }, + }); +} diff --git a/src/lib/notifications/retry-policy.ts b/src/lib/notifications/retry-policy.ts index 604007db7..6dbdfe8d8 100644 --- a/src/lib/notifications/retry-policy.ts +++ b/src/lib/notifications/retry-policy.ts @@ -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; } /** diff --git a/src/lib/notifications/senders/__tests__/ntfy.test.ts b/src/lib/notifications/senders/__tests__/ntfy.test.ts new file mode 100644 index 000000000..a80a8a202 --- /dev/null +++ b/src/lib/notifications/senders/__tests__/ntfy.test.ts @@ -0,0 +1,214 @@ +/** + * 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() }), + 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) { + return { + eventType: "SYSTEM_ALERT" as const, + userId: "user-1", + title: "Title", + message: "Body", + ...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; 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" }, + 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(); + }, + ); + }); +}); diff --git a/src/lib/notifications/senders/__tests__/webhook.test.ts b/src/lib/notifications/senders/__tests__/webhook.test.ts index aab98366d..6655238cb 100644 --- a/src/lib/notifications/senders/__tests__/webhook.test.ts +++ b/src/lib/notifications/senders/__tests__/webhook.test.ts @@ -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,10 @@ vi.mock("@/lib/safe-fetch", () => ({ }, })); +const annotateMock = vi.fn(); vi.mock("@/lib/logging/context", () => ({ getEvent: () => ({ addWarning: vi.fn(), addExternalCall: vi.fn() }), + annotate: (...args: unknown[]) => annotateMock(...args), })); vi.mock("@/lib/notifications/senders/push-attempt-record", () => ({ @@ -53,15 +58,29 @@ function payload(over?: Record) { }; } +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 +134,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 +146,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 +176,87 @@ describe("sendViaWebhook", () => { (init as { headers?: Record }).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" }, + 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"); + }); + }); }); diff --git a/src/lib/notifications/senders/ntfy.ts b/src/lib/notifications/senders/ntfy.ts index ea966adac..7c601420c 100644 --- a/src/lib/notifications/senders/ntfy.ts +++ b/src/lib/notifications/senders/ntfy.ts @@ -6,10 +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 { isUrgentPayload } from "@/lib/notifications/types"; +import { + annotatePrivateOriginEgress, + evaluateNotificationTarget, + PRIVATE_ORIGIN_NOT_APPROVED, +} from "@/lib/notifications/egress-policy"; /** * Send notification via ntfy (simple HTTP POST). @@ -60,11 +65,24 @@ 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) { + 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 +90,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 +131,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 +149,14 @@ 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: PRIVATE_ORIGIN_NOT_APPROVED } : {}), }; } } diff --git a/src/lib/notifications/senders/webhook.ts b/src/lib/notifications/senders/webhook.ts index 34de62f72..74483c9b0 100644 --- a/src/lib/notifications/senders/webhook.ts +++ b/src/lib/notifications/senders/webhook.ts @@ -6,9 +6,14 @@ 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, +} from "@/lib/notifications/egress-policy"; /** * Send a notification via a generic outbound webhook (v1.17.1). @@ -20,10 +25,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, @@ -63,6 +73,17 @@ export async function sendViaWebhook( : "default", }); + const policy = evaluateNotificationTarget(config.url); + if (!policy.allowed || !policy.canonicalOrigin) { + 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 +91,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 +132,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 +152,14 @@ 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: PRIVATE_ORIGIN_NOT_APPROVED } : {}), }; } }