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)
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.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Summary
apirouterreturns fullClientUserrecords — includingemail— to any authenticated caller, with no authorization narrowing to self/admin. TheDbInterfacecontract types these endpoints asPartialUser | ClientUser(i.e. email may be omitted), but the router never enforces thePartialUserdowngrade itself; it just forwards whatever the backend returns, and both the sqlite and postgres backends always returnClientUser(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 emailsGET /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):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 fullClientUser(self, or a global/abode admin) versus aPartialUserfor everyone else.Suggested direction
Enforce the downgrade at the web layer (the backend interfaces stay capable of returning either): return
ClientUseronly when the caller is the user in question or an admin (global, or abode admin for co-resident listings), and strip toPartialUserotherwise. Consider whetherGET /users/by-emailshould 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.