fix(custom-widgets): address v2 merge review (#6446)

This commit is contained in:
Thomas authored and ajnart committed 2026-08-13 19:49:25 +02:00
1 parent acdfcbb18e
commit 3aa14e22ed
123 files changed
+10348 -931

No files matched your search

@@ -16,6 +16,8 @@ import { customWidgetSourceSchema, hasSameCustomWidgetSourceAuthentication } fro
import { and, db, eq, handleTransactionsAsync } from "@homarr/db";
import { customWidgetDefinitions, customWidgetSecrets } from "@homarr/db/schema";
import { readConfigurationRequestBody } from "../body";
interface RouteContext {
params: Promise<{ token: string }>;
}
@@ -44,15 +46,18 @@ export async function POST(request: NextRequest, context: RouteContext) {
if (!pending || pending.status !== "pending") {
return NextResponse.json({ error: "This credential request is invalid, completed, or expired." }, { status: 404 });
}
const body = (await request.json().catch(() => null)) as {
baseUrl?: unknown;
networkScope?: unknown;
secrets?: Record<string, unknown>;
} | null;
const parsedBody = await readConfigurationRequestBody(request);
if (parsedBody.status === "too-large") {
return NextResponse.json({ error: "The configuration payload is too large." }, { status: 413 });
}
if (parsedBody.status !== "ok") {
return NextResponse.json({ error: "Enter a valid source configuration." }, { status: 400 });
}
const body = parsedBody.data;
const sourceResult = customWidgetSourceSchema.safeParse({
...pending.source,
baseUrl: body?.baseUrl,
networkScope: body?.networkScope,
baseUrl: body.baseUrl,
networkScope: body.networkScope,
});
if (!sourceResult.success) {
return NextResponse.json(
@@ -61,7 +66,7 @@ export async function POST(request: NextRequest, context: RouteContext) {
);
}
const secrets = pending.kinds.flatMap((kind) => {
const value = body?.secrets?.[kind];
const value = body.secrets[kind];
return typeof value === "string" && value.length > 0 && value.length <= 8192
? [{ sourceId: pending.sourceId, kind, value }]
: [];
@@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import { MAX_CONFIGURATION_REQUEST_BODY_BYTES, readConfigurationRequestBody } from "./body";
describe("custom widget configuration request body", () => {
it("rejects a streaming payload once it exceeds the byte budget", async () => {
const request = new Request("http://localhost/configure", {
method: "POST",
body: JSON.stringify({
baseUrl: "https://example.com",
networkScope: "public",
secrets: { apiKey: "x".repeat(MAX_CONFIGURATION_REQUEST_BODY_BYTES) },
}),
});
await expect(readConfigurationRequestBody(request)).resolves.toEqual({ status: "too-large" });
});
it("strictly parses a bounded configuration object", async () => {
const body = { baseUrl: "https://example.com", networkScope: "public", secrets: { apiKey: "secret" } };
await expect(
readConfigurationRequestBody(
new Request("http://localhost/configure", { method: "POST", body: JSON.stringify(body) }),
),
).resolves.toEqual({ status: "ok", data: body });
await expect(
readConfigurationRequestBody(
new Request("http://localhost/configure", {
method: "POST",
body: JSON.stringify({ ...body, unexpected: true }),
}),
),
).resolves.toEqual({ status: "invalid" });
});
});
@@ -0,0 +1,52 @@
import { z } from "zod/v4";
export const MAX_CONFIGURATION_REQUEST_BODY_BYTES = 24 * 1024;
const configurationRequestBodySchema = z.strictObject({
baseUrl: z.string(),
networkScope: z.enum(["public", "private", "loopback"]),
secrets: z.record(z.string(), z.string()),
});
export async function readConfigurationRequestBody(
request: Request,
): Promise<
{ status: "ok"; data: z.infer<typeof configurationRequestBodySchema> } | { status: "invalid" | "too-large" }
> {
const contentLength = request.headers.get("content-length");
if (contentLength && Number(contentLength) > MAX_CONFIGURATION_REQUEST_BODY_BYTES) {
return { status: "too-large" };
}
if (!request.body) return { status: "invalid" };
const reader = request.body.getReader();
const chunks: Uint8Array[] = [];
let byteLength = 0;
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
byteLength += value.byteLength;
if (byteLength > MAX_CONFIGURATION_REQUEST_BODY_BYTES) {
await reader.cancel();
return { status: "too-large" };
}
chunks.push(value);
}
} catch {
return { status: "invalid" };
}
const bytes = new Uint8Array(byteLength);
let offset = 0;
for (const chunk of chunks) {
bytes.set(chunk, offset);
offset += chunk.byteLength;
}
try {
const candidate = JSON.parse(new TextDecoder().decode(bytes)) as unknown;
const result = configurationRequestBodySchema.safeParse(candidate);
return result.success ? { status: "ok", data: result.data } : { status: "invalid" };
} catch {
return { status: "invalid" };
}
}