Skip to content

fix(runtime): withhold a permission denial's authorization payload from the wire (#7450) - #7520

Merged
os-help merged 2 commits into
mainfrom
claude/issue-7450-dispatcher-details-allowlist
Aug 11, 2026
Merged

fix(runtime): withhold a permission denial's authorization payload from the wire (#7450)#7520
os-help merged 2 commits into
mainfrom
claude/issue-7450-dispatcher-details-allowlist

Conversation

@os-help

@os-help os-help commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

Fixes #7450

Implements the maintainer's 2026-08-11 ruling (comment 5248469105, option A): the runtime dispatcher aligns with REST — positions / permissionSets are server-side diagnostics and are not serialized; REST's shape (message + code + route-derived object) is the contract for both transports.

packages/rest is untouched — it already ships the ruled contract, verified against mapDataError'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").


⚠️ Correction to the card's premise, with a measurement

The card says an ordinary /data CRUD denial "answers with error.details.positions, error.details.permissionSets…". On today's main it 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:

try {
    const domainRoute = this.domainRegistry.resolve(cleanPath, method);
    if (domainRoute) {
        return domainRoute.handler({ path: cleanPath, method, body, query }, context);   // ← no await
    }
    
} catch (e) {
    if (isPermissionDeniedError(e)) {  }    // ← never runs for any registry route
}

In an async function a bare return <promise> settles outside the enclosing try. Measured directly (probe test, both halves green):

  • isPermissionDeniedError(new PermissionDeniedError(…))true (the matcher is not the problem), and
  • async () => { try { return rejectingPromise } catch { … } }rejects, does not catch.

Every domain that can raise an object-gate denial — /data first among them — resolves through that branch, and the only awaited branch inside the try is /discovery. So the dispatcher's PERMISSION_DENIED arm was unreachable in practice. The denial escaped to the Hono catch-all (packages/adapters/hono/src/index.ts:431), which answers:

errorJson(c, err.message, err.statusCode || 500)
// → { success: false, error: { message, code: 403 } }

i.e. a numeric code — the #3842 shape error-envelope.ts exists to prevent — and no PERMISSION_DENIED string for a client to branch on.

Consequence for scope. Narrowing the spread alone would have been a no-op on the wire: /data would 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 /data path. 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-enters this.delete(childName, …) for every child of the row being deleted, so the child's own trip through the security middleware throws with opCtx.object === <child>. A DELETE /data/parent/1 denied there carries details.object: 'child'third-party information, even though the field looks like an echo of the caller's own input.

Chosen: the response's object is derived from the request path, and error.details contributes nothing at all. Not a filter over details, an independent source:

response: this.error(e.message, 403, permissionDeniedErrorDetails(cleanPath))

permissionDeniedErrorDetails takes only the path — its signature makes it impossible for a field of error.details to reach the body (pinned as a test). This mirrors REST exactly, which takes req.params?.object, and it is precisely what an "allowlist operation + object" reading would have got wrong: mutation M3 below implements that reading, reaches the ruled field set, and still answers app_child_object. Two tests catch it.

operation is dropped too. The ruling names REST's shape, and REST ships no operation; 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 own object (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:

[HttpDispatcher] PERMISSION_DENIED on DELETE /data/app_parent_object/1 — operation=delete object=app_child_object positions=[org_member, everyone] permissionSets=[app_reader]

Changes

File
packages/runtime/src/security/permission-denied-envelope.ts newrouteObjectFromPath, permissionDeniedErrorDetails, describeDeniedDiagnostics, with the reasoning for route-derivation
packages/runtime/src/http-dispatcher.ts the catch uses the builder + logs the withheld payload; the domain-registry branch return awaits
packages/runtime/src/security/permission-denied-envelope.test.ts new — 9 unit cases
packages/runtime/src/domains/data-permission-denied-envelope.test.ts new — 5 end-to-end cases through dispatch(), incl. the cascade child and the cross-transport parity check
.changeset/dispatcher-permission-denied-details-allowlist.md patch, @objectstack/runtime

How transport parity is pinned (and why not by a live cross-import)

The first push had this test import mapDataError at its declaration
(../../../rest/src/rest-server.js) so both real mappers ran over one error. The TypeScript Type Check gate rejected that, correctly: tsc follows the relative path and 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 — recorded 227, measured 240. That ledger is a ratchet and may only shrink, and the alternative (re-exporting mapDataError from packages/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 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 checks more than the original did: error.details must hold the route object alone, and the error member must carry no key beyond code / 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.

# Mutation Result
M1 restore the original { code, ...(e.details ?? {}) } spread 4 failed — the leak assertions, the details equality, and the parity check
M2 drop the await on the domain-registry return 5 failed — every e2e case; the denial escapes dispatch() (this is main's behaviour)
M3 forward e.details.object instead of the route-derived one — the "allowlist operation + object" reading 2 failed — the cascade-child case and the parity check
M4 drop the server-side diagnostics log 1 failed — the log case
M5 unanchor the /data match (/^\/data\///\/data\//) 1 failed — the anchoring case
M6 describeDeniedDiagnostics returns a fixed string 1 failed — the rendering case
M7 permissionDeniedErrorDetails always emits object 1 failed — the REST-parity omission case

Fixture discipline: every denial fixture carries a fully populated details (operation + object + positions + permissionSets) and a developerMessage, so the unfixed code genuinely leaks under them — M1 and M2 confirm it does.

Verification

  • pnpm lint — clean
  • pnpm typecheck — clean (126 tasks) · runtime test-layer re-measure — 226, at or under the 227 ledger
  • @objectstack/runtime1990 passed (124 files)
  • @objectstack/rest1341 passed (82 files)
  • @objectstack/adapters-hono — 73 passed · plugin-security — 922 passed · plugin-hono-server — 187 passed · client — 279 passed

Wire-visible narrowing

  • error.details.positions / .permissionSets / .operationgone from dispatcher 403s (no consumer in this repo or apps/ reads them; grepped).
  • error.details.object — now the object the request addressed, not whichever object the gate refused.
  • A /data denial's error.code — now the string PERMISSION_DENIED rather than the number 403, 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 details payload only.

…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
@vercel

vercel Bot commented Aug 11, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 11, 2026 5:14am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/runtime.

20 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/api/client-sdk.mdx (via packages/runtime)
  • content/docs/api/index.mdx (via @objectstack/runtime)
  • content/docs/api/wire-format.mdx (via @objectstack/runtime)
  • content/docs/automation/hook-bodies.mdx (via @objectstack/runtime)
  • content/docs/concepts/metadata-lifecycle.mdx (via @objectstack/runtime)
  • content/docs/concepts/north-star.mdx (via packages/runtime)
  • content/docs/data-modeling/drivers.mdx (via @objectstack/runtime)
  • content/docs/deployment/index.mdx (via @objectstack/runtime)
  • content/docs/deployment/production-readiness.mdx (via @objectstack/runtime)
  • content/docs/deployment/single-project-mode.mdx (via @objectstack/runtime)
  • content/docs/deployment/vercel.mdx (via @objectstack/runtime)
  • content/docs/getting-started/your-first-project.mdx (via @objectstack/runtime)
  • content/docs/kernel/cluster.mdx (via @objectstack/runtime)
  • content/docs/permissions/authentication.mdx (via @objectstack/runtime)
  • content/docs/permissions/authorization.mdx (via packages/runtime)
  • content/docs/permissions/system-context.mdx (via packages/runtime)
  • content/docs/plugins/packages.mdx (via @objectstack/runtime)
  • content/docs/protocol/kernel/http-protocol.mdx (via @objectstack/runtime)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/runtime)
  • content/docs/protocol/kernel/lifecycle.mdx (via @objectstack/runtime)

2 release-owned page(s) also reference the affected code. These are read-only:

  • content/docs/releases/implementation-status.mdx (via @objectstack/runtime)
  • content/docs/releases/v17.mdx (via @objectstack/runtime)

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

…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
@os-help
os-help marked this pull request as ready for review August 11, 2026 05:34
@os-help
os-help added this pull request to the merge queue Aug 11, 2026
Merged via the queue into main with commit 35b36f2 Aug 11, 2026
27 checks passed
@os-help
os-help deleted the claude/issue-7450-dispatcher-details-allowlist branch August 11, 2026 05:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants