diff --git a/README.md b/README.md index 220a252..47e1274 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,11 @@ The frontend communicates with the StableRoute API backend. - **`NEXT_PUBLIC_STABLEROUTE_API_BASE`**: Specifies the base URL of the StableRoute API backend (defaults to `http://localhost:3001` if unset). +The API base must be an absolute `http` or `https` URL. Shared API helpers also +require request paths to start with `/`, which keeps frontend requests on the +configured StableRoute API origin and avoids leaking requests to arbitrary +hosts through misconfiguration or absolute-path call sites. + ### API Endpoints Consumed - **`/api/v1/pairs`**: Lists registered pairs (`GET`) and registers new pairs (`POST`). diff --git a/src/lib/__tests__/apiClient.test.ts b/src/lib/__tests__/apiClient.test.ts index eaa0423..5bf37d5 100644 --- a/src/lib/__tests__/apiClient.test.ts +++ b/src/lib/__tests__/apiClient.test.ts @@ -1,4 +1,30 @@ -import { apiFetch, registerAuthErrorHandler } from "../apiClient"; +import { + apiDelete, + apiFetch, + apiPatch, + apiPost, + registerAuthErrorHandler, +} from "../apiClient"; + +function loadApiClientWithBase(base: string) { + const previousBase = process.env.NEXT_PUBLIC_STABLEROUTE_API_BASE; + try { + jest.resetModules(); + process.env.NEXT_PUBLIC_STABLEROUTE_API_BASE = base; + let loadedApiClient: typeof import("../apiClient") | undefined; + jest.isolateModules(() => { + loadedApiClient = require("../apiClient") as typeof import("../apiClient"); + }); + return loadedApiClient!; + } finally { + if (previousBase === undefined) { + delete process.env.NEXT_PUBLIC_STABLEROUTE_API_BASE; + } else { + process.env.NEXT_PUBLIC_STABLEROUTE_API_BASE = previousBase; + } + jest.resetModules(); + } +} function mockResponse(status: number, body: string, contentType = "application/json") { global.fetch = jest.fn().mockResolvedValue({ @@ -15,6 +41,12 @@ describe("apiFetch", () => { it("returns parsed JSON on success", async () => { mockResponse(200, JSON.stringify({ id: 1 })); await expect(apiFetch("/test")).resolves.toEqual({ id: 1 }); + expect(global.fetch).toHaveBeenCalledWith( + "http://localhost:3001/test", + expect.objectContaining({ + headers: expect.objectContaining({ "Content-Type": "application/json" }), + }) + ); }); it("returns undefined for 204", async () => { @@ -52,6 +84,117 @@ describe("apiFetch", () => { expect(err).toBeInstanceOf(Error); expect(err.status).toBe(400); }); + + it("uses a valid configured http API base", async () => { + const { apiFetch: configuredApiFetch } = loadApiClientWithBase("https://api.example.test"); + mockResponse(200, JSON.stringify({ id: 2 })); + + await expect(configuredApiFetch("/test")).resolves.toEqual({ id: 2 }); + + expect(global.fetch).toHaveBeenCalledWith( + "https://api.example.test/test", + expect.any(Object) + ); + }); + + it("rejects non-http API base schemes without leaking the raw value", () => { + const rawBase = "javascript:alert(1)"; + + try { + loadApiClientWithBase(rawBase); + throw new Error("expected loadApiClientWithBase to throw"); + } catch (error) { + const message = (error as Error).message; + expect(message).toBe("Invalid StableRoute API base URL configuration"); + expect(message).not.toContain(rawBase); + } + }); + + it("rejects unparseable API base values without leaking the raw value", () => { + const rawBase = "not a url"; + + try { + loadApiClientWithBase(rawBase); + throw new Error("expected loadApiClientWithBase to throw"); + } catch (error) { + const message = (error as Error).message; + expect(message).toBe("Invalid StableRoute API base URL configuration"); + expect(message).not.toContain(rawBase); + } + }); + + it("rejects absolute request paths before fetch", async () => { + global.fetch = jest.fn(); + + await expect(apiFetch("https://evil.example/api")).rejects.toThrow( + "API request path must be a relative path starting with /" + ); + + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it("rejects protocol-relative request paths before fetch", async () => { + global.fetch = jest.fn(); + + await expect(apiFetch("//evil.example/api")).rejects.toThrow( + "API request path must be a relative path starting with /" + ); + + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it("rejects backslash paths that would resolve to another origin", async () => { + global.fetch = jest.fn(); + + await expect(apiFetch("/\\evil.example/api")).rejects.toThrow( + "API request path must stay on the configured API origin" + ); + + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it("posts JSON through the shared wrapper", async () => { + mockResponse(200, JSON.stringify({ ok: true })); + + await expect(apiPost("/test", { label: "prod" })).resolves.toEqual({ ok: true }); + + expect(global.fetch).toHaveBeenCalledWith( + "http://localhost:3001/test", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ label: "prod" }), + }) + ); + }); + + it("patches JSON through the shared wrapper", async () => { + mockResponse(200, JSON.stringify({ ok: true })); + + await expect(apiPatch("/test", { fee_bps: 10 })).resolves.toEqual({ ok: true }); + + expect(global.fetch).toHaveBeenCalledWith( + "http://localhost:3001/test", + expect.objectContaining({ + method: "PATCH", + body: JSON.stringify({ fee_bps: 10 }), + }) + ); + }); + + it("deletes through the shared wrapper", async () => { + global.fetch = jest.fn().mockResolvedValue({ + status: 204, + ok: true, + text: () => Promise.resolve(""), + } as unknown as Response); + + await expect(apiDelete("/test")).resolves.toBeUndefined(); + + expect(global.fetch).toHaveBeenCalledWith( + "http://localhost:3001/test", + expect.objectContaining({ method: "DELETE" }) + ); + }); }); describe("registerAuthErrorHandler", () => { diff --git a/src/lib/apiClient.ts b/src/lib/apiClient.ts index 5df2924..7f35ea3 100644 --- a/src/lib/apiClient.ts +++ b/src/lib/apiClient.ts @@ -1,6 +1,32 @@ const API_BASE = process.env.NEXT_PUBLIC_STABLEROUTE_API_BASE ?? "http://localhost:3001"; +function parseApiBase(value: string): URL { + let url: URL; + try { + url = new URL(value); + } catch { + throw new Error("Invalid StableRoute API base URL configuration"); + } + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new Error("Invalid StableRoute API base URL configuration"); + } + return url; +} + +const API_BASE_URL = parseApiBase(API_BASE); + +function resolveApiUrl(path: string): string { + if (!path.startsWith("/") || path.startsWith("//")) { + throw new Error("API request path must be a relative path starting with /"); + } + const url = new URL(path, API_BASE_URL); + if (url.origin !== API_BASE_URL.origin) { + throw new Error("API request path must stay on the configured API origin"); + } + return url.toString(); +} + export type ApiError = { error: string; message: string; @@ -22,7 +48,7 @@ export async function apiFetch( path: string, init: RequestInit = {} ): Promise { - const res = await fetch(`${API_BASE}${path}`, { + const res = await fetch(resolveApiUrl(path), { headers: { "Content-Type": "application/json", ...(init.headers ?? {}) }, ...init, });