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
This commit is contained in:
Marc-André Bombeck committed 2026-09-10 16:39:24 +02:00
1 parent ea3a8deed2
commit 6c75c4f39f
8 files changed
+725 -29

No files matched your search

+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");
@@ -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,
});
});
});
+105
View File
@@ -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<string> = new Set();
export function configuredNotificationPrivateOrigins(): ReadonlySet<string> {
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 },
});
}
+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,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<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" },
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();
},
);
});
});
@@ -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<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 +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<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" },
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");
});
});
});
+41 -9
View File
@@ -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 } : {}),
};
}
}
+45 -8
View File
@@ -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 } : {}),
};
}
}