fix(web): harden config PATCH allowlists against prototype-chain keys

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jonathan
2026-07-13 15:06:49 +02:00
parent 6decdc7fc9
commit bbe1616ed2
5 changed files with 20 additions and 5 deletions
+3 -2
View File
@@ -87,9 +87,10 @@ describe("config DELETE", () => {
const before = await prisma.config.count(); const before = await prisma.config.count();
expect(before).toBeGreaterThan(0); expect(before).toBeGreaterThan(0);
const res = await DELETE(delReq("__proto__")); for (const bad of ["__proto__", "constructor"]) {
const res = await DELETE(delReq(bad));
expect(res.status).toBe(400); expect(res.status).toBe(400);
}
// Must not have collapsed to deleteMany({ where: {} }) and wiped the table. // Must not have collapsed to deleteMany({ where: {} }) and wiped the table.
expect(await prisma.config.count()).toBe(before); expect(await prisma.config.count()).toBe(before);
expect(await prisma.config.findUnique({ where: { key: "qobuz.password" } })).not.toBeNull(); expect(await prisma.config.findUnique({ where: { key: "qobuz.password" } })).not.toBeNull();
@@ -26,4 +26,11 @@ describe("discover config API", () => {
expect(body["discover.chunkSize"]).toBe("25"); expect(body["discover.chunkSize"]).toBe("25");
expect(await prisma.config.findUnique({ where: { key: "nope" } })).toBeNull(); expect(await prisma.config.findUnique({ where: { key: "nope" } })).toBeNull();
}); });
it("ignores prototype-chain keys (__proto__, constructor) and writes no Config row", async () => {
const before = await prisma.config.count();
const res = await patch({ __proto__: "x", constructor: "y" });
expect((await res.json()).updated).toBe(0);
expect(await prisma.config.count()).toBe(before);
});
}); });
+1 -1
View File
@@ -26,7 +26,7 @@ export async function PATCH(request: Request) {
return Response.json({ error: "invalid JSON" }, { status: 400 }); return Response.json({ error: "invalid JSON" }, { status: 400 });
} }
const entries = Object.entries((body ?? {}) as Record<string, unknown>).filter( const entries = Object.entries((body ?? {}) as Record<string, unknown>).filter(
([k]) => k in DEFAULTS, ([k]) => Object.prototype.hasOwnProperty.call(DEFAULTS, k),
); );
for (const [key, value] of entries) { for (const [key, value] of entries) {
await prisma.config.upsert({ await prisma.config.upsert({
@@ -26,4 +26,11 @@ describe("monitor config API", () => {
expect(body["monitor.pollIntervalHours"]).toBe("12"); expect(body["monitor.pollIntervalHours"]).toBe("12");
expect(await prisma.config.findUnique({ where: { key: "nope" } })).toBeNull(); expect(await prisma.config.findUnique({ where: { key: "nope" } })).toBeNull();
}); });
it("ignores prototype-chain keys (__proto__, constructor) and writes no Config row", async () => {
const before = await prisma.config.count();
const res = await patch({ __proto__: "x", constructor: "y" });
expect((await res.json()).updated).toBe(0);
expect(await prisma.config.count()).toBe(before);
});
}); });
+1 -1
View File
@@ -24,7 +24,7 @@ export async function PATCH(request: Request) {
return Response.json({ error: "invalid JSON" }, { status: 400 }); return Response.json({ error: "invalid JSON" }, { status: 400 });
} }
const entries = Object.entries((body ?? {}) as Record<string, unknown>).filter( const entries = Object.entries((body ?? {}) as Record<string, unknown>).filter(
([k]) => k in DEFAULTS, ([k]) => Object.prototype.hasOwnProperty.call(DEFAULTS, k),
); );
for (const [key, value] of entries) { for (const [key, value] of entries) {
await prisma.config.upsert({ await prisma.config.upsert({