Skip to content

fix: enforce token↔host binding; resolve permission scopes against desired ∪ state with apply-time re-resolution - #39

Merged
2000game merged 2 commits into
mainfrom
fix/token-binding-scope-deadlock-30-29-33
Jul 9, 2026
Merged

2000game merged 2 commits into
mainfrom
fix/token-binding-scope-deadlock-30-29-33

Conversation

@2000game

@2000game 2000game commented Jul 9, 2026

Copy link
Copy Markdown
Member

Fixes a security bug and the permission-scope deadlock from the 2026-07-08 codebase review.

#30 (security) — stored token sent to CT_HOST-overridden host

authedSession now reads the stored credentials (host + token) and refuses — before any network call — when the stored token's host differs from the resolved host, naming both hosts and pointing at ct auth login. An explicit CT_LOGINTOKEN env token has no stored binding and is exempt. The query-param token handshake stays (codebase evidence: an Authorization header yields a null CSRF token and breaks writes) — documented at the call site; the binding check makes it safe.

#29 + #33 item 3 — scope-resolution deadlock and stale dataIds

One shared re-resolution design:

  • Plan time: scope keys resolve against desired ∪ state — a grant scoped to a group declared in the same config is valid and renders as scope=[<key> (created this apply)]; truly unknown keys remain a hard error. Read-only ct plan no longer aborts.
  • Apply time: every scoped tuple re-resolves its dataId from the post-execute state right before the PUT — grants always carry fresh ids, including groups recreated in the same run. A still-pending tuple is refused rather than silently written as a global grant.
  • docs/permissions.md scope-resolution section rewritten (the old text documented the deadlock as a deliberate constraint).

Verification: 225 passed / 4 skipped, typecheck + lint clean.

Closes #30. Closes #29.
Completes #33 (items 1, 2, 4 in #38).

2000game added 2 commits July 9, 2026 07:24
…#30)

authedSession paired resolveConfig() (CT_HOST env precedence) with a token
read that discarded the stored host, so a CT_HOST override sent the stored
login token — as a URL query param — to a foreign host, leaking it into that
server's access logs even on a failed login.

Compare the stored host against the resolved host BEFORE any network call and
refuse on mismatch, naming both hosts. An explicit CT_LOGINTOKEN env token has
no stored binding and is exempt. The token still rides as a query param: the
handshake requires it (an Authorization header yields a null CSRF token and
breaks writes, per ctClient's header comment) — documented at the call site.
…er execute (#29, #33 item 3)

Two halves of one fix, sharing a re-resolution point:

#29 — a config declaring a group AND a grant scoped to it could never be
planned or applied: resolveScope threw for any key absent from state, and it
runs (via desiredTuples → buildPermissionPlan) BEFORE executePlan creates
anything. Now scope keys resolve against DESIRED ∪ STATE: a key declared in the
config but not yet created is valid and renders as pending
(`scope=[<key> (created this apply)]`) instead of aborting even read-only
`ct plan`. Truly unknown keys (neither declared nor in state) still hard-fail.

#33 item 3 — buildPermissionPlan resolved dataIds from PRE-apply state, so a
group recreated in the same apply had its grant PUT with the old dangling id.
Every scoped tuple now retains its symbolic scopeKey; applyPermissionPlan
re-resolves each against the POST-execute state just before writing, so grants
always carry fresh ids. A pending tuple that reaches the writer un-resolved is
refused rather than silently written as a global grant.

buildPermissionPlan gains the desired resources (for the declared-group set);
apply passes post-execute state to applyPermissionPlan.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant