Summary
The current auth model has multiple critical gaps. The biggest: anyone who knows a username can rewrite that user's profile, photo, location, and birthday with no proof of identity. This single issue bundles four security fixes that are tightly related and should ship together.
Findings
1. /api/user/* mutation endpoints have no auth (privilege escalation)
PUT /api/user/:username (server/index.js:396) — change full name, location, marker color, birthday, display name. No auth check.
POST /api/user/:username/photo (server/index.js:508) — upload an avatar for any user.
DELETE /api/user/:username/photo (server/index.js:535) — delete any user's avatar.
GET /api/user/:username (server/index.js:383) — read full profile (including coordinates) of any user.
PUT /api/user/:username/password (server/index.js:429) is gated by the old-password check so it's less urgent, but should also require auth for defense-in-depth.
2. The x-username header is decorative
requireAdmin (server/index.js:162-181) and requireAuth (where used at all) trust a plaintext x-username header set by the client. Anyone can set the header to any value — including an admin's. The bcrypt password check at login produces no token; nothing carries the auth result forward.
3. No rate limits on public endpoints
/api/login, /api/register, /api/reset-password, /api/verify-reset-token (and /api/forgot-password once #20 lands) are unauthenticated and unthrottled. Credential stuffing and reset-token brute force are wide open.
4. CORS is wide open
app.use(cors()) (server/index.js:138) accepts any origin. With cookie-based auth this becomes a CSRF vector.
Proposed Approach
- Add a
requireAuth middleware that validates a real session token (httpOnly cookie or Authorization: Bearer <jwt>) and apply it to all /api/user/:username/* routes plus everywhere x-username is currently trusted.
- Issue the token on
POST /api/login. Verify on every request with a server secret read from env (SESSION_SECRET).
- Phase the migration: keep the legacy
x-username header working for one release, then remove it.
- Add
express-rate-limit (5 attempts / 15 min / IP) to all public endpoints.
- Restrict CORS to
process.env.ALLOWED_ORIGINS?.split(','). Default to http://localhost:3000 for dev.
- Document
SESSION_SECRET and ALLOWED_ORIGINS in .env.example.
Acceptance Criteria
Additional Context
Bundled because they share a single migration: introducing real auth is the prerequisite for tightening the other three. Splitting would require duplicate work.
Related: #19 (audit log will record auth events once this is in), #20 (Resend will use the same rate limiter).
Summary
The current auth model has multiple critical gaps. The biggest: anyone who knows a username can rewrite that user's profile, photo, location, and birthday with no proof of identity. This single issue bundles four security fixes that are tightly related and should ship together.
Findings
1.
/api/user/*mutation endpoints have no auth (privilege escalation)PUT /api/user/:username(server/index.js:396) — change full name, location, marker color, birthday, display name. No auth check.POST /api/user/:username/photo(server/index.js:508) — upload an avatar for any user.DELETE /api/user/:username/photo(server/index.js:535) — delete any user's avatar.GET /api/user/:username(server/index.js:383) — read full profile (including coordinates) of any user.PUT /api/user/:username/password(server/index.js:429) is gated by the old-password check so it's less urgent, but should also require auth for defense-in-depth.2. The
x-usernameheader is decorativerequireAdmin(server/index.js:162-181) andrequireAuth(where used at all) trust a plaintextx-usernameheader set by the client. Anyone can set the header to any value — including an admin's. The bcrypt password check at login produces no token; nothing carries the auth result forward.3. No rate limits on public endpoints
/api/login,/api/register,/api/reset-password,/api/verify-reset-token(and/api/forgot-passwordonce #20 lands) are unauthenticated and unthrottled. Credential stuffing and reset-token brute force are wide open.4. CORS is wide open
app.use(cors())(server/index.js:138) accepts any origin. With cookie-based auth this becomes a CSRF vector.Proposed Approach
requireAuthmiddleware that validates a real session token (httpOnly cookie orAuthorization: Bearer <jwt>) and apply it to all/api/user/:username/*routes plus everywherex-usernameis currently trusted.POST /api/login. Verify on every request with a server secret read from env (SESSION_SECRET).x-usernameheader working for one release, then remove it.express-rate-limit(5 attempts / 15 min / IP) to all public endpoints.process.env.ALLOWED_ORIGINS?.split(','). Default tohttp://localhost:3000for dev.SESSION_SECRETandALLOWED_ORIGINSin.env.example.Acceptance Criteria
PUT /api/user/:username,POST /api/user/:username/photo,DELETE /api/user/:username/photoreject requests where the authenticated user is not:username(or admin).GET /api/user/:usernamerequires auth.Additional Context
Bundled because they share a single migration: introducing real auth is the prerequisite for tightening the other three. Splitting would require duplicate work.
Related: #19 (audit log will record auth events once this is in), #20 (Resend will use the same rate limiter).