fix: harden v2 custom widgets and Workshop (#6480)
This commit is contained in:
1 parent
e8a2c87c2d
commit
557ebe0a2f
138 files changed
+5151
-1976
No files matched your search
@@ -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 },
|
||||
|
||||
Reference in new issue
Block a user