From b40a9ea8fe06ea5c9fdff29f19811f91f34b0dde Mon Sep 17 00:00:00 2001 From: Xiangzhe Date: Fri, 14 Aug 2026 22:19:50 -0300 Subject: [PATCH] fix(radar): close final client trust boundaries --- README.md | 2 +- src/app/api/radar/local-model-state/route.ts | 28 +++++++++++++++----- src/lib/radar/feedSchema.ts | 15 ++++++++++- tests/unit/radar-local-state-route.test.ts | 25 +++++++++++++++++ tests/unit/radar-localized-feed.test.ts | 15 +++++++++++ 5 files changed, 77 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index de1062c633..e828da2f80 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ | 🌐 Providers | 291 | **339** | more queued | | 🧠 Documented models | 500+ | **1200+** | — | | 🖼️ Modality Bridge | — | 🆕 vision | video | -| 📡 Radar free catalog | — | — | 🔭 next | +| 📡 Radar free catalog | — | 🆕 opt-in | — | | ⚖️ Quota-aware scheduling | — | — | 🔭 next | | 📊 Quota telemetry | — | — | 🔭 next | diff --git a/src/app/api/radar/local-model-state/route.ts b/src/app/api/radar/local-model-state/route.ts index e569526ac0..664e3d0b57 100644 --- a/src/app/api/radar/local-model-state/route.ts +++ b/src/app/api/radar/local-model-state/route.ts @@ -16,6 +16,10 @@ import { import { isAuthenticated } from "@/shared/utils/apiAuth"; import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors"; import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags"; +import { + readRequestBodyWithLimit, + RequestBodyTooLargeError, +} from "@/shared/middleware/bodySizeGuard"; export const dynamic = "force-dynamic"; export const revalidate = 0; @@ -72,11 +76,19 @@ async function authorize(request: Request): Promise { return null; } -async function readJson(request: Request): Promise { +const RADAR_LOCAL_STATE_BODY_LIMIT_BYTES = 4 * 1024; +type ReadJsonResult = + { ok: true; value: unknown } | { ok: false; status: 400 | 413; message: string }; + +async function readJson(request: Request): Promise { try { - return await request.json(); - } catch { - return null; + const bytes = await readRequestBodyWithLimit(request, RADAR_LOCAL_STATE_BODY_LIMIT_BYTES); + const raw = new TextDecoder("utf-8", { fatal: true }).decode(bytes); + return { ok: true, value: JSON.parse(raw) as unknown }; + } catch (cause: unknown) { + return cause instanceof RequestBodyTooLargeError + ? { ok: false, status: 413, message: "Request body too large" } + : { ok: false, status: 400, message: "Invalid request body" }; } } @@ -106,7 +118,9 @@ export async function PATCH(request: Request): Promise { const authError = await authorize(request); if (authError) return authError; - const parsed = overrideSchema.safeParse(await readJson(request)); + const body = await readJson(request); + if (!body.ok) return error(body.status, body.message); + const parsed = overrideSchema.safeParse(body.value); if (!parsed.success) return error(400, "Invalid Radar local override"); try { @@ -129,7 +143,9 @@ export async function PUT(request: Request): Promise { const authError = await authorize(request); if (authError) return authError; - const parsed = tombstoneSchema.safeParse(await readJson(request)); + const body = await readJson(request); + if (!body.ok) return error(body.status, body.message); + const parsed = tombstoneSchema.safeParse(body.value); if (!parsed.success) return error(400, "Invalid Radar tombstone"); try { diff --git a/src/lib/radar/feedSchema.ts b/src/lib/radar/feedSchema.ts index 8701d9f46b..e8895f6cc7 100644 --- a/src/lib/radar/feedSchema.ts +++ b/src/lib/radar/feedSchema.ts @@ -103,9 +103,22 @@ const MetadataEvidenceUrlSchema = z } }); +const SetupKeyUrlSchema = z + .string() + .url() + .superRefine((value, ctx) => { + const parsed = new URL(value); + if (parsed.protocol !== "https:" || parsed.username || parsed.password || parsed.port) { + ctx.addIssue({ + code: "custom", + message: "setup key URL must use credential-free HTTPS on the default port", + }); + } + }); + const SetupSchema = z .object({ - keyUrl: z.string().url().nullable(), + keyUrl: SetupKeyUrlSchema.nullable(), steps: z.array(RadarLocalizedTextSchema), }) .nullable(); diff --git a/tests/unit/radar-local-state-route.test.ts b/tests/unit/radar-local-state-route.test.ts index 29dd7664e4..08970609bb 100644 --- a/tests/unit/radar-local-state-route.test.ts +++ b/tests/unit/radar-local-state-route.test.ts @@ -145,6 +145,31 @@ test("strict schemas reject arbitrary fields, empty patches, and control charact } }); +test("PATCH e PUT rejeitam o corpo pelo byte real antes de materializar JSON excessivo", async () => { + process.env.RADAR_ENABLED = "true"; + const headers = await authHeaders(); + const oversizedBody = JSON.stringify({ + provider: "groq", + modelId: "model", + enabled: true, + padding: "x".repeat(16 * 1024), + }); + + for (const [method, handler] of [ + ["PATCH", route.PATCH], + ["PUT", route.PUT], + ] as const) { + const response = await handler( + new Request("http://localhost:20128/api/radar/local-model-state", { + method, + headers: { "Content-Type": "application/json", ...headers }, + body: oversizedBody, + }) + ); + assert.equal(response.status, 413, method); + } +}); + test("GET returns no-store local state for restore controls", async () => { process.env.RADAR_ENABLED = "true"; const response = await route.GET(request("GET", undefined, await authHeaders())); diff --git a/tests/unit/radar-localized-feed.test.ts b/tests/unit/radar-localized-feed.test.ts index 4b1b8404e3..55dac7a96b 100644 --- a/tests/unit/radar-localized-feed.test.ts +++ b/tests/unit/radar-localized-feed.test.ts @@ -43,3 +43,18 @@ test("RadarFeedSchema preserves schema-v1 legacy setup and quirk strings", () => assert.equal(parsed.quirks[0]!.title, "Shared quota"); assert.equal(parsed.quirks[0]!.body, "Models share one pool."); }); + +test("RadarFeedSchema rejects unsafe setup.keyUrl values", () => { + for (const keyUrl of [ + "http://console.example.test/keys", + "https://user:secret@console.example.test/keys", + "https://console.example.test:444/keys", + ]) { + const unsafe = structuredClone(fixture) as { + models: Array<{ setup: { keyUrl: string | null } | null }>; + }; + assert.ok(unsafe.models[0]?.setup); + unsafe.models[0]!.setup!.keyUrl = keyUrl; + assert.equal(RadarFeedSchema.safeParse(unsafe).success, false, keyUrl); + } +});