diff --git a/frontend/app/api/auth/referral/apply/route.ts b/frontend/app/api/auth/referral/apply/route.ts index a59e3467..799c8b85 100644 --- a/frontend/app/api/auth/referral/apply/route.ts +++ b/frontend/app/api/auth/referral/apply/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import { applyAuthResponseCookies, buildBackendRequestHeaders, + buildJsonBackendRequestHeaders, } from "@/lib/backend-auth"; import { buildProxyExceptionResponse, @@ -22,10 +23,7 @@ export async function POST(req: NextRequest) { const body = await req.text(); const res = await fetch(`${API_BASE}/api/auth/referral/apply`, { method: "POST", - headers: { - ...auth.headers, - "Content-Type": "application/json", - }, + headers: buildJsonBackendRequestHeaders(auth.headers), body, cache: "no-store", }); diff --git a/frontend/app/api/ops/config/route.ts b/frontend/app/api/ops/config/route.ts index 74063efa..36bb7e1b 100644 --- a/frontend/app/api/ops/config/route.ts +++ b/frontend/app/api/ops/config/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; -import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth"; +import { applyAuthResponseCookies, buildBackendRequestHeaders, buildJsonBackendRequestHeaders } from "@/lib/backend-auth"; import { buildProxyExceptionResponse } from "@/lib/api-proxy"; import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth"; @@ -28,7 +28,7 @@ export async function PUT(req: NextRequest) { if (authError) return authError; const body = await req.text(); - const res = await fetch(BACKEND, { method: "PUT", headers: { ...auth.headers, "Content-Type": "application/json" }, body, cache: "no-store" }); + const res = await fetch(BACKEND, { method: "PUT", headers: buildJsonBackendRequestHeaders(auth.headers), body, cache: "no-store" }); const raw = await res.text(); const response = new NextResponse(raw, { status: res.status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } }); return applyAuthResponseCookies(response, auth.response); diff --git a/frontend/app/api/ops/sensitive-config/route.ts b/frontend/app/api/ops/sensitive-config/route.ts index a11a9f89..02cf94d4 100644 --- a/frontend/app/api/ops/sensitive-config/route.ts +++ b/frontend/app/api/ops/sensitive-config/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; -import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth"; +import { applyAuthResponseCookies, buildBackendRequestHeaders, buildJsonBackendRequestHeaders } from "@/lib/backend-auth"; import { buildProxyExceptionResponse } from "@/lib/api-proxy"; import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth"; @@ -28,7 +28,7 @@ export async function PUT(req: NextRequest) { if (authError) return authError; const body = await req.text(); - const res = await fetch(BACKEND, { method: "PUT", headers: { ...auth.headers, "Content-Type": "application/json" }, body, cache: "no-store" }); + const res = await fetch(BACKEND, { method: "PUT", headers: buildJsonBackendRequestHeaders(auth.headers), body, cache: "no-store" }); const raw = await res.text(); const response = new NextResponse(raw, { status: res.status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } }); return applyAuthResponseCookies(response, auth.response); diff --git a/frontend/app/api/ops/subscriptions/extend/route.ts b/frontend/app/api/ops/subscriptions/extend/route.ts index eb3b45cc..79e7ae77 100644 --- a/frontend/app/api/ops/subscriptions/extend/route.ts +++ b/frontend/app/api/ops/subscriptions/extend/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; -import { applyAuthResponseCookies, buildBackendRequestHeaders } from "@/lib/backend-auth"; +import { applyAuthResponseCookies, buildBackendRequestHeaders, buildJsonBackendRequestHeaders } from "@/lib/backend-auth"; import { buildProxyExceptionResponse } from "@/lib/api-proxy"; import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth"; @@ -14,9 +14,9 @@ export async function POST(req: NextRequest) { if (authError) return authError; const body = await req.text(); - const headers: Record = { ...auth.headers as Record, "Content-Type": "application/json" }; + const headers = buildJsonBackendRequestHeaders(auth.headers); if (ENTITLEMENT_TOKEN) { - headers.Authorization = `Bearer ${ENTITLEMENT_TOKEN}`; + headers.set("Authorization", `Bearer ${ENTITLEMENT_TOKEN}`); } const res = await fetch(`${API_BASE}/api/ops/subscriptions/extend`, { method: "POST", headers, body, cache: "no-store", diff --git a/frontend/app/api/ops/subscriptions/grant/route.ts b/frontend/app/api/ops/subscriptions/grant/route.ts index 97e205d4..0d5106fb 100644 --- a/frontend/app/api/ops/subscriptions/grant/route.ts +++ b/frontend/app/api/ops/subscriptions/grant/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import { applyAuthResponseCookies, buildBackendRequestHeaders, + buildJsonBackendRequestHeaders, } from "@/lib/backend-auth"; import { buildProxyExceptionResponse } from "@/lib/api-proxy"; import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth"; @@ -166,7 +167,7 @@ export async function POST(req: NextRequest) { const res = await fetch(`${API_BASE}/api/ops/subscriptions/grant`, { method: "POST", - headers: { ...(auth.headers as Record), "Content-Type": "application/json" }, + headers: buildJsonBackendRequestHeaders(auth.headers), body, cache: "no-store", }); diff --git a/frontend/app/api/ops/users/grant-points/route.ts b/frontend/app/api/ops/users/grant-points/route.ts index 37880d55..792624ac 100644 --- a/frontend/app/api/ops/users/grant-points/route.ts +++ b/frontend/app/api/ops/users/grant-points/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import { applyAuthResponseCookies, buildBackendRequestHeaders, + buildJsonBackendRequestHeaders, } from "@/lib/backend-auth"; import { buildProxyExceptionResponse } from "@/lib/api-proxy"; import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth"; @@ -24,10 +25,7 @@ export async function POST(req: NextRequest) { const body = await req.text(); const res = await fetch(`${API_BASE}/api/ops/users/grant-points`, { method: "POST", - headers: { - ...auth.headers, - "Content-Type": "application/json", - }, + headers: buildJsonBackendRequestHeaders(auth.headers), body, cache: "no-store", }); diff --git a/frontend/components/ops/__tests__/backendAuthHeaders.test.ts b/frontend/components/ops/__tests__/backendAuthHeaders.test.ts new file mode 100644 index 00000000..2c5fe3b5 --- /dev/null +++ b/frontend/components/ops/__tests__/backendAuthHeaders.test.ts @@ -0,0 +1,40 @@ +import fs from "node:fs"; +import path from "node:path"; + +function assert(condition: unknown, message: string): asserts condition { + if (!condition) throw new Error(message); +} + +function collectRouteFiles(dir: string): string[] { + return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) return collectRouteFiles(fullPath); + return entry.name === "route.ts" ? [fullPath] : []; + }); +} + +export function runTests() { + const projectRoot = process.cwd(); + const apiRoot = path.join(projectRoot, "app", "api"); + const unsafeRoutes = collectRouteFiles(apiRoot) + .filter((routePath) => + /\.\.\.\s*(?:\(\s*)?auth\.headers/.test(fs.readFileSync(routePath, "utf8")), + ) + .map((routePath) => path.relative(projectRoot, routePath)); + + assert( + unsafeRoutes.length === 0, + `backend auth Headers must not be object-spread because that drops authorization headers: ${unsafeRoutes.join(", ")}`, + ); + + const helperSource = fs.readFileSync( + path.join(projectRoot, "lib", "backend-auth.ts"), + "utf8", + ); + assert( + helperSource.includes("buildJsonBackendRequestHeaders") && + helperSource.includes("new Headers(headers)") && + helperSource.includes('result.set("Content-Type", "application/json")'), + "backend auth must expose a safe JSON header builder", + ); +} diff --git a/frontend/lib/backend-auth.ts b/frontend/lib/backend-auth.ts index 40990efd..96752bc3 100644 --- a/frontend/lib/backend-auth.ts +++ b/frontend/lib/backend-auth.ts @@ -125,6 +125,12 @@ export function applyAuthResponseCookies( return target; } +export function buildJsonBackendRequestHeaders(headers: HeadersInit) { + const result = new Headers(headers); + result.set("Content-Type", "application/json"); + return result; +} + export function requireBackendAuthUser(auth: BackendHeaderBuildResult) { if (auth.authUserId) return null; return applyAuthResponseCookies(