mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-15 19:52:50 +03:00
fix(radar): close final client trust boundaries
This commit is contained in:
@@ -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 |
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user