mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
fix(nightscout): pin approved private origins
This commit is contained in:
1 parent
8db21ab718
commit
b9879e8829
8 files changed
+322
-13
No files matched your search
@@ -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");
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user