fix: harden v2 custom widgets and Workshop (#6480)

This commit is contained in:
Thomas authored and GitHub committed 2026-07-31 13:28:53 +02:00
1 parent e8a2c87c2d
commit 557ebe0a2f
138 files changed
+5151 -1976

No files matched your search

+106 -35
View File
@@ -77,6 +77,55 @@ await expectStatus(
{ method: "PATCH", headers: authorSession.headers, body: JSON.stringify({ isAdmin: true }) },
404,
);
for (const [field, value] of [
["displayName", "Homarr Team"],
["avatarUrl", "https://example.invalid/forged-avatar.png"],
["githubUsername", "homarr-labs"],
["githubProfileUrl", "https://github.com/homarr-labs"],
]) {
await expectStatus(
`/api/collections/users/records/${author.id}`,
{ method: "PATCH", headers: authorSession.headers, body: JSON.stringify({ [field]: value }) },
404,
);
}
const forgedAvatar = new FormData();
forgedAvatar.set(
"avatar",
new Blob(
[
Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
"base64",
),
],
{ type: "image/png" },
),
"forged-avatar.png",
);
const avatarResponse = await fetch(`${baseUrl}/api/collections/users/records/${author.id}`, {
method: "PATCH",
headers: authorSession.headers,
body: forgedAvatar,
});
const avatarResponseBody = await avatarResponse.json().catch(() => null);
if (avatarResponse.status !== 400 || !JSON.stringify(avatarResponseBody).includes("managed by GitHub OAuth")) {
throw new Error(
`Workshop user avatar upload bypassed the OAuth identity hook: ${avatarResponse.status} ${JSON.stringify(avatarResponseBody)}`,
);
}
const unchangedAuthor = await request(`/api/collections/users/records/${author.id}`, {
headers: authorSession.headers,
});
if (
unchangedAuthor.displayName !== "Widget Author" ||
unchangedAuthor.avatar ||
unchangedAuthor.avatarUrl ||
unchangedAuthor.githubUsername ||
unchangedAuthor.githubProfileUrl
) {
throw new Error("Workshop users can forge OAuth provider identity fields");
}
const visitorPassword = "WorkshopVisitor123!";
const visitor = await createUser("widget-visitor@example.invalid", "Widget Visitor", visitorPassword);
@@ -105,7 +154,7 @@ const submission = await request("/api/collections/submissions/records", {
type: "customWidget",
title: "Workshop runtime probe",
description: "PocketBase integration test",
widgetSchema: "homarr-custom-css-v1",
widgetSchema: widget.$schema,
content: JSON.stringify(widget),
author: author.id,
revision: 999,
@@ -121,38 +170,8 @@ if (
submission.changelog !== "" ||
submission.outdated !== false
) {
throw new Error("Submission publication was not validated and normalized server-side");
throw new Error("Submission publication metadata was not initialized server-side");
}
await expectStatus(
"/api/collections/submissions/records",
{
method: "POST",
headers: visitorSession.headers,
body: JSON.stringify({
type: "customCss",
title: "Oversized stylesheet",
content: "x".repeat(16_385),
widgetSchema: "homarr-custom-widget-v2",
author: visitor.id,
}),
},
400,
);
await expectStatus(
"/api/collections/submissions/records",
{
method: "POST",
headers: visitorSession.headers,
body: JSON.stringify({
type: "customWidget",
title: "Invalid widget payload",
content: ".dashboard { color: red; }",
widgetSchema: "homarr-custom-widget-v2",
author: visitor.id,
}),
},
400,
);
const ownerVotes = await request("/api/collections/votes/records", { headers: authorSession.headers });
if (
ownerVotes.items.length !== 1 ||
@@ -295,7 +314,7 @@ const report = await request("/api/collections/reports/records", {
await expectStatus(`/api/collections/reports/records/${report.id}`, { headers: visitorSession.headers }, 404);
await expectStatus("/api/collections/reports/records", { headers: visitorSession.headers }, 200);
const publicReports = await request("/api/collections/reports/records", { headers: visitorSession.headers });
if (publicReports.items.length !== 0) throw new Error("Report details must remain private to Workshop administrators");
if (publicReports.items.length !== 0) throw new Error("Report details must remain private to Workshop moderators");
await expectStatus(
`/api/collections/reports/records/${report.id}`,
{ method: "DELETE", headers: visitorSession.headers },
@@ -317,14 +336,45 @@ await expectStatus(
const reports = await request("/api/collections/reports/records", { headers: authorSession.headers });
if (reports.items.length !== 1 || reports.items[0].reporter !== visitor.id || reports.items[0].status !== "open") {
throw new Error("Workshop administrators must be able to review reports");
throw new Error("Workshop moderators must be able to review reports");
}
await expectStatus(
`/api/collections/reports/records/${report.id}`,
{
method: "PATCH",
headers: authorSession.headers,
body: JSON.stringify({ category: "spam", explanation: "Moderator-authored replacement" }),
},
404,
);
const dismissedReport = await request(`/api/collections/reports/records/${report.id}`, {
method: "PATCH",
headers: authorSession.headers,
body: JSON.stringify({ status: "dismissed" }),
});
if (dismissedReport.status !== "dismissed") throw new Error("Workshop administrators must be able to dismiss reports");
if (dismissedReport.status !== "dismissed") throw new Error("Workshop moderators must be able to dismiss reports");
const reopenedReport = await request("/api/collections/reports/records", {
method: "POST",
headers: visitorSession.headers,
body: JSON.stringify({
submission: submission.id,
reporter: visitor.id,
category: "malicious",
explanation: "The later submission revision now needs another review",
status: "dismissed",
}),
});
if (reopenedReport.status !== "open" || reopenedReport.id === report.id) {
throw new Error("A dismissed Workshop report must be replaceable with a fresh open report");
}
const reopenedReports = await request("/api/collections/reports/records", { headers: authorSession.headers });
if (
reopenedReports.items.length !== 1 ||
reopenedReports.items[0].id !== reopenedReport.id ||
reopenedReports.items[0].status !== "open"
) {
throw new Error("Re-reporting must replace only the reporter's dismissed report");
}
const visitorSubmission = await request("/api/collections/submissions/records", {
method: "POST",
@@ -340,6 +390,27 @@ const visitorSubmission = await request("/api/collections/submissions/records",
outdated: false,
}),
});
await expectStatus(
`/api/collections/submissions/records/${visitorSubmission.id}`,
{
method: "PATCH",
headers: authorSession.headers,
body: JSON.stringify({
title: "Moderator-authored replacement",
content: JSON.stringify({ ...widget, name: "Moderator-authored replacement" }),
expectedRevision: 1,
}),
},
404,
);
const moderatedStatus = await request(`/api/collections/submissions/records/${visitorSubmission.id}`, {
method: "PATCH",
headers: authorSession.headers,
body: JSON.stringify({ outdated: true, expectedRevision: 1 }),
});
if (!moderatedStatus.outdated || moderatedStatus.revision !== 2) {
throw new Error("Workshop moderators must be limited to CAS-protected submission status changes");
}
await expectStatus(
`/api/collections/submissions/records/${visitorSubmission.id}`,
{ method: "DELETE", headers: authorSession.headers },