diff --git a/src/lib/__tests__/safe-fetch-pinned.test.ts b/src/lib/__tests__/safe-fetch-pinned.test.ts index ea7f7f86c..b7bf3af8c 100644 --- a/src/lib/__tests__/safe-fetch-pinned.test.ts +++ b/src/lib/__tests__/safe-fetch-pinned.test.ts @@ -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"); diff --git a/src/lib/__tests__/safe-fetch.test.ts b/src/lib/__tests__/safe-fetch.test.ts index c75585177..9b461c171 100644 --- a/src/lib/__tests__/safe-fetch.test.ts +++ b/src/lib/__tests__/safe-fetch.test.ts @@ -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")); diff --git a/src/lib/__tests__/ssrf-pin-inventory.test.ts b/src/lib/__tests__/ssrf-pin-inventory.test.ts index f6e29ed6d..6e598849a 100644 --- a/src/lib/__tests__/ssrf-pin-inventory.test.ts +++ b/src/lib/__tests__/ssrf-pin-inventory.test.ts @@ -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. diff --git a/src/lib/nightscout/__tests__/client-pinned.test.ts b/src/lib/nightscout/__tests__/client-pinned.test.ts new file mode 100644 index 000000000..c132bdaf7 --- /dev/null +++ b/src/lib/nightscout/__tests__/client-pinned.test.ts @@ -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 { + 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((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((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); + }); +}); diff --git a/src/lib/nightscout/__tests__/client.test.ts b/src/lib/nightscout/__tests__/client.test.ts index 56a48d959..7005a56e3 100644 --- a/src/lib/nightscout/__tests__/client.test.ts +++ b/src/lib/nightscout/__tests__/client.test.ts @@ -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 () => { diff --git a/src/lib/nightscout/client.ts b/src/lib/nightscout/client.ts index a504ba127..e41be4bcd 100644 --- a/src/lib/nightscout/client.ts +++ b/src/lib/nightscout/client.ts @@ -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, }, ); diff --git a/src/lib/safe-fetch-dispatcher.ts b/src/lib/safe-fetch-dispatcher.ts index ee32a25e3..4ced48b20 100644 --- a/src/lib/safe-fetch-dispatcher.ts +++ b/src/lib/safe-fetch-dispatcher.ts @@ -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. diff --git a/src/lib/safe-fetch.ts b/src/lib/safe-fetch.ts index e6718c312..05684822e 100644 --- a/src/lib/safe-fetch.ts +++ b/src/lib/safe-fetch.ts @@ -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 { 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.