Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
Expand Down
145 changes: 144 additions & 1 deletion src/lib/__tests__/apiClient.test.ts
Original file line number Diff line number Diff line change
@@ -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({
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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", () => {
Expand Down
28 changes: 27 additions & 1 deletion src/lib/apiClient.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -22,7 +48,7 @@ export async function apiFetch<T>(
path: string,
init: RequestInit = {}
): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, {
const res = await fetch(resolveApiUrl(path), {
headers: { "Content-Type": "application/json", ...(init.headers ?? {}) },
...init,
});
Expand Down