Skip to content

Security

_david edited this page Aug 21, 2026 · 1 revision

Security

Protections in place

  • NoSQL injection: QuerySafe class (utils/querySafe.ts) whitelists allowed fields and rejects values containing $ or javascript: before any value reaches a Mongo query. Important gotcha: QuerySafe.safeQuery() only accepts typeof value === 'string' — passing a Mongoose ObjectId instance 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 through safeQuery.
  • 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._id from the verified JWT — never a client-supplied id. Enforced centrally in verifyToken.middleware.ts (forces req.body.candidateId) plus an explicit existing-document ownership check in baseUpdateDocument/baseDeleteDocument (services/index.ts).

Known gaps (not yet fixed)

  • 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).

Incident history

2026-08-21 — Critical: broken access control (IDOR) across all CV section CRUD

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.

2026-08-21 — Critical: public profile / PDF export leaked every candidate's CV data

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().

2026-08-21 — Password hash leaked in candidate responses

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.

2026-08-21 — POST /api/v2/auth/register completely broken

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.