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 },
+54 -16
View File
@@ -51,21 +51,29 @@ describe("canonicalOrigin", () => {
});
it.each([
["IPv4 loopback", "http://127.0.0.1:8080"],
["IPv4 loopback, non-canonical", "http://127.10.20.30"],
["IPv4 unspecified", "http://0.0.0.0:8080"],
["link-local / metadata", "http://169.254.169.254"],
["IPv6 loopback", "http://[::1]:8080"],
["IPv6 unspecified", "http://[::]:8080"],
["IPv6 link-local", "http://[fe80::1]:8080"],
["IPv4-mapped loopback", "http://[::ffff:127.0.0.1]:8080"],
["IPv4-mapped metadata", "http://[::ffff:169.254.169.254]"],
["localhost", "http://localhost:8080"],
["a *.localhost name", "http://gotify.localhost"],
])("refuses %s as a grant", (_label, value) => {
expect(canonicalOrigin(value)).toBeNull();
});
it("keeps loopback grantable: an exact loopback origin is a deliberate host-networking decision", () => {
expect(canonicalOrigin("http://127.0.0.1:8080")).toBe(
"http://127.0.0.1:8080",
);
expect(canonicalOrigin("http://127.10.20.30")).toBe("http://127.10.20.30");
expect(canonicalOrigin("http://[::1]:8080")).toBe("http://[::1]:8080");
expect(canonicalOrigin("http://localhost:1337")).toBe(
"http://localhost:1337",
);
expect(canonicalOrigin("http://gotify.localhost")).toBe(
"http://gotify.localhost",
);
});
it("keeps mDNS and other reserved-looking names grantable when listed exactly", () => {
// The input-time floor refuses `.local` and `.internal` unlisted; an
// operator naming the exact origin is the override, and a loopback or
@@ -120,16 +128,16 @@ describe("parsePrivateOrigins", () => {
const onInvalid = vi.fn();
expect(
parsePrivateOrigins(
"https://gotify.lan, https://*.lan, http://127.0.0.1:8080, not an origin",
"https://gotify.lan, https://*.lan, http://169.254.169.254, not an origin",
onInvalid,
),
).toEqual(new Set(["https://gotify.lan"]));
expect(onInvalid.mock.calls.map(([entry]) => entry)).toEqual([
"https://*.lan",
"http://127.0.0.1:8080",
"http://169.254.169.254",
"not an origin",
]);
expect(onInvalid.mock.calls[1][1]).toMatch(/loopback.*cannot be granted/);
expect(onInvalid.mock.calls[1][1]).toMatch(/link-local.*cannot be granted/);
expect(onInvalid.mock.calls[0][1]).toMatch(/one exact http\(s\) origin/);
});
@@ -204,13 +212,41 @@ describe("evaluateOriginGrant", () => {
});
});
it("approves a listed loopback origin and refuses an unlisted one", () => {
const loopbackGrants = new Set([
"http://localhost:1337",
"http://[::1]:8080",
]);
expect(
evaluateOriginGrant("http://localhost:1337", loopbackGrants),
).toEqual({
allowed: true,
canonicalOrigin: "http://localhost:1337",
privateOriginApproved: true,
reasonCode: null,
});
expect(
evaluateOriginGrant("http://[::1]:8080", loopbackGrants),
).toMatchObject({ allowed: true, privateOriginApproved: true });
for (const origin of [
"http://localhost:1338",
"http://127.0.0.1:1337",
"http://[::1]:8081",
]) {
expect(evaluateOriginGrant(origin, loopbackGrants)).toEqual({
allowed: false,
canonicalOrigin: origin,
privateOriginApproved: false,
reasonCode: PRIVATE_ORIGIN_NOT_APPROVED_CODE,
});
}
});
it.each([
["IPv4 loopback", "http://127.0.0.1:8080"],
["localhost", "http://localhost:8080"],
["a *.localhost name", "http://gotify.localhost"],
["link-local / metadata", "http://169.254.169.254"],
["IPv6 loopback", "http://[::1]:8080"],
["unspecified", "http://0.0.0.0:8080"],
["IPv6 link-local", "http://[fe80::1]:8080"],
["IPv6 unspecified", "http://[::]:8080"],
])(
"answers %s with the not-grantable code, not a request to list it (M1)",
(_label, origin) => {
@@ -236,10 +272,12 @@ describe("redactGrantEntry / describeGrantRejection", () => {
});
it("names the never-grantable class apart from a grammar error", () => {
expect(describeGrantRejection("http://localhost:1337")).toMatch(
/loopback.*LAN address/,
expect(describeGrantRejection("http://169.254.169.254")).toMatch(
/link-local.*cannot be granted/,
);
expect(describeGrantRejection("http://[fe80::1]:1337")).toMatch(
/link-local/,
);
expect(describeGrantRejection("http://[::1]:1337")).toMatch(/loopback/);
expect(describeGrantRejection("https://*.lan")).toMatch(/exact http/);
expect(describeGrantRejection("http://10.0.0.4/path")).toMatch(
/exact http/,
+20 -25
View File
@@ -246,6 +246,11 @@ describe("pinnedPublicDispatcher", () => {
describe("isOperatorGrantableIp", () => {
it("keeps the private ranges an operator may deliberately list", () => {
expect(isOperatorGrantableIp("10.0.0.5")).toBe(true);
// Loopback is grantable: a host-networking deployment lists it exactly.
expect(isOperatorGrantableIp("127.0.0.1")).toBe(true);
expect(isOperatorGrantableIp("127.255.0.1")).toBe(true);
expect(isOperatorGrantableIp("::1")).toBe(true);
expect(isOperatorGrantableIp("::ffff:127.0.0.1")).toBe(true);
expect(isOperatorGrantableIp("172.16.0.9")).toBe(true);
expect(isOperatorGrantableIp("192.168.1.20")).toBe(true);
expect(isOperatorGrantableIp("100.64.0.7")).toBe(true);
@@ -259,17 +264,14 @@ describe("isOperatorGrantableIp", () => {
});
it.each([
["IPv4 loopback", "127.0.0.1"],
["IPv4 loopback, high octet", "127.255.0.1"],
["IPv4 unspecified", "0.0.0.0"],
["IPv4 link-local / metadata", "169.254.169.254"],
["IPv6 loopback", "::1"],
["IPv6 unspecified", "::"],
["IPv6 link-local", "fe80::1"],
["IPv4-mapped loopback", "::ffff:127.0.0.1"],
["IPv4-mapped metadata", "::ffff:169.254.169.254"],
["IPv4-mapped unspecified", "::ffff:0.0.0.0"],
["6to4 metadata", "2002:a9fe:a9fe::"],
["NAT64 loopback", "64:ff9b::7f00:1"],
["NAT64 metadata", "64:ff9b::a9fe:a9fe"],
["not an address", "gotify.lan"],
["empty", ""],
])("never grants %s, listed or not", (_label, ip) => {
@@ -297,10 +299,9 @@ describe("pinnedOperatorApprovedDispatcher", () => {
}
it.each([
["loopback", { address: "127.0.0.1", family: 4 }],
["link-local / metadata", { address: "169.254.169.254", family: 4 }],
["unspecified", { address: "0.0.0.0", family: 4 }],
["IPv6 loopback", { address: "::1", family: 6 }],
["IPv6 unspecified", { address: "::", family: 6 }],
["IPv6 link-local", { address: "fe80::1", family: 6 }],
["IPv4-mapped metadata", { address: "::ffff:169.254.169.254", family: 6 }],
])(
@@ -348,11 +349,11 @@ describe("pinnedOperatorApprovedDispatcher", () => {
expect(errorCodes(caught)).not.toContain("ENOTFOUND");
}, 20_000);
it("drops the loopback answer from a mixed set and dials only the private one", async () => {
// A real server on loopback proves the point: the listed name resolves to
// loopback AND to an unroutable private address; the pinned lookup must
// hand undici only the private survivor, so the loopback server never
// sees a request and the dial fails at the socket, not at the resolver.
it("drops the metadata answer from a mixed set and dials the loopback one", async () => {
// A real server on loopback proves both halves: the listed name resolves
// to the metadata endpoint AND to loopback; the pinned lookup drops the
// metadata answer and hands undici the loopback survivor, which is
// grantable, so the server receives exactly one request.
let requests = 0;
const server = http.createServer((_req, res) => {
requests += 1;
@@ -365,24 +366,18 @@ describe("pinnedOperatorApprovedDispatcher", () => {
const port = (server.address() as AddressInfo).port;
try {
resolveTo([
{ address: "169.254.169.254", family: 4 },
{ address: "127.0.0.1", family: 4 },
{ address: "10.255.255.1", family: 4 },
]);
const dispatcher = getPinnedOperatorApprovedDispatcher();
let caught: unknown;
try {
await undiciFetch(`http://gotify-listed.example.test:${port}/message`, {
dispatcher,
signal: AbortSignal.timeout(500),
});
} catch (error) {
caught = error;
}
const res = await undiciFetch(
`http://gotify-listed.example.test:${port}/message`,
{ dispatcher, signal: AbortSignal.timeout(5_000) },
);
expect(caught).toBeDefined();
expect(errorCodes(caught)).not.toContain("ENOTFOUND");
expect(requests).toBe(0);
expect(res.status).toBe(200);
expect(requests).toBe(1);
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
@@ -5,11 +5,9 @@
* an operator-approved private origin reaches the pinned Undici connector.
* This test keeps the Nightscout client, SafeFetch, dispatcher, socket, and
* HTTP server real. Only DNS is deterministic: the approved test hostname is
* resolved to the loopback server from inside the connector. Because the
* operator-approved dispatcher refuses a loopback answer by policy (the grant
* opens the operator's network, never the container), the grantable-address
* predicate is stubbed to admit loopback for this harness only; the policy
* itself is proven in `safe-fetch-dispatcher.test.ts`.
* resolved to the loopback server from inside the connector. Loopback is a
* grantable answer under the operator-approved policy (a host-networking
* deployment lists it), so the floor runs for real here.
*/
import dns from "node:dns";
import http from "node:http";
@@ -24,16 +22,6 @@ import {
vi,
} from "vitest";
vi.mock("@/lib/validations/notifications", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@/lib/validations/notifications")>();
return {
...actual,
isOperatorGrantableIp: (ip: string) =>
ip === "127.0.0.1" || actual.isOperatorGrantableIp(ip),
};
});
import { _resetPinnedDispatcherForTests } from "@/lib/safe-fetch-dispatcher";
import { fetchSgvEntries } from "../client";
@@ -60,7 +60,7 @@ describe("configuredNotificationPrivateOrigins", () => {
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";
"https://gotify.lan, https://*.lan, http://169.254.169.254";
expect(configuredNotificationPrivateOrigins()).toEqual(
new Set(["https://gotify.lan"]),
@@ -74,8 +74,8 @@ describe("configuredNotificationPrivateOrigins", () => {
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");
expect(lines[1]).toMatch(/loopback.*cannot be granted/);
expect(lines[1]).toContain("http://169.254.169.254");
expect(lines[1]).toMatch(/link-local.*cannot be granted/);
});
it("warns without echoing a query, fragment or userinfo (M3)", () => {
@@ -152,20 +152,36 @@ describe("evaluateNotificationTarget", () => {
});
});
it("keeps the raw-string floor: an octal loopback spelling is refused as never grantable", () => {
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_grantable",
reasonCode: "private_origin_not_approved",
});
});
it("approves a listed loopback target and refuses an unlisted one", () => {
process.env.NOTIFICATION_PRIVATE_ORIGINS = "http://localhost:8080";
expect(
evaluateNotificationTarget("http://localhost:8080/message"),
).toMatchObject({ allowed: true, privateOriginApproved: true });
for (const url of [
"http://127.0.0.1:8080/message",
"http://localhost:9090/message",
"http://[::1]:8080/message",
]) {
expect(evaluateNotificationTarget(url)).toMatchObject({
allowed: false,
reasonCode: "private_origin_not_approved",
});
}
});
it.each([
["loopback", "http://127.0.0.1:8080/message"],
["localhost", "http://localhost:8080/message"],
["metadata", "http://169.254.169.254/latest"],
["IPv6 loopback", "http://[::1]:8080/message"],
["unspecified", "http://0.0.0.0:8080/message"],
["IPv6 link-local", "http://[fe80::1]:8080/message"],
])("answers a %s target with the not-grantable code (M1)", (_l, url) => {
process.env.NOTIFICATION_PRIVATE_ORIGINS = "https://gotify.example.com";
expect(evaluateNotificationTarget(url)).toMatchObject({
+2 -2
View File
@@ -24,8 +24,8 @@ import { annotate, getEvent } from "@/lib/logging/context";
* 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,
* forbidden; link-local, the metadata range and the unspecified address stay
* refused even when listed (loopback is grantable when listed exactly). Nothing not listed widens, and there is no `=true`, CIDR,
* wildcard or suffix form. The grant lives in the server environment only —
* no request field and no settings toggle can confer it.
*
@@ -218,9 +218,9 @@ describe("sendViaNtfy", () => {
);
});
it("reports the not-grantable code for a loopback target, without dialling (M1)", async () => {
it("reports the not-grantable code for a metadata target, without dialling (M1)", async () => {
const result = await sendViaNtfy(
{ serverUrl: "http://localhost:8080", topic: "health" },
{ serverUrl: "http://169.254.169.254", topic: "health" },
payload(),
);
@@ -266,9 +266,9 @@ describe("sendViaWebhook", () => {
});
});
it("reports the not-grantable code for a loopback target, without dialling (M1)", async () => {
it("reports the not-grantable code for a metadata target, without dialling (M1)", async () => {
const result = await sendViaWebhook(
{ url: "http://127.0.0.1:8080/message" },
{ url: "http://169.254.169.254/latest" },
payload(),
);
+4 -4
View File
@@ -400,7 +400,7 @@ export const settingsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
...stdResponses,
"422": {
description:
"Body matched neither accepted shape, or `enabled` is true while the stored config has no server URL and topic; or the server URL is on a private network the operator has not listed in `NOTIFICATION_PRIVATE_ORIGINS` (`meta.errorCode` = `private_origin_not_approved`), or it is a loopback, link-local or metadata address that no grant can open (`private_origin_not_grantable`). Nothing was stored.",
"Body matched neither accepted shape, or `enabled` is true while the stored config has no server URL and topic; or the server URL is on a private network the operator has not listed in `NOTIFICATION_PRIVATE_ORIGINS` (`meta.errorCode` = `private_origin_not_approved`), or it is a link-local, metadata or unspecified address that no grant can open (`private_origin_not_grantable`). Nothing was stored.",
content: { "application/json": { schema: errorEnvelope } },
},
},
@@ -516,7 +516,7 @@ export const settingsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
...stdResponses,
"422": {
description:
"Body matched neither accepted shape, or `enabled` is true while no webhook URL is stored; or the URL is on a private network the operator has not listed in `NOTIFICATION_PRIVATE_ORIGINS` (`meta.errorCode` = `private_origin_not_approved`), or it is a loopback, link-local or metadata address that no grant can open (`private_origin_not_grantable`). Nothing was stored.",
"Body matched neither accepted shape, or `enabled` is true while no webhook URL is stored; or the URL is on a private network the operator has not listed in `NOTIFICATION_PRIVATE_ORIGINS` (`meta.errorCode` = `private_origin_not_approved`), or it is a link-local, metadata or unspecified address that no grant can open (`private_origin_not_grantable`). Nothing was stored.",
content: { "application/json": { schema: errorEnvelope } },
},
},
@@ -719,7 +719,7 @@ export const settingsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
...stdResponses,
"422": {
description:
"The saved server is on a private network the operator has not approved (`meta.errorCode` = `private_origin_not_approved`), or is a loopback, link-local or metadata address that no grant can open (`private_origin_not_grantable`). The resolved address is never echoed.",
"The saved server is on a private network the operator has not approved (`meta.errorCode` = `private_origin_not_approved`), or is a link-local, metadata or unspecified address that no grant can open (`private_origin_not_grantable`). The resolved address is never echoed.",
content: { "application/json": { schema: errorEnvelope } },
},
},
@@ -765,7 +765,7 @@ export const settingsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
...stdResponses,
"422": {
description:
"The saved URL is on a private network the operator has not approved (`meta.errorCode` = `private_origin_not_approved`), or is a loopback, link-local or metadata address that no grant can open (`private_origin_not_grantable`). The resolved address is never echoed.",
"The saved URL is on a private network the operator has not approved (`meta.errorCode` = `private_origin_not_approved`), or is a link-local, metadata or unspecified address that no grant can open (`private_origin_not_grantable`). The resolved address is never echoed.",
content: { "application/json": { schema: errorEnvelope } },
},
},
+19 -19
View File
@@ -20,9 +20,9 @@ import {
export const PRIVATE_ORIGIN_NOT_APPROVED_CODE =
"private_origin_not_approved" as const;
/**
* The target is loopback, unspecified, link-local or the metadata range: no
* grant can ever open it, so telling the user to ask the operator would send
* them in a circle. On host networking the LAN address is the answer.
* The target is the unspecified address, link-local or the metadata range:
* no grant can ever open it, so telling the user to ask the operator would
* send them in a circle.
*/
export const PRIVATE_ORIGIN_NOT_GRANTABLE_CODE =
"private_origin_not_grantable" as const;
@@ -34,15 +34,14 @@ export type OriginReason =
| typeof INVALID_ORIGIN;
/**
* A hostname no grant may name: `localhost` / `*.localhost` (loopback by
* definition, RFC 6761) or a literal address outside the grantable ranges.
* DNS names are not judged here — a name that resolves into those ranges is
* dropped at dial time by the pinned dispatcher.
* A hostname no grant may name: a literal address outside the grantable
* ranges (unspecified, link-local, metadata). Loopback and `localhost` are
* grantable when listed exactly — a host-networking deployment lists them —
* and DNS names are not judged here: a name that resolves into a refused
* range is dropped at dial time by the pinned dispatcher.
*/
export function isNeverGrantableHost(hostname: string): boolean {
const lower = hostname.toLowerCase();
if (lower === "localhost" || lower.endsWith(".localhost")) return true;
const literal = lower.replace(/^\[|\]$/g, "");
const literal = hostname.toLowerCase().replace(/^\[|\]$/g, "");
return isIP(literal) !== 0 && !isOperatorGrantableIp(literal);
}
@@ -68,7 +67,7 @@ export function describeGrantRejection(entry: string): string {
(url.protocol === "http:" || url.protocol === "https:") &&
isNeverGrantableHost(url.hostname)
) {
return "loopback, link-local and metadata addresses cannot be granted; on host networking list the LAN address of the relay instead";
return "the unspecified address, link-local and metadata addresses cannot be granted; list the address the relay actually listens on";
}
} catch {
// fall through to the grammar sentence
@@ -88,10 +87,11 @@ export interface OriginVerdict {
*
* Credentials, paths, queries, and fragments are deliberately forbidden: the
* operator grants a complete canonical scheme/host/port trust unit, never a
* suffix, wildcard, capability URL, or prefix. A literal address in loopback,
* the unspecified range, link-local or the metadata range is refused too —
* suffix, wildcard, capability URL, or prefix. A literal address in the
* unspecified range, link-local or the metadata range is refused too —
* `isOperatorGrantableIp` says why — and the pinned dispatcher applies the
* same floor when a listed DNS name resolves there.
* same floor when a listed DNS name resolves there. Loopback is grantable
* when listed exactly: an operator on host networking lists it on purpose.
*/
export function canonicalOrigin(value: string): string | null {
try {
@@ -100,11 +100,11 @@ export function canonicalOrigin(value: string): string | null {
if (url.username || url.password) return null;
if (url.pathname !== "/" || url.search || url.hash) return null;
if (!url.hostname || url.hostname.includes("*")) return null;
// Loopback, unspecified, link-local and metadata literals, and
// `localhost` / `*.localhost`, are refused at parse time. Other
// reserved-looking names (`.local` mDNS, `.internal`, `.lan`) stay
// grantable: an explicitly listed origin is what the list is for, and
// the dial-time floor still drops a loopback or metadata answer.
// Unspecified, link-local and metadata literals are refused at parse
// time. Loopback, `localhost` and reserved-looking names (`.local` mDNS,
// `.internal`, `.lan`) stay grantable: an explicitly listed origin is
// what the list is for, and the dial-time floor still drops a metadata
// or link-local answer.
if (isNeverGrantableHost(url.hostname)) return null;
return url.origin;
} catch {
+6 -5
View File
@@ -71,9 +71,10 @@ function pinnedLookupWithPolicy(
return;
}
// The operator-approved policy is not "anything that parses": a listed
// name that resolves to loopback, link-local or the metadata range is
// refused here exactly as an unlisted one would be. The grant opens the
// operator's own network, never the container or the cloud host.
// name that resolves to link-local, the metadata range or the
// unspecified address is refused here exactly as an unlisted one would
// be. The grant opens the operator's own network (loopback included, on
// host networking), never the cloud host's metadata endpoint.
const allowed = addresses.filter((address) =>
policy === "public"
? isPublicIp(address.address)
@@ -176,8 +177,8 @@ export function getPinnedPublicDispatcher(): Agent {
* the complete canonical scheme/host/port trust grant. Resolution is still
* performed once inside Undici's connector and the vetted answer set is
* pinned to the socket, so a later DNS answer cannot replace the destination.
* Loopback, unspecified, link-local and metadata answers are dropped
* regardless of the grant (`isOperatorGrantableIp`).
* Unspecified, link-local and metadata answers are dropped regardless of the
* grant (`isOperatorGrantableIp`).
*/
export function getPinnedOperatorApprovedDispatcher(): Agent {
cachedOperatorApproved ??= createPinnedDispatcher("operator-approved");
@@ -121,11 +121,28 @@ describe("Nightscout exact-origin policy", () => {
});
});
it("names the entry and the reason when a loopback grant is listed, without its token (M2)", () => {
it("keeps a loopback Nightscout grant working exactly as before", () => {
// A host-networking self-host lists http://localhost:1337; the shared
// floor must not turn that into a failed sync on upgrade.
expect(parseNightscoutPrivateOrigins("http://localhost:1337")).toEqual(
new Set(["http://localhost:1337"]),
);
expect(
evaluateNightscoutOrigin(
"http://localhost:1337",
parseNightscoutPrivateOrigins("http://localhost:1337"),
),
).toMatchObject({ allowed: true, privateOriginApproved: true });
expect(
parseNightscoutPrivateOrigins("http://127.0.0.1:1337, http://[::1]:1337"),
).toEqual(new Set(["http://127.0.0.1:1337", "http://[::1]:1337"]));
});
it("names the entry and the reason when a metadata grant is listed, without its token (M2)", () => {
let caught: unknown;
try {
parseNightscoutPrivateOrigins(
"https://cgm.lan, http://localhost:1337/?token=SECRET123",
"https://cgm.lan, http://169.254.169.254:1337/?token=SECRET123",
);
} catch (err) {
caught = err;
@@ -134,9 +151,9 @@ describe("Nightscout exact-origin policy", () => {
const message = (caught as Error).message;
expect((caught as Error).name).toBe("NightscoutOriginConfigError");
expect(message).toContain(
'NIGHTSCOUT_PRIVATE_ORIGINS entry "http://localhost:1337/"',
'NIGHTSCOUT_PRIVATE_ORIGINS entry "http://169.254.169.254:1337/"',
);
expect(message).toMatch(/loopback.*LAN address/);
expect(message).toMatch(/link-local.*cannot be granted/);
expect(message).not.toContain("SECRET123");
expect(message).not.toContain("token=");
});
+3 -3
View File
@@ -17,9 +17,9 @@ export const NIGHTSCOUT_INVALID_ORIGIN_REASON = INVALID_ORIGIN;
* Thrown when `NIGHTSCOUT_PRIVATE_ORIGINS` holds an entry that is not a
* grant. The message names the entry (scheme and host only) and the reason,
* so the sync ledger and the connect form show the operator what to edit
* instead of a bare "invalid entry". Loopback and `localhost` entries
* became invalid with the shared address floor (#947); a host-networking
* deployment lists the LAN address instead.
* instead of a bare "invalid entry". Loopback and `localhost` entries stay
* valid grants exactly as before #947; the shared floor refuses only the
* unspecified address, link-local and the metadata range.
*/
export class NightscoutOriginConfigError extends Error {
constructor(redactedEntry: string, reason: string) {
+15 -14
View File
@@ -234,14 +234,14 @@ export function isPublicIp(ip: string): boolean {
* `NIGHTSCOUT_PRIVATE_ORIGINS`).
*
* A grant exists so a relay on the operator's own network can be reached, so
* RFC1918, CGNAT (a Tailscale address) and IPv6 unique-local stay grantable.
* Four ranges are refused even when the operator lists a name that resolves
* there, because no notification relay lives on them and each one is a
* different admin surface: loopback is the app itself inside the container
* (and on host networking, every admin port of the host), the unspecified
* address is a resolver misfire, link-local is the cloud-metadata endpoint.
* The transition formats (`::ffff:`, 6to4, NAT64) are unwrapped first so an
* IPv6 spelling of loopback does not slip past the IPv4 verdict.
* RFC1918, CGNAT (a Tailscale address), IPv6 unique-local and loopback stay
* grantable: an operator on host networking who lists an exact loopback
* origin with its port has made the same deliberate decision as one who
* lists a LAN origin. Two classes are refused even when the operator lists a
* name that resolves there, because no relay lives on them: the unspecified
* address is a resolver misfire, and link-local is the cloud-metadata
* endpoint. The transition formats (`::ffff:`, 6to4, NAT64) are unwrapped
* first so an IPv6 spelling of those does not slip past the IPv4 verdict.
*/
export function isOperatorGrantableIp(ip: string): boolean {
if (!ip) return false;
@@ -258,12 +258,14 @@ export function isOperatorGrantableIp(ip: string): boolean {
const bytes = parseIpv6Bytes(lower);
if (!bytes) return false;
// Unspecified, loopback and link-local IPv6. Unique-local (fc00::/7)
// deliberately stays out of this list: it is the IPv6 analogue of
// RFC1918 and exactly what an operator lists.
// Unspecified and link-local IPv6. Unique-local (fc00::/7) and loopback
// (::1) deliberately stay out of this list: they are what an operator
// on a private or host network lists. `::1` is answered before the
// embedded-IPv4 unwrap, which would otherwise read it as the
// IPv4-compatible spelling of 0.0.0.1 and refuse it as unspecified.
if (bytesEqual(bytes, 0, Array(16).fill(0))) return false;
if (bytesEqual(bytes, 0, Array(15).fill(0)) && bytes[15] === 1) {
return false;
return true;
}
if (bytes[0] === 0xfe && (bytes[1] & 0xc0) === 0x80) return false;
@@ -275,10 +277,9 @@ export function isOperatorGrantableIp(ip: string): boolean {
return false;
}
/** 127/8, 0/8 and 169.254/16: the ranges no operator grant may open. */
/** 0/8 and 169.254/16: the ranges no operator grant may open. */
function isNeverGrantableIpv4(ip: [number, number, number, number]): boolean {
const [a, b] = ip;
if (a === 127) return true;
if (a === 0) return true;
if (a === 169 && b === 254) return true;
return false;