fix: close the upload and playground findings from the security audit, release v2.5.0 - #1556
Merged
Merged
Conversation
Records how a pull request is filed here: based on upstream main rather than the fork's, and ending in one Co-Authored-By line per distinct model across the branch's commits, so the models that touched a branch are visible at review time. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
POST /v1/instrument-records/upload re-read its result by instrument with the request's optional groupId as the only other filter. Prisma drops an undefined filter, so a body without groupId answered with every record for that instrument across every group, to any role holding create InstrumentRecord. The response is now the rows keyed on the sessions this call created, which are the caller's own writes by construction. accessibleQuery cannot scope this read: a STANDARD uploader holds no read InstrumentRecord rule, so CASL would throw rather than filter. The instrument lookup also forwards the caller's ability, as the single-record create path already does. Co-Authored-By: Claude Fable 5.1
The playground evaluated every instrument, including one arriving in a share link, in its own window: the same origin whose IndexedDB holds the API token the upload dialog obtained. Opening a crafted link ran attacker-chosen code with that token in reach. The preview is now an iframe of a second Vite entry, preview.html, served from a different origin, and the editor and the frame speak only through postMessage payloads parsed against zod schemas. A hosted build names the preview origin with PLAYGROUND_PREVIEW_ORIGIN; a dev server uses the other loopback name, which is why the server now binds 127.0.0.1 so both answer. When no origin but the editor's own is available the viewer refuses to render instead of falling back. The frame keeps allow-same-origin on a real second origin rather than an opaque sandbox: interactive instruments render in a nested frame that reads parent.document, static assets need a service worker, and libui's theme hook reads localStorage, none of which an opaque origin permits. Adds a playground vitest project for the protocol, and a Playwright spec that starts the playground and checks the origin split, that share-link code runs but cannot reach the editor page, that an interactive instrument still renders, and that submissions come back to the editor. Co-Authored-By: Claude Fable 5.1
Tailwind's sm/md/lg variants are viewport media queries. Before the preview moved into a frame, the editor page's width satisfied them even in the narrow split-view panel; the frame's viewport is that panel, so the same instrument switched to its phone layout, and the summary lost its grid and its copy, download and print actions. The frame now loads its own stylesheet, which imports react-core's and collapses every breakpoint to 1px, so the preview renders an instrument's desktop layout as it did before. Co-Authored-By: Claude Fable 5.1
`{ action: 'manage', subject: 'all' }` was written out at every route only an administrator may
reach. `ADMIN_ONLY` names that intent, and its doc comment says why nothing narrower will do: the
guard checks the subject type alone, so a conditional rule or a granted additional permission
satisfies any other declaration.
Co-Authored-By: Claude Opus 5.5 (1M context)
Creating, editing and deleting a user were gated on the matching `User` action. No base level grants one, but an administrator can, and the holder could then promote themselves to ADMIN, add themselves to every group, create an administrator, or set an administrator's password and log in as them. The three routes are now `ADMIN_ONLY`, like the permissions route beside them, so no grant reaches a user's level, groups or password. A non-administrator who was granted a `User` write loses it. Only administrators reach these routes now, so an administrator may no longer delete, disable or demote their own account: the last one would otherwise lock every admin-only route for good. Co-Authored-By: Claude Opus 5.5 (1M context)
Every route that writes a user is admin-only, so a grant of any `User` action but `read` reaches nothing, yet the permissions editor still offered all four and the route stored them. The permissions route now refuses such a grant, and the editor offers only Read once User is chosen and leaves User out once a write is chosen. Grants stored before this still load, since the read model is unchanged. The editor marks them "No effect" and leaves them out of its next save, which the route would otherwise refuse. That a stored one still reaches none of the routes it names is now checked in the api's integration suite, which can write one directly; the e2e suite can no longer create one. Co-Authored-By: Claude Opus 5.5 (1M context)
Choosing Manage (All) on All in the permissions editor now shows what that grant amounts to: every group's data, every user's account and permissions, and instrument creation, which can run code on the server. It is the one grant that passes every admin-only route. Co-Authored-By: Claude Opus 5.5 (1M context)
6 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes three high-severity findings from the security audit, and cuts v2.5.0. The third landed after the release commit, so it ships in the next release rather than in v2.5.0.
POST /v1/instrument-records/uploadreturned every group's records (49fd5a9)The upload service re-read its result by instrument id with the request's optional
groupIdas the only other filter. Prisma drops an undefined filter, so a body withoutgroupIdanswered with every record for that instrument across every group, to any role holdingcreate InstrumentRecord, including STANDARD, which cannot read records at all.The response is now the rows keyed on the sessions the call itself created, which are the caller's own writes by construction.
accessibleQueryis deliberately not AND-ed in: a STANDARD uploader holds noread InstrumentRecordrule, so CASL would throw rather than filter. The instrument lookup also forwards the caller's ability, as the single-record create path already does. ThegroupIdfield stays optional, since admins importing without a group is existing, tested behaviour.A playground share link ran attacker JavaScript on the origin holding the API token (
66c1425,35e5ef1)The playground evaluated every instrument, including one arriving in a share link, in its own window, the same origin whose IndexedDB persists the token the upload dialog obtains.
Previews now run in an iframe of a second Vite entry,
preview.html, served from a different origin, and the editor and the frame talk only throughpostMessagepayloads parsed against zod schemas. A hosted build names the preview origin withPLAYGROUND_PREVIEW_ORIGIN; a dev server uses the other loopback name, which is why the server now binds127.0.0.1so both answer. When no origin but the editor's own is available, the viewer refuses to render rather than fall back. Token persistence and share-link auto-load are unchanged by decision: the frame is the control.The frame is a real second origin with
allow-same-originkept, not an opaque sandbox: interactive instruments render in a nested frame that readsparent.document, static assets need a service worker, and libui's theme hook readslocalStorage, none of which an opaque origin permits. The follow-up commit collapses Tailwind's breakpoints in the frame's stylesheet so the preview keeps the desktop layout it had when the editor page's width satisfied them.Deploy step outstanding for the hosted playground: point a second hostname at the same static build and build with
PLAYGROUND_PREVIEW_ORIGINset to it. Until then the hosted preview panel shows "Preview Unavailable" instead of running code on the editor's origin.A
Userwrite grant let its holder make themselves an administrator (031e9e4,6cc9ec3,14d5bdc,d4be83f)Creating, editing and deleting a user were gated on the matching
Useraction. No base level grants one, but an admin can, and the holder could then set their ownbasePermissionLevelto ADMIN, add themselves to every group (updateByIdnever checkedgroupIds), create an admin, or set an admin's password and log in as them. Scoping the grant to a group did not help: the scope is checked against the stored row, before the write.POST,PATCHandDELETE /v1/usersare nowADMIN_ONLY, a new name for{ action: 'manage', subject: 'all' }that the seven existing admin-only routes now use too. A non-admin previously granted aUserwrite loses it.Userwrites are no longer grantable:PUT /v1/users/:id/permissionsrefuses them and the permissions editor stops offering them. Grants stored before this still load; the editor marks them "No effect" and drops them on its next save.Also on the branch
chore: release v2.5.0, whose changelog lists the first two fixes above.odc-open-prandodc-commitskill docs.Verification
apps/api/src/instrument-records/__tests__/instrument-records.service.spec.ts(result query keyed on created sessions; ability forwarded) and the newplaygroundvitest project,apps/playground/src/preview/__tests__/protocol.test.ts(message schemas, error round trip, origin resolution). Both fail against the old code.testing/src/specs/authorization.spec.tsseeds a record in a foreign group and asserts a STANDARD upload withoutgroupIdreturns only its own row; red against the old service with the foreign row present. Newtesting/src/specs/playground.spec.tsstarts the playground alongside the other servers and checks the origin split, that share-link code runs but cannot reach the editor page (red when the frame is pointed at the editor's origin), that an interactive instrument renders, and that submissions come back.pnpm lint34/34,pnpm test152 files / 1337 tests,pnpm test:e2e194/194, all from the repo root. Lint was re-run after the release commit; the full e2e run predates it.apps/api/src/users/__tests__/users.controller.spec.ts(every write route refuses a group manager grantedmanage User),users.service.spec.ts(the self-guard), the schemas and the editor;apps/api/test/suites/02-user-permissions.suite.tswrites amanage Usergrant directly and checks that all five escalation requests get 403. E2E inauthorization.spec.ts(the grant is refused; an admin cannot delete or disable themselves) andadmin-management.spec.ts(only Read is offered on User; the warning appears). Each went red against the code it guards.pnpm lint34/34,pnpm test152 files / 1371 tests,pnpm test:e2e197/198: the failure was a gatewayECONNRESETingateway-assignment.spec.ts, which passed 10/10 when re-run alone. The warning's final wording was checked by web lint and the editor's unit tests only.Not verified: the hosted playground with a real second hostname (dev only exercises the loopback pair), and the static-assets interactive example inside the frame (the e2e covers the React one).
Co-Authored-By: Claude Fable 5.1
Co-Authored-By: Claude Opus 5 (1M context) noreply@anthropic.com
Co-Authored-By: Claude Opus 5.5 (1M context)