fix(runtime): withhold a permission denial's authorization payload from the wire (#7450) - #7520
Conversation
…om the wire (#7450) The runtime dispatcher's `dispatch()` catch spread the whole `PermissionDeniedError.details` into the 403 body (`{ code: 'PERMISSION_DENIED', ...(e.details ?? {}) }`), and `buildApiError` puts everything that is not the `code` on the wire as `error.details` — so the security gate's `positions` / `permissionSets` were client-facing on this transport while `@objectstack/rest`'s `mapDataError` shipped none of them. Per the maintainer's 2026-08-11 ruling both transports now carry REST's shape: message + code + the ROUTE-derived object. The object is derived from `cleanPath`, not from `error.details`. `cascadeDeleteRelations` re-enters `delete()` per child, so a cascade denial's `details.object` names a child the caller never addressed; forwarding it would have reached the ruled field set and still disclosed a third party's API name. The catch now reads no field of `error.details` at all. The full withheld payload goes to a server log line instead — it is diagnostics, not garbage. Also: the domain-registry branch returned its handler's promise without awaiting, so a rejection settled outside the enclosing `try` and never reached that catch. Every domain that can raise an object-gate denial resolves through that branch, which made the `PERMISSION_DENIED` arm unreachable in practice — denials escaped to the Hono catch-all, which answered a numeric `code` and no `PERMISSION_DENIED` string. It now awaits, which is what makes the ruled envelope apply; awaiting alone would have started shipping the leak, so the two changes land together. Non-denial errors are unchanged: the catch rethrows them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PLXNUARnwCaXaQ9xg4ZBhe
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 20 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also reference the affected code. These are read-only:
|
…age boundary (#7450) The cross-transport parity test reached `mapDataError` at its declaration (`../../../rest/src/rest-server.js`). tsc follows that: it pulled eleven of `@objectstack/rest`'s modules into this package's program outside its `rootDir`, adding 13 raw errors (TS6059 ×12, TS7006 ×2) to the runtime TEST_DEBT ledger — which is a ratchet and may only shrink. The `TypeScript Type Check` gate caught it: recorded 227, measured 240. A disclosure fix must not widen the reference package's API surface or its type-check debt to buy itself a test, so parity is now pinned by TWO tests over ONE fixture instead of by a live cross-import: `packages/rest/src/rest.test.ts` (PR #7449) asserts what `mapDataError` produces for that fixture, and this file asserts the dispatcher agrees with it, field by field, against a transcribed constant that names its source. Change either side's shape and the other side's pin fails. The rewritten case also tightens what it checks — `error.details` must hold the route object ALONE, and the error member must carry no key beyond code/message/httpStatus/details. Mutation coverage is unchanged: restoring the spread still fails 4 cases, and forwarding `e.details.object` (the naive allowlist) still fails the cascade case and this one. Measured after the fix: 226 raw errors, at or under the ledger's 227, with zero attributable to these files. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PLXNUARnwCaXaQ9xg4ZBhe
Fixes #7450
Implements the maintainer's 2026-08-11 ruling (comment
5248469105, option A): the runtime dispatcher aligns with REST —positions/permissionSetsare server-side diagnostics and are not serialized; REST's shape (message + code + route-derived object) is the contract for both transports.packages/restis untouched — it already ships the ruled contract, verified againstmapDataError's 403 branch and the pin PR #7449 added (rest.test.ts, "never ships a PERMISSION_DENIED developer half or its structured details to the client").The card says an ordinary
/dataCRUD denial "answers witherror.details.positions,error.details.permissionSets…". On today'smainit does not, and the reason matters for what the fix has to be.dispatch()'s domain-registry branch returned its handler's promise without awaiting it:In an async function a bare
return <promise>settles outside the enclosingtry. Measured directly (probe test, both halves green):isPermissionDeniedError(new PermissionDeniedError(…))→true(the matcher is not the problem), andasync () => { try { return rejectingPromise } catch { … } }→ rejects, does not catch.Every domain that can raise an object-gate denial —
/datafirst among them — resolves through that branch, and the only awaited branch inside thetryis/discovery. So the dispatcher'sPERMISSION_DENIEDarm was unreachable in practice. The denial escaped to the Hono catch-all (packages/adapters/hono/src/index.ts:431), which answers:i.e. a numeric
code— the #3842 shapeerror-envelope.tsexists to prevent — and noPERMISSION_DENIEDstring for a client to branch on.Consequence for scope. Narrowing the spread alone would have been a no-op on the wire:
/datawould keep answering the adapter's shape, and the ruled contract would still not hold on this transport. So this PR does both, and they must land together — awaiting alone would have started shipping exactly the payload the ruling withholds.The leak was therefore latent, not live, on the Hono
/datapath. The ruling stands unchanged either way — this narrows what the now-reachable catch may say. Triage's premise verification was right about the code it read; the reachability of that catch was the one link nobody measured.The cascade-child case, and what was chosen for it
ObjectQL.cascadeDeleteRelations(packages/objectql/src/engine.ts:8669) re-entersthis.delete(childName, …)for every child of the row being deleted, so the child's own trip through the security middleware throws withopCtx.object === <child>. ADELETE /data/parent/1denied there carriesdetails.object: 'child'— third-party information, even though the field looks like an echo of the caller's own input.Chosen: the response's
objectis derived from the request path, anderror.detailscontributes nothing at all. Not a filter overdetails, an independent source:permissionDeniedErrorDetailstakes only the path — its signature makes it impossible for a field oferror.detailsto reach the body (pinned as a test). This mirrors REST exactly, which takesreq.params?.object, and it is precisely what an "allowlistoperation+object" reading would have got wrong: mutation M3 below implements that reading, reaches the ruled field set, and still answersapp_child_object. Two tests catch it.operationis dropped too. The ruling names REST's shape, and REST ships nooperation; carrying it on one transport only would rebuild the divergence this card exists to close.A denial on a route whose path names no object carries no
object— REST's...(object ? { object } : {})behaviour.Nothing is thrown away
The full withheld payload —
operation, the gate's ownobject(on a cascade, the child, which is the single most useful field for an operator debugging a false denial),positions,permissionSets— is rendered to a server log line:Changes
packages/runtime/src/security/permission-denied-envelope.tsrouteObjectFromPath,permissionDeniedErrorDetails,describeDeniedDiagnostics, with the reasoning for route-derivationpackages/runtime/src/http-dispatcher.tsreturn awaitspackages/runtime/src/security/permission-denied-envelope.test.tspackages/runtime/src/domains/data-permission-denied-envelope.test.tsdispatch(), incl. the cascade child and the cross-transport parity check.changeset/dispatcher-permission-denied-details-allowlist.md@objectstack/runtimeHow transport parity is pinned (and why not by a live cross-import)
The first push had this test import
mapDataErrorat its declaration(
../../../rest/src/rest-server.js) so both real mappers ran over one error. TheTypeScript Type Checkgate rejected that, correctly: tsc follows the relative path and pulled eleven of@objectstack/rest's modules into this package's program outside itsrootDir, adding 13 raw errors (TS6059 ×12, TS7006 ×2) to the runtime TEST_DEBT ledger — recorded 227, measured 240. That ledger is a ratchet and may only shrink, and the alternative (re-exportingmapDataErrorfrompackages/rest's public entry) would widen the reference package's API surface to buy this card a test. Neither is a price a disclosure fix should pay.Parity is now pinned by two tests over one fixture:
packages/rest/src/rest.test.ts(PR #7449) asserts whatmapDataErrorproduces for that fixture, and this file asserts the dispatcher agrees with it — field by field, against a transcribed constant that names its source. Change either side's shape and the other side's pin fails. The rewritten case also checks more than the original did:error.detailsmust hold the route object alone, and the error member must carry no key beyondcode/message/httpStatus/details.Measured after the rewrite: 226 raw errors in the runtime test layer, at or under the ledger's 227, with zero attributable to these files.
Mutation table — every new test proven able to fail
Each mutation applied to the fixed source, suite re-run, then reverted; final state re-verified at 14/14 green. M1 and M3 re-verified against the rewritten parity test.
{ code, ...(e.details ?? {}) }spreaddetailsequality, and the parity checkawaiton the domain-registry returndispatch()(this ismain's behaviour)e.details.objectinstead of the route-derived one — the "allowlist operation + object" reading/datamatch (/^\/data\//→/\/data\//)describeDeniedDiagnosticsreturns a fixed stringpermissionDeniedErrorDetailsalways emitsobjectFixture discipline: every denial fixture carries a fully populated
details(operation+object+positions+permissionSets) and adeveloperMessage, so the unfixed code genuinely leaks under them — M1 and M2 confirm it does.Verification
pnpm lint— cleanpnpm typecheck— clean (126 tasks) · runtime test-layer re-measure — 226, at or under the 227 ledger@objectstack/runtime— 1990 passed (124 files)@objectstack/rest— 1341 passed (82 files)@objectstack/adapters-hono— 73 passed ·plugin-security— 922 passed ·plugin-hono-server— 187 passed ·client— 279 passedWire-visible narrowing
error.details.positions/.permissionSets/.operation— gone from dispatcher 403s (no consumer in this repo orapps/reads them; grepped).error.details.object— now the object the request addressed, not whichever object the gate refused./datadenial'serror.code— now the stringPERMISSION_DENIEDrather than the number403, and the body is the standard{ success: false, error: { code, message, httpStatus, details } }envelope rather than the adapter's fallback.Out of scope
The user-facing copy half (#7414 / PR #7449, #7451) and the console-render half (#7366) are untouched. This is the structured
detailspayload only.