mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
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:
1 parent
ea3a8deed2
commit
6c75c4f39f
8 files changed
+725
-29
No files matched your search
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user