fix(nightscout): pin approved private origins

This commit is contained in:
Marc-André Bombeck committed 2026-07-29 15:54:01 +02:00
1 parent 8db21ab718
commit b9879e8829
8 files changed
+322 -13

No files matched your search

+1 -1
View File
@@ -182,7 +182,7 @@ describe("safeFetch pinned path — real transport", () => {
timeoutMs: 5000,
},
),
).rejects.toMatchObject({ kind: "network" });
).rejects.toMatchObject({ kind: "private_host" });
expect(lookupHosts).toContain("pinned.test");
expect(lookupHosts).toContain("redirect-target.test");
+54
View File
@@ -119,6 +119,60 @@ describe("safeFetch", () => {
expect(fetchSpy).not.toHaveBeenCalled();
}, 20_000);
it("rejects mutually exclusive public and private destination policies", async () => {
await expect(
safeFetch(
"https://example.com/api",
{},
{
requirePublicHost: true,
operatorApprovedPrivateOrigin: "https://example.com",
},
),
).rejects.toMatchObject({ kind: "private_host" });
expect(fetchSpy).not.toHaveBeenCalled();
});
it("rejects a target outside the exact operator-approved origin", async () => {
await expect(
safeFetch(
"http://192.168.1.51:1337/api",
{},
{
operatorApprovedPrivateOrigin: "http://192.168.1.50:1337",
},
),
).rejects.toMatchObject({ kind: "private_host" });
expect(fetchSpy).not.toHaveBeenCalled();
});
it("rejects a path-shaped value masquerading as an origin grant", async () => {
await expect(
safeFetch(
"http://192.168.1.50:1337/api",
{},
{
operatorApprovedPrivateOrigin:
"http://192.168.1.50:1337/api?from=request",
},
),
).rejects.toMatchObject({ kind: "private_host" });
expect(fetchSpy).not.toHaveBeenCalled();
});
it("forbids redirects on an operator-approved private origin", async () => {
await expect(
safeFetch(
"http://192.168.1.50:1337/api",
{ redirect: "follow" },
{
operatorApprovedPrivateOrigin: "http://192.168.1.50:1337",
},
),
).rejects.toMatchObject({ kind: "private_host" });
expect(fetchSpy).not.toHaveBeenCalled();
});
it("wraps a native network error as kind: 'network'", async () => {
fetchSpy.mockRejectedValueOnce(new TypeError("network down"));
@@ -38,6 +38,17 @@ describe("SSRF requirePublicHost pin inventory", () => {
expect(read("geo.ts")).toMatch(/requirePublicHost:\s*true/);
});
it("Nightscout routes exact operator-approved private origins through the private pin", () => {
const src = read("nightscout/client.ts");
expect(src).toMatch(
/operatorApprovedPrivateOrigin:\s*policy\.canonicalOrigin/,
);
expect(src).toMatch(/requirePublicHost:\s*!policy\.privateOriginApproved/);
expect(src).not.toMatch(
/operatorApprovedPrivateOrigin:\s*opts\.allowPrivateHost/,
);
});
// 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.
@@ -0,0 +1,146 @@
/**
* End-to-end Nightscout transport regression.
*
* The ordinary client suite mocks SafeFetch and therefore cannot prove that
* 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.
*/
import dns from "node:dns";
import http from "node:http";
import type { AddressInfo } from "node:net";
import {
afterAll,
afterEach,
beforeAll,
describe,
expect,
it,
vi,
} from "vitest";
import { _resetPinnedDispatcherForTests } from "@/lib/safe-fetch-dispatcher";
import { fetchSgvEntries } from "../client";
const ORIGINAL_PRIVATE_ORIGINS = process.env.NIGHTSCOUT_PRIVATE_ORIGINS;
let server: http.Server;
let port: number;
let requestCount = 0;
function pinTestHostnameToLoopback(): ReturnType<typeof vi.spyOn> {
return vi.spyOn(dns, "lookup").mockImplementation(((
_hostname: string,
_opts: dns.LookupAllOptions,
callback: (
err: NodeJS.ErrnoException | null,
addresses: dns.LookupAddress[],
) => void,
) => {
callback(null, [{ address: "127.0.0.1", family: 4 }]);
}) as unknown as typeof dns.lookup);
}
beforeAll(async () => {
server = http.createServer((_req, res) => {
requestCount += 1;
res.writeHead(200, { "content-type": "application/json" });
res.end(
JSON.stringify([
{
_id: "private-cgm-reading",
type: "sgv",
sgv: 109,
date: 1_718_000_000_000,
},
]),
);
});
await new Promise<void>((resolve) =>
server.listen(0, "127.0.0.1", () => resolve()),
);
port = (server.address() as AddressInfo).port;
});
afterEach(() => {
_resetPinnedDispatcherForTests();
vi.restoreAllMocks();
requestCount = 0;
if (ORIGINAL_PRIVATE_ORIGINS === undefined) {
delete process.env.NIGHTSCOUT_PRIVATE_ORIGINS;
} else {
process.env.NIGHTSCOUT_PRIVATE_ORIGINS = ORIGINAL_PRIVATE_ORIGINS;
}
});
afterAll(async () => {
await new Promise<void>((resolve) => server.close(() => resolve()));
});
describe("Nightscout exact private-origin connect-time pin", () => {
it("reaches the real server only through connector-time pinned DNS", async () => {
const origin = `http://nightscout-private.test:${port}`;
process.env.NIGHTSCOUT_PRIVATE_ORIGINS = origin;
const lookup = pinTestHostnameToLoopback();
await expect(
fetchSgvEntries({
baseUrl: origin,
token: "",
count: 1,
allowPrivateHost: false,
timeoutMs: 5_000,
}),
).resolves.toEqual([
{
id: "private-cgm-reading",
sgv: 109,
date: 1_718_000_000_000,
},
]);
expect(lookup).toHaveBeenCalled();
expect(lookup.mock.calls[0]?.[0]).toBe("nightscout-private.test");
expect(requestCount).toBe(1);
});
it("denies a sibling port before DNS or a socket can be reached", async () => {
process.env.NIGHTSCOUT_PRIVATE_ORIGINS = `http://nightscout-private.test:${port}`;
const lookup = pinTestHostnameToLoopback();
await expect(
fetchSgvEntries({
baseUrl: `http://nightscout-private.test:${port + 1}`,
token: "",
count: 1,
allowPrivateHost: true,
}),
).rejects.toMatchObject({
reasonCode: "private_origin_not_approved",
});
expect(lookup).not.toHaveBeenCalled();
expect(requestCount).toBe(0);
});
it("does not let the legacy request boolean create a private trust grant", async () => {
delete process.env.NIGHTSCOUT_PRIVATE_ORIGINS;
const lookup = pinTestHostnameToLoopback();
await expect(
fetchSgvEntries({
baseUrl: `http://127.0.0.1:${port}`,
token: "",
count: 1,
allowPrivateHost: true,
}),
).rejects.toMatchObject({
reasonCode: "private_origin_not_approved",
});
expect(lookup).not.toHaveBeenCalled();
expect(requestCount).toBe(0);
});
});
@@ -165,6 +165,11 @@ describe("fetchSgvEntries — SSRF gating", () => {
expect(entries).toHaveLength(2);
expect(safeFetchMock).toHaveBeenCalledTimes(1);
const safeFetchOptions = safeFetchMock.mock.calls[0]![2];
expect(safeFetchOptions).toMatchObject({
requirePublicHost: false,
operatorApprovedPrivateOrigin: "http://192.168.1.50:1337",
});
});
it("does not let the legacy user boolean authorize private egress", async () => {
+12 -7
View File
@@ -193,11 +193,12 @@ export class NightscoutPolicyError extends Error {
/**
* Fetch the most recent `count` SGV entries from the user's instance.
*
* SSRF floor: `requirePublicHost` is `true` unless the user opted a private
* host in via `allowPrivateHost`. Redirects pinned to manual + a timeout
* always composed. Throws `NightscoutApiError` on a non-2xx response; re-raises
* a `SafeFetchError` (private host / timeout / network) unchanged so the caller
* can classify it.
* SSRF floor: public origins use `requirePublicHost`; an exact private origin
* approved by server configuration uses SafeFetch's private-capable pinned
* dispatcher. The legacy `allowPrivateHost` user boolean is never authority.
* Redirects are pinned to manual and a timeout is always composed. Throws
* `NightscoutApiError` on a non-2xx response; re-raises a `SafeFetchError`
* (private host / timeout / network) unchanged so the caller can classify it.
*/
export async function fetchSgvEntries(
opts: FetchSgvOptions,
@@ -234,9 +235,13 @@ export async function fetchSgvEntries(
{ method: "GET", headers },
{
// Public origins receive the full literal + DNS-pinned classifier.
// An exact operator origin may resolve privately; redirects remain
// manual, so no response can move the request outside that grant.
// An exact operator origin receives the private-capable connect-time
// pin. The grant comes only from the server-side exact-origin policy;
// the legacy request boolean never reaches SafeFetch.
requirePublicHost: !policy.privateOriginApproved,
...(policy.privateOriginApproved
? { operatorApprovedPrivateOrigin: policy.canonicalOrigin }
: {}),
timeoutMs: opts.timeoutMs ?? NIGHTSCOUT_TIMEOUT_MS,
},
);
+7 -2
View File
@@ -74,10 +74,15 @@ function pinnedLookupWithPolicy(
: isIP(address.address) !== 0,
);
if (allowed.length === 0) {
const refused: NodeJS.ErrnoException = new Error(
const refused = new Error(
`safeFetch refused private resolved address for ${hostname}`,
);
) as NodeJS.ErrnoException & { safeFetchKind: "private_host" };
refused.code = "ENOTFOUND";
// Undici wraps connector errors below `TypeError("fetch failed")`.
// Preserve an explicit machine-readable marker so SafeFetch can
// distinguish a policy refusal from an ordinary NXDOMAIN result and
// return the redacted `private_host` classification to its caller.
refused.safeFetchKind = "private_host";
// Preserve the result shape Undici requested. Auto-select-family asks
// for `all: true`; handing that path a scalar empty string can mask the
// policy error behind an invalid-address error for IPv6 answers.
+86 -3
View File
@@ -1,6 +1,9 @@
import { fetch as undiciFetch } from "undici";
import { isPublicUrl } from "@/lib/validations/notifications";
import { getPinnedPublicDispatcher } from "@/lib/safe-fetch-dispatcher";
import {
getPinnedOperatorApprovedDispatcher,
getPinnedPublicDispatcher,
} from "@/lib/safe-fetch-dispatcher";
/**
* Defaults applied to every `safeFetch` call. Centralised so the values
@@ -36,6 +39,16 @@ export interface SafeFetchOptions {
* IP pin via a dedicated undici dispatcher.
*/
requirePublicHost?: boolean;
/**
* Exact server-approved origin for a deliberately private destination.
*
* This is a server-side trust grant, not request metadata: callers must
* derive it from operator configuration after exact-origin validation.
* `safeFetch` verifies the target still has this origin, forbids redirects,
* and routes the connect through the private-capable pinned dispatcher.
* It is mutually exclusive with `requirePublicHost`.
*/
operatorApprovedPrivateOrigin?: string;
/**
* Optional caller-owned signal. Composed with the timeout signal so a
* caller-side abort still wins even when the timeout has not yet
@@ -72,6 +85,20 @@ function composeSignals(
return AbortSignal.any([caller, timeout]);
}
function hasPrivateHostPolicyCause(error: unknown): boolean {
if (!error || typeof error !== "object") return false;
const value = error as {
safeFetchKind?: unknown;
cause?: unknown;
errors?: unknown[];
};
return (
value.safeFetchKind === "private_host" ||
hasPrivateHostPolicyCause(value.cause) ||
(value.errors ?? []).some(hasPrivateHostPolicyCause)
);
}
/**
* Default-safe outbound fetch wrapper.
*
@@ -101,8 +128,48 @@ export async function safeFetch(
opts: SafeFetchOptions = {},
): Promise<Response> {
const target = url.toString();
const operatorApprovedOrigin = opts.operatorApprovedPrivateOrigin;
if (opts.requirePublicHost && !isPublicUrl(target)) {
if (opts.requirePublicHost && operatorApprovedOrigin !== undefined) {
throw new SafeFetchError(
"safeFetch received mutually exclusive destination policies",
"private_host",
);
}
if (operatorApprovedOrigin !== undefined) {
let targetOrigin: string;
let approvedOrigin: string;
try {
targetOrigin = new URL(target).origin;
const approvedUrl = new URL(operatorApprovedOrigin);
if (
(approvedUrl.protocol !== "http:" &&
approvedUrl.protocol !== "https:") ||
approvedUrl.username ||
approvedUrl.password ||
approvedUrl.pathname !== "/" ||
approvedUrl.search ||
approvedUrl.hash ||
operatorApprovedOrigin !== approvedUrl.origin
) {
throw new TypeError("approval must be one canonical http(s) origin");
}
approvedOrigin = approvedUrl.origin;
} catch {
throw new SafeFetchError(
"safeFetch received an invalid operator-approved origin",
"private_host",
);
}
if (targetOrigin !== approvedOrigin) {
throw new SafeFetchError(
"safeFetch target does not match the operator-approved origin",
"private_host",
);
}
} else if (opts.requirePublicHost && !isPublicUrl(target)) {
throw new SafeFetchError(
`safeFetch refused private or non-public host: ${target}`,
"private_host",
@@ -112,6 +179,13 @@ export async function safeFetch(
const redirect: RequestRedirect =
init.redirect ?? (opts.followRedirects ? "follow" : "manual");
if (operatorApprovedOrigin !== undefined && redirect !== "manual") {
throw new SafeFetchError(
"safeFetch forbids redirects for an operator-approved private origin",
"private_host",
);
}
const signal = composeSignals(
init.signal ?? opts.signal ?? undefined,
opts.timeoutMs ?? DEFAULT_TIMEOUT_MS,
@@ -122,7 +196,9 @@ export async function safeFetch(
// between the input-time `isPublicUrl` accept and the connect call.
const dispatcher = opts.requirePublicHost
? getPinnedPublicDispatcher()
: undefined;
: operatorApprovedOrigin !== undefined
? getPinnedOperatorApprovedDispatcher()
: undefined;
const finalInit = {
...init,
@@ -149,6 +225,13 @@ export async function safeFetch(
}
return await fetch(target, finalInit);
} catch (err) {
if (hasPrivateHostPolicyCause(err)) {
throw new SafeFetchError(
"safeFetch refused a private resolved destination",
"private_host",
err,
);
}
// `AbortSignal.timeout` aborts with `TimeoutError` (`DOMException`
// named "TimeoutError"); a caller-side abort surfaces as
// `AbortError`. Both manifest as the same `err.name` check.