[PR #8843] fix(security): enforce authorization on SCIM management endpoints and normalize passkey ownership #16496

Closed
opened 2026-04-13 10:32:35 -05:00 by GiteaMirror · 0 comments
Owner

Original Pull Request: https://github.com/better-auth/better-auth/pull/8843

State: closed
Merged: Yes


Summary

All 6 IDOR advisories in better-auth share a single structural root cause: createAuthEndpoint provides authentication middleware but no authorization primitives, so every plugin independently invents ownership and role checks. This PR addresses the SCIM vulnerability, normalizes the passkey plugin's existing fix, and introduces shared authorization middleware that establishes a canonical pattern for all future plugins.

Shared authorization middleware

Two composable middleware exported from better-auth/api:

  • requireResourceOwnership: fetches a resource by ID from the request body or query, verifies resource[ownerField] === session.user.id, and returns the verified resource on ctx.context.verifiedResource. Accepts optional notFoundError, forbiddenError, and forbiddenStatus overrides so plugins can preserve domain-specific error codes and HTTP status contracts.
  • requireOrgRole: looks up the caller's membership by {userId, organizationId}, parses comma-delimited role strings, and verifies at least one role appears in the allowed list. Returns the verified member on ctx.context.verifiedMember.

Both slot into the use: [sessionMiddleware, ...] array, making authorization visible in the endpoint definition rather than buried in handler bodies.

SCIM (GHSA-2g28-66mv-wghh, High 8.8)

The vulnerability: every SCIM management endpoint (generate-token, list/get/delete-provider-connection) checked org membership but never checked the member's role. A regular member could generate SCIM bearer tokens with the same privileges as an owner.

The fix introduces resolveRequiredRoles(ctx, opts), which defaults to ["admin", creatorRole] (reading organization.creatorRole from the org plugin config) and is overridable via requiredRole on the SCIM plugin options. All 4 management endpoints now call hasRequiredRole() before proceeding. The assertSCIMProviderAccess helper, shared by get and delete, receives the resolved roles and enforces them on org-scoped providers.

Legacy providers without a userId field (created before providerOwnership was enabled) remain accessible; the ownership guard is provider.userId && provider.userId !== userId, so it activates only when ownership data exists.

Passkey normalization (GHSA-4vcf-q4xf-f48m)

deletePasskey and updatePasskey previously used inline fetch-then-verify ownership checks. Both now use requireResourceOwnership in the use array with PASSKEY_ERROR_CODES.PASSKEY_NOT_FOUND and PASSKEY_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_REGISTER_THIS_PASSKEY passed as error overrides. The forbiddenStatus: "UNAUTHORIZED" config preserves the original HTTP status contract.

Regression tests were added to verify that user A cannot delete or update user B's passkey.

Stripe test improvements

The Stripe referenceMiddleware identity fast-path (self-reference check) was already present on main. This PR improves test coverage by replacing synthetic "some-other-id" values with actual user IDs, properly exercising the IDOR scenario through the authorizeReference callback path. A new test verifies billing portal sessions work with custom (non-user) reference IDs.

Test plan

  • vitest packages/better-auth/src/api/middlewares/authorization.test.ts — shared middleware (1 test)
  • vitest packages/scim/src/scim.management.test.ts — SCIM role-based authorization (33 tests)
  • vitest packages/stripe/test/stripe.test.ts — Stripe reference authorization (98 tests)
  • vitest packages/passkey/src/passkey.test.ts — Passkey IDOR regression (16 tests, 2 new)
**Original Pull Request:** https://github.com/better-auth/better-auth/pull/8843 **State:** closed **Merged:** Yes --- ## Summary All 6 IDOR advisories in better-auth share a single structural root cause: `createAuthEndpoint` provides authentication middleware but no authorization primitives, so every plugin independently invents ownership and role checks. This PR addresses the SCIM vulnerability, normalizes the passkey plugin's existing fix, and introduces shared authorization middleware that establishes a canonical pattern for all future plugins. ### Shared authorization middleware Two composable middleware exported from `better-auth/api`: - **`requireResourceOwnership`**: fetches a resource by ID from the request body or query, verifies `resource[ownerField] === session.user.id`, and returns the verified resource on `ctx.context.verifiedResource`. Accepts optional `notFoundError`, `forbiddenError`, and `forbiddenStatus` overrides so plugins can preserve domain-specific error codes and HTTP status contracts. - **`requireOrgRole`**: looks up the caller's membership by `{userId, organizationId}`, parses comma-delimited role strings, and verifies at least one role appears in the allowed list. Returns the verified member on `ctx.context.verifiedMember`. Both slot into the `use: [sessionMiddleware, ...]` array, making authorization visible in the endpoint definition rather than buried in handler bodies. ### SCIM (GHSA-2g28-66mv-wghh, High 8.8) The vulnerability: every SCIM management endpoint (`generate-token`, `list/get/delete-provider-connection`) checked org membership but never checked the member's role. A regular `member` could generate SCIM bearer tokens with the same privileges as an `owner`. The fix introduces `resolveRequiredRoles(ctx, opts)`, which defaults to `["admin", creatorRole]` (reading `organization.creatorRole` from the org plugin config) and is overridable via `requiredRole` on the SCIM plugin options. All 4 management endpoints now call `hasRequiredRole()` before proceeding. The `assertSCIMProviderAccess` helper, shared by `get` and `delete`, receives the resolved roles and enforces them on org-scoped providers. Legacy providers without a `userId` field (created before `providerOwnership` was enabled) remain accessible; the ownership guard is `provider.userId && provider.userId !== userId`, so it activates only when ownership data exists. ### Passkey normalization (GHSA-4vcf-q4xf-f48m) `deletePasskey` and `updatePasskey` previously used inline fetch-then-verify ownership checks. Both now use `requireResourceOwnership` in the `use` array with `PASSKEY_ERROR_CODES.PASSKEY_NOT_FOUND` and `PASSKEY_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_REGISTER_THIS_PASSKEY` passed as error overrides. The `forbiddenStatus: "UNAUTHORIZED"` config preserves the original HTTP status contract. Regression tests were added to verify that user A cannot delete or update user B's passkey. ### Stripe test improvements The Stripe `referenceMiddleware` identity fast-path (self-reference check) was already present on `main`. This PR improves test coverage by replacing synthetic `"some-other-id"` values with actual user IDs, properly exercising the IDOR scenario through the `authorizeReference` callback path. A new test verifies billing portal sessions work with custom (non-user) reference IDs. ## Test plan - [ ] `vitest packages/better-auth/src/api/middlewares/authorization.test.ts` — shared middleware (1 test) - [ ] `vitest packages/scim/src/scim.management.test.ts` — SCIM role-based authorization (33 tests) - [ ] `vitest packages/stripe/test/stripe.test.ts` — Stripe reference authorization (98 tests) - [ ] `vitest packages/passkey/src/passkey.test.ts` — Passkey IDOR regression (16 tests, 2 new)
GiteaMirror added the pull-request label 2026-04-13 10:32:35 -05:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/better-auth#16496