fix(custom-widgets): address v2 merge review (#6446)
This commit is contained in:
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" };
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user