diff --git a/web/app/api/history/route.ts b/web/app/api/history/route.ts index 3d6ce933..b6405717 100644 --- a/web/app/api/history/route.ts +++ b/web/app/api/history/route.ts @@ -1,4 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; +import { BodyTooLargeError, readJsonWithLimit } from "@/lib/body-limit"; // In-memory store for local development (will be replaced with Vercel KV in production) const memoryHistory = new Map(); @@ -89,22 +90,48 @@ function hasOverlap(a: string[] = [], b: string[] = []): boolean { export async function POST(request: NextRequest) { const sessionId = getSessionId(request); + let body: unknown; try { - const body = await request.json(); + body = await readJsonWithLimit(request); + } catch (error) { + if (error instanceof BodyTooLargeError) { + return NextResponse.json( + { error: `Request body exceeds ${error.limit} bytes` }, + { status: 413 } + ); + } + return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); + } + + try { + if (!body || typeof body !== "object" || Array.isArray(body)) { + return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); + } + const bodyRecord = body as Record; const id = crypto.randomUUID(); - const normalizedUrlHashes = normalizeHashes(body.normalizedUrlHashes); + const normalizedUrlHashes = normalizeHashes(bodyRecord.normalizedUrlHashes); + const flagsObj = (bodyRecord.flags && typeof bodyRecord.flags === "object") + ? bodyRecord.flags as Record + : null; + const flags: HistoryEntry["flags"] | undefined = flagsObj + ? { + ...(typeof flagsObj.skipCache === "boolean" ? { skipCache: flagsObj.skipCache } : {}), + ...(typeof flagsObj.deepResearch === "boolean" ? { deepResearch: flagsObj.deepResearch } : {}), + } + : undefined; + const entry: HistoryEntry = { id, - query: body.query || "", - url: body.url || null, - result: body.result || "", - provider: body.provider || "unknown", + query: typeof bodyRecord.query === "string" ? bodyRecord.query : "", + url: typeof bodyRecord.url === "string" ? bodyRecord.url : null, + result: typeof bodyRecord.result === "string" ? bodyRecord.result : "", + provider: typeof bodyRecord.provider === "string" ? bodyRecord.provider : "unknown", timestamp: Date.now(), - charCount: body.charCount || 0, - resolveTime: body.resolveTime || 0, - profile: body.profile || undefined, - flags: body.flags || undefined, - providers: Array.isArray(body.providers) ? body.providers : undefined, + charCount: typeof bodyRecord.charCount === "number" ? bodyRecord.charCount : 0, + resolveTime: typeof bodyRecord.resolveTime === "number" ? bodyRecord.resolveTime : 0, + ...(typeof bodyRecord.profile === "string" ? { profile: bodyRecord.profile } : {}), + ...(flags ? { flags } : {}), + ...(Array.isArray(bodyRecord.providers) ? { providers: bodyRecord.providers.filter((p): p is string => typeof p === "string") } : {}), normalizedUrlHashes, }; diff --git a/web/app/api/resolve/route.ts b/web/app/api/resolve/route.ts index 6b6c7360..bd64325c 100644 --- a/web/app/api/resolve/route.ts +++ b/web/app/api/resolve/route.ts @@ -9,6 +9,7 @@ import { Logger } from "@/lib/log"; import { searchViaExaMcpWithMistral } from "@/lib/resolvers/query"; import { isUrl, queryProviders, urlProviders, ProviderKeys } from "@/lib/resolvers/index"; import { validateResolveRequest, validateUrlForFetchAsync } from "@/lib/validation"; +import { BodyTooLargeError, readJsonWithLimit } from "@/lib/body-limit"; // Allow up to 60 seconds for resolver operations export const maxDuration = 60; @@ -210,7 +211,19 @@ export async function POST(request: NextRequest) { // counter twice, halving the effective limit to 15 req/min. const log = new Logger(); try { - const body = await request.json(); + let body: unknown; + try { + body = await readJsonWithLimit(request); + } catch (error) { + if (error instanceof BodyTooLargeError) { + return NextResponse.json( + { error: `Request body exceeds ${error.limit} bytes` }, + { status: 413 } + ); + } + return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); + } + const validation = validateResolveRequest(body); if (!validation.success || !validation.data) { return NextResponse.json({ error: validation.error || "Invalid request" }, { status: 400 }); @@ -250,13 +263,13 @@ export async function POST(request: NextRequest) { const budget = new ResolutionBudget(profile); // Extract optional user-provided API keys from request body - const userKeys: ProviderKeys = { - SERPER_API_KEY: body.serper_api_key, - TAVILY_API_KEY: body.tavily_api_key, - EXA_API_KEY: body.exa_api_key, - FIRECRAWL_API_KEY: body.firecrawl_api_key, - MISTRAL_API_KEY: body.mistral_api_key, - }; + const bodyRecord = (body && typeof body === "object" ? body : {}) as Record; + const userKeys: ProviderKeys = {}; + if (typeof bodyRecord.serper_api_key === "string") userKeys.SERPER_API_KEY = bodyRecord.serper_api_key; + if (typeof bodyRecord.tavily_api_key === "string") userKeys.TAVILY_API_KEY = bodyRecord.tavily_api_key; + if (typeof bodyRecord.exa_api_key === "string") userKeys.EXA_API_KEY = bodyRecord.exa_api_key; + if (typeof bodyRecord.firecrawl_api_key === "string") userKeys.FIRECRAWL_API_KEY = bodyRecord.firecrawl_api_key; + if (typeof bodyRecord.mistral_api_key === "string") userKeys.MISTRAL_API_KEY = bodyRecord.mistral_api_key; let markdown: string; let provider: string; diff --git a/web/app/api/ui-state/route.ts b/web/app/api/ui-state/route.ts index 448c4dd4..f16234f8 100644 --- a/web/app/api/ui-state/route.ts +++ b/web/app/api/ui-state/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import crypto from "crypto"; +import { BodyTooLargeError, readJsonWithLimit } from "@/lib/body-limit"; // In-memory store for UI state (sessionId -> UIState with TTL) interface StoredState { @@ -66,9 +67,20 @@ export async function GET(request: NextRequest) { // POST endpoint: Save UI state export async function POST(request: NextRequest) { + let body: unknown; + try { + body = await readJsonWithLimit(request); + } catch (error) { + if (error instanceof BodyTooLargeError) { + return NextResponse.json( + { error: `Request body exceeds ${error.limit} bytes` }, + { status: 413 } + ); + } + return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); + } + try { - const body = await request.json(); - if (!body || typeof body !== "object" || Array.isArray(body)) { return NextResponse.json({ error: "Invalid state payload" }, { status: 400 }); } diff --git a/web/tests/api/history-route.test.ts b/web/tests/api/history-route.test.ts new file mode 100644 index 00000000..cbc9b947 --- /dev/null +++ b/web/tests/api/history-route.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest"; +import { NextRequest } from "next/server"; + +import { DELETE, GET, POST } from "../../app/api/history/route"; +import { MAX_BODY_BYTES } from "../../lib/body-limit"; + +describe("/api/history route", () => { + it("rejects an oversized body with 413 Payload Too Large", async () => { + const postReq = new NextRequest("http://localhost/api/history", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ query: "test", padding: "x".repeat(MAX_BODY_BYTES + 64) }), + }); + + const postRes = await POST(postReq); + expect(postRes.status).toBe(413); + const data = await postRes.json(); + expect(data.error).toContain("Request body exceeds"); + }); + + it("saves and retrieves history entries", async () => { + const postReq = new NextRequest("http://localhost/api/history", { + method: "POST", + headers: { + "content-type": "application/json", + cookie: "ui-session=test-history-session", + }, + body: JSON.stringify({ + query: "rust async tutorial", + result: "# Rust Async", + provider: "jina", + }), + }); + + const postRes = await POST(postReq); + expect(postRes.status).toBe(200); + const postData = await postRes.json(); + expect(postData.ok).toBe(true); + expect(postData.id).toBeDefined(); + + const getReq = new NextRequest("http://localhost/api/history", { + headers: { cookie: "ui-session=test-history-session" }, + }); + + const getRes = await GET(getReq); + expect(getRes.status).toBe(200); + const getData = await getRes.json(); + expect(getData.entries.length).toBeGreaterThanOrEqual(1); + expect(getData.entries[0].query).toBe("rust async tutorial"); + }); + + it("deletes a history entry by id", async () => { + const postReq = new NextRequest("http://localhost/api/history", { + method: "POST", + headers: { + "content-type": "application/json", + cookie: "ui-session=test-delete-session", + }, + body: JSON.stringify({ query: "to be deleted" }), + }); + + const postRes = await POST(postReq); + const { id } = await postRes.json(); + + const deleteReq = new NextRequest(`http://localhost/api/history?id=${id}`, { + method: "DELETE", + headers: { cookie: "ui-session=test-delete-session" }, + }); + + const deleteRes = await DELETE(deleteReq); + expect(deleteRes.status).toBe(200); + + const getReq = new NextRequest("http://localhost/api/history", { + headers: { cookie: "ui-session=test-delete-session" }, + }); + const getData = await (await GET(getReq)).json(); + expect(getData.entries.find((e: { id: string }) => e.id === id)).toBeUndefined(); + }); +}); diff --git a/web/tests/api/resolve-body-limit.test.ts b/web/tests/api/resolve-body-limit.test.ts new file mode 100644 index 00000000..ac902259 --- /dev/null +++ b/web/tests/api/resolve-body-limit.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("../../lib/routing", () => ({ + ResolutionBudget: vi.fn().mockImplementation(() => ({ + canTry: vi.fn().mockReturnValue(true), + recordAttempt: vi.fn(), + getState: vi.fn().mockReturnValue({}), + })), + planProviderOrder: vi.fn().mockReturnValue(["jina"]), + detectJsHeavy: vi.fn().mockReturnValue(false), + isPaidProvider: vi.fn().mockReturnValue(false), +})); + +vi.mock("../../lib/validation", () => ({ + validateResolveRequest: vi.fn().mockImplementation((body) => ({ + success: true, + data: body, + })), + validateUrlForFetchAsync: vi.fn().mockResolvedValue({ valid: true }), +})); + +vi.mock("../../lib/cache", () => ({ + get: vi.fn().mockResolvedValue(null), + set: vi.fn().mockResolvedValue(true), +})); + +vi.mock("../../lib/records", () => ({ + save: vi.fn(), +})); + +import { POST } from "../../app/api/resolve/route"; +import { MAX_BODY_BYTES } from "../../lib/body-limit"; + +describe("POST /api/resolve body limit", () => { + it("rejects an oversized body with 413 Payload Too Large", async () => { + const postReq = new NextRequest("http://localhost/api/resolve", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ query: "test", padding: "x".repeat(MAX_BODY_BYTES + 64) }), + }); + + const postRes = await POST(postReq); + expect(postRes.status).toBe(413); + const data = await postRes.json(); + expect(data.error).toContain("Request body exceeds"); + }); +}); diff --git a/web/tests/api/ui-state-route.test.ts b/web/tests/api/ui-state-route.test.ts index 4eb3b56d..b5d090d4 100644 --- a/web/tests/api/ui-state-route.test.ts +++ b/web/tests/api/ui-state-route.test.ts @@ -2,8 +2,22 @@ import { describe, expect, it } from "vitest"; import { NextRequest } from "next/server"; import { GET, POST } from "../../app/api/ui-state/route"; +import { MAX_BODY_BYTES } from "../../lib/body-limit"; describe("/api/ui-state route", () => { + it("rejects an oversized body with 413 Payload Too Large", async () => { + const postReq = new NextRequest("http://localhost/api/ui-state", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ padding: "x".repeat(MAX_BODY_BYTES + 64) }), + }); + + const postRes = await POST(postReq); + expect(postRes.status).toBe(413); + const data = await postRes.json(); + expect(data.error).toContain("Request body exceeds"); + }); + it("stores and restores custom profile provider selection", async () => { const payload = { activeProfile: "custom",