From fe0dd68c5a4112f72346b7bfdf52834c9a716550 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 29 Jul 2026 21:00:53 +0900 Subject: [PATCH 1/7] fix(frontend): pin trusted backend DNS addresses --- frontend/package.json | 1 + frontend/pnpm-lock.yaml | 3 + frontend/src/app/api/[...path]/route.test.ts | 43 ++- frontend/src/app/api/[...path]/route.ts | 6 +- .../src/app/auth/oidc/callback/route.test.ts | 12 + frontend/src/app/auth/session/route.test.ts | 13 + frontend/src/lib/backend-request.test.ts | 185 +++++++++++ frontend/src/lib/backend-request.ts | 297 ++++++++++++++++++ frontend/src/lib/backend-session-probe.ts | 3 +- 9 files changed, 556 insertions(+), 7 deletions(-) create mode 100644 frontend/src/lib/backend-request.test.ts create mode 100644 frontend/src/lib/backend-request.ts diff --git a/frontend/package.json b/frontend/package.json index 450060e0d..25870e065 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -29,6 +29,7 @@ "tailwind-merge": "^3.5.0", "tailwindcss": "^4", "tw-animate-css": "^1.4.0", + "undici": "7.28.0", "uuid": "^14.0.1", "vis-network": "^10.0.2" }, diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index effc1257f..50aacd394 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -58,6 +58,9 @@ importers: tw-animate-css: specifier: ^1.4.0 version: 1.4.0 + undici: + specifier: 7.28.0 + version: 7.28.0 uuid: specifier: ^14.0.1 version: 14.0.1 diff --git a/frontend/src/app/api/[...path]/route.test.ts b/frontend/src/app/api/[...path]/route.test.ts index 19504fd6a..69f1066e2 100644 --- a/frontend/src/app/api/[...path]/route.test.ts +++ b/frontend/src/app/api/[...path]/route.test.ts @@ -1,6 +1,14 @@ import { NextRequest } from "next/server"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +const { backendDnsLookupMock } = vi.hoisted(() => ({ + backendDnsLookupMock: vi.fn(), +})); + +vi.mock("node:dns/promises", () => ({ + lookup: backendDnsLookupMock, +})); + import { GET, POST, PUT } from "./route"; const ORIGINAL_ENV = { ...process.env }; @@ -11,6 +19,10 @@ describe("/api runtime proxy route", () => { vi.unstubAllEnvs(); process.env = { ...ORIGINAL_ENV }; vi.stubEnv("BACKEND_INTERNAL_URL", "https://api.naruon.net"); + backendDnsLookupMock.mockReset(); + backendDnsLookupMock.mockResolvedValue([ + { address: "8.8.8.8", family: 4 }, + ]); }); afterEach(() => { @@ -25,6 +37,7 @@ describe("/api runtime proxy route", () => { "fetch", vi.fn(async (input: URL | RequestInfo, init?: RequestInit) => { const headers = init?.headers as Headers; + expect(init).toHaveProperty("dispatcher"); return Response.json({ target_url: String(input), auth_header: headers.get("authorization"), @@ -63,6 +76,29 @@ describe("/api runtime proxy route", () => { }); }); + it("rejects a backend hostname that resolves to the metadata network", async () => { + backendDnsLookupMock.mockResolvedValue([ + { address: "169.254.169.254", family: 4 }, + ]); + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + vi.spyOn(console, "error").mockImplementation(() => undefined); + + const response = await GET( + new NextRequest("https://frontend.naruon.net/api/tasks"), + { + params: Promise.resolve({ path: ["tasks"] }), + }, + ); + + expect(response.status).toBe(503); + expect(backendDnsLookupMock).toHaveBeenCalledWith("api.naruon.net", { + all: true, + verbatim: true, + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + it("rejects unsupported query parameters before proxying", async () => { const fetchMock = vi.fn(); vi.stubGlobal("fetch", fetchMock); @@ -335,7 +371,10 @@ describe("/api runtime proxy route", () => { }); it("preserves a validated global IPv6 backend authority", async () => { - vi.stubEnv("BACKEND_INTERNAL_URL", "https://[2001:db8::1]:8443"); + vi.stubEnv( + "BACKEND_INTERNAL_URL", + "https://[2001:4860:4860::8888]:8443", + ); const fetchMock = vi.fn(async (input: URL | RequestInfo) => Response.json({ target_url: String(input) }), ); @@ -347,7 +386,7 @@ describe("/api runtime proxy route", () => { ); await expect(response.json()).resolves.toEqual({ - target_url: "https://[2001:db8::1]:8443/api/tasks", + target_url: "https://[2001:4860:4860::8888]:8443/api/tasks", }); }); }); diff --git a/frontend/src/app/api/[...path]/route.ts b/frontend/src/app/api/[...path]/route.ts index 7f0e1d685..460176d87 100644 --- a/frontend/src/app/api/[...path]/route.ts +++ b/frontend/src/app/api/[...path]/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; +import { fetchTrustedBackend } from "@/lib/backend-request"; import { trustedBackendOrigin } from "@/lib/backend-url"; import { SESSION_COOKIE_NAME, normalizeSessionToken } from "@/lib/session-cookie"; @@ -278,10 +279,7 @@ async function proxyApiRequest( let response: Response; try { - // `target` is rebuilt by trustedBackendOrigin() from operator-only runtime - // configuration, then constrained to the validated API path/query above. - // codeql[js/request-forgery] - response = await fetch(target, init); + response = await fetchTrustedBackend(target, init); } catch (error) { // If the backend isn't available (e.g. during build), return a 503 instead of throwing console.error("proxy_fetch_failed", proxyFailureDetails(error)); diff --git a/frontend/src/app/auth/oidc/callback/route.test.ts b/frontend/src/app/auth/oidc/callback/route.test.ts index 241d86e75..95444a4b3 100644 --- a/frontend/src/app/auth/oidc/callback/route.test.ts +++ b/frontend/src/app/auth/oidc/callback/route.test.ts @@ -9,10 +9,18 @@ const { postOidcTokenRequestMock } = vi.hoisted(() => ({ >(), })); +const { backendDnsLookupMock } = vi.hoisted(() => ({ + backendDnsLookupMock: vi.fn(), +})); + vi.mock("@/lib/oidc-token-client", () => ({ postOidcTokenRequest: postOidcTokenRequestMock, })); +vi.mock("node:dns/promises", () => ({ + lookup: backendDnsLookupMock, +})); + const ORIGINAL_ENV = { ...process.env }; function oidcStateCookie(state: string, verifier: string, returnTo: string) { @@ -33,6 +41,10 @@ describe("/auth/oidc/callback route", () => { vi.stubEnv("NEXT_PUBLIC_OIDC_ISSUER_URL", "https://login.example.com/realms/naruon/"); vi.stubEnv("NEXT_PUBLIC_OIDC_CLIENT_ID", "naruon-web"); vi.stubEnv("NEXT_PUBLIC_OIDC_REDIRECT_URI", "https://app.example.com/auth/callback"); + backendDnsLookupMock.mockReset(); + backendDnsLookupMock.mockResolvedValue([ + { address: "8.8.8.8", family: 4 }, + ]); postOidcTokenRequestMock.mockReset(); postOidcTokenRequestMock.mockResolvedValue({ access_token: "test-header.test-payload.test-signature", diff --git a/frontend/src/app/auth/session/route.test.ts b/frontend/src/app/auth/session/route.test.ts index 8d676eb9b..8a780aa7f 100644 --- a/frontend/src/app/auth/session/route.test.ts +++ b/frontend/src/app/auth/session/route.test.ts @@ -1,6 +1,14 @@ import { NextRequest } from "next/server"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +const { backendDnsLookupMock } = vi.hoisted(() => ({ + backendDnsLookupMock: vi.fn(), +})); + +vi.mock("node:dns/promises", () => ({ + lookup: backendDnsLookupMock, +})); + import { DELETE, GET, POST } from "./route"; const ORIGINAL_ENV = { ...process.env }; @@ -27,6 +35,10 @@ describe("/auth/session route", () => { vi.unstubAllGlobals(); process.env = { ...ORIGINAL_ENV }; vi.stubEnv("BACKEND_INTERNAL_URL", "https://api.naruon.net"); + backendDnsLookupMock.mockReset(); + backendDnsLookupMock.mockResolvedValue([ + { address: "8.8.8.8", family: 4 }, + ]); }); afterEach(() => { @@ -45,6 +57,7 @@ describe("/auth/session route", () => { const fetchMock = vi.fn(async (input: URL | RequestInfo, init?: RequestInit) => { expect(String(input)).toBe("https://api.naruon.net/api/auth/session"); expect(new Headers(init?.headers).get("authorization")).toBe(`Bearer ${token}`); + expect(init).toHaveProperty("dispatcher"); return verifiedSessionResponse(); }); vi.stubGlobal("fetch", fetchMock); diff --git a/frontend/src/lib/backend-request.test.ts b/frontend/src/lib/backend-request.test.ts new file mode 100644 index 000000000..d9b2ba9d9 --- /dev/null +++ b/frontend/src/lib/backend-request.test.ts @@ -0,0 +1,185 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + createPinnedBackendLookup, + resolveBackendAddresses, + type BackendDnsLookup, +} from "./backend-request"; + +const ORIGINAL_ENV = { ...process.env }; + +describe("backend destination pinning", () => { + beforeEach(() => { + vi.unstubAllEnvs(); + process.env = { ...ORIGINAL_ENV }; + delete process.env.ALLOW_DOCKER_BACKEND_INTERNAL_URL; + }); + + afterEach(() => { + vi.unstubAllEnvs(); + process.env = { ...ORIGINAL_ENV }; + }); + + it("accepts and deduplicates only globally routable public DNS results", async () => { + const dnsLookup = vi.fn().mockResolvedValue([ + { address: "8.8.8.8", family: 4 }, + { address: "8.8.8.8", family: 4 }, + { address: "2001:4860:4860::8888", family: 6 }, + ]); + + await expect( + resolveBackendAddresses( + new URL("https://api.naruon.net/api/tasks"), + dnsLookup, + ), + ).resolves.toEqual([ + { address: "8.8.8.8", family: 4 }, + { address: "2001:4860:4860::8888", family: 6 }, + ]); + expect(dnsLookup).toHaveBeenCalledWith("api.naruon.net", { + all: true, + verbatim: true, + }); + }); + + it.each([ + "127.0.0.1", + "10.0.0.8", + "100.64.0.1", + "169.254.169.254", + "192.168.1.10", + "::1", + "::ffff:127.0.0.1", + "0:0:0:0:0:ffff:7f00:1", + "fc00::1", + "fe80::1", + ])("rejects non-global public DNS answer %s", async (address) => { + const dnsLookup = vi.fn().mockResolvedValue([ + { address, family: address.includes(":") ? 6 : 4 }, + ]); + + await expect( + resolveBackendAddresses( + new URL("https://api.naruon.net/api/tasks"), + dnsLookup, + ), + ).rejects.toThrow( + address.toLowerCase().includes("ffff:") + ? "IPv4-mapped IPv6" + : "globally routable", + ); + }); + + it("rejects the entire public destination when one answer is private", async () => { + const dnsLookup = vi.fn().mockResolvedValue([ + { address: "8.8.8.8", family: 4 }, + { address: "169.254.169.254", family: 4 }, + ]); + + await expect( + resolveBackendAddresses( + new URL("https://api.naruon.net/api/tasks"), + dnsLookup, + ), + ).rejects.toThrow("globally routable"); + }); + + it("allows only loopback answers for the exact development backend", async () => { + const loopbackLookup = vi.fn().mockResolvedValue([ + { address: "127.0.0.1", family: 4 }, + { address: "::1", family: 6 }, + ]); + + await expect( + resolveBackendAddresses( + new URL("http://localhost:8000/api/tasks"), + loopbackLookup, + ), + ).resolves.toEqual([ + { address: "127.0.0.1", family: 4 }, + { address: "::1", family: 6 }, + ]); + + const publicLookup = vi.fn().mockResolvedValue([ + { address: "8.8.8.8", family: 4 }, + ]); + await expect( + resolveBackendAddresses( + new URL("http://localhost:8000/api/tasks"), + publicLookup, + ), + ).rejects.toThrow("only to loopback"); + }); + + it("allows private Compose answers but rejects metadata and public addresses", async () => { + vi.stubEnv("ALLOW_DOCKER_BACKEND_INTERNAL_URL", "1"); + const privateLookup = vi.fn().mockResolvedValue([ + { address: "172.18.0.4", family: 4 }, + { address: "fd00::4", family: 6 }, + ]); + + await expect( + resolveBackendAddresses( + new URL("http://backend:8000/api/tasks"), + privateLookup, + ), + ).resolves.toEqual([ + { address: "172.18.0.4", family: 4 }, + { address: "fd00::4", family: 6 }, + ]); + + for (const address of ["169.254.169.254", "8.8.8.8", "fe80::1"]) { + const dnsLookup = vi.fn().mockResolvedValue([ + { address, family: address.includes(":") ? 6 : 4 }, + ]); + await expect( + resolveBackendAddresses( + new URL("http://backend:8000/api/tasks"), + dnsLookup, + ), + ).rejects.toThrow("private or loopback"); + } + }); + + it("returns only prevalidated addresses and rejects another hostname", async () => { + const pinnedLookup = createPinnedBackendLookup( + "api.naruon.net", + [{ address: "8.8.8.8", family: 4 }], + ); + const invokeLookup = pinnedLookup as unknown as ( + hostname: string, + options: { all: false; family: number }, + callback: ( + error: Error | null, + address: string, + family: number, + ) => void, + ) => void; + + await expect( + new Promise<{ address: string; family: number }>((resolve, reject) => { + invokeLookup( + "api.naruon.net", + { all: false, family: 0 }, + (error, address, family) => { + if (error) reject(error); + else resolve({ address, family }); + }, + ); + }), + ).resolves.toEqual({ address: "8.8.8.8", family: 4 }); + + await expect( + new Promise((resolve, reject) => { + invokeLookup( + "attacker.example", + { all: false, family: 0 }, + (error) => { + if (error) reject(error); + else resolve(); + }, + ); + }), + ).rejects.toThrow("unexpected hostname"); + }); +}); diff --git a/frontend/src/lib/backend-request.ts b/frontend/src/lib/backend-request.ts new file mode 100644 index 000000000..2b0efc62d --- /dev/null +++ b/frontend/src/lib/backend-request.ts @@ -0,0 +1,297 @@ +import { lookup as systemLookup } from "node:dns/promises"; +import { + BlockList, + isIP, + type LookupFunction, +} from "node:net"; +import { Agent, type Dispatcher } from "undici"; + +import { trustedBackendOrigin } from "@/lib/backend-url"; +import { normalizeHostname } from "@/lib/host-policy"; + +const BACKEND_DNS_TIMEOUT_MS = 5_000; + +type AddressFamily = 4 | 6; +type BackendDestinationKind = "compose" | "loopback" | "public"; + +export interface BackendResolvedAddress { + address: string; + family: AddressFamily; +} + +export type BackendDnsLookup = ( + hostname: string, + options: { all: true; verbatim: true }, +) => Promise; + +type DispatcherRequestInit = RequestInit & { + dispatcher: Dispatcher; +}; + +const NON_GLOBAL_ADDRESSES = new BlockList(); + +for (const [network, prefix] of [ + ["0.0.0.0", 8], + ["10.0.0.0", 8], + ["100.64.0.0", 10], + ["127.0.0.0", 8], + ["169.254.0.0", 16], + ["172.16.0.0", 12], + ["192.0.0.0", 24], + ["192.0.2.0", 24], + ["192.88.99.0", 24], + ["192.168.0.0", 16], + ["198.18.0.0", 15], + ["198.51.100.0", 24], + ["203.0.113.0", 24], + ["224.0.0.0", 4], + ["240.0.0.0", 4], +] as const) { + NON_GLOBAL_ADDRESSES.addSubnet(network, prefix, "ipv4"); +} + +for (const [network, prefix] of [ + ["::", 96], + ["64:ff9b::", 96], + ["64:ff9b:1::", 48], + ["100::", 64], + ["2001::", 23], + ["2001:db8::", 32], + ["2002::", 16], + ["3fff::", 20], + ["5f00::", 16], + ["fc00::", 7], + ["fe80::", 10], + ["fec0::", 10], + ["ff00::", 8], +] as const) { + NON_GLOBAL_ADDRESSES.addSubnet(network, prefix, "ipv6"); +} + +const COMPOSE_ADDRESSES = new BlockList(); + +for (const [network, prefix] of [ + ["10.0.0.0", 8], + ["127.0.0.0", 8], + ["172.16.0.0", 12], + ["192.168.0.0", 16], +] as const) { + COMPOSE_ADDRESSES.addSubnet(network, prefix, "ipv4"); +} + +for (const [network, prefix] of [ + ["::1", 128], + ["fc00::", 7], +] as const) { + COMPOSE_ADDRESSES.addSubnet(network, prefix, "ipv6"); +} + +function isIpv4MappedIpv6(address: string): boolean { + const normalized = address.toLowerCase(); + return ( + normalized.startsWith("::ffff:") || + normalized.startsWith("0:0:0:0:0:ffff:") + ); +} + +function isLoopbackAddress(address: string, family: AddressFamily): boolean { + if (family === 4) return address.startsWith("127."); + return address === "::1"; +} + +function destinationKind(target: URL): BackendDestinationKind { + const hostname = normalizeHostname(target); + if (target.protocol === "https:") return "public"; + if ( + target.protocol === "http:" && + target.port === "8000" && + (hostname === "127.0.0.1" || hostname === "localhost") + ) { + return "loopback"; + } + if ( + target.protocol === "http:" && + target.port === "8000" && + hostname === "backend" && + process.env.ALLOW_DOCKER_BACKEND_INTERNAL_URL === "1" + ) { + return "compose"; + } + throw new Error("Backend request target is outside the trusted origin policy"); +} + +function validateResolvedAddress( + address: string, + kind: BackendDestinationKind, +): BackendResolvedAddress { + const family = isIP(address); + if (family !== 4 && family !== 6) { + throw new Error("Backend origin resolved to an invalid IP address"); + } + if (family === 6 && isIpv4MappedIpv6(address)) { + throw new Error( + "Backend origin must not resolve to an IPv4-mapped IPv6 address", + ); + } + if (kind === "loopback") { + if (!isLoopbackAddress(address, family)) { + throw new Error( + "Development backend origin must resolve only to loopback addresses", + ); + } + return { address, family }; + } + + const addressType = family === 4 ? "ipv4" : "ipv6"; + if (kind === "compose") { + if (!COMPOSE_ADDRESSES.check(address, addressType)) { + throw new Error( + "Compose backend origin must resolve only to private or loopback addresses", + ); + } + return { address, family }; + } + if (NON_GLOBAL_ADDRESSES.check(address, addressType)) { + throw new Error( + "Public backend origin must resolve only to globally routable addresses", + ); + } + return { address, family }; +} + +function deduplicateAddresses( + addresses: readonly BackendResolvedAddress[], +): BackendResolvedAddress[] { + const seen = new Set(); + return addresses.filter(({ address, family }) => { + const key = `${family}:${address}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} + +async function withDnsTimeout(operation: Promise): Promise { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + operation, + new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error("Backend origin DNS resolution timed out")), + BACKEND_DNS_TIMEOUT_MS, + ); + }), + ]); + } finally { + if (timer) clearTimeout(timer); + } +} + +export async function resolveBackendAddresses( + target: URL, + dnsLookup: BackendDnsLookup = systemLookup, +): Promise { + const kind = destinationKind(target); + const hostname = normalizeHostname(target); + const literalFamily = isIP(hostname); + if (literalFamily === 4 || literalFamily === 6) { + return [validateResolvedAddress(hostname, kind)]; + } + + const resolved = await withDnsTimeout( + dnsLookup(hostname, { all: true, verbatim: true }), + ); + if (resolved.length === 0) { + throw new Error("Backend origin did not resolve to an IP address"); + } + return deduplicateAddresses( + resolved.map(({ address }) => validateResolvedAddress(address, kind)), + ); +} + +export function createPinnedBackendLookup( + expectedHostname: string, + addresses: readonly BackendResolvedAddress[], +): LookupFunction { + if (addresses.length === 0) { + throw new Error("Backend origin requires a pinned IP address"); + } + + return (( + hostname: string, + options: unknown, + callback: (...args: unknown[]) => void, + ) => { + if ( + hostname.replace(/\.+$/, "").toLowerCase() !== + expectedHostname.replace(/\.+$/, "").toLowerCase() + ) { + callback(new Error("Backend pinned lookup rejected an unexpected hostname")); + return; + } + const requestedFamily = + typeof options === "object" && + options !== null && + "family" in options && + (options.family === 4 || options.family === 6) + ? options.family + : 0; + const eligible = addresses.filter( + ({ family }) => requestedFamily === 0 || family === requestedFamily, + ); + if (eligible.length === 0) { + callback(new Error("Backend origin has no address in the requested family")); + return; + } + const wantsAll = + typeof options === "object" && + options !== null && + "all" in options && + options.all === true; + if (wantsAll) { + callback(null, eligible); + return; + } + callback(null, eligible[0].address, eligible[0].family); + }) as LookupFunction; +} + +function assertTrustedTarget(target: URL): void { + const trustedOrigin = trustedBackendOrigin(); + if ( + target.origin !== trustedOrigin.origin || + target.username || + target.password || + target.hash + ) { + throw new Error("Backend request target does not match the trusted origin"); + } +} + +export async function fetchTrustedBackend( + target: URL, + init: RequestInit = {}, +): Promise { + assertTrustedTarget(target); + const addresses = await resolveBackendAddresses(target); + const hostname = normalizeHostname(target); + const dispatcher = new Agent({ + connect: { + lookup: createPinnedBackendLookup(hostname, addresses), + }, + }); + + try { + const requestInit: DispatcherRequestInit = { + ...init, + dispatcher, + }; + // The target origin and every DNS answer were validated above, and this + // per-request dispatcher can connect only to the resulting pinned IPs. + const response = await globalThis.fetch(target, requestInit); // lgtm[js/request-forgery] + return response; + } finally { + void dispatcher.close().catch(() => undefined); + } +} diff --git a/frontend/src/lib/backend-session-probe.ts b/frontend/src/lib/backend-session-probe.ts index 667a45201..93b5c247c 100644 --- a/frontend/src/lib/backend-session-probe.ts +++ b/frontend/src/lib/backend-session-probe.ts @@ -1,3 +1,4 @@ +import { fetchTrustedBackend } from "@/lib/backend-request"; import { trustedBackendOrigin } from "@/lib/backend-url"; const BACKEND_SESSION_PROBE_TIMEOUT_MS = 15_000; @@ -7,7 +8,7 @@ export async function fetchTrustedBackendSession( ): Promise { try { const target = new URL("/api/auth/session", trustedBackendOrigin()); - const response = await fetch(target, { + const response = await fetchTrustedBackend(target, { method: "GET", headers: { Accept: "application/json", From f3441a8fa348604dfbfe988b460b123bea745a9d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 29 Jul 2026 21:13:07 +0900 Subject: [PATCH 2/7] fix(frontend): place CodeQL suppression before pinned fetch --- frontend/src/lib/backend-request.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/src/lib/backend-request.ts b/frontend/src/lib/backend-request.ts index 2b0efc62d..d495c5ed0 100644 --- a/frontend/src/lib/backend-request.ts +++ b/frontend/src/lib/backend-request.ts @@ -289,7 +289,8 @@ export async function fetchTrustedBackend( }; // The target origin and every DNS answer were validated above, and this // per-request dispatcher can connect only to the resulting pinned IPs. - const response = await globalThis.fetch(target, requestInit); // lgtm[js/request-forgery] + // lgtm[js/request-forgery] + const response = await globalThis.fetch(target, requestInit); return response; } finally { void dispatcher.close().catch(() => undefined); From a0128d2fb20ae8831d0780ce24c84cf44853ddab Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 29 Jul 2026 21:17:26 +0900 Subject: [PATCH 3/7] chore(frontend): remove ineffective CodeQL suppression --- frontend/src/lib/backend-request.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/frontend/src/lib/backend-request.ts b/frontend/src/lib/backend-request.ts index d495c5ed0..19e70186f 100644 --- a/frontend/src/lib/backend-request.ts +++ b/frontend/src/lib/backend-request.ts @@ -289,7 +289,6 @@ export async function fetchTrustedBackend( }; // The target origin and every DNS answer were validated above, and this // per-request dispatcher can connect only to the resulting pinned IPs. - // lgtm[js/request-forgery] const response = await globalThis.fetch(target, requestInit); return response; } finally { From 6e2f11297a8d6abe2d0167ea924677ce7b27124b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 29 Jul 2026 21:27:22 +0900 Subject: [PATCH 4/7] fix(frontend): normalize pinned backend addresses --- frontend/src/lib/backend-request.test.ts | 77 ++++++++++++++++++++++++ frontend/src/lib/backend-request.ts | 12 ++-- 2 files changed, 82 insertions(+), 7 deletions(-) diff --git a/frontend/src/lib/backend-request.test.ts b/frontend/src/lib/backend-request.test.ts index d9b2ba9d9..85895b3fe 100644 --- a/frontend/src/lib/backend-request.test.ts +++ b/frontend/src/lib/backend-request.test.ts @@ -1,22 +1,35 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { Agent } from "undici"; import { createPinnedBackendLookup, + fetchTrustedBackend, resolveBackendAddresses, type BackendDnsLookup, } from "./backend-request"; +const { systemLookupMock } = vi.hoisted(() => ({ + systemLookupMock: vi.fn(), +})); + +vi.mock("node:dns/promises", () => ({ + lookup: systemLookupMock, +})); + const ORIGINAL_ENV = { ...process.env }; describe("backend destination pinning", () => { beforeEach(() => { vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + systemLookupMock.mockReset(); process.env = { ...ORIGINAL_ENV }; delete process.env.ALLOW_DOCKER_BACKEND_INTERNAL_URL; }); afterEach(() => { vi.unstubAllEnvs(); + vi.unstubAllGlobals(); process.env = { ...ORIGINAL_ENV }; }); @@ -51,6 +64,9 @@ describe("backend destination pinning", () => { "::1", "::ffff:127.0.0.1", "0:0:0:0:0:ffff:7f00:1", + "0::ffff:7f00:1", + "0:0::ffff:7f00:1", + "::FFFF:7F00:1", "fc00::1", "fe80::1", ])("rejects non-global public DNS answer %s", async (address) => { @@ -182,4 +198,65 @@ describe("backend destination pinning", () => { }), ).rejects.toThrow("unexpected hostname"); }); + + it("normalizes bracketed IPv6 hostnames in the pinned lookup", async () => { + const pinnedLookup = createPinnedBackendLookup( + "2001:4860:4860::8888", + [{ address: "2001:4860:4860::8888", family: 6 }], + ); + const invokeLookup = pinnedLookup as unknown as ( + hostname: string, + options: { all: false; family: number }, + callback: ( + error: Error | null, + address: string, + family: number, + ) => void, + ) => void; + + await expect( + new Promise<{ address: string; family: number }>((resolve, reject) => { + invokeLookup( + "[2001:4860:4860::8888]", + { all: false, family: 6 }, + (error, address, family) => { + if (error) reject(error); + else resolve({ address, family }); + }, + ); + }), + ).resolves.toEqual({ + address: "2001:4860:4860::8888", + family: 6, + }); + }); + + it("wires validated DNS answers into the fetch dispatcher", async () => { + vi.stubEnv("BACKEND_INTERNAL_URL", "https://api.naruon.net"); + systemLookupMock.mockResolvedValue([ + { address: "8.8.8.8", family: 4 }, + { address: "2001:4860:4860::8888", family: 6 }, + ]); + const expectedResponse = new Response(null, { status: 204 }); + const fetchMock = vi.fn().mockResolvedValue(expectedResponse); + vi.stubGlobal("fetch", fetchMock); + const target = new URL("https://api.naruon.net/api/tasks"); + + await expect( + fetchTrustedBackend(target, { redirect: "manual" }), + ).resolves.toBe(expectedResponse); + + expect(systemLookupMock).toHaveBeenCalledWith("api.naruon.net", { + all: true, + verbatim: true, + }); + expect(fetchMock).toHaveBeenCalledOnce(); + const [fetchedTarget, requestInit] = fetchMock.mock.calls[0] as [ + URL, + RequestInit & { dispatcher: Agent }, + ]; + expect(fetchedTarget).toBe(target); + expect(requestInit.redirect).toBe("manual"); + expect(requestInit.dispatcher).toBeInstanceOf(Agent); + }); }); diff --git a/frontend/src/lib/backend-request.ts b/frontend/src/lib/backend-request.ts index 19e70186f..7d60cda39 100644 --- a/frontend/src/lib/backend-request.ts +++ b/frontend/src/lib/backend-request.ts @@ -86,12 +86,11 @@ for (const [network, prefix] of [ COMPOSE_ADDRESSES.addSubnet(network, prefix, "ipv6"); } +const IPV4_MAPPED_IPV6_ADDRESSES = new BlockList(); +IPV4_MAPPED_IPV6_ADDRESSES.addSubnet("::ffff:0:0", 96, "ipv6"); + function isIpv4MappedIpv6(address: string): boolean { - const normalized = address.toLowerCase(); - return ( - normalized.startsWith("::ffff:") || - normalized.startsWith("0:0:0:0:0:ffff:") - ); + return IPV4_MAPPED_IPV6_ADDRESSES.check(address, "ipv6"); } function isLoopbackAddress(address: string, family: AddressFamily): boolean { @@ -224,8 +223,7 @@ export function createPinnedBackendLookup( callback: (...args: unknown[]) => void, ) => { if ( - hostname.replace(/\.+$/, "").toLowerCase() !== - expectedHostname.replace(/\.+$/, "").toLowerCase() + normalizeHostname(hostname) !== normalizeHostname(expectedHostname) ) { callback(new Error("Backend pinned lookup rejected an unexpected hostname")); return; From 0ada486fa29d35482607b942f97ac902509853fc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 30 Jul 2026 11:32:05 +0900 Subject: [PATCH 5/7] fix(frontend): pin backend requests without lock drift --- frontend/package.json | 1 - frontend/pnpm-lock.yaml | 3 - frontend/src/app/api/[...path]/route.test.ts | 21 ++- .../src/app/auth/oidc/callback/route.test.ts | 18 +- frontend/src/app/auth/session/route.test.ts | 45 ++++- frontend/src/lib/backend-request.test.ts | 139 ++++++++++++-- frontend/src/lib/backend-request.ts | 178 ++++++++++++++---- .../src/test/fetch-backed-node-request.ts | 63 +++++++ 8 files changed, 402 insertions(+), 66 deletions(-) create mode 100644 frontend/src/test/fetch-backed-node-request.ts diff --git a/frontend/package.json b/frontend/package.json index 25870e065..450060e0d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -29,7 +29,6 @@ "tailwind-merge": "^3.5.0", "tailwindcss": "^4", "tw-animate-css": "^1.4.0", - "undici": "7.28.0", "uuid": "^14.0.1", "vis-network": "^10.0.2" }, diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 50aacd394..effc1257f 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -58,9 +58,6 @@ importers: tw-animate-css: specifier: ^1.4.0 version: 1.4.0 - undici: - specifier: 7.28.0 - version: 7.28.0 uuid: specifier: ^14.0.1 version: 14.0.1 diff --git a/frontend/src/app/api/[...path]/route.test.ts b/frontend/src/app/api/[...path]/route.test.ts index 69f1066e2..117294e7f 100644 --- a/frontend/src/app/api/[...path]/route.test.ts +++ b/frontend/src/app/api/[...path]/route.test.ts @@ -1,14 +1,21 @@ import { NextRequest } from "next/server"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -const { backendDnsLookupMock } = vi.hoisted(() => ({ +import { createFetchBackedNodeRequest } from "@/test/fetch-backed-node-request"; + +const { backendDnsLookupMock, httpsRequestMock } = vi.hoisted(() => ({ backendDnsLookupMock: vi.fn(), + httpsRequestMock: vi.fn(), })); vi.mock("node:dns/promises", () => ({ lookup: backendDnsLookupMock, })); +vi.mock("node:https", () => ({ + request: httpsRequestMock, +})); + import { GET, POST, PUT } from "./route"; const ORIGINAL_ENV = { ...process.env }; @@ -23,6 +30,8 @@ describe("/api runtime proxy route", () => { backendDnsLookupMock.mockResolvedValue([ { address: "8.8.8.8", family: 4 }, ]); + httpsRequestMock.mockReset(); + httpsRequestMock.mockImplementation(createFetchBackedNodeRequest()); }); afterEach(() => { @@ -37,7 +46,6 @@ describe("/api runtime proxy route", () => { "fetch", vi.fn(async (input: URL | RequestInfo, init?: RequestInit) => { const headers = init?.headers as Headers; - expect(init).toHaveProperty("dispatcher"); return Response.json({ target_url: String(input), auth_header: headers.get("authorization"), @@ -74,6 +82,15 @@ describe("/api runtime proxy route", () => { user_header: null, request_body: '{"state":"open"}', }); + expect(httpsRequestMock).toHaveBeenCalledWith( + expect.any(URL), + expect.objectContaining({ + agent: false, + lookup: expect.any(Function), + servername: "api.naruon.net", + }), + expect.any(Function), + ); }); it("rejects a backend hostname that resolves to the metadata network", async () => { diff --git a/frontend/src/app/auth/oidc/callback/route.test.ts b/frontend/src/app/auth/oidc/callback/route.test.ts index 95444a4b3..f5b176f72 100644 --- a/frontend/src/app/auth/oidc/callback/route.test.ts +++ b/frontend/src/app/auth/oidc/callback/route.test.ts @@ -1,6 +1,8 @@ import { NextRequest } from "next/server"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createFetchBackedNodeRequest } from "@/test/fetch-backed-node-request"; + import { POST } from "./route"; const { postOidcTokenRequestMock } = vi.hoisted(() => ({ @@ -9,8 +11,9 @@ const { postOidcTokenRequestMock } = vi.hoisted(() => ({ >(), })); -const { backendDnsLookupMock } = vi.hoisted(() => ({ +const { backendDnsLookupMock, httpsRequestMock } = vi.hoisted(() => ({ backendDnsLookupMock: vi.fn(), + httpsRequestMock: vi.fn(), })); vi.mock("@/lib/oidc-token-client", () => ({ @@ -21,6 +24,10 @@ vi.mock("node:dns/promises", () => ({ lookup: backendDnsLookupMock, })); +vi.mock("node:https", () => ({ + request: httpsRequestMock, +})); + const ORIGINAL_ENV = { ...process.env }; function oidcStateCookie(state: string, verifier: string, returnTo: string) { @@ -45,6 +52,8 @@ describe("/auth/oidc/callback route", () => { backendDnsLookupMock.mockResolvedValue([ { address: "8.8.8.8", family: 4 }, ]); + httpsRequestMock.mockReset(); + httpsRequestMock.mockImplementation(createFetchBackedNodeRequest()); postOidcTokenRequestMock.mockReset(); postOidcTokenRequestMock.mockResolvedValue({ access_token: "test-header.test-payload.test-signature", @@ -97,9 +106,10 @@ describe("/auth/oidc/callback route", () => { expect(setCookie).toContain("Max-Age=0"); expect(setCookie).not.toContain("verifier-123"); expect(fetchMock).toHaveBeenCalledTimes(1); - expect(fetchMock.mock.calls[0]?.[1]).toEqual(expect.objectContaining({ - cache: "no-store", - redirect: "manual", + expect(httpsRequestMock.mock.calls[0]?.[1]).toEqual(expect.objectContaining({ + agent: false, + method: "GET", + servername: "api.naruon.net", signal: expect.any(AbortSignal), })); expect(postOidcTokenRequestMock).toHaveBeenCalledTimes(1); diff --git a/frontend/src/app/auth/session/route.test.ts b/frontend/src/app/auth/session/route.test.ts index 8a780aa7f..8d41a0b5e 100644 --- a/frontend/src/app/auth/session/route.test.ts +++ b/frontend/src/app/auth/session/route.test.ts @@ -1,14 +1,27 @@ import { NextRequest } from "next/server"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -const { backendDnsLookupMock } = vi.hoisted(() => ({ +import { createFetchBackedNodeRequest } from "@/test/fetch-backed-node-request"; + +const { backendDnsLookupMock, httpRequestMock, httpsRequestMock } = vi.hoisted(() => ({ backendDnsLookupMock: vi.fn(), + httpRequestMock: vi.fn(), + httpsRequestMock: vi.fn(), })); vi.mock("node:dns/promises", () => ({ lookup: backendDnsLookupMock, })); +vi.mock("node:http", async (importOriginal) => ({ + ...(await importOriginal()), + request: httpRequestMock, +})); + +vi.mock("node:https", () => ({ + request: httpsRequestMock, +})); + import { DELETE, GET, POST } from "./route"; const ORIGINAL_ENV = { ...process.env }; @@ -39,6 +52,10 @@ describe("/auth/session route", () => { backendDnsLookupMock.mockResolvedValue([ { address: "8.8.8.8", family: 4 }, ]); + httpRequestMock.mockReset(); + httpRequestMock.mockImplementation(createFetchBackedNodeRequest()); + httpsRequestMock.mockReset(); + httpsRequestMock.mockImplementation(createFetchBackedNodeRequest()); }); afterEach(() => { @@ -57,7 +74,6 @@ describe("/auth/session route", () => { const fetchMock = vi.fn(async (input: URL | RequestInfo, init?: RequestInit) => { expect(String(input)).toBe("https://api.naruon.net/api/auth/session"); expect(new Headers(init?.headers).get("authorization")).toBe(`Bearer ${token}`); - expect(init).toHaveProperty("dispatcher"); return verifiedSessionResponse(); }); vi.stubGlobal("fetch", fetchMock); @@ -92,6 +108,15 @@ describe("/auth/session route", () => { expect(setCookie).not.toContain("access_token"); expect(setCookie).not.toContain("attacker-fixed-session"); expect(fetchMock).toHaveBeenCalledTimes(1); + expect(httpsRequestMock).toHaveBeenCalledWith( + expect.any(URL), + expect.objectContaining({ + agent: false, + lookup: expect.any(Function), + servername: "api.naruon.net", + }), + expect.any(Function), + ); }); it("stores a session when browser origin matches the forwarded host", async () => { @@ -314,11 +339,11 @@ describe("/auth/session route", () => { authenticated: true, }); expect(fetchMock).toHaveBeenCalledTimes(1); - const [input, init] = fetchMock.mock.calls[0]; + const [input] = fetchMock.mock.calls[0]; expect(String(input)).toBe("http://127.0.0.1:8000/api/auth/session"); - expect(init).toEqual(expect.objectContaining({ - cache: "no-store", - redirect: "manual", + expect(httpRequestMock.mock.calls[0]?.[1]).toEqual(expect.objectContaining({ + agent: false, + method: "GET", signal: expect.any(AbortSignal), })); }); @@ -357,12 +382,14 @@ describe("/auth/session route", () => { authenticated: true, }); expect(fetchMock).toHaveBeenCalledTimes(1); - const [input, init] = fetchMock.mock.calls[0]; + const [input] = fetchMock.mock.calls[0]; expect(String(input)).toBe( "https://[2001:4860:4860::8888]:8443/api/auth/session", ); - expect(init).toEqual(expect.objectContaining({ - redirect: "manual", + expect(httpsRequestMock.mock.calls[0]?.[1]).toEqual(expect.objectContaining({ + agent: false, + method: "GET", + servername: undefined, signal: expect.any(AbortSignal), })); }); diff --git a/frontend/src/lib/backend-request.test.ts b/frontend/src/lib/backend-request.test.ts index 85895b3fe..3480c67a8 100644 --- a/frontend/src/lib/backend-request.test.ts +++ b/frontend/src/lib/backend-request.test.ts @@ -1,5 +1,6 @@ +import { Readable } from "node:stream"; + import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { Agent } from "undici"; import { createPinnedBackendLookup, @@ -8,7 +9,8 @@ import { type BackendDnsLookup, } from "./backend-request"; -const { systemLookupMock } = vi.hoisted(() => ({ +const { httpsRequestMock, systemLookupMock } = vi.hoisted(() => ({ + httpsRequestMock: vi.fn(), systemLookupMock: vi.fn(), })); @@ -16,12 +18,17 @@ vi.mock("node:dns/promises", () => ({ lookup: systemLookupMock, })); +vi.mock("node:https", () => ({ + request: httpsRequestMock, +})); + const ORIGINAL_ENV = { ...process.env }; describe("backend destination pinning", () => { beforeEach(() => { vi.unstubAllEnvs(); vi.unstubAllGlobals(); + httpsRequestMock.mockReset(); systemLookupMock.mockReset(); process.env = { ...ORIGINAL_ENV }; delete process.env.ALLOW_DOCKER_BACKEND_INTERNAL_URL; @@ -231,32 +238,136 @@ describe("backend destination pinning", () => { }); }); - it("wires validated DNS answers into the fetch dispatcher", async () => { + it("wires validated DNS answers into a one-shot HTTPS request", async () => { vi.stubEnv("BACKEND_INTERNAL_URL", "https://api.naruon.net"); systemLookupMock.mockResolvedValue([ { address: "8.8.8.8", family: 4 }, { address: "2001:4860:4860::8888", family: 6 }, ]); - const expectedResponse = new Response(null, { status: 204 }); - const fetchMock = vi.fn().mockResolvedValue(expectedResponse); - vi.stubGlobal("fetch", fetchMock); + const requestEnd = vi.fn(); + const requestOnce = vi.fn(); + httpsRequestMock.mockImplementation((_target, _options, callback) => { + const incoming = Readable.from([]) as Readable & { + rawHeaders: string[]; + statusCode: number; + statusMessage: string; + }; + incoming.rawHeaders = ["X-Backend", "pinned"]; + incoming.statusCode = 204; + incoming.statusMessage = "No Content"; + callback(incoming); + return { + destroy: vi.fn(), + end: requestEnd, + once: requestOnce, + }; + }); const target = new URL("https://api.naruon.net/api/tasks"); - await expect( - fetchTrustedBackend(target, { redirect: "manual" }), - ).resolves.toBe(expectedResponse); + const response = await fetchTrustedBackend(target, { + headers: { Accept: "application/json" }, + redirect: "manual", + }); expect(systemLookupMock).toHaveBeenCalledWith("api.naruon.net", { all: true, verbatim: true, }); - expect(fetchMock).toHaveBeenCalledOnce(); - const [fetchedTarget, requestInit] = fetchMock.mock.calls[0] as [ + expect(httpsRequestMock).toHaveBeenCalledOnce(); + const [fetchedTarget, requestOptions] = httpsRequestMock.mock.calls[0] as [ URL, - RequestInit & { dispatcher: Agent }, + { + agent: boolean; + headers: Record; + lookup: ReturnType; + method: string; + servername: string; + }, ]; expect(fetchedTarget).toBe(target); - expect(requestInit.redirect).toBe("manual"); - expect(requestInit.dispatcher).toBeInstanceOf(Agent); + expect(requestOptions).toMatchObject({ + agent: false, + headers: { accept: "application/json" }, + method: "GET", + servername: "api.naruon.net", + }); + expect(requestEnd).toHaveBeenCalledWith(); + expect(requestOnce).toHaveBeenCalledWith("error", expect.any(Function)); + expect(response.status).toBe(204); + expect(response.headers.get("x-backend")).toBe("pinned"); + + const invokeLookup = requestOptions.lookup as unknown as ( + hostname: string, + options: { all: false; family: number }, + callback: ( + error: Error | null, + address: string, + family: number, + ) => void, + ) => void; + await expect( + new Promise<{ address: string; family: number }>((resolve, reject) => { + invokeLookup( + "api.naruon.net", + { all: false, family: 0 }, + (error, address, family) => { + if (error) reject(error); + else resolve({ address, family }); + }, + ); + }), + ).resolves.toEqual({ address: "8.8.8.8", family: 4 }); + }); + + it("forwards an ArrayBuffer body and streams the backend response", async () => { + vi.stubEnv("BACKEND_INTERNAL_URL", "https://api.naruon.net"); + systemLookupMock.mockResolvedValue([ + { address: "8.8.8.8", family: 4 }, + ]); + const requestEnd = vi.fn(); + httpsRequestMock.mockImplementation((_target, _options, callback) => { + const incoming = Readable.from([Buffer.from("created")]) as Readable & { + rawHeaders: string[]; + statusCode: number; + statusMessage: string; + }; + incoming.rawHeaders = ["Content-Type", "text/plain"]; + incoming.statusCode = 201; + incoming.statusMessage = "Created"; + callback(incoming); + return { + destroy: vi.fn(), + end: requestEnd, + once: vi.fn(), + }; + }); + const body = new TextEncoder().encode("payload").buffer; + + const response = await fetchTrustedBackend( + new URL("https://api.naruon.net/api/tasks"), + { body, method: "POST", redirect: "manual" }, + ); + + expect(requestEnd).toHaveBeenCalledOnce(); + expect( + new TextDecoder().decode(requestEnd.mock.calls[0][0] as Uint8Array), + ).toBe("payload"); + expect(response.status).toBe(201); + await expect(response.text()).resolves.toBe("created"); + }); + + it("rejects automatic redirect modes before opening a socket", async () => { + vi.stubEnv("BACKEND_INTERNAL_URL", "https://api.naruon.net"); + systemLookupMock.mockResolvedValue([ + { address: "8.8.8.8", family: 4 }, + ]); + httpsRequestMock.mockClear(); + + await expect( + fetchTrustedBackend(new URL("https://api.naruon.net/api/tasks"), { + redirect: "follow", + }), + ).rejects.toThrow("handled manually"); + expect(httpsRequestMock).not.toHaveBeenCalled(); }); }); diff --git a/frontend/src/lib/backend-request.ts b/frontend/src/lib/backend-request.ts index 7d60cda39..e4aca6259 100644 --- a/frontend/src/lib/backend-request.ts +++ b/frontend/src/lib/backend-request.ts @@ -1,10 +1,13 @@ import { lookup as systemLookup } from "node:dns/promises"; import { - BlockList, - isIP, - type LookupFunction, -} from "node:net"; -import { Agent, type Dispatcher } from "undici"; + request as httpRequest, + type ClientRequest, + type IncomingMessage, + type RequestOptions, +} from "node:http"; +import { request as httpsRequest } from "node:https"; +import { BlockList, isIP, type LookupFunction } from "node:net"; +import { Readable } from "node:stream"; import { trustedBackendOrigin } from "@/lib/backend-url"; import { normalizeHostname } from "@/lib/host-policy"; @@ -24,10 +27,6 @@ export type BackendDnsLookup = ( options: { all: true; verbatim: true }, ) => Promise; -type DispatcherRequestInit = RequestInit & { - dispatcher: Dispatcher; -}; - const NON_GLOBAL_ADDRESSES = new BlockList(); for (const [network, prefix] of [ @@ -116,7 +115,9 @@ function destinationKind(target: URL): BackendDestinationKind { ) { return "compose"; } - throw new Error("Backend request target is outside the trusted origin policy"); + throw new Error( + "Backend request target is outside the trusted origin policy", + ); } function validateResolvedAddress( @@ -222,10 +223,10 @@ export function createPinnedBackendLookup( options: unknown, callback: (...args: unknown[]) => void, ) => { - if ( - normalizeHostname(hostname) !== normalizeHostname(expectedHostname) - ) { - callback(new Error("Backend pinned lookup rejected an unexpected hostname")); + if (normalizeHostname(hostname) !== normalizeHostname(expectedHostname)) { + callback( + new Error("Backend pinned lookup rejected an unexpected hostname"), + ); return; } const requestedFamily = @@ -239,7 +240,9 @@ export function createPinnedBackendLookup( ({ family }) => requestedFamily === 0 || family === requestedFamily, ); if (eligible.length === 0) { - callback(new Error("Backend origin has no address in the requested family")); + callback( + new Error("Backend origin has no address in the requested family"), + ); return; } const wantsAll = @@ -267,6 +270,125 @@ function assertTrustedTarget(target: URL): void { } } +function outgoingHeaders( + headersInit: HeadersInit | undefined, +): Record { + const headers: Record = {}; + new Headers(headersInit).forEach((value, name) => { + headers[name] = value; + }); + return headers; +} + +function incomingHeaders(response: IncomingMessage): Headers { + const headers = new Headers(); + for (let index = 0; index < response.rawHeaders.length; index += 2) { + const name = response.rawHeaders[index]; + const value = response.rawHeaders[index + 1]; + if (name !== undefined && value !== undefined) { + headers.append(name, value); + } + } + return headers; +} + +function hasNullResponseBody(method: string, status: number): boolean { + return ( + method === "HEAD" || status === 204 || status === 205 || status === 304 + ); +} + +function endRequest( + request: ClientRequest, + body: BodyInit | null | undefined, +): void { + if (body === null || body === undefined) { + request.end(); + return; + } + if (typeof body === "string" || body instanceof URLSearchParams) { + request.end(body.toString()); + return; + } + if (body instanceof ArrayBuffer) { + request.end(new Uint8Array(body)); + return; + } + if (ArrayBuffer.isView(body)) { + request.end(new Uint8Array(body.buffer, body.byteOffset, body.byteLength)); + return; + } + throw new TypeError( + "Trusted backend requests support only buffered request bodies", + ); +} + +function performPinnedRequest( + target: URL, + init: RequestInit, + lookup: LookupFunction, +): Promise { + if (init.redirect !== undefined && init.redirect !== "manual") { + throw new Error("Trusted backend redirects must be handled manually"); + } + + const method = (init.method ?? "GET").toUpperCase(); + const requestOptions: RequestOptions = { + agent: false, + headers: outgoingHeaders(init.headers), + lookup, + method, + signal: init.signal ?? undefined, + }; + + return new Promise((resolve, reject) => { + const handleResponse = (response: IncomingMessage) => { + const status = response.statusCode; + if (status === undefined || status < 200 || status > 599) { + response.destroy(); + reject(new Error("Backend returned an invalid HTTP status")); + return; + } + + let body: BodyInit | null = null; + if (hasNullResponseBody(method, status)) { + response.resume(); + } else { + body = Readable.toWeb(response) as ReadableStream; + } + resolve( + new Response(body, { + headers: incomingHeaders(response), + status, + statusText: response.statusMessage ?? "", + }), + ); + }; + + const request = + target.protocol === "https:" + ? httpsRequest( + target, + { + ...requestOptions, + servername: isIP(normalizeHostname(target)) + ? undefined + : normalizeHostname(target), + }, + handleResponse, + ) + : httpRequest(target, requestOptions, handleResponse); + request.once("error", reject); + + try { + endRequest(request, init.body); + } catch (error) { + request.destroy(); + reject(error); + } + }); +} + export async function fetchTrustedBackend( target: URL, init: RequestInit = {}, @@ -274,22 +396,12 @@ export async function fetchTrustedBackend( assertTrustedTarget(target); const addresses = await resolveBackendAddresses(target); const hostname = normalizeHostname(target); - const dispatcher = new Agent({ - connect: { - lookup: createPinnedBackendLookup(hostname, addresses), - }, - }); - - try { - const requestInit: DispatcherRequestInit = { - ...init, - dispatcher, - }; - // The target origin and every DNS answer were validated above, and this - // per-request dispatcher can connect only to the resulting pinned IPs. - const response = await globalThis.fetch(target, requestInit); - return response; - } finally { - void dispatcher.close().catch(() => undefined); - } + // A one-shot Node request cannot reuse a socket opened outside this policy. + // Its lookup callback can return only the addresses validated above, while + // the original URL preserves the Host header and HTTPS SNI hostname. + return performPinnedRequest( + target, + init, + createPinnedBackendLookup(hostname, addresses), + ); } diff --git a/frontend/src/test/fetch-backed-node-request.ts b/frontend/src/test/fetch-backed-node-request.ts new file mode 100644 index 000000000..fc8f4a047 --- /dev/null +++ b/frontend/src/test/fetch-backed-node-request.ts @@ -0,0 +1,63 @@ +import { EventEmitter } from "node:events"; +import type { ClientRequest, IncomingMessage, RequestOptions } from "node:http"; +import { Readable } from "node:stream"; + +type ResponseHandler = (response: IncomingMessage) => void; + +/** + * Adapt mocked Node HTTP requests to the test's stubbed global fetch. + * + * Production uses node:http(s) directly so DNS pinning controls the socket. + * Route tests keep their concise fetch fixtures by installing this adapter as + * the mocked node:https request implementation. + */ +export function createFetchBackedNodeRequest() { + return ( + target: URL, + options: RequestOptions, + handleResponse: ResponseHandler, + ): ClientRequest => { + const events = new EventEmitter(); + const request = events as unknown as ClientRequest; + let destroyed = false; + + request.destroy = ((error?: Error) => { + destroyed = true; + if (error) events.emit("error", error); + return request; + }) as ClientRequest["destroy"]; + + request.end = ((body?: string | Uint8Array) => { + if (destroyed) return request; + void Promise.resolve() + .then(() => + globalThis.fetch(target, { + body: body as BodyInit | undefined, + headers: new Headers(options.headers as HeadersInit), + method: options.method, + signal: options.signal, + }), + ) + .then(async (response) => { + if (destroyed) return; + const bytes = new Uint8Array(await response.arrayBuffer()); + const incoming = Readable.from( + bytes.byteLength > 0 ? [bytes] : [], + ) as IncomingMessage; + incoming.rawHeaders = Array.from(response.headers.entries()).flat(); + incoming.statusCode = response.status; + incoming.statusMessage = response.statusText; + handleResponse(incoming); + }) + .catch((error: unknown) => { + events.emit( + "error", + error instanceof Error ? error : new Error(String(error)), + ); + }); + return request; + }) as ClientRequest["end"]; + + return request; + }; +} From 02e816b5f64da27542740a8e4e8624b2c8fdf16b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 30 Jul 2026 13:05:45 +0900 Subject: [PATCH 6/7] test(frontend): enforce changed-line coverage --- frontend/package.json | 2 +- frontend/src/lib/backend-request.test.ts | 283 ++++++++++++++++++++++- 2 files changed, 283 insertions(+), 2 deletions(-) diff --git a/frontend/package.json b/frontend/package.json index 450060e0d..8563a9c87 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,7 +9,7 @@ "start": "next start", "lint": "eslint", "test": "vitest run", - "coverage": "vitest run src/components/project-trace-readiness.test.ts --coverage --coverage.provider=v8 --coverage.reporter=json-summary --coverage.reporter=json --coverage.include=src/components/project-trace-readiness.ts", + "coverage": "vitest run --coverage --coverage.provider=v8 --coverage.reporter=json-summary --coverage.reporter=json --coverage.include='src/**/*.ts' --coverage.include='src/**/*.tsx' --coverage.exclude='src/**/*.test.ts' --coverage.exclude='src/**/*.test.tsx' --coverage.exclude='src/test/**'", "typecheck": "tsc --noEmit", "full:smoke": "node scripts/full-product-ui-smoke.mjs", "pilot:smoke": "node scripts/pilot-ui-smoke.mjs", diff --git a/frontend/src/lib/backend-request.test.ts b/frontend/src/lib/backend-request.test.ts index 3480c67a8..3dded008d 100644 --- a/frontend/src/lib/backend-request.test.ts +++ b/frontend/src/lib/backend-request.test.ts @@ -9,7 +9,8 @@ import { type BackendDnsLookup, } from "./backend-request"; -const { httpsRequestMock, systemLookupMock } = vi.hoisted(() => ({ +const { httpRequestMock, httpsRequestMock, systemLookupMock } = vi.hoisted(() => ({ + httpRequestMock: vi.fn(), httpsRequestMock: vi.fn(), systemLookupMock: vi.fn(), })); @@ -18,6 +19,10 @@ vi.mock("node:dns/promises", () => ({ lookup: systemLookupMock, })); +vi.mock("node:http", () => ({ + request: httpRequestMock, +})); + vi.mock("node:https", () => ({ request: httpsRequestMock, })); @@ -28,6 +33,8 @@ describe("backend destination pinning", () => { beforeEach(() => { vi.unstubAllEnvs(); vi.unstubAllGlobals(); + vi.useRealTimers(); + httpRequestMock.mockReset(); httpsRequestMock.mockReset(); systemLookupMock.mockReset(); process.env = { ...ORIGINAL_ENV }; @@ -37,6 +44,7 @@ describe("backend destination pinning", () => { afterEach(() => { vi.unstubAllEnvs(); vi.unstubAllGlobals(); + vi.useRealTimers(); process.env = { ...ORIGINAL_ENV }; }); @@ -164,6 +172,51 @@ describe("backend destination pinning", () => { } }); + it("rejects invalid destinations, empty DNS answers, and malformed addresses", async () => { + const dnsLookup = vi.fn(); + + await expect( + resolveBackendAddresses( + new URL("http://localhost:9000/api/tasks"), + dnsLookup, + ), + ).rejects.toThrow("outside the trusted origin policy"); + expect(dnsLookup).not.toHaveBeenCalled(); + + dnsLookup.mockResolvedValueOnce([]); + await expect( + resolveBackendAddresses( + new URL("https://api.naruon.net/api/tasks"), + dnsLookup, + ), + ).rejects.toThrow("did not resolve"); + + dnsLookup.mockResolvedValueOnce([{ address: "not-an-ip", family: 4 }]); + await expect( + resolveBackendAddresses( + new URL("https://api.naruon.net/api/tasks"), + dnsLookup, + ), + ).rejects.toThrow("invalid IP address"); + }); + + it("fails closed when backend DNS resolution times out", async () => { + vi.useFakeTimers(); + const pendingLookup = vi + .fn() + .mockReturnValue(new Promise(() => undefined)); + const resolution = resolveBackendAddresses( + new URL("https://api.naruon.net/api/tasks"), + pendingLookup, + ); + const rejection = expect(resolution).rejects.toThrow( + "DNS resolution timed out", + ); + + await vi.advanceTimersByTimeAsync(5_000); + await rejection; + }); + it("returns only prevalidated addresses and rejects another hostname", async () => { const pinnedLookup = createPinnedBackendLookup( "api.naruon.net", @@ -206,6 +259,74 @@ describe("backend destination pinning", () => { ).rejects.toThrow("unexpected hostname"); }); + it("supports all-address lookup and rejects empty or unavailable families", async () => { + expect(() => createPinnedBackendLookup("api.naruon.net", [])).toThrow( + "requires a pinned IP address", + ); + + const pinnedLookup = createPinnedBackendLookup("api.naruon.net", [ + { address: "8.8.8.8", family: 4 }, + { address: "2001:4860:4860::8888", family: 6 }, + ]) as unknown as ( + hostname: string, + options: unknown, + callback: (...args: unknown[]) => void, + ) => void; + + await expect( + new Promise( + (resolve, reject) => { + pinnedLookup( + "api.naruon.net", + { all: true, family: 6 }, + (error, addresses) => { + if (error) reject(error); + else + resolve( + addresses as readonly { address: string; family: number }[], + ); + }, + ); + }, + ), + ).resolves.toEqual([ + { address: "2001:4860:4860::8888", family: 6 }, + ]); + + await expect( + new Promise((resolve, reject) => { + pinnedLookup( + "api.naruon.net", + { all: false, family: 5 }, + (error) => { + if (error) reject(error); + else resolve(); + }, + ); + }), + ).resolves.toBeUndefined(); + + const ipv4OnlyLookup = createPinnedBackendLookup("api.naruon.net", [ + { address: "8.8.8.8", family: 4 }, + ]) as unknown as ( + hostname: string, + options: unknown, + callback: (...args: unknown[]) => void, + ) => void; + await expect( + new Promise((resolve, reject) => { + ipv4OnlyLookup( + "api.naruon.net", + { all: false, family: 6 }, + (error) => { + if (error) reject(error); + else resolve(); + }, + ); + }), + ).rejects.toThrow("no address in the requested family"); + }); + it("normalizes bracketed IPv6 hostnames in the pinned lookup", async () => { const pinnedLookup = createPinnedBackendLookup( "2001:4860:4860::8888", @@ -238,6 +359,21 @@ describe("backend destination pinning", () => { }); }); + it.each([ + "https://other.example/api/tasks", + "https://user@api.naruon.net/api/tasks", + "https://user:secret@api.naruon.net/api/tasks", + "https://api.naruon.net/api/tasks#fragment", + ])("rejects a request target outside the configured origin: %s", async (url) => { + vi.stubEnv("BACKEND_INTERNAL_URL", "https://api.naruon.net"); + + await expect(fetchTrustedBackend(new URL(url))).rejects.toThrow( + "does not match the trusted origin", + ); + expect(systemLookupMock).not.toHaveBeenCalled(); + expect(httpsRequestMock).not.toHaveBeenCalled(); + }); + it("wires validated DNS answers into a one-shot HTTPS request", async () => { vi.stubEnv("BACKEND_INTERNAL_URL", "https://api.naruon.net"); systemLookupMock.mockResolvedValue([ @@ -319,6 +455,47 @@ describe("backend destination pinning", () => { ).resolves.toEqual({ address: "8.8.8.8", family: 4 }); }); + it("uses the HTTP transport for Compose and preserves an empty status text", async () => { + vi.stubEnv("ALLOW_DOCKER_BACKEND_INTERNAL_URL", "1"); + vi.stubEnv("BACKEND_INTERNAL_URL", "http://backend:8000"); + systemLookupMock.mockResolvedValue([ + { address: "172.18.0.4", family: 4 }, + ]); + const requestEnd = vi.fn(); + httpRequestMock.mockImplementation((_target, _options, callback) => { + const incoming = Readable.from([]) as Readable & { + rawHeaders: string[]; + statusCode: number; + statusMessage?: string; + }; + incoming.rawHeaders = ["X-Backend", "loopback", "Dangling"]; + incoming.statusCode = 205; + callback(incoming); + return { + destroy: vi.fn(), + end: requestEnd, + once: vi.fn(), + }; + }); + + const response = await fetchTrustedBackend( + new URL("http://backend:8000/api/tasks"), + { + body: new URLSearchParams({ state: "ready" }), + method: "POST", + redirect: "manual", + }, + ); + + expect(httpRequestMock).toHaveBeenCalledOnce(); + expect(httpsRequestMock).not.toHaveBeenCalled(); + expect(requestEnd).toHaveBeenCalledWith("state=ready"); + expect(response.status).toBe(205); + expect(response.statusText).toBe(""); + expect(response.headers.get("x-backend")).toBe("loopback"); + expect(response.headers.has("dangling")).toBe(false); + }); + it("forwards an ArrayBuffer body and streams the backend response", async () => { vi.stubEnv("BACKEND_INTERNAL_URL", "https://api.naruon.net"); systemLookupMock.mockResolvedValue([ @@ -356,6 +533,110 @@ describe("backend destination pinning", () => { await expect(response.text()).resolves.toBe("created"); }); + it("forwards string, null, and typed-array bodies without coercing bytes", async () => { + vi.stubEnv("BACKEND_INTERNAL_URL", "https://api.naruon.net"); + systemLookupMock.mockResolvedValue([ + { address: "8.8.8.8", family: 4 }, + ]); + const requestEnd = vi.fn(); + httpsRequestMock.mockImplementation((_target, _options, callback) => { + const incoming = Readable.from([]) as Readable & { + rawHeaders: string[]; + statusCode: number; + statusMessage: string; + }; + incoming.rawHeaders = []; + incoming.statusCode = 204; + incoming.statusMessage = "No Content"; + callback(incoming); + return { + destroy: vi.fn(), + end: requestEnd, + once: vi.fn(), + }; + }); + + await fetchTrustedBackend( + new URL("https://api.naruon.net/api/string"), + { + body: "payload", + method: "POST", + redirect: "manual", + }, + ); + await fetchTrustedBackend(new URL("https://api.naruon.net/api/null"), { + body: null, + method: "POST", + redirect: "manual", + }); + const view = new Uint16Array([0x1234, 0x5678]); + await fetchTrustedBackend(new URL("https://api.naruon.net/api/view"), { + body: view, + method: "POST", + redirect: "manual", + }); + + expect(requestEnd.mock.calls[0]).toEqual(["payload"]); + expect(requestEnd.mock.calls[1]).toEqual([]); + expect(requestEnd.mock.calls[2][0]).toEqual( + new Uint8Array(view.buffer, view.byteOffset, view.byteLength), + ); + }); + + it("rejects unsupported bodies and destroys the unopened request", async () => { + vi.stubEnv("BACKEND_INTERNAL_URL", "https://api.naruon.net"); + systemLookupMock.mockResolvedValue([ + { address: "8.8.8.8", family: 4 }, + ]); + const requestDestroy = vi.fn(); + const requestEnd = vi.fn(); + httpsRequestMock.mockReturnValue({ + destroy: requestDestroy, + end: requestEnd, + once: vi.fn(), + }); + + await expect( + fetchTrustedBackend(new URL("https://api.naruon.net/api/tasks"), { + body: new Blob(["streaming"]), + method: "POST", + redirect: "manual", + }), + ).rejects.toThrow("only buffered request bodies"); + expect(requestDestroy).toHaveBeenCalledOnce(); + expect(requestEnd).not.toHaveBeenCalled(); + }); + + it("rejects an invalid backend HTTP response status", async () => { + vi.stubEnv("BACKEND_INTERNAL_URL", "https://api.naruon.net"); + systemLookupMock.mockResolvedValue([ + { address: "8.8.8.8", family: 4 }, + ]); + const responseDestroy = vi.fn(); + httpsRequestMock.mockImplementation((_target, _options, callback) => { + const incoming = Readable.from([]) as Readable & { + destroy: () => void; + rawHeaders: string[]; + statusCode?: number; + }; + incoming.destroy = responseDestroy; + incoming.rawHeaders = []; + callback(incoming); + return { + destroy: vi.fn(), + end: vi.fn(), + once: vi.fn(), + }; + }); + + await expect( + fetchTrustedBackend(new URL("https://api.naruon.net/api/tasks"), { + redirect: "manual", + }), + ).rejects.toThrow("invalid HTTP status"); + expect(responseDestroy).toHaveBeenCalledOnce(); + }); + it("rejects automatic redirect modes before opening a socket", async () => { vi.stubEnv("BACKEND_INTERNAL_URL", "https://api.naruon.net"); systemLookupMock.mockResolvedValue([ From 1dc6e3a702d43e640c07795be354533635c22d51 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 30 Jul 2026 14:12:02 +0900 Subject: [PATCH 7/7] fix(frontend): connect backend via pinned IP --- frontend/src/app/api/[...path]/route.test.ts | 5 +- .../src/app/auth/oidc/callback/route.test.ts | 2 +- frontend/src/app/auth/session/route.test.ts | 9 +- frontend/src/lib/backend-request.test.ts | 250 +++++------------- frontend/src/lib/backend-request.ts | 81 ++---- .../src/test/fetch-backed-node-request.ts | 14 +- 6 files changed, 112 insertions(+), 249 deletions(-) diff --git a/frontend/src/app/api/[...path]/route.test.ts b/frontend/src/app/api/[...path]/route.test.ts index 117294e7f..0325aae55 100644 --- a/frontend/src/app/api/[...path]/route.test.ts +++ b/frontend/src/app/api/[...path]/route.test.ts @@ -83,10 +83,11 @@ describe("/api runtime proxy route", () => { request_body: '{"state":"open"}', }); expect(httpsRequestMock).toHaveBeenCalledWith( - expect.any(URL), expect.objectContaining({ agent: false, - lookup: expect.any(Function), + family: 4, + hostname: "8.8.8.8", + path: "/api/tasks?limit=1", servername: "api.naruon.net", }), expect.any(Function), diff --git a/frontend/src/app/auth/oidc/callback/route.test.ts b/frontend/src/app/auth/oidc/callback/route.test.ts index f5b176f72..c4a6cc73e 100644 --- a/frontend/src/app/auth/oidc/callback/route.test.ts +++ b/frontend/src/app/auth/oidc/callback/route.test.ts @@ -106,7 +106,7 @@ describe("/auth/oidc/callback route", () => { expect(setCookie).toContain("Max-Age=0"); expect(setCookie).not.toContain("verifier-123"); expect(fetchMock).toHaveBeenCalledTimes(1); - expect(httpsRequestMock.mock.calls[0]?.[1]).toEqual(expect.objectContaining({ + expect(httpsRequestMock.mock.calls[0]?.[0]).toEqual(expect.objectContaining({ agent: false, method: "GET", servername: "api.naruon.net", diff --git a/frontend/src/app/auth/session/route.test.ts b/frontend/src/app/auth/session/route.test.ts index 8d41a0b5e..12fc0866d 100644 --- a/frontend/src/app/auth/session/route.test.ts +++ b/frontend/src/app/auth/session/route.test.ts @@ -109,10 +109,11 @@ describe("/auth/session route", () => { expect(setCookie).not.toContain("attacker-fixed-session"); expect(fetchMock).toHaveBeenCalledTimes(1); expect(httpsRequestMock).toHaveBeenCalledWith( - expect.any(URL), expect.objectContaining({ agent: false, - lookup: expect.any(Function), + family: 4, + hostname: "8.8.8.8", + path: "/api/auth/session", servername: "api.naruon.net", }), expect.any(Function), @@ -341,7 +342,7 @@ describe("/auth/session route", () => { expect(fetchMock).toHaveBeenCalledTimes(1); const [input] = fetchMock.mock.calls[0]; expect(String(input)).toBe("http://127.0.0.1:8000/api/auth/session"); - expect(httpRequestMock.mock.calls[0]?.[1]).toEqual(expect.objectContaining({ + expect(httpRequestMock.mock.calls[0]?.[0]).toEqual(expect.objectContaining({ agent: false, method: "GET", signal: expect.any(AbortSignal), @@ -386,7 +387,7 @@ describe("/auth/session route", () => { expect(String(input)).toBe( "https://[2001:4860:4860::8888]:8443/api/auth/session", ); - expect(httpsRequestMock.mock.calls[0]?.[1]).toEqual(expect.objectContaining({ + expect(httpsRequestMock.mock.calls[0]?.[0]).toEqual(expect.objectContaining({ agent: false, method: "GET", servername: undefined, diff --git a/frontend/src/lib/backend-request.test.ts b/frontend/src/lib/backend-request.test.ts index 3dded008d..55b8efb26 100644 --- a/frontend/src/lib/backend-request.test.ts +++ b/frontend/src/lib/backend-request.test.ts @@ -3,7 +3,6 @@ import { Readable } from "node:stream"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { - createPinnedBackendLookup, fetchTrustedBackend, resolveBackendAddresses, type BackendDnsLookup, @@ -217,148 +216,6 @@ describe("backend destination pinning", () => { await rejection; }); - it("returns only prevalidated addresses and rejects another hostname", async () => { - const pinnedLookup = createPinnedBackendLookup( - "api.naruon.net", - [{ address: "8.8.8.8", family: 4 }], - ); - const invokeLookup = pinnedLookup as unknown as ( - hostname: string, - options: { all: false; family: number }, - callback: ( - error: Error | null, - address: string, - family: number, - ) => void, - ) => void; - - await expect( - new Promise<{ address: string; family: number }>((resolve, reject) => { - invokeLookup( - "api.naruon.net", - { all: false, family: 0 }, - (error, address, family) => { - if (error) reject(error); - else resolve({ address, family }); - }, - ); - }), - ).resolves.toEqual({ address: "8.8.8.8", family: 4 }); - - await expect( - new Promise((resolve, reject) => { - invokeLookup( - "attacker.example", - { all: false, family: 0 }, - (error) => { - if (error) reject(error); - else resolve(); - }, - ); - }), - ).rejects.toThrow("unexpected hostname"); - }); - - it("supports all-address lookup and rejects empty or unavailable families", async () => { - expect(() => createPinnedBackendLookup("api.naruon.net", [])).toThrow( - "requires a pinned IP address", - ); - - const pinnedLookup = createPinnedBackendLookup("api.naruon.net", [ - { address: "8.8.8.8", family: 4 }, - { address: "2001:4860:4860::8888", family: 6 }, - ]) as unknown as ( - hostname: string, - options: unknown, - callback: (...args: unknown[]) => void, - ) => void; - - await expect( - new Promise( - (resolve, reject) => { - pinnedLookup( - "api.naruon.net", - { all: true, family: 6 }, - (error, addresses) => { - if (error) reject(error); - else - resolve( - addresses as readonly { address: string; family: number }[], - ); - }, - ); - }, - ), - ).resolves.toEqual([ - { address: "2001:4860:4860::8888", family: 6 }, - ]); - - await expect( - new Promise((resolve, reject) => { - pinnedLookup( - "api.naruon.net", - { all: false, family: 5 }, - (error) => { - if (error) reject(error); - else resolve(); - }, - ); - }), - ).resolves.toBeUndefined(); - - const ipv4OnlyLookup = createPinnedBackendLookup("api.naruon.net", [ - { address: "8.8.8.8", family: 4 }, - ]) as unknown as ( - hostname: string, - options: unknown, - callback: (...args: unknown[]) => void, - ) => void; - await expect( - new Promise((resolve, reject) => { - ipv4OnlyLookup( - "api.naruon.net", - { all: false, family: 6 }, - (error) => { - if (error) reject(error); - else resolve(); - }, - ); - }), - ).rejects.toThrow("no address in the requested family"); - }); - - it("normalizes bracketed IPv6 hostnames in the pinned lookup", async () => { - const pinnedLookup = createPinnedBackendLookup( - "2001:4860:4860::8888", - [{ address: "2001:4860:4860::8888", family: 6 }], - ); - const invokeLookup = pinnedLookup as unknown as ( - hostname: string, - options: { all: false; family: number }, - callback: ( - error: Error | null, - address: string, - family: number, - ) => void, - ) => void; - - await expect( - new Promise<{ address: string; family: number }>((resolve, reject) => { - invokeLookup( - "[2001:4860:4860::8888]", - { all: false, family: 6 }, - (error, address, family) => { - if (error) reject(error); - else resolve({ address, family }); - }, - ); - }), - ).resolves.toEqual({ - address: "2001:4860:4860::8888", - family: 6, - }); - }); - it.each([ "https://other.example/api/tasks", "https://user@api.naruon.net/api/tasks", @@ -375,14 +232,14 @@ describe("backend destination pinning", () => { }); it("wires validated DNS answers into a one-shot HTTPS request", async () => { - vi.stubEnv("BACKEND_INTERNAL_URL", "https://api.naruon.net"); + vi.stubEnv("BACKEND_INTERNAL_URL", "https://api.naruon.net:8443"); systemLookupMock.mockResolvedValue([ { address: "8.8.8.8", family: 4 }, { address: "2001:4860:4860::8888", family: 6 }, ]); const requestEnd = vi.fn(); const requestOnce = vi.fn(); - httpsRequestMock.mockImplementation((_target, _options, callback) => { + httpsRequestMock.mockImplementation((_options, callback) => { const incoming = Readable.from([]) as Readable & { rawHeaders: string[]; statusCode: number; @@ -398,10 +255,15 @@ describe("backend destination pinning", () => { once: requestOnce, }; }); - const target = new URL("https://api.naruon.net/api/tasks"); + const target = new URL( + "https://api.naruon.net:8443/api/tasks?state=ready", + ); const response = await fetchTrustedBackend(target, { - headers: { Accept: "application/json" }, + headers: { + Accept: "application/json", + Host: "attacker.example", + }, redirect: "manual", }); @@ -410,21 +272,31 @@ describe("backend destination pinning", () => { verbatim: true, }); expect(httpsRequestMock).toHaveBeenCalledOnce(); - const [fetchedTarget, requestOptions] = httpsRequestMock.mock.calls[0] as [ - URL, + const [requestOptions] = httpsRequestMock.mock.calls[0] as [ { agent: boolean; + family: number; headers: Record; - lookup: ReturnType; + hostname: string; method: string; + path: string; + port: string; + protocol: string; servername: string; }, ]; - expect(fetchedTarget).toBe(target); expect(requestOptions).toMatchObject({ agent: false, - headers: { accept: "application/json" }, + family: 4, + headers: { + accept: "application/json", + host: "api.naruon.net:8443", + }, + hostname: "8.8.8.8", method: "GET", + path: "/api/tasks?state=ready", + port: "8443", + protocol: "https:", servername: "api.naruon.net", }); expect(requestEnd).toHaveBeenCalledWith(); @@ -432,27 +304,43 @@ describe("backend destination pinning", () => { expect(response.status).toBe(204); expect(response.headers.get("x-backend")).toBe("pinned"); - const invokeLookup = requestOptions.lookup as unknown as ( - hostname: string, - options: { all: false; family: number }, - callback: ( - error: Error | null, - address: string, - family: number, - ) => void, - ) => void; - await expect( - new Promise<{ address: string; family: number }>((resolve, reject) => { - invokeLookup( - "api.naruon.net", - { all: false, family: 0 }, - (error, address, family) => { - if (error) reject(error); - else resolve({ address, family }); - }, - ); - }), - ).resolves.toEqual({ address: "8.8.8.8", family: 4 }); + }); + + it("uses a validated IPv6 literal when no IPv4 answer exists", async () => { + vi.stubEnv("BACKEND_INTERNAL_URL", "https://api.naruon.net"); + systemLookupMock.mockResolvedValue([ + { address: "2001:4860:4860::8888", family: 6 }, + ]); + httpsRequestMock.mockImplementation((_options, callback) => { + const incoming = Readable.from([]) as Readable & { + rawHeaders: string[]; + statusCode: number; + statusMessage: string; + }; + incoming.rawHeaders = []; + incoming.statusCode = 204; + incoming.statusMessage = "No Content"; + callback(incoming); + return { + destroy: vi.fn(), + end: vi.fn(), + once: vi.fn(), + }; + }); + + await fetchTrustedBackend( + new URL("https://api.naruon.net/api/tasks"), + { redirect: "manual" }, + ); + + expect(httpsRequestMock.mock.calls[0]?.[0]).toMatchObject({ + family: 6, + headers: { host: "api.naruon.net" }, + hostname: "2001:4860:4860::8888", + path: "/api/tasks", + protocol: "https:", + servername: "api.naruon.net", + }); }); it("uses the HTTP transport for Compose and preserves an empty status text", async () => { @@ -462,7 +350,7 @@ describe("backend destination pinning", () => { { address: "172.18.0.4", family: 4 }, ]); const requestEnd = vi.fn(); - httpRequestMock.mockImplementation((_target, _options, callback) => { + httpRequestMock.mockImplementation((_options, callback) => { const incoming = Readable.from([]) as Readable & { rawHeaders: string[]; statusCode: number; @@ -489,6 +377,14 @@ describe("backend destination pinning", () => { expect(httpRequestMock).toHaveBeenCalledOnce(); expect(httpsRequestMock).not.toHaveBeenCalled(); + expect(httpRequestMock.mock.calls[0]?.[0]).toMatchObject({ + family: 4, + headers: { host: "backend:8000" }, + hostname: "172.18.0.4", + path: "/api/tasks", + port: "8000", + protocol: "http:", + }); expect(requestEnd).toHaveBeenCalledWith("state=ready"); expect(response.status).toBe(205); expect(response.statusText).toBe(""); @@ -502,7 +398,7 @@ describe("backend destination pinning", () => { { address: "8.8.8.8", family: 4 }, ]); const requestEnd = vi.fn(); - httpsRequestMock.mockImplementation((_target, _options, callback) => { + httpsRequestMock.mockImplementation((_options, callback) => { const incoming = Readable.from([Buffer.from("created")]) as Readable & { rawHeaders: string[]; statusCode: number; @@ -539,7 +435,7 @@ describe("backend destination pinning", () => { { address: "8.8.8.8", family: 4 }, ]); const requestEnd = vi.fn(); - httpsRequestMock.mockImplementation((_target, _options, callback) => { + httpsRequestMock.mockImplementation((_options, callback) => { const incoming = Readable.from([]) as Readable & { rawHeaders: string[]; statusCode: number; @@ -613,7 +509,7 @@ describe("backend destination pinning", () => { { address: "8.8.8.8", family: 4 }, ]); const responseDestroy = vi.fn(); - httpsRequestMock.mockImplementation((_target, _options, callback) => { + httpsRequestMock.mockImplementation((_options, callback) => { const incoming = Readable.from([]) as Readable & { destroy: () => void; rawHeaders: string[]; diff --git a/frontend/src/lib/backend-request.ts b/frontend/src/lib/backend-request.ts index e4aca6259..5810e5ce3 100644 --- a/frontend/src/lib/backend-request.ts +++ b/frontend/src/lib/backend-request.ts @@ -6,7 +6,7 @@ import { type RequestOptions, } from "node:http"; import { request as httpsRequest } from "node:https"; -import { BlockList, isIP, type LookupFunction } from "node:net"; +import { BlockList, isIP } from "node:net"; import { Readable } from "node:stream"; import { trustedBackendOrigin } from "@/lib/backend-url"; @@ -210,54 +210,6 @@ export async function resolveBackendAddresses( ); } -export function createPinnedBackendLookup( - expectedHostname: string, - addresses: readonly BackendResolvedAddress[], -): LookupFunction { - if (addresses.length === 0) { - throw new Error("Backend origin requires a pinned IP address"); - } - - return (( - hostname: string, - options: unknown, - callback: (...args: unknown[]) => void, - ) => { - if (normalizeHostname(hostname) !== normalizeHostname(expectedHostname)) { - callback( - new Error("Backend pinned lookup rejected an unexpected hostname"), - ); - return; - } - const requestedFamily = - typeof options === "object" && - options !== null && - "family" in options && - (options.family === 4 || options.family === 6) - ? options.family - : 0; - const eligible = addresses.filter( - ({ family }) => requestedFamily === 0 || family === requestedFamily, - ); - if (eligible.length === 0) { - callback( - new Error("Backend origin has no address in the requested family"), - ); - return; - } - const wantsAll = - typeof options === "object" && - options !== null && - "all" in options && - options.all === true; - if (wantsAll) { - callback(null, eligible); - return; - } - callback(null, eligible[0].address, eligible[0].family); - }) as LookupFunction; -} - function assertTrustedTarget(target: URL): void { const trustedOrigin = trustedBackendOrigin(); if ( @@ -326,18 +278,25 @@ function endRequest( function performPinnedRequest( target: URL, init: RequestInit, - lookup: LookupFunction, + address: BackendResolvedAddress, ): Promise { if (init.redirect !== undefined && init.redirect !== "manual") { throw new Error("Trusted backend redirects must be handled manually"); } const method = (init.method ?? "GET").toUpperCase(); + const headers = outgoingHeaders(init.headers); + // Never let a caller select a different virtual host on the pinned backend. + headers.host = target.host; const requestOptions: RequestOptions = { agent: false, - headers: outgoingHeaders(init.headers), - lookup, + family: address.family, + headers, + hostname: address.address, method, + path: `${target.pathname}${target.search}`, + port: target.port || undefined, + protocol: target.protocol, signal: init.signal ?? undefined, }; @@ -368,7 +327,6 @@ function performPinnedRequest( const request = target.protocol === "https:" ? httpsRequest( - target, { ...requestOptions, servername: isIP(normalizeHostname(target)) @@ -377,7 +335,7 @@ function performPinnedRequest( }, handleResponse, ) - : httpRequest(target, requestOptions, handleResponse); + : httpRequest(requestOptions, handleResponse); request.once("error", reject); try { @@ -395,13 +353,10 @@ export async function fetchTrustedBackend( ): Promise { assertTrustedTarget(target); const addresses = await resolveBackendAddresses(target); - const hostname = normalizeHostname(target); - // A one-shot Node request cannot reuse a socket opened outside this policy. - // Its lookup callback can return only the addresses validated above, while - // the original URL preserves the Host header and HTTPS SNI hostname. - return performPinnedRequest( - target, - init, - createPinnedBackendLookup(hostname, addresses), - ); + const address = + addresses.find(({ family }) => family === 4) ?? addresses[0]!; + // Connect directly to the validated literal address. Host and SNI retain the + // configured authority, while no second DNS lookup or URL-derived hostname + // can redirect the socket. + return performPinnedRequest(target, init, address); } diff --git a/frontend/src/test/fetch-backed-node-request.ts b/frontend/src/test/fetch-backed-node-request.ts index fc8f4a047..eb7a86e66 100644 --- a/frontend/src/test/fetch-backed-node-request.ts +++ b/frontend/src/test/fetch-backed-node-request.ts @@ -13,10 +13,20 @@ type ResponseHandler = (response: IncomingMessage) => void; */ export function createFetchBackedNodeRequest() { return ( - target: URL, options: RequestOptions, handleResponse: ResponseHandler, ): ClientRequest => { + const headers = new Headers(options.headers as HeadersInit); + const authority = + headers.get("host") ?? + `${String(options.hostname ?? "")}${ + options.port ? `:${String(options.port)}` : "" + }`; + const target = new URL( + `${String(options.protocol ?? "http:")}//${authority}${String( + options.path ?? "/", + )}`, + ); const events = new EventEmitter(); const request = events as unknown as ClientRequest; let destroyed = false; @@ -33,7 +43,7 @@ export function createFetchBackedNodeRequest() { .then(() => globalThis.fetch(target, { body: body as BodyInit | undefined, - headers: new Headers(options.headers as HeadersInit), + headers, method: options.method, signal: options.signal, }),