Skip to content

finding: ObjectStack's /admin/ mounts shape-check the request body BEFORE authenticating, so an anonymous caller can probe required-parameter shapes #9654

Description

@os-zhuang

Observed 2026-08-18 while building the derived /admin/ route-walk for #9482. Filed unassigned, finding — low severity, and recorded mainly because it is what makes a naive route-walking security test vacuous.

What was measured

/admin/unlock-user is representative (auth-plugin.ts), and /admin/oauth2/toggle-disabled plus the /admin/sso/* bridges follow the same order:

1. parse body
2. if (!userId) return 400 INVALID_REQUEST <-- before any identity is established
3. session = await authApi.getSession(...)
4. if (!session) return 401 UNAUTHENTICATED
5. if (!isAdmin) return 403 PERMISSION_DENIED

So an anonymous POST with an empty body draws:

POST /admin/unlock-user {} -> 400 {"code":"INVALID_REQUEST","message":"userId is required"}
POST /admin/sso/register {} -> 400 {"code":"INVALID_REQUEST","message":"Missing required field(s): providerId, issuer, domain, clientId, clientSecret"}

better-auth's own admin endpoints do the same, one layer down — an empty-body /admin/ban-user answers 400 VALIDATION_ERROR [body.userId] to anonymous, member and platform admin alike.

Why it matters, and why it is small

The disclosure is a parameter-shape oracle to an unauthenticated caller: field names and which are required. That is close to free information for a documented API, which is why this is filed as a finding rather than a defect. Nothing is read, written or leaked beyond the schema shape, and with a valid payload the gate still refuses correctly (403 PERMISSION_DENIED on the ObjectStack mounts).

The larger practical consequence is for testing, and it is the reason this is written down at all: a route-walking security probe built on empty bodies never reaches the authorization check on any of these routes. It observes a 400 for the member, a 400 for the admin, asserts "non-2xx", and goes green while proving nothing about the gate. #9482's pin fires every route with a payload valid enough to reach authorization for exactly this reason, and its header records the measurement.

If it is ever addressed, the fix is an ordering change — authenticate, then authorize, then validate — which is a behaviour change on ~9 routes and wants its own decision rather than a rider on a test card.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions