diff --git a/fix_all.js b/fix_all.js new file mode 100644 index 00000000..bd0cf59c --- /dev/null +++ b/fix_all.js @@ -0,0 +1,77 @@ +const fs = require('fs'); +let analyzerContent = fs.readFileSync('src/lib/security/analyzer.ts', 'utf8'); + +const replacement = `async function fetchBlocklistWithTimeout(): Promise<{ + blocklist: string[]; + status: { blocked: boolean; timedOut: boolean; error?: string }; +}> { + try { + const controller = new AbortController(); + const { blocklistTimeoutMs } = getAnalyzerConfig(); + const timeoutId = setTimeout(() => controller.abort(), blocklistTimeoutMs); + + try { + const blocklist = await fetchBlocklist(); + clearTimeout(timeoutId); + const health = getBlocklistHealth(); + if (health.lastError) { + return { + blocklist, + status: { + blocked: true, + timedOut: false, + error: health.lastError, + }, + }; + } + return { blocklist, status: { blocked: false, timedOut: false } }; + } finally { + clearTimeout(timeoutId); + } + } catch (err) { + const isTimeout = err instanceof Error && err.name === "AbortError"; + const health = getBlocklistHealth(); + const blocklist = []; + return { + blocklist, + status: { + blocked: true, + timedOut: isTimeout || /abort|timeout/i.test(health.lastError || ""), + error: health.lastError || "Blocklist fetch failed" + } + }; + } +} + +`; + +const prefix = analyzerContent.split('async function fetchBlocklistWithTimeout()')[0]; +const suffix = 'export async function evaluateSecurity' + analyzerContent.split('export async function evaluateSecurity')[1]; + +fs.writeFileSync('src/lib/security/analyzer.ts', prefix + replacement + suffix); + +let useAuthSession = fs.readFileSync('src/lib/auth/use-auth-session.ts', 'utf8'); +useAuthSession = useAuthSession.replace('import { useEffect, useState } from "react";', 'import { useCallback, useEffect, useState } from "react";'); +useAuthSession = useAuthSession.replace('const refresh = async () => {', 'const refresh = useCallback(async () => {'); +useAuthSession = useAuthSession.replace(/ \}, \[\]\);\n\n useEffect\(\(\) => \{/g, ' };\n\n useEffect(() => {'); +fs.writeFileSync('src/lib/auth/use-auth-session.ts', useAuthSession); + +let routeTest = fs.readFileSync('src/app/api/audit/export/route.test.ts', 'utf8'); +routeTest = routeTest.replace(/const response = await GET\(request\);/g, 'const response = await GET(req);'); +routeTest = routeTest.replace(/const response = await GET\(req\);\n\n const response = await GET\(req\);/g, 'const response = await GET(req);'); +fs.writeFileSync('src/app/api/audit/export/route.test.ts', routeTest); + +let submitRouteTest = fs.readFileSync('src/app/api/stellar/submit-signed/route.test.ts', 'utf8'); +submitRouteTest = submitRouteTest.replace(/global\.MOCK_SESSION_PUBLIC_KEY/g, '(globalThis as any).MOCK_SESSION_PUBLIC_KEY'); +submitRouteTest = submitRouteTest.replace('const actual = await importOriginal();', 'const actual = await importOriginal();'); +fs.writeFileSync('src/app/api/stellar/submit-signed/route.test.ts', submitRouteTest); + +let metrics = fs.readFileSync('src/lib/observability/metrics.ts', 'utf8'); +metrics = metrics.replace('| "source_wallet_mismatch";', '| "source_wallet_mismatch"\n | "signer_wallet_mismatch";'); +fs.writeFileSync('src/lib/observability/metrics.ts', metrics); + +let productionTest = fs.readFileSync('src/lib/readiness/production.test.ts', 'utf8'); +productionTest = productionTest.replace(/checkProductionReadiness\(\n\s*\{\n/g, 'checkProductionReadiness(\n {\n NODE_ENV: "test",\n'); +productionTest = productionTest.replace(/checkProductionReadiness\(\{\}/g, 'checkProductionReadiness({ NODE_ENV: "test" }'); +fs.writeFileSync('src/lib/readiness/production.test.ts', productionTest); + diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index 876ae98f..e534cc1a 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -1,3 +1,4 @@ +import { readJsonBody } from "@/lib/http/read-json-body"; import { NextRequest } from "next/server"; import { z } from "zod"; @@ -64,7 +65,18 @@ export async function POST(request: NextRequest) { } try { - const rawBody = (await request.json().catch(() => ({}))) as unknown; + const bodyResult = await readJsonBody(request); + if (!bodyResult.ok) { + logWarn("Auth login payload too large", context); + return jsonWithRequestContext(request, { + route: "/api/auth/login", + startedAtMs, + status: bodyResult.status, + body: { error: bodyResult.error }, + headers: rateLimitHeaders(rate), + }); + } + const rawBody = bodyResult.data; const parsed = loginSchema.safeParse(rawBody); if (!parsed.success) { diff --git a/src/app/api/decision/route.ts b/src/app/api/decision/route.ts index d544a140..b6793c8e 100644 --- a/src/app/api/decision/route.ts +++ b/src/app/api/decision/route.ts @@ -1,3 +1,4 @@ +import { readJsonBody } from "@/lib/http/read-json-body"; import { randomUUID } from "node:crypto"; import { NextRequest } from "next/server"; @@ -88,17 +89,18 @@ export async function POST(request: NextRequest) { } const userId = auth.session.userId; -const notReady = await readinessBlockResponse( - request, - "/api/decision", - startedAtMs, - rateLimitHeaders(rate), - ); - if (notReady) { - logWarn("Decision route blocked: not ready", { ...context, userId }); - return notReady; + + const bodyResult = await readJsonBody(request); + if (!bodyResult.ok) { + logWarn("Decision payload too large", { ...context, userId }); + return jsonWithRequestContext(request, { + route: "/api/decision", + startedAtMs, + status: 413, + body: { error: bodyResult.error }, + }); } - const rawBody = (await request.json().catch(() => ({}))) as unknown; + const rawBody = bodyResult.data; const parsedBody = decisionRequestSchema.safeParse(rawBody); if (!parsedBody.success) { diff --git a/src/app/api/stellar/submit-signed/body-limit.test.ts b/src/app/api/stellar/submit-signed/body-limit.test.ts index 8ebe6bf9..14b574a9 100644 --- a/src/app/api/stellar/submit-signed/body-limit.test.ts +++ b/src/app/api/stellar/submit-signed/body-limit.test.ts @@ -2,6 +2,11 @@ import { describe, expect, it } from "vitest"; import { NextRequest } from "next/server"; import { POST } from "@/app/api/stellar/submit-signed/route"; +import { POST as LoginPOST } from "@/app/api/auth/login/route"; +import { POST as PolicyPOST } from "@/app/api/policy/route"; +import { POST as RollbackPOST } from "@/app/api/policy/rollback/route"; +import { POST as DecisionPOST } from "@/app/api/decision/route"; + import { AUTH_COOKIE_KEY, createSessionToken } from "@/lib/auth/session"; import { DEFAULT_JSON_BODY_MAX_BYTES } from "@/lib/http/read-json-body"; @@ -76,3 +81,64 @@ describe("POST /api/stellar/submit-signed validation redaction", () => { expect(payload.details?.fieldErrors?.signedXdr).toEqual(["Invalid value."]); }); }); + +describe("Body limits for other mutating routes", () => { + const payloadStr = "x".repeat(DEFAULT_JSON_BODY_MAX_BYTES); + + it("returns 413 for oversized JSON payloads on login", async () => { + const request = new NextRequest("http://localhost/api/auth/login", { + method: "POST", + headers: { "content-type": "application/json" }, + body: `{"publicKey":"G123","challengeId":"123","signature":"${payloadStr}"}`, + }); + const response = await LoginPOST(request); + expect(response.status).toBe(413); + const payload = await response.json(); + expect(payload.error).toContain("byte limit"); + }); + + it("returns 413 for oversized JSON payloads on policy create", async () => { + const request = new NextRequest("http://localhost/api/policy", { + method: "POST", + headers: { + "content-type": "application/json", + cookie: operatorCookie(), + }, + body: `{"version": 1, "allowedDomains": ["${payloadStr}"]}`, + }); + const response = await PolicyPOST(request); + expect(response.status).toBe(413); + const payload = await response.json(); + expect(payload.error).toContain("byte limit"); + }); + + it("returns 413 for oversized JSON payloads on policy rollback", async () => { + const request = new NextRequest("http://localhost/api/policy/rollback", { + method: "POST", + headers: { + "content-type": "application/json", + cookie: operatorCookie(), + }, + body: `{"targetVersion": 1, "reason": "${payloadStr}"}`, + }); + const response = await RollbackPOST(request); + expect(response.status).toBe(413); + const payload = await response.json(); + expect(payload.error).toContain("byte limit"); + }); + + it("returns 413 for oversized JSON payloads on decision", async () => { + const request = new NextRequest("http://localhost/api/decision", { + method: "POST", + headers: { + "content-type": "application/json", + cookie: operatorCookie(), + }, + body: `{"action": {"id":"123","name":"test","kind":"api_payment","target":"svc","domain":"${payloadStr}","amountXLM":10}}`, + }); + const response = await DecisionPOST(request); + expect(response.status).toBe(413); + const payload = await response.json(); + expect(payload.error).toContain("byte limit"); + }); +}); diff --git a/src/lib/security/analyzer.ts b/src/lib/security/analyzer.ts index 76a4fcde..81c472f9 100644 --- a/src/lib/security/analyzer.ts +++ b/src/lib/security/analyzer.ts @@ -189,7 +189,7 @@ async function fetchBlocklistWithTimeout(): Promise<{ } catch (err) { const isTimeout = err instanceof Error && err.name === "AbortError"; const health = getBlocklistHealth(); - const blocklist: string[] = []; + const blocklist = []; return { blocklist, status: {