Skip to content

A nonexistent /api/v1/auth/admin/* path answers 200 with an empty body — and three real admin endpoints are indistinguishable from it #15417

Description

@hotlong

Measured in passing while driving sys_user write doors for cloud#1989, on a real
walled boot of the cloud control plane (OS_MODE=cloud, posture isolated,
Organizations mounted) at framework pin 5b2ad1b41.

The measurement, with the control that gives it meaning

Requests below are a rank-and-file member's session cookie unless stated.
Everything was read back from the sqlite file with the server stopped.

POST /api/v1/auth/admin/definitely-not-a-route-1989   -> 200  {}     <-- CONTROL: this path does not exist
POST /api/v1/auth/admin/update-user  (member)          -> 200  {}     nothing landed
POST /api/v1/auth/admin/update-user  (PLATFORM ADMIN)  -> 200  {}     nothing landed either
GET  /api/v1/auth/admin/list-users   (member)          -> 200  {}
GET  /api/v1/auth/admin/list-users   (PLATFORM ADMIN)  -> 200  {}
POST /api/v1/auth/admin/set-role     (member)          -> 200  {}     role stayed null

POST /api/v1/auth/admin/ban-user     (member)          -> 403  {"success":false,"error":{"code":"PERMISSION_DENIED","message":"Admin role required"}}
POST /api/v1/auth/admin/set-user-password (member)     -> 403  same envelope

The positive control that the auth surface itself is live:
POST /api/v1/auth/update-user as the member returned 200 {"status":true} and
the new name was present in the sqlite file afterwards.

The finding

Two things, and the first is the one that matters:

  1. A path that does not exist answers 200 with an empty body. A caller
    cannot tell "your write was accepted" from "there is no such route". Every
    client-side success check — res.ok, status === 200, "no error thrown" —
    reads a nonexistent admin endpoint as a completed admin action. This is the
    silent-success shape; it should be a 404.

  2. update-user, list-users and set-role under /api/v1/auth/admin/ are
    indistinguishable from that nonexistent path on this composition, while
    ban-user and set-user-password answer a real guarded 403. So part of the
    better-auth admin family is reachable and part resolves to nothing, with no
    way to tell from the wire which is which.

What this is NOT

Not a privilege leak. Nothing an ordinary member sent through these paths changed
any row — the store was read from the sqlite file with the server stopped and
every target row was byte-unchanged. The platform admin got the same vacuous 200,
which is what proves the endpoints are absent rather than silently permissive.

Why it still deserves a card

A vacuous 200 is exactly the answer that makes a future security probe conclude
"admitted" and file a false p0, or conclude "refused" and miss a real one — the
cloud#1989 measurement had to add a nonexistent-route control specifically to
tell them apart, which is a control no ordinary caller can run.

Filed with no assignee by the cloud#1989 measurement seat, which is
measurement-only. Step 1 for whoever takes it: reproduce on a framework-side boot
to confirm the mounting rather than the cloud composition is what decides it.

Activity

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

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions