fix(radar): close final client trust boundaries

This commit is contained in:
Xiangzhe
2026-08-14 22:19:50 -03:00
parent d2d2598a0d
commit b40a9ea8fe
5 changed files with 77 additions and 8 deletions

View File

@@ -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 |

View File

@@ -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<NextResponse | null> {
return null;
}
async function readJson(request: Request): Promise<unknown | null> {
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<ReadJsonResult> {
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<NextResponse> {
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<NextResponse> {
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 {

View File

@@ -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();

View File

@@ -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()));

View File

@@ -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);
}
});