security: apirouter exposes user emails (full ClientUser) to any authenticated caller #14

Closed
opened 2026-07-23 01:37:17 +02:00 by codinget · 0 comments
Owner

Summary

apirouter returns full ClientUser records — including email — to any authenticated caller, with no authorization narrowing to self/admin. The DbInterface contract types these endpoints as PartialUser | ClientUser (i.e. email may be omitted), but the router never enforces the PartialUser downgrade itself; it just forwards whatever the backend returns, and both the sqlite and postgres backends always return ClientUser (with email).

Net effect: any logged-in user can enumerate every other user's email address.

Affected routes (src/webapi/apirouter.ts)

  • GET /usersdb.listUsers() — lists all users with emails
  • GET /users/:uiddb.getUserById()
  • GET /users/by-emaildb.getUserByEmail() (also an enumeration oracle)
  • GET /abodes/:aid/usersdb.listUsersByAbodeId()

Contract vs. behaviour

DbInterface (src/db/types/DbInterface.ts):

listUsers(): Promise<(PartialUser | ClientUser)[]>;
getUserById(uid: string): Promise<PartialUser | ClientUser>;
getUserByEmail(email: string): Promise<PartialUser | ClientUser>;
listUsersByAbodeId(aid: string): Promise<(PartialUser | ClientUser)[]>;

PartialUser = Omit<User, "email" | "password">. So the type permits hiding email, but nothing does. The router is the natural place to decide who may see the full ClientUser (self, or a global/abode admin) versus a PartialUser for everyone else.

Suggested direction

Enforce the downgrade at the web layer (the backend interfaces stay capable of returning either): return ClientUser only when the caller is the user in question or an admin (global, or abode admin for co-resident listings), and strip to PartialUser otherwise. Consider whether GET /users/by-email should be admin-only, since it doubles as an email/account existence oracle.

Context

Spotted while reviewing the export/import PR (#13). The forced-export scoping there has the analogous concern — a non-admin export includes co-residents' user records (email included) for referential integrity — but that is consistent with this broader, pre-existing router behaviour, so it is deliberately out of scope for that PR and tracked here instead.

## Summary `apirouter` returns full `ClientUser` records — **including `email`** — to any authenticated caller, with no authorization narrowing to self/admin. The `DbInterface` contract types these endpoints as `PartialUser | ClientUser` (i.e. email *may* be omitted), but the router never enforces the `PartialUser` downgrade itself; it just forwards whatever the backend returns, and both the sqlite and postgres backends always return `ClientUser` (with email). Net effect: any logged-in user can enumerate every other user's email address. ## Affected routes (`src/webapi/apirouter.ts`) - `GET /users` → `db.listUsers()` — lists **all** users with emails - `GET /users/:uid` → `db.getUserById()` - `GET /users/by-email` → `db.getUserByEmail()` (also an enumeration oracle) - `GET /abodes/:aid/users` → `db.listUsersByAbodeId()` ## Contract vs. behaviour `DbInterface` (`src/db/types/DbInterface.ts`): ```ts listUsers(): Promise<(PartialUser | ClientUser)[]>; getUserById(uid: string): Promise<PartialUser | ClientUser>; getUserByEmail(email: string): Promise<PartialUser | ClientUser>; listUsersByAbodeId(aid: string): Promise<(PartialUser | ClientUser)[]>; ``` `PartialUser = Omit<User, "email" | "password">`. So the *type* permits hiding email, but nothing does. The router is the natural place to decide who may see the full `ClientUser` (self, or a global/abode admin) versus a `PartialUser` for everyone else. ## Suggested direction Enforce the downgrade at the web layer (the backend interfaces stay capable of returning either): return `ClientUser` only when the caller is the user in question or an admin (global, or abode admin for co-resident listings), and strip to `PartialUser` otherwise. Consider whether `GET /users/by-email` should be admin-only, since it doubles as an email/account existence oracle. ## Context Spotted while reviewing the export/import PR (#13). The forced-export scoping there has the analogous concern — a non-admin export includes co-residents' user records (email included) for referential integrity — but that is consistent with this broader, pre-existing router behaviour, so it is deliberately **out of scope** for that PR and tracked here instead.
codinget added the Kind/Security
Agent
Opus
Agentic
Priority
High
2
labels 2026-07-23 01:37:17 +02:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Reference: codinget/abode#14