fix(egress): keep loopback grantable under an exact operator origin

A Nightscout self-host on host networking with
NIGHTSCOUT_PRIVATE_ORIGINS=http://localhost:1337 worked before and must
keep working unchanged; an operator who lists an exact loopback origin
with its port has made the same deliberate decision as one who lists a
LAN origin. The never-grantable floor is now the unspecified address,
link-local and the metadata endpoint only. Loopback (127/8, ::1,
localhost) is grantable when listed exactly, still resolved and pinned
at connect time with redirects forbidden, and still refused when not
listed.

::1 is answered before the embedded-IPv4 unwrap, which read it as the
IPv4-compatible spelling of 0.0.0.1 and refused it as unspecified. The
pinned Nightscout transport test runs the real floor again. Each refused
range keeps its own test; loopback moves to the grantable-when-listed
set with a test that an unlisted loopback is still refused, and a
Nightscout grant of http://localhost:1337 is asserted to parse.

Refs #947
This commit is contained in:
Marc-André Bombeck committed 2026-09-10 19:08:04 +02:00
1 parent 9c306f04e7
commit 94bed12edc
31 files changed
+236 -156

No files matched your search

@@ -179,25 +179,47 @@ describe("PUT /api/settings/webhook — private targets", () => {
expect(json.meta).toBeUndefined();
});
it("answers a loopback target with the not-grantable code, listed or not (M1)", async () => {
it("saves a listed loopback target and refuses an unlisted one with the listable code", async () => {
// Host networking: an exact loopback origin with its port is a grant like
// any other. Unlisted, it is refused with the code that says "list it",
// because listing it does open it.
process.env.NOTIFICATION_PRIVATE_ORIGINS = "http://localhost:8080";
const refused = await put("webhook", {
url: "http://127.0.0.1:8080/message",
enabled: true,
});
expect(refused.status).toBe(422);
await expect(refused.json()).resolves.toMatchObject({
meta: { errorCode: "private_origin_not_approved" },
});
const saved = await put("webhook", {
url: "http://localhost:8080/message",
enabled: true,
});
expect(saved.status).toBe(200);
expect(prisma.notificationChannel.upsert).toHaveBeenCalledTimes(1);
});
it("answers a metadata or unspecified target with the not-grantable code, listed or not (M1)", async () => {
// This used to pin a dead end: the route said "list it", the parser
// dropped the listing, and the next save said "list it" again. A target
// no grant can open gets its own code and a message that says so.
vi.spyOn(console, "warn").mockImplementation(() => {});
process.env.NOTIFICATION_PRIVATE_ORIGINS =
"http://127.0.0.1:8080, http://localhost:8080";
"http://169.254.169.254, http://0.0.0.0:8080";
for (const url of [
"http://127.0.0.1:8080/message",
"http://localhost:8080/message",
"http://169.254.169.254/latest",
"http://[::1]:8080/message",
"http://0.0.0.0:8080/message",
"http://[fe80::1]:8080/message",
]) {
const response = await put("webhook", { url, enabled: true });
expect(response.status).toBe(422);
const json = await response.json();
expect(json.meta).toEqual({ errorCode: "private_origin_not_grantable" });
expect(json.error).toMatch(/LAN address/);
expect(json.error).toMatch(/no operator grant can open/);
expect(json.error).not.toContain("NOTIFICATION_PRIVATE_ORIGINS");
expect(json.details.issues.length).toBeGreaterThan(0);
}
@@ -321,7 +343,7 @@ describe("POST /api/settings/{webhook,ntfy}/test — the refusal names itself",
expect(response.status).toBe(422);
const json = await response.json();
expect(json.meta).toEqual({ errorCode: "private_origin_not_grantable" });
expect(json.error).toMatch(/LAN address/);
expect(json.error).toMatch(/no operator grant can open/);
expect(json.error).not.toContain("NOTIFICATION_PRIVATE_ORIGINS");
},
);
+1 -1
View File
@@ -32,7 +32,7 @@ const ntfySettingsSchema = ntfySettingsSchemaWith(isAllowedNotificationTarget);
const PRIVATE_ORIGIN_REFUSAL =
"This server is on a private network. The operator has to list its exact origin (scheme://host:port) in NOTIFICATION_PRIVATE_ORIGINS before HealthLog can send to it.";
const NOT_GRANTABLE_REFUSAL =
"This server is loopback, link-local or a metadata address, which no operator grant can open. Use the relay's LAN address instead.";
"This server is a link-local, metadata or unspecified address, which no operator grant can open. Use the address the relay actually listens on.";
/**
* Refusal for an enable attempt on a channel the operator switched off
+1 -1
View File
@@ -52,7 +52,7 @@ export const POST = apiHandler(async () => {
});
return apiError(
result.errorCode === "private_origin_not_grantable"
? "The server is loopback, link-local or a metadata address, which no operator grant can open. Use the relay's LAN address instead."
? "The server is a link-local, metadata or unspecified address, which no operator grant can open. Use the address the relay actually listens on."
: "The server is on a private network the operator has not approved. List its exact origin (scheme://host:port) in NOTIFICATION_PRIVATE_ORIGINS on the server.",
422,
{ errorCode: result.errorCode },
+1 -1
View File
@@ -34,7 +34,7 @@ const webhookSettingsSchema = webhookSettingsSchemaWith(
const PRIVATE_ORIGIN_REFUSAL =
"This address is on a private network. The operator has to list its exact origin (scheme://host:port) in NOTIFICATION_PRIVATE_ORIGINS before HealthLog can send to it.";
const NOT_GRANTABLE_REFUSAL =
"This address is loopback, link-local or a metadata address, which no operator grant can open. Use the relay's LAN address instead.";
"This address is a link-local, metadata or unspecified address, which no operator grant can open. Use the address the relay actually listens on.";
/**
* Generic-webhook channel config (v1.17.1).
+1 -1
View File
@@ -51,7 +51,7 @@ export const POST = apiHandler(async () => {
});
return apiError(
result.errorCode === "private_origin_not_grantable"
? "The target is loopback, link-local or a metadata address, which no operator grant can open. Use the relay's LAN address instead."
? "The target is a link-local, metadata or unspecified address, which no operator grant can open. Use the address the relay actually listens on."
: "The target is on a private network the operator has not approved. List its exact origin (scheme://host:port) in NOTIFICATION_PRIVATE_ORIGINS on the server.",
422,
{ errorCode: result.errorCode },