Tenant Tracker: The Guard That Registers Too Late
Tenant Tracker is Express 4.18 + Mongoose. Middleware registration in app.js:
app.use('/api', requireAuth); // verifies JWT, sets req.user = {id, role}
app.use('/admin', adminRouter);
app.use('/admin', requireAdmin); // 403 unless req.user.role === 'admin'
adminRouter, mounted at /admin, declares GET /users, which returns every user document including passwordHash, and POST /users/:id:
const patch = {};
for (const k of ['email','displayName','role']) if (k in req.body) patch[k] = req.body[k];
await User.updateOne({_id: req.params.id}, {$set: patch});
res.json({ok: true});
requireAuth accepts any valid HS256 JWT signed with the app secret and trusts the role claim inside it; the claim is minted at login and never refreshed. Signup is open, and POST /api/signup echoes back the new account's _id.
1. Does `requireAdmin` protect `POST /admin/users/:id`? Trace the middleware chain for a request from an ordinary signed-in user and state exactly what executes.
Read the worked answer
No. The guard is dead code for every path the router matches.
Express processes app.use/route layers strictly in registration order. For POST /admin/users/<id>:
/apiprefix does not match -requireAuthdoes not run on this path at all.app.use('/admin', adminRouter)matches the prefix, strips/admin, and hands/users/<id>to the router.- The router's
POST /users/:idhandler runs and callsres.json({ok:true}). - Because the handler sent a response and never called
next(), the chain stops.requireAdmin- registered on the next line - is never reached.
Same for GET /admin/users: the router's GET /users responds directly, so an unauthenticated user gets the full user dump with password hashes.
Two consequences worth stating explicitly:
requireAdmindoes run for/adminpaths the router does not define (e.g.GET /admin/nope→ 404 fall-through hits the guard). That is why casual testing with a wrong sub-path shows a 403 and masks the bug.requireAuthis scoped to/api, so these admin routes have no authentication at all, not merely missing authorisation.req.userinsiderequireAdminwould be undefined, and the checkundefined.role === 'admin'would throw before returning 403 anyway.
2. You have a normal account. Give the minimal request sequence that ends with admin access and a password-hash dump, naming the state that must be refreshed between steps.
Read the worked answer
Step 0 – signup, capture _id and token
POST /api/signup {"email":"[email protected]","password":"..."}
-> 200 {"id":"6512...","token":"<JWT role=user>"}
Step 1 – write the role onto your own document (guard unreachable, but _id is the only thing needed)
POST /admin/users/6512... {"role":"admin"}
-> 200 {"ok":true}
If you send {"role":"admin","email":"[email protected]","displayName":"a"} the loop happily copies all three; sending only role is enough and keeps the audit trail quiet.
Step 2 – refresh the token. requireAdmin reads req.user.role from the JWT claim, not from Mongo. The token you hold still says role: user, so it is worthless. Log in again:
POST /api/login -> new JWT containing role=admin
This staleness step is the one people skip, conclude "escalation failed", and abandon a working chain.
Step 3 – cash out
GET /admin/users with Authorization: Bearer <new token>
-> 200 [ {_id, email, passwordHash, role}, ... ]
Verification that it is really the claim and not the DB: before step 2, GET /admin/users with the old token still returns 403 even though your document already says admin. That confirms the claim is the authority.
3. The team reorders the middleware and drops `role` from the patch list. What still gives privilege escalation in this design, and how would you verify each item?
Read the worked answer
1. The JWT claim is the authorisation source of truth and is never revalidated. With ordering fixed and role removed from the patch list, a demoted admin keeps admin rights until the token expires, and any leaked admin token cannot be revoked. Verify: mint a token as admin, have an operator flip that document to role: 'user', and confirm GET /admin/users still returns 200 with the old token. The fix is to load the role from the DB per request (or check a token version) rather than trusting the claim.
2. k in req.body walks the prototype chain. in returns true for inherited properties, not just own ones. If any other route in the app is a prototype-pollution sink (Object.assign, deep-merge helpers, qs with allowPrototypes), then 'role' in req.body becomes true for a body that never mentions role, and patch.role = req.body.role picks up the polluted Object.prototype.role. Verify: poison Object.prototype through the suspected sink in one request, then send POST /admin/users/<own id> with {"email":"[email protected]"} and watch role appear in the update. Use Object.hasOwn(req.body, k) and an explicit allowlist.
3. $set: patch on a caller-chosen _id. Even with role removed, this endpoint is an unauthenticated arbitrary-field writer for any user id. Any field added later (e.g. tenantId, emailVerified, credits) is instantly mass-assignable. Verify by id-enumerating another tenant's _id and patching email.
4. Route-order auditing is a pattern, not a one-off. Grep every app.use(prefix, router) and confirm no security middleware is registered after a router on the same prefix; guards belong in the router definition (router.use(requireAdmin)) or before the mount.