fix(custom-widgets): finish v2 hardening

This commit is contained in:
Thomas Camlong committed 2026-07-20 23:39:57 +02:00
1 parent 7c66c912e7
commit 1c02678a7e
97 files changed
+2833 -12393

No files matched your search

+1 -2
View File
@@ -31,9 +31,8 @@ ENV PORT=8090
RUN addgroup -S pocketbase && adduser -S -G pocketbase pocketbase
COPY --from=website /app/apps/docs/build /pb_public
COPY apps/workshop/entrypoint.sh /usr/local/bin/workshop-entrypoint
COPY apps/workshop/pb_hooks /pb_hooks
COPY apps/workshop/pb_migrations /pb_migrations
RUN chmod +x /usr/local/bin/workshop-entrypoint && mkdir -p /pb_data && chown -R pocketbase:pocketbase /pb_data /pb_hooks /pb_migrations /pb_public
RUN chmod +x /usr/local/bin/workshop-entrypoint && mkdir -p /pb_data && chown -R pocketbase:pocketbase /pb_data /pb_migrations /pb_public
USER pocketbase
EXPOSE 8090
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 CMD wget -q --spider "http://127.0.0.1:${PORT}/api/health" || exit 1
+11 -4
View File
@@ -3,7 +3,7 @@
Workshop is a small PocketBase service that serves the public documentation site and stores one Custom JSX v2 widget per submission.
The runtime image extends `ghcr.io/muchobien/pocketbase:0.39`; Homarr adds only the built documentation, PocketBase
collections, one small user-role guard, and its explicit serve arguments.
collections, API rules, and its explicit serve arguments.
It intentionally has no approval queue, user bans, comments, email notifications, audit log, reusable widget connections,
or remote control over installed widgets. Submissions publish immediately. Authenticated users can vote and report;
@@ -20,8 +20,9 @@ container. With the image directly, set both values: `docker run -e PORT=3003 -p
Open `http://127.0.0.1:18090/_/`, create the first PocketBase superuser, and configure GitHub OAuth on the `users` collection.
The `users.isAdmin` field defaults to `false` and cannot be changed by regular users. Appoint administrators from the
PocketBase dashboard with a superuser account.
The `users.isAdmin` field defaults to `false`. Collection rules allow account creation only through OAuth without that
field and reject regular-user updates to it. Appoint administrators from the PocketBase dashboard with a superuser
account.
Users must sign in again after the role changes. Setting the value back to `false` removes Workshop administrator access.
@@ -39,8 +40,13 @@ Run the disposable service integration test from the repository root:
pnpm test:workshop
```
The test uses a separate Compose project and deletes its named volume on exit. It never writes fixture users or admin
roles into the normal `homarr-workshop_pb_data` volume.
The Workshop does not maintain a second Custom Widget schema. Its frontend validates submissions with
`customWidgetImportSchema` from `@homarr/custom-widgets`, and Homarr validates canonical content again before installation.
The validated `$schema` is also stored as `widgetSchema` for listing compatibility badges; it must match the downloaded
manifest before Homarr enables installation.
PocketBase API rules enforce submission ownership, one vote/report per user and submission, and administrator-only
moderation. Reports are dismissed by deleting them; PocketBase cascade deletion removes related votes and reports when a
submission is deleted.
@@ -53,7 +59,8 @@ Build the documentation and serve it from PocketBase using the same production i
pnpm docker:docs
```
Open `http://127.0.0.1:3003`. Set `DOCS_EXPOSE_PORT` to use another host port. The preview does not mount or persist PocketBase data.
Open `http://127.0.0.1:3003`. Set `DOCS_EXPOSE_PORT` to use another host port. PocketBase data persists in the
`homarr-workshop_pb_data` named volume.
Point a local Homarr checkout at it with:
-1
View File
@@ -14,7 +14,6 @@ services:
- "127.0.0.1:${PB_EXPOSE_PORT:-8090}:${PB_EXPOSE_PORT:-8090}"
volumes:
- pb_data:/pb_data
- ./pb_hooks:/pb_hooks:ro
- ./pb_migrations:/pb_migrations:ro
security_opt:
- no-new-privileges:true
-1
View File
@@ -5,7 +5,6 @@ exec pocketbase serve \
--http="0.0.0.0:${PORT:-8090}" \
--origins="${PB_ALLOWED_ORIGINS:-*}" \
--dir=/pb_data \
--hooksDir=/pb_hooks \
--migrationsDir=/pb_migrations \
--publicDir=/pb_public \
--indexFallback=true
-6
View File
@@ -1,6 +0,0 @@
/// <reference path="../pb_data/types.d.ts" />
onRecordCreate((e) => {
e.record.set("isAdmin", false);
e.next();
}, "users");
@@ -17,6 +17,7 @@ migrate(
const adminRule = "@request.auth.isAdmin = true";
users.listRule = adminRule;
users.viewRule = `id = @request.auth.id || ${adminRule}`;
users.createRule = '@request.context = "oauth2" && @request.body.isAdmin:isset = false';
users.updateRule = "id = @request.auth.id && @request.body.isAdmin:isset = false";
users.deleteRule = null;
app.save(users);
@@ -32,6 +33,7 @@ migrate(
fields: [
{ type: "text", name: "title", required: true, min: 3, max: 100 },
{ type: "text", name: "description", max: 2000 },
{ type: "text", name: "widgetSchema", required: true, min: 1, max: 100 },
{ type: "text", name: "content", required: true, max: 1000000 },
{
type: "file",
@@ -126,7 +128,7 @@ migrate(
listRule: "",
viewRule: "",
viewQuery: `
SELECT s.id, s.title, s.description, s.screenshots, s.author, u.displayName AS authorName,
SELECT s.id, s.title, s.description, s.widgetSchema, s.screenshots, s.author, u.displayName AS authorName,
s.created, s.updated,
COALESCE(SUM(CASE WHEN v.value = 1 THEN 1 ELSE 0 END), 0) AS upvotes,
COALESCE(SUM(CASE WHEN v.value = -1 THEN 1 ELSE 0 END), 0) AS downvotes,
+9 -9
View File
@@ -1,32 +1,32 @@
#!/bin/sh
set -eu
WORKSHOP_TEST_DATA_DIR=$(mktemp -d)
WORKSHOP_TEST_PORT=${WORKSHOP_TEST_PORT:-18090}
WORKSHOP_TEST_PROJECT="homarr-workshop-test-$$"
cleanup() {
WORKSHOP_DATA_DIR="$WORKSHOP_TEST_DATA_DIR" PB_EXPOSE_PORT="$WORKSHOP_TEST_PORT" \
docker compose -f apps/workshop/docker-compose.yml down --remove-orphans >/dev/null 2>&1 || true
rm -rf "$WORKSHOP_TEST_DATA_DIR"
PB_EXPOSE_PORT="$WORKSHOP_TEST_PORT" docker compose -p "$WORKSHOP_TEST_PROJECT" \
-f apps/workshop/docker-compose.yml down --volumes --remove-orphans >/dev/null 2>&1 || true
}
trap cleanup EXIT
WORKSHOP_DATA_DIR="$WORKSHOP_TEST_DATA_DIR" PB_EXPOSE_PORT="$WORKSHOP_TEST_PORT" \
docker compose -f apps/workshop/docker-compose.yml up --build -d
PB_EXPOSE_PORT="$WORKSHOP_TEST_PORT" docker compose -p "$WORKSHOP_TEST_PROJECT" \
-f apps/workshop/docker-compose.yml up --build -d
for attempt in $(seq 1 60); do
if curl --fail --silent "http://127.0.0.1:$WORKSHOP_TEST_PORT/api/health" >/dev/null; then
break
fi
if [ "$attempt" -eq 60 ]; then
WORKSHOP_DATA_DIR="$WORKSHOP_TEST_DATA_DIR" PB_EXPOSE_PORT="$WORKSHOP_TEST_PORT" \
docker compose -f apps/workshop/docker-compose.yml logs --no-color workshop
PB_EXPOSE_PORT="$WORKSHOP_TEST_PORT" docker compose -p "$WORKSHOP_TEST_PROJECT" \
-f apps/workshop/docker-compose.yml logs --no-color workshop
exit 1
fi
sleep 1
done
docker exec homarr-workshop-workshop-1 pocketbase superuser create \
docker compose -p "$WORKSHOP_TEST_PROJECT" -f apps/workshop/docker-compose.yml exec -T workshop \
pocketbase superuser create \
workshop-test@example.invalid 'WorkshopLocalTest123!' --dir=/pb_data
WORKSHOP_TEST_URL="http://127.0.0.1:$WORKSHOP_TEST_PORT" node apps/workshop/tests/workshop.integration.mjs
+20 -5
View File
@@ -37,6 +37,9 @@ for (const removed of ["comments", "workshop_admin_actions", "workshop_admins"])
// Production uses GitHub OAuth. Password auth is enabled only in this disposable database.
const usersCollection = await request("/api/collections/users", { headers: rootHeaders });
if (usersCollection.createRule !== '@request.context = "oauth2" && @request.body.isAdmin:isset = false') {
throw new Error("User creation must be limited to OAuth without administrator fields");
}
await request("/api/collections/users", {
method: "PATCH",
headers: rootHeaders,
@@ -67,8 +70,7 @@ const signIn = async (email, password) => {
};
const authorPassword = "WorkshopAuthor123!";
const author = await createUser("widget-author@example.invalid", "Widget Author", authorPassword, { isAdmin: true });
if (author.isAdmin !== false) throw new Error("New users must not be able to start as Workshop administrators");
const author = await createUser("widget-author@example.invalid", "Widget Author", authorPassword);
const authorSession = await signIn(author.email, authorPassword);
await expectStatus(
`/api/collections/users/records/${author.id}`,
@@ -104,11 +106,16 @@ const submission = await request("/api/collections/submissions/records", {
body: JSON.stringify({
title: "Workshop runtime probe",
description: "PocketBase integration test",
widgetSchema: widget.$schema,
content: JSON.stringify(widget),
author: author.id,
}),
});
if (submission.author !== author.id || submission.title !== "Workshop runtime probe") {
if (
submission.author !== author.id ||
submission.title !== "Workshop runtime probe" ||
submission.widgetSchema !== widget.$schema
) {
throw new Error("Submission publication failed");
}
@@ -117,7 +124,8 @@ const updatedSubmission = await request(`/api/collections/submissions/records/${
headers: authorSession.headers,
body: JSON.stringify({ title: "Updated runtime probe" }),
});
if (updatedSubmission.title !== "Updated runtime probe") throw new Error("Authors must be able to edit their submission");
if (updatedSubmission.title !== "Updated runtime probe")
throw new Error("Authors must be able to edit their submission");
await expectStatus(
`/api/collections/submissions/records/${submission.id}`,
@@ -161,7 +169,13 @@ await expectStatus(
);
const listing = await request(`/api/collections/workshop_listings/records/${submission.id}`);
if (listing.score !== 1 || listing.upvotes !== 1 || listing.downvotes !== 0 || listing.authorName !== "Widget Author") {
if (
listing.score !== 1 ||
listing.upvotes !== 1 ||
listing.downvotes !== 0 ||
listing.authorName !== "Widget Author" ||
listing.widgetSchema !== widget.$schema
) {
throw new Error("Workshop listing data is incorrect");
}
@@ -222,6 +236,7 @@ const visitorSubmission = await request("/api/collections/submissions/records",
body: JSON.stringify({
title: "Administrator deletion probe",
content: JSON.stringify(widget),
widgetSchema: widget.$schema,
author: visitor.id,
}),
});