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/app/api/[...path]/route.test.ts b/frontend/src/app/api/[...path]/route.test.ts index 19504fd6a..0325aae55 100644 --- a/frontend/src/app/api/[...path]/route.test.ts +++ b/frontend/src/app/api/[...path]/route.test.ts @@ -1,6 +1,21 @@ import { NextRequest } from "next/server"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +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 }; @@ -11,6 +26,12 @@ 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 }, + ]); + httpsRequestMock.mockReset(); + httpsRequestMock.mockImplementation(createFetchBackedNodeRequest()); }); afterEach(() => { @@ -61,6 +82,39 @@ describe("/api runtime proxy route", () => { user_header: null, request_body: '{"state":"open"}', }); + expect(httpsRequestMock).toHaveBeenCalledWith( + expect.objectContaining({ + agent: false, + family: 4, + hostname: "8.8.8.8", + path: "/api/tasks?limit=1", + servername: "api.naruon.net", + }), + expect.any(Function), + ); + }); + + 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 () => { @@ -335,7 +389,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 +404,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..c4a6cc73e 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,10 +11,23 @@ const { postOidcTokenRequestMock } = vi.hoisted(() => ({ >(), })); +const { backendDnsLookupMock, httpsRequestMock } = vi.hoisted(() => ({ + backendDnsLookupMock: vi.fn(), + httpsRequestMock: vi.fn(), +})); + vi.mock("@/lib/oidc-token-client", () => ({ postOidcTokenRequest: postOidcTokenRequestMock, })); +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) { @@ -33,6 +48,12 @@ 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 }, + ]); + httpsRequestMock.mockReset(); + httpsRequestMock.mockImplementation(createFetchBackedNodeRequest()); postOidcTokenRequestMock.mockReset(); postOidcTokenRequestMock.mockResolvedValue({ access_token: "test-header.test-payload.test-signature", @@ -85,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]?.[0]).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 8d676eb9b..12fc0866d 100644 --- a/frontend/src/app/auth/session/route.test.ts +++ b/frontend/src/app/auth/session/route.test.ts @@ -1,6 +1,27 @@ import { NextRequest } from "next/server"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +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 }; @@ -27,6 +48,14 @@ 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 }, + ]); + httpRequestMock.mockReset(); + httpRequestMock.mockImplementation(createFetchBackedNodeRequest()); + httpsRequestMock.mockReset(); + httpsRequestMock.mockImplementation(createFetchBackedNodeRequest()); }); afterEach(() => { @@ -79,6 +108,16 @@ 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.objectContaining({ + agent: false, + family: 4, + hostname: "8.8.8.8", + path: "/api/auth/session", + servername: "api.naruon.net", + }), + expect.any(Function), + ); }); it("stores a session when browser origin matches the forwarded host", async () => { @@ -301,11 +340,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]?.[0]).toEqual(expect.objectContaining({ + agent: false, + method: "GET", signal: expect.any(AbortSignal), })); }); @@ -344,12 +383,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]?.[0]).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 new file mode 100644 index 000000000..55b8efb26 --- /dev/null +++ b/frontend/src/lib/backend-request.test.ts @@ -0,0 +1,550 @@ +import { Readable } from "node:stream"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + fetchTrustedBackend, + resolveBackendAddresses, + type BackendDnsLookup, +} from "./backend-request"; + +const { httpRequestMock, httpsRequestMock, systemLookupMock } = vi.hoisted(() => ({ + httpRequestMock: vi.fn(), + httpsRequestMock: vi.fn(), + systemLookupMock: vi.fn(), +})); + +vi.mock("node:dns/promises", () => ({ + lookup: systemLookupMock, +})); + +vi.mock("node:http", () => ({ + request: httpRequestMock, +})); + +vi.mock("node:https", () => ({ + request: httpsRequestMock, +})); + +const ORIGINAL_ENV = { ...process.env }; + +describe("backend destination pinning", () => { + beforeEach(() => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + vi.useRealTimers(); + httpRequestMock.mockReset(); + httpsRequestMock.mockReset(); + systemLookupMock.mockReset(); + process.env = { ...ORIGINAL_ENV }; + delete process.env.ALLOW_DOCKER_BACKEND_INTERNAL_URL; + }); + + afterEach(() => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + vi.useRealTimers(); + 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", + "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) => { + 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("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.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: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((_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:8443/api/tasks?state=ready", + ); + + const response = await fetchTrustedBackend(target, { + headers: { + Accept: "application/json", + Host: "attacker.example", + }, + redirect: "manual", + }); + + expect(systemLookupMock).toHaveBeenCalledWith("api.naruon.net", { + all: true, + verbatim: true, + }); + expect(httpsRequestMock).toHaveBeenCalledOnce(); + const [requestOptions] = httpsRequestMock.mock.calls[0] as [ + { + agent: boolean; + family: number; + headers: Record; + hostname: string; + method: string; + path: string; + port: string; + protocol: string; + servername: string; + }, + ]; + expect(requestOptions).toMatchObject({ + agent: false, + 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(); + expect(requestOnce).toHaveBeenCalledWith("error", expect.any(Function)); + expect(response.status).toBe(204); + expect(response.headers.get("x-backend")).toBe("pinned"); + + }); + + 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 () => { + 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((_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(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(""); + 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([ + { address: "8.8.8.8", family: 4 }, + ]); + const requestEnd = vi.fn(); + httpsRequestMock.mockImplementation((_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("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((_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((_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([ + { 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 new file mode 100644 index 000000000..5810e5ce3 --- /dev/null +++ b/frontend/src/lib/backend-request.ts @@ -0,0 +1,362 @@ +import { lookup as systemLookup } from "node:dns/promises"; +import { + request as httpRequest, + type ClientRequest, + type IncomingMessage, + type RequestOptions, +} from "node:http"; +import { request as httpsRequest } from "node:https"; +import { BlockList, isIP } from "node:net"; +import { Readable } from "node:stream"; + +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; + +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"); +} + +const IPV4_MAPPED_IPV6_ADDRESSES = new BlockList(); +IPV4_MAPPED_IPV6_ADDRESSES.addSubnet("::ffff:0:0", 96, "ipv6"); + +function isIpv4MappedIpv6(address: string): boolean { + return IPV4_MAPPED_IPV6_ADDRESSES.check(address, "ipv6"); +} + +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)), + ); +} + +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"); + } +} + +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, + 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, + family: address.family, + headers, + hostname: address.address, + method, + path: `${target.pathname}${target.search}`, + port: target.port || undefined, + protocol: target.protocol, + 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( + { + ...requestOptions, + servername: isIP(normalizeHostname(target)) + ? undefined + : normalizeHostname(target), + }, + handleResponse, + ) + : httpRequest(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 = {}, +): Promise { + assertTrustedTarget(target); + const addresses = await resolveBackendAddresses(target); + 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/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", 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..eb7a86e66 --- /dev/null +++ b/frontend/src/test/fetch-backed-node-request.ts @@ -0,0 +1,73 @@ +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 ( + 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; + + 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, + 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; + }; +}