-
Notifications
You must be signed in to change notification settings - Fork 0
Security
-
NoSQL injection:
QuerySafeclass (utils/querySafe.ts) whitelists allowed fields and rejects values containing$orjavascript:before any value reaches a Mongo query. Important gotcha:QuerySafe.safeQuery()only acceptstypeof value === 'string'— passing a MongooseObjectIdinstance directly (instead of.toString()-ing it first) causes the filter to be silently dropped, not rejected. This caused a real critical bug (see Incidents below) — always stringify ids explicitly before passing them throughsafeQuery. -
Password hashing: bcrypt, 12 rounds (
utils/bcrypt.ts). Never stored or compared in plaintext. -
Password never returned by the API:
candidate.service.ts's read paths explicitly.select('-password')/ whitelist-select. -
Token blacklist: Redis (
blacklist:{token}key, TTL = remaining token lifetime) with in-memory fallback. -
Rate limiting: Redis-backed, 100 req/15min general / 150 req/15min on
/auth/*; falls back to in-memory on Redis failure. -
Ownership enforcement: every authenticated write is scoped to
req.user._idfrom the verified JWT — never a client-supplied id. Enforced centrally inverifyToken.middleware.ts(forcesreq.body.candidateId) plus an explicit existing-document ownership check inbaseUpdateDocument/baseDeleteDocument(services/index.ts).
-
CORS
origin: '*'(config/cors.config.ts) — open to all origins in every environment, including production. -
No request body size limit on
bodyParser.json()(server.ts) — DoS risk via oversized payloads. - No HTTP security headers (Helmet not installed).
- No HTTPS/HSTS enforcement at the app layer (relies entirely on Render's edge).
Discovered via a full manual API regression pass. req.user._id (set by verifyToken from the JWT) was not cross-checked anywhere in candidate_profile/* or candidate.service.ts — every list/create/update/delete trusted a client-supplied candidateId/_id in the request body instead. Live-confirmed: an unrelated authenticated user could read, overwrite, or delete another candidate's education/experience/etc. records and even overwrite their entire profile, just by supplying that candidate's id in the request body.
Fix: verifyToken middleware now force-overwrites req.body.candidateId with the authenticated user's own id (closes list/create/export). baseUpdateDocument additionally checks the existing target document's candidateId against the authenticated user before allowing an update (closes the "hijack via update" variant, where a correct-but-attacker's-own candidateId in the payload alone wasn't sufficient protection). baseDeleteDocument already had an ownership check, but it was fed a spoofable value — fixed by the same middleware change.
Discovered while testing an unrelated feature. candidate_me/index.ts's handlerGetAboutMe passed a raw Mongoose ObjectId (not a string) into QuerySafe.safeQuery(). Per the gotcha above, this silently dropped the candidateId filter, so GET /api/me/:email (public, no auth required) and the PDF export returned every candidate's CV section data blended together for any request. Live-confirmed: a brand-new, empty test account's public profile returned another real user's private resume content. Fixed with an explicit .toString().
GET /api/v1/candidate/:email and PUT/PATCH /candidate/update returned the bcrypt password hash in the response body. Root cause: one read path had no field selection at all; another had a field-selection helper that was silently a no-op due to a double-wrapping bug in how it built the Mongoose .select() string. Fixed by explicitly excluding password on both paths.
Missing await on the password-hashing call in the legacy v2 auth implementation meant a Promise object was passed to Mongoose as the password field, failing every registration attempt. See Architecture for why this duplicate code path exists at all.
Full diffs, root-cause writeups, and live-test transcripts for all of the above: agent-hub/evidence/implementer/2026-08-21/ in the repo.