Repository navigation
fix(plugin-auth): a SCIM bearer write carrying a Cookie and no Origin is no longer refused 403 MISSING_OR_NULL_ORIGIN - #33243
Conversation
Claude-Session: https://claude.ai/code/session_017xRBcSZ51q2s3nMfakhdJ3 Co-authored-by: Claude <noreply@anthropic.com>
…re the origin check A SCIM bearer write carrying any Cookie and no Origin was refused 403 MISSING_OR_NULL_ORIGIN by better-auth's validateOrigin. SCIM authenticates by the bearer alone, so the Cookie carries no authority there. Claude-Session: https://claude.ai/code/session_017xRBcSZ51q2s3nMfakhdJ3 Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 22 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 3e67de58753aadf0120fb46576bf91eaf1a618f4 && git checkout 3e67de58753aadf0120fb46576bf91eaf1a618f4
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 295ce127e0ff5167def12353ddf37c9ca31f74e0 c8abb2726467e0fdb378f4035273e641fbf03fb3 && git checkout -B drift-repro 295ce127e0ff5167def12353ddf37c9ca31f74e0 && git merge --no-ff c8abb2726467e0fdb378f4035273e641fbf03fb3
node scripts/docs-audit/affected-docs.mjs --json 295ce127e0ff5167def12353ddf37c9ca31f74e0
|
|
review#2 · os-project-manager · session_01FEkQ5nhu4dRbKVQKDB4CDB Contract-review-claim: session_01FEkQ5nhu4dRbKVQKDB4CDB · head c8abb27 · 2026-11-14T15:08Z |
|
review#2 · os-project-manager · session_01FEkQ5nhu4dRbKVQKDB4CDB Contract reviewServed-tier: Inputs read: card #33237 body and all five comments (triage 6875420457, claim 6877865244, downstream note 6878485932, dev report 6878554536, seat note 6878588507), the PR body, file list and net diff at this head, and the check-runs on this head. Live-tree code was read on ① Derived judgments
② Semver level
③ Boundary flags
④ Design judgment
Findings:
No must-fix and no should-fix. Implemented-by: VERDICT: PASS |
|
domain:server#7 · sunhaolin · session_017xRBcSZ51q2s3nMfakhdJ3 席位核对 · 2026-11-14T15:19Z · ACCEPTHead
State change: ready, and auto-merge armed ( |
Fixes #33237
Clause-②: no
Metadata-protocol: none
维护者速读
AuthManager.handleRequest在把请求交给 better-auth 之前,对带Authorization: Bearer的/scim/v2/*请求去掉Cookie头;其余路由一律不动。Origin,于是带 Cookie 罐(如 Cloudflare__cf_bm、粘性会话 Cookie)的身份提供方(Okta 已实测)做 SCIM 开通与停用全部被拒 403,离职员工的访问无法由目录撤销。SCIM 只认 bearer,Cookie 在该路径上不带任何权限。/scim/v2/*且带 bearer」这一种请求;无 bearer 的 SCIM 请求与所有浏览器路由仍走原来的来源检查。回滚即 revert 本 PR 的那一个if块。packages/plugins/plugin-auth/src/auth-manager-request-routing.ts(新增的if块,判定条件三项是风险点);packages/plugins/plugin-auth/src/scim-bearer-cookie-origin.test.ts(四个用例与boot()里把来源检查打开的那几行)。pnpm --filter @objectstack/plugin-auth exec vitest run src/scim-bearer-cookie-origin.test.ts,预期 4 个用例全绿。What changes
packages/plugins/plugin-auth/src/auth-manager-request-routing.ts,handleRequest: right after the vendor client-address rewrite, a request whose better-auth endpoint path is under/scim/v2(isScimProtocolPath, the predicate the SCIM request scope already keys on) AND that carriesAuthorization: Bearerfollowed by a credential AND that carries aCookieis rebuilt without theCookieheader before better-auth sees it. This is the route that relaxes no check: no global or per-path origin-check exemption is added, andskipOriginCheck/disableCSRFCheckare not touched.Why it is safe, measured on better-auth 1.7.12 and
@better-auth/scim1.7.12 as installed:validateOrigin(dist/api/middlewares/origin-check.mjs) readsuseCookies = headers.has("cookie")off the requesthandleRequesthands it; with a cookie and no Origin or Referer it throwsMISSING_OR_NULL_ORIGIN. That is the whole defect./scim/v2/*mutation endpoint isuse: [authMiddleware], and that middleware (createSCIMConnectionMiddleware) authenticates fromAuthorization: Beareralone: no bearer is401 SCIM bearer token is required. The platform'sverifyBearerToken(auth-manager-plugin-list.ts) reads onlyinput.token.authSystemWriteContextreturnsactor: svc:scim:CONNECTION_IDfirst and never consults the session resolver when a SCIM connection is in scope), so stripping the cookie changes no attribution either.The landing is the expected file; no producer elsewhere was needed.
auth-manager.ts,auth-plugin.tsand the better-auth pin are untouched..changeset/33237-scim-bearer-cookie-origin.md:@objectstack/plugin-authpatch.Tests
New file
packages/plugins/plugin-auth/src/scim-bearer-cookie-origin.test.ts(realAuthManager, realObjectQLon better-sqlite3, SCIM on, a minted connection credential: thescim-transaction-scope.test.tsharness shape). Four cases:POST /UserswithCookie: __cf_bm=...; better-auth.session_token=and no Origin: 201 and thesys_userrow exists.PATCH /Users/{id}active:falsewith the same Cookie and no Origin: 200, resourceactive: false.POST /Userswith the Cookie and NO bearer: still 403,codeMISSING_OR_NULL_ORIGIN, nosys_userrow written.POST /sign-outwith no Origin: still 403,codeMISSING_OR_NULL_ORIGIN.How production semantics are ensured: two things would mask the bug under vitest, and the harness turns off both.
handleRequestsynthesizes a same-origin Origin outside production: every case runs withvi.stubEnv('NODE_ENV', 'production')and asserts it reads back. And better-auth reads NODE_ENV once at module load (@better-auth/core/env), so under vitest it defaultsskipOriginChecktotrue(create-context.mjs:disableOriginCheckunset meansisTest()):boot()sets the production valuefalseon the live$contextand assertsskipCSRFCheckisfalse. Before that line was added, cases 3 and 4 were red on the unfixed tree (the control POST answered 200), which is how the masking was found.Red on the unfixed tree (commit e9076fa, test only): cases 1 and 2 fail with
{"message":"Missing or null Origin","code":"MISSING_OR_NULL_ORIGIN"}: expected 403 to be 201(andto be 200); cases 3 and 4 pass.Ablation (one-time, on the fix commit c8abb27, through
scripts/ablation-replace.mjs): the anchorheaders.delete('cookie');replaced by a comment, anchor 1 to 0, blob 944e5a37b0bd to efaf0d39e2b3; cases 1 and 2 red again with 403, cases 3 and 4 green; restored, blob after restore 944e5a37b0bd equals HEAD andgit diff HEADempty. The test imports./auth-manager.jsrelatively, so the subject resolves tosrc/, nodist/step.Acceptance notes
/scim/v2/*bearer request that carries a Cookie AND an untrusted Origin used to be refused 403INVALID_ORIGIN; it now reaches the SCIM bearer check like any bearer client. Same reasoning: the origin check guards cookie authority, and there is none on this path.skipOriginCheck = falseon the context asboot()here does. Observation only; no card.Verification
All readings at head c8abb27 (worktree on
origin/main6c5d31f).plugin-auth tests and typecheck: 270/270 files, 5118 passed, typecheck exit 0
pnpm --filter @objectstack/plugin-auth exec vitest run --maxWorkers=2(whole package):Test Files 270 passed (270),Tests 5118 passed | 10 skipped (5128), lockVERDICT command-exit 0.pnpm --filter @objectstack/plugin-auth typecheck: exit 0;check:test-typecheck: OK ... 10 file(s) / 72 error(s) / 23 pinned signature(s) held(the new file adds none).lint: exit 0
pnpm lint(repo-wide, run on the dispatch's request): lockVERDICT command-exit 0.eslint --no-inline-configon the two changed TS files: exit 0.derived gate families: 65 exit 0, 1 NOT MEASURED
node scripts/pm/dispatch-gates.mjs --commands: 66 derived families, each run locally;--ranreconciliation:66 derived famil(ies) accounted for — 65 run, 1 NOT-MEASURED (1 DERIVED from a recorded exit 3).pnpm check:dual-build-cjs-loadsexit 3PREREQUISITE NOT MET(it needs every package'sdist/; this worktree built only plugin-auth's closure): NOT MEASURED locally, CI runs it.node scripts/check-nul-bytes.mjs: exit 0.test:repo, the dogfood battery (no!in the title, no narrowing arm), the UI lanes.Overlap check before push: open drafts #33227 and #33221 do not touch
auth-manager-request-routing.ts; no open PR from #32115 was found.Generated by Claude Code