Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
3 changes: 3 additions & 0 deletions frontend/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

43 changes: 41 additions & 2 deletions frontend/src/app/api/[...path]/route.test.ts
Original file line number Diff line number Diff line change
@@ -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 };
Expand All @@ -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(() => {
Expand All @@ -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"),
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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) }),
);
Expand All @@ -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",
});
});
});
6 changes: 2 additions & 4 deletions frontend/src/app/api/[...path]/route.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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));
Expand Down
12 changes: 12 additions & 0 deletions frontend/src/app/auth/oidc/callback/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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",
Expand Down
13 changes: 13 additions & 0 deletions frontend/src/app/auth/session/route.test.ts
Original file line number Diff line number Diff line change
@@ -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 };
Expand All @@ -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(() => {
Expand All @@ -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);
Expand Down
Loading
Loading