Skip to content

plugin-auth: a SCIM bearer write that carries any Cookie and no Origin is refused 403 MISSING_OR_NULL_ORIGIN #33237

Closed

Description

@objectstack-fleet

repo:cloud#5 · hotlong · session_01Q4EjjPP1zuWBNq3nBXVei9

Found while fixing objectstack-ai/cloud#4491 (hosted SCIM writes refused). Measured in-process by the cloud os-dev agent through plugin-auth handleRequest, at cloud's framework pin c5758b0.

Measured

  • POST /api/v1/auth/scim/v2/Users with a valid SCIM bearer and no Cookie answers 201.
  • The same request with any Cookie header and no Origin answers 403 MISSING_OR_NULL_ORIGIN. The same holds for PATCH … active:false. This includes an unrelated edge cookie such as Cloudflare's __cf_bm, and expired names with empty values.
  • Cause: better-auth 1.7.12 validateOrigin decides useCookies = headers.has('cookie') and then requires an Origin. plugin-auth forwards the Cookie header into the vendor on /scim/v2/* and does not exempt that path.
  • @better-auth/scim authenticates /scim/v2/* by the bearer alone, so a cookie never carries authority there. The origin check protects nothing on this path; it only refuses real IdP clients that keep a cookie jar (Okta did on staging), or a sticky-session load-balancer cookie on self-hosted.
  • The EE suite cannot see it: apps/objectos-ee/test/scim-leaver-lifecycle.e2e.test.ts (cloud repo) always sends an Origin.

Expected

A /scim/v2/* request authenticated by a SCIM bearer is not subject to the browser origin/CSRF check. Either do not forward Cookie into the vendor on /scim/v2/*, or skip the origin check there for bearer-authenticated calls. Every cookie-authenticated browser route keeps its origin check.

Acceptance

  • A SCIM bearer POST and PATCH carrying a Cookie and no Origin succeed.
  • A cookie-authenticated non-SCIM POST with no Origin is still refused (a positive control).
  • A SCIM POST with a cookie and no bearer is still refused.

The cloud PR objectstack-ai/cloud#4498 carries a hosted proxy strip of the same shape. Whether it lands as a transition is the maintainer's decision on cloud#4491. Once this ships in a release and cloud's pin carries it, that strip retires.

Carrier: objectstack. Triage sets the lane and the level.

Activity

  1. objectstack-fleet commented on Nov 14, 2026

    @objectstack-fleet
    ContributorAuthor

    triage#1 · hotlong · session_01Q4EjjMnyLqJdzuktEmRPXH

    Path: none

    Triage: first touch → domain:server · p1 · pm:queue · 2026-11-14T09:44Z

    ⛔ Not a claim.

    • Row: a SCIM bearer write (create, and PATCH active:false deprovisioning) that carries any Cookie and no Origin is refused 403. A real IdP that keeps a cookie jar (Okta on staging) cannot provision or deprovision users, and an offboarded person's access is not removed by the directory. Directory sync is stuck with no remedy (p1 row).
    • Reach: it needs the Enterprise plan with SCIM, a discount (−1 → p2). Okta is measured, so the real-user raise applies (+1 → p1). Deprovisioning is the security-relevant half: the card states the mechanism, and no lower-privileged actor gains a grant, so this is graded as a defect, not a security finding.
    • Fix lead: exempt /scim/v2/* (bearer-only) from the cookie-origin check in plugin-auth's forward to better-auth, or strip the Cookie on that path.
  2. added
    domain:serverDomain lane — seat card indexed by label:pm:seat
    pm:queueReady for the PM dispatch loop
    priority:p1High: required for production / M2
    on Nov 14, 2026
  3. objectstack-fleet commented on Nov 14, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:server#7 · sunhaolin · session_017xRBcSZ51q2s3nMfakhdJ3

    Claim: PM loop round 1
    Session: session_017xRBcSZ51q2s3nMfakhdJ3
    Account: sunhaolin
    Branch: claude/issue-33237-scim-bearer-cookie-origin
    Worktree: objectstack-issue-33237
    Domain: domain:server
    Seat: domain:server#7
    File surface: packages/plugins/plugin-auth/src/auth-manager-request-routing.ts (AuthManager.handleRequest, the request forwarded into better-auth on /scim/v2/*), its tests under packages/plugins/plugin-auth/src/**, root .changeset/. ⛔ Not pnpm-workspace.yaml / the better-auth pin, not auth-manager.ts / auth-plugin.ts (open drafts #33227 and #33221 hold them), not the SCIM reactivation files #32115 holds. Stop on breach; explain in the report.
    Container & model: M, mode:subagent, model: opus (default judgment tier; dispatch-gates --tier: no path-derived mandate), effort: xhigh (auth boundary)
    Clause-②: no
    Metadata-protocol: none
    Responsibility: plugin-auth AuthManager.handleRequest (objectstack) forwards the Cookie header into better-auth on /scim/v2/*, where better-auth 1.7.12's validateOrigin requires an Origin whenever a cookie is present | cloud's hosted proxy strip (objectstack-ai/cloud#4498, a transition, the maintainer's call on cloud#4491); nothing on self-hosted | every SCIM IdP client that keeps a cookie jar or sits behind a sticky-session cookie (Okta measured on staging); provisioning and deprovisioning both refused today
    Thread-read: 6875420457
    Premise: holds (seat reading 2026-11-14T13:12Z on origin/main 6c5d31fa: handleRequest forwards headers untouched into auth.handler(request); no /scim/v2 cookie special case; better-auth 1.7.12 origin-check: const useCookies = headers.has("cookie") → MISSING_OR_NULL_ORIGIN when no Origin)
    Serial constraints cleared: #32115 (os-tesla, in flight, surface packages/plugins/plugin-auth/src/**, SCIM reactivation) — package-level overlap, file-level UNKNOWN; open drafts #33227 and #33221 touch auth-manager.ts / auth-plugin.ts, not auth-manager-request-routing.ts. Ordinary concurrency; the dev reads #32115's open PR file list (if any) before pushing.

    Dispatched 2026-11-14T13:17Z.

  4. added
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    and removed
    pm:queueReady for the PM dispatch loop
    on Nov 14, 2026
  5. objectstack-fleet commented on Nov 14, 2026

    @objectstack-fleet
    ContributorAuthor

    repo:cloud#5 · hotlong · session_01Q4EjjPP1zuWBNq3nBXVei9

    Downstream note, 2026-11-14T14:06Z. The maintainer ruled on objectstack-ai/cloud#4491 (p1) to wait for this platform fix rather than work around it in cloud: 「A:等框架修(推荐)」. cloud#4491 is now pm:blocked on this card. Once this ships in a release, cloud bumps its pin and Okta/Entra provisioning on hosted environments works again. Triage sets this card's lane and level; the cloud seat only records the dependency.

  6. objectstack-fleet commented on Nov 14, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:server#7 · sunhaolin · session_017xRBcSZ51q2s3nMfakhdJ3
    os-dev-report

    {
     "issue": 33237,
     "status": "done",
     "branch": "claude/issue-33237-scim-bearer-cookie-origin",
     "pr": "https://github.com/objectstack-ai/objectstack/pull/33243",
     "session": "session_017xRBcSZ51q2s3nMfakhdJ3",
     "premise_still_valid": true,
     "summary": "Premise held on origin/main 6c5d31fa: handleRequest forwarded Cookie into better-auth on /scim/v2, and better-auth 1.7.12 validateOrigin answers 403 MISSING_OR_NULL_ORIGIN for any cookie with no Origin. Fix (PM's suggested route, no check relaxed): in AuthManager.handleRequest, a request whose endpoint path is under /scim/v2 (isScimProtocolPath) AND carries Authorization: Bearer plus a credential AND carries a Cookie is rebuilt without the Cookie before the vendor sees it; landing is the expected file auth-manager-request-routing.ts, no producer elsewhere. Measured that the cookie carries no authority there: @better-auth/scim authMiddleware authenticates from the bearer alone, the platform verifyBearerToken reads only input.token, and authSystemWriteContext attributes SCIM writes to the connection before any session resolver. PM mechanism assumption 2 needed one addition: under vitest better-auth also defaults skipOriginCheck=true (NODE_ENV read once at module load as 'test'), so NODE_ENV=production alone does NOT surface the bug in-process; the new test sets skipOriginCheck=false on the live $context in boot() and asserts skipCSRFCheck=false. Card assignee not written (sunhaolin set by PM); PR assignee sunhaolin via pr_create.",
     "tests": "New packages/plugins/plugin-auth/src/scim-bearer-cookie-origin.test.ts, 4 cases under vi.stubEnv NODE_ENV=production + skipOriginCheck=false: SCIM bearer POST /Users + Cookie(__cf_bm, empty session_token) no Origin -> 201 + sys_user row; SCIM bearer PATCH active:false + Cookie no Origin -> 200 active:false; SCIM POST + Cookie NO bearer -> 403 code MISSING_OR_NULL_ORIGIN, no sys_user row; CONTROL real session cookie POST /sign-out no Origin -> 403 MISSING_OR_NULL_ORIGIN. Red on unfixed tree (e9076fa2a2): cases 1,2 fail '{\"message\":\"Missing or null Origin\",\"code\":\"MISSING_OR_NULL_ORIGIN\"}: expected 403 to be 201/200', cases 3,4 pass. Green at c8abb27264 together with scim-transaction-scope + scim-refusal-audit: 3 files, 33 passed. Whole plugin-auth package at c8abb27264: Test Files 270 passed (270), Tests 5118 passed | 10 skipped (5128), lock VERDICT command-exit 0; typecheck exit 0 (check:test-typecheck OK, 72 errors / 23 signatures held, unchanged). Ablation via scripts/ablation-replace.mjs on committed fix: anchor headers.delete('cookie') 1->0, blob 944e5a37b0bd->efaf0d39e2b3, cases 1,2 red (403), 3,4 green; restored blob 944e5a37b0bd == HEAD, git diff HEAD empty. Test imports src relatively (./auth-manager.js), no dist on the path, so no rebuild/dist preflight leg.",
     "gates": "Local at c8abb27264: dispatch-gates --commands derived 66 families; all run; --ran reconciliation '66 derived famil(ies) accounted for, 65 run, 1 NOT-MEASURED (1 DERIVED from a recorded exit 3)'. 65 exit 0. pnpm check:dual-build-cjs-loads exit 3 PREREQUISITE NOT MET (needs every package dist; only plugin-auth closure built) = NOT MEASURED locally. check-nul-bytes exit 0. pnpm lint (dispatch asked) lock VERDICT command-exit 0; eslint --no-inline-config on the two changed TS files exit 0. CI check-runs on PR head c8abb27264 at report time: all gate-carrying lanes in_progress (Lint, Type Check, Check Changeset, Governed Surface Queue Guard, PR-body guards, ...); opt-in/queue lanes skipped. Not run locally, CI-owned: test:repo, dogfood, UI lanes.",
     "dogfood_battery": "n/a: no ! in the PR title and no (narrowing) arm",
     "mcp_calls": "0",
     "api_writes": "2: git push (branch, 3 pushes, not REST); relay pr_create (POST /repos/objectstack-ai/objectstack/dispatches -> fleet-write run 43603456865 -> POST /pulls + POST /issues/33243/assignees); post-stamped os-dev-report comment on #33237 (POST /repos/objectstack-ai/objectstack/dispatches via relay)",
     "open_questions": [],
     "security_findings": "none",
     "out_of_scope_findings": [
      "carrier: none · noted, not filed: better-auth skips the origin check wholesale under a test NODE_ENV (skipOriginCheck defaults to isTest() at module load), so any plugin-auth test meant to observe it must set skipOriginCheck=false on $context; PR Acceptance notes",
      "carrier: none · noted, not filed: behaviour change beyond the card's three cases: a /scim/v2 bearer request with a Cookie AND an untrusted Origin was 403 INVALID_ORIGIN, now reaches the SCIM bearer check; PR Acceptance notes",
      "carrier: cloud · noted, not filed: cloud#4498 hosted proxy strip can retire once a release with this reaches cloud's pin (maintainer's call on cloud#4491)"
     ]
    }
  7. objectstack-fleet commented on Nov 14, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:server#7 · sunhaolin · session_017xRBcSZ51q2s3nMfakhdJ3

    Seat note · 2026-11-14T14:13Z · ⛔ Not a claim

    The dev report (6878554536) is in, and PR #33243 is open as a draft at c8abb272, with Fixes #33237. The PR is labelled needs:contract-review. On the maintainer's 「契约复审等 review 座位」, the review seat runs that review; this seat does not.

    For the reviewer:

    • Route: In AuthManager.handleRequest, a request is rebuilt without its Cookie header when all three hold: the path is under /scim/v2, it carries Authorization: Bearer, and it has a Cookie. No check is relaxed. The dev measured that the cookie carries no authority on this path: authMiddleware reads the bearer only, and authSystemWriteContext attributes the write to the SCIM connection.
    • Behaviour change beyond the card's three cases (judge it): A /scim/v2 bearer request with a Cookie and an untrusted Origin used to get 403 INVALID_ORIGIN. It now reaches the SCIM bearer check.
    • Test harness: Under vitest, better-auth defaults skipOriginCheck to true. The new test therefore sets skipOriginCheck=false on $context, and both negative cases stay refused: SCIM without a bearer → 403, and a browser session without an Origin → 403.
    • Ablation: Removing the strip turns cases 1 and 2 red. The blob was restored to match HEAD.
    • Local gates: 65 of 66 ran green. check:dual-build-cjs-loads was NOT MEASURED locally and is left to CI.
  8. objectstack-fleet commented on Nov 14, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:server#7 · sunhaolin · session_017xRBcSZ51q2s3nMfakhdJ3

    Landed · 2026-11-14T15:45Z

    PR #33243 merged at a2be78d0 (2026-11-14T15:44Z), and Fixes #33237 closed this card. I checked on origin/main: the /scim/v2 bearer Cookie strip is in auth-manager-request-routing.ts, and scim-bearer-cookie-origin.test.ts is present.

    Delivered: a /scim/v2/* request that carries a bearer and a Cookie, with no Origin, now reaches the SCIM bearer check instead of being refused 403 MISSING_OR_NULL_ORIGIN. A no-bearer SCIM request and a browser session with no Origin are still refused.

    Left open, for whoever picks them up:

    • Review nit 2: the untrusted-Origin arm has no pin.
    • The cloud#4498 hosted proxy strip can retire once a release that carries this fix reaches cloud's pin. That call is the maintainer's, on cloud#4491.

    State change: pm:dispatched removed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

bugSomething isn't workingdomain:serverDomain lane — seat card indexed by label:pm:seatpriority:p1High: required for production / M2

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions