Audited revision: main @ 4254582 (working tree, post-fix)
Audit date: 2026-05-30
Auditor role: Senior QA Engineer & Security Auditor
Scope: Full MERN monorepo (server/ Express API + client/ React/Vite), all 8 milestones per PLANNING.md / TASK.md.
The FAQ Platform is a well-architected, feature-complete MVP. Code quality is high: clean service/controller/route separation, centralized config and constants, consistent error handling, soft-delete + audit logging, a single swappable AI boundary, refresh-token rotation with hashed storage + revocation, bcrypt password hashing, and rate limiting on sensitive endpoints. React's default escaping means there is no DOM-XSS in the SPA (no dangerouslySetInnerHTML anywhere).
The audit nonetheless found 1 Critical, 1 High, 4 Medium, and 10 Low issues. The Critical (well-known JWT secret fallback that silently secures production) and the High (NoSQL operator injection on public list endpoints) were fixed and pinned with regression tests during this audit, along with all Medium items and one Low. Remaining Low items are documented with recommendations; most are accepted MVP trade-offs already noted in PLANNING.md.
| Check | Command | Before | After |
|---|---|---|---|
| Lint (server + client) | npm run lint |
0 errors, 13 warnings | 0 errors, 1 warning* |
| Unit/Integration/API tests | npm test |
50 passed / 7 suites | 55 passed / 8 suites |
| Client build | npm run build |
✅ | ✅ |
| Dependency scan | npm audit |
3 moderate (transitive) | 3 moderate (transitive) |
* The remaining warnings are pre-existing unused eslint-disable directives and one unused import — cosmetic, non-blocking (see §7).
Note on "TypeScript type checks": this project is intentionally pure ESM JavaScript, not TypeScript, so a
tsctype-check is not applicable. ESLint (flat config,@eslint/js+ React plugins) is the static-analysis gate and is run in its place.
A professional test pyramid for this stack. ✅ = present/used, ➕ = added in this audit, ⬜ = recommended (not yet implemented).
| Layer | Tooling | Status | Coverage |
|---|---|---|---|
| Unit (pure logic) | Jest | ✅ | gibberish heuristics, cosine similarity, badge thresholds, spam escalation, job handlers |
| Integration / API | Jest + Supertest + mongodb-memory-server |
✅ ➕ | auth, queries, forum, badges/bans, FAQ, chatbot RAG, admin, maintenance crons, security regressions |
| Authentication & authorization | Jest + Supertest | ✅ ➕ | token required, admin-only gates, ownership checks, banCheck, chatbot session ownership |
| Input validation & edge cases | Jest + Supertest | ✅ ➕ | gibberish/duplicate gates, empty bodies, NoSQL injection, upload extension smuggling |
| AI determinism | Mock-mode AI boundary | ✅ | offline embeddings + canned chat keep CI free and deterministic |
| Security | Jest regression suite (tests/security.test.js) |
➕ | headers, NoSQL injection, upload hardening, session isolation, ban enforcement |
| Dependency vuln scan | npm audit |
✅ | run each milestone; see §6 |
| UI/UX validation | manual review of components/pages | ✅ | React auto-escaping verified; focus-visible/responsive CSS present |
| Client unit/component | Vitest + React Testing Library | ⬜ | recommended — no client tests currently exist |
| End-to-end (E2E) | Playwright / Cypress | ⬜ | recommended — the TASK.md demo-loop checklist is the manual E2E script today |
| Performance/load | k6 / autocannon | ⬜ | recommended for the in-memory vector scan ceiling (see PERF-01) |
The test suite uses an in-memory MongoDB and the AI mock boundary, so the entire suite is deterministic, offline, and quota-free — exactly right for CI.
Severity definitions — Critical: remote compromise / auth bypass. High: significant data exposure or integrity loss. Medium: exploitable under conditions / defence-in-depth gap. Low: hardening, minor integrity, or accepted trade-off.
- Description:
config/env.jsresolved secrets via arequired(key, fallback)helper that always supplied a fallback ('dev-access-secret'/'dev-refresh-secret'), so it never actually enforced anything. A production deployment with the env vars unset would silently sign and verify JWTs with these well-known values published in this public repo. - Reproduction: Start the server without
JWT_ACCESS_SECRET. Sign a token with{ sub: <any user's _id> }usingdev-access-secret. Send it asAuthorization: Bearer …. Theauthmiddleware loads that user fresh from the DB → full session as any user, including admins. - Impact: Complete authentication bypass / account & admin takeover.
- Fix applied: Replaced
required()with asecret()resolver that throws on startup in production if the secret is missing or still equals the dev default. Dev/test keep the convenient fallback (so CI and local runs are unaffected).
- Description: No
helmetor equivalent; responses lackedX-Content-Type-Options,X-Frame-Options,Referrer-Policy, and leakedX-Powered-By: Express. - Impact: Clickjacking, MIME-sniffing, framework fingerprinting; compounds SEC-03.
- Fix applied: Added a dependency-free header middleware in
app.js(nosniff,X-Frame-Options: DENY,Referrer-Policy: no-referrer, removesX-Powered-By). Recommendation: adopthelmet+ a CSP in production.
🟡 SEC-06 — Stack traces returned to clients outside production (Medium → Low) — OPEN (recommendation)
- Description:
middleware/error.jsincludeserr.stackin the JSON response unlessconfig.isProd. The defaultNODE_ENVisdevelopment, so a deployment that forgets to setNODE_ENV=productionleaks internal stack traces. - Impact: Information disclosure (paths, dependency internals).
- Recommendation: Gate stack exposure on an explicit debug flag, or hide it whenever
NODE_ENV !== 'development'. EnsureNODE_ENV=productionin the deploy workflow.
- Access + refresh tokens live in
localStorage(client/src/api/client.js), so any XSS could exfiltrate them. Already documented as an MVP trade-off; recommend httpOnly, SameSite cookies for production. React's escaping keeps XSS risk low today.
authService.registerchecks presence + password length ≥ 8 but does not validate email format or boundnamelength. bcrypt also silently truncates passwords beyond 72 bytes.- Recommendation: Add an email regex (or
validator), capname/title/bodylengths at the schema, and reject > 72-byte passwords explicitly.
M1 strengths: bcrypt hashing; refresh tokens stored as SHA-256 hashes with TTL index, rotated on use and revoked on logout; auth reloads the user from the DB so a forged/role-tampered token can't elevate (role is read from the DB doc, not the token claim); password_hash is select:false and stripped in toJSON.
- Description:
queryService.listQueriesassignedreq.queryvalues straight into the Mongo filter (filter.category = opts.category, etc.). Express'qsparser turns?category[$ne]=xinto{ $ne: 'x' }, injecting a Mongo operator into the filter. The same pattern existed infaqService.listFaqs(category) andadminService.listModeration(status/type). The/api/queriesroute is public (optionalAuth). - Reproduction:
GET /api/queries?category[$ne]=nopereturned all queries not in category "nope" (operator honored) instead of treating the value as a literal string. - Impact: Filter logic manipulation / unintended data selection; malformed operators can also throw 500s (availability).
- Fix applied: All user-supplied list filters are now coerced with
String()before entering the query (consistent withcreateQuery, which already did this). Pinned bysecurity.test.js › NoSQL operator injection.
- Description:
middleware/upload.jsvalidated onlyfile.mimetype(client-controlled, spoofable) but derived the stored extension fromoriginalname(also attacker-controlled). Uploads are served from/uploadsviaexpress.static, which setsContent-Typeby extension. An attacker could sendContent-Type: image/pngwith filenameevil.html→ stored as*.html→ served as HTML/script on the API origin. - Reproduction: Upload a "screenshot" with
contentType: image/png,filename: evil.html→ previously stored with a.htmlextension. - Impact: Stored XSS in the API origin; content-spoofing.
- Fix applied: Extension is now derived from a server-side MIME→extension allowlist (never from the filename). The
/uploadsstatic handler additionally setsContent-Disposition: attachmentandX-Content-Type-Options: nosniff, so a malicious upload can never render inline. Pinned bysecurity.test.js › upload filename.
vectorService.findSimilarQueries(and the chatbot/cluster/FAQ search) load all embeddings into memory and score them per request — O(n) per create/search. Correct for MVP scale and explicitly called out inPLANNING.md §15. Recommendation: swapvectorServicefor a dedicated vector index beyond low-thousands of documents (the interface already isolates this).
M2 strengths: two-layer gibberish gate with AI escalation only on borderline scores (quota-aware); embedding cache keyed by content hash (no redundant AI calls); duplicate detection warns + flags into the moderation queue (never auto-rejects, per spec); tags normalized + capped; the Query text index backs $text search.
- Banned users could still
POST /:id/report(query & answer), letting a banned account flood the moderation queue. AddedbanCheckto both report routes. Pinned bysecurity.test.js › banned users cannot report.
adminService.mergeQueriesreassigns the duplicate's answers to the canonical query but does not clear/recomputeis_acceptedon moved answers orcanonical.accepted_answer_id. A merged thread could display a second "accepted" answer or an inconsistent status.- Recommendation: On merge, reset
is_accepted=falseon moved answers (or re-run finalization for the canonical thread).
- When
finalizeSolutionssoft-deletes pruned answers, the +2 reputation points their likes awarded are not reversed, causing minor reputation drift. Low impact. Recommendation: reconcile points (or accept as historical reward).
- A single user can report the same content repeatedly, each creating a
ModerationQueueentry.writeLimiter(40/min) caps the rate but not the total. Recommendation: dedupe(reporter_id, target)or collapse in the queue.
M3 strengths: ownership enforced on edit/markSolution/delete; self-like blocked; one-like-per-user via a unique index; awardPoints is the single reputation entry point (badges always stay in sync); Path A vs Path B point logic matches spec; resolution actions are audit-logged.
GET /api/users/:idis unauthenticated and returnsban_reason,ban_expires_at,requires_approval, andnegative_badgesto anyone.PLANNING.md §11/§12intends negative badges to be visible, but the free-textban_reasonis arguably sensitive.- Recommendation: Keep badges public; restrict
ban_reasonto admins/self.
M4 strengths: self-ban prevented; timed vs permanent bans handled; banCheck lifts expired bans lazily and via cron; suspended → permaban, restricted → approval-required; every governance action is audit-logged and notifies the user; awardPoints floors reputation at 0.
- Description:
GET /api/chatbot/session/:tokenhad no auth and returned full chat history for any token.getOrCreateSessionreused an existing session purely by token, regardless of which user owned it, and accepted a client-chosensession_token(session-fixation surface). A user holding/guessing another user's token could read their history or append to their session. - Impact: Cross-user chat history disclosure; session hijack/fixation.
- Fix applied:
- Sessions are now reused only if anonymous or owned by the same authenticated user; a logged-in user may claim an anonymous session, but replaying another user's token yields a fresh session.
- New sessions always mint a server-side
crypto.randomUUID()(client can't fixate the token). getSessionnow requires ownership for user-bound sessions (route usesoptionalAuth; anonymous sessions remain readable by their unguessable token, preserving the existing UX).- Pinned by
security.test.js › chatbot session ownership.
- The RAG prompt concatenates the user message with retrieved FAQ/community text. Standard LLM risk; answers are grounded and the system degrades to grounded text on error. In mock mode this is inert. Recommendation for production: keep retrieved content clearly delimited and treat model output as untrusted.
M5 strengths: notification endpoints are correctly scoped by recipient_id (no IDOR); FAQ promotion guards resolved-state and double-promotion; graceful AI fallback on 429/errors; embeddings stripped from all FAQ/notification serializers.
adminRoutescorrectly appliesrouter.use(auth, admin)to the entire router — all admin tooling is admin-gated.adminService.listUsersescapes regex before building the searchRegExp(no ReDoS / regex injection).setRolevalidates the role against theROLESenum.listModerationfilter coercion was hardened as part of SEC-02 (admin-only, lower risk, fixed for consistency).
setRolelets an admin change their own role touser, potentially removing the last administrator. Recommendation: block self-demotion and/or refuse to demote the final admin.
M6 strengths: merge/dismiss/resolve all audit-logged; amalgamation clustering is read-only (admin decides); pagination caps (limit ≤ 50) prevent unbounded scans.
- Job trigger safety:
POST /api/jobs/:name/runis admin-gated and resolves handlers from a static registry map (jobs[name]) — no dynamicrequire/eval, so no command/handler injection. Unknown names → 404. - Idempotency/safety:
soft-delete-purgewrites anAuditLogbefore hard-deleting;lru-evictiononly flipsis_archived; jobs are disabled during tests (scheduling lives inserver.js, notapp.js). - Rate limiting:
authLimiter(auth),aiLimiter(AI),writeLimiter(content writes) applied; all disabled underNODE_ENV=test. - Seed integrity: the uncommitted
seed/seed.jschange (usingpathToFileURLfor the direct-invocation guard) is a correct Windows-compatibility fix — recommend committing it.
DELETE /queries/:idandDELETE /answers/:iduseauthbut notbanCheck(unlikePATCH). Deleting one's own content while banned is low-risk and soft-delete retains the record for moderation, so this is accepted; noted for consistency.
- See §6.
| ID | Severity | Fix | Files |
|---|---|---|---|
| SEC-01 | Critical | Fail-fast on default/missing JWT secrets in production | server/config/env.js |
| SEC-02 | High | String-coerce all list filters (kill NoSQL operator injection) | server/services/queryService.js, faqService.js, adminService.js |
| SEC-03 | Medium | MIME-derived upload extension + Content-Disposition: attachment + nosniff on /uploads |
server/middleware/upload.js, server/app.js |
| SEC-04 | Medium | Baseline security headers; remove X-Powered-By; cap urlencoded body |
server/app.js |
| SEC-05 | Medium | Bind chatbot sessions to owner; server-minted tokens; ownership-checked reads | server/services/chatbotService.js, controllers/chatbotController.js, routes/chatbotRoutes.js |
| SEC-07 | Low | Add banCheck to query/answer report routes |
server/routes/queryRoutes.js, routes/answerRoutes.js |
All fixes are backward-compatible with the existing API and were verified against the full test suite.
server/tests/security.test.js — 5 regression tests, each pinning a fixed vulnerability:
- Baseline security headers present +
X-Powered-Byhidden. - NoSQL operator-injection payload on
categoryis neutralized. image/pngupload namedevil.htmlis stored as.png.- Chatbot session isolation: cross-user read → 403; token replay → fresh session; owner read → 200.
- Banned user is blocked (403) from reporting content.
Result: npm test → 55 passed / 8 suites (was 50 / 7). Lint 0 errors; client build green.
npm audit reports 3 moderate issues, all transitive and not directly reachable by app code:
| Package | Via | Advisory | Note |
|---|---|---|---|
uuid < 11.1.1 |
node-cron@3 → uuid |
GHSA-w5hq-g745-h8pq (missing buffer bounds check in v3/v5/v6 when buf provided) |
The app never calls uuid with a buf argument; node-cron uses it internally for task IDs. |
gaxios 6.4.0–6.7.1 |
@google/genai |
depends on vulnerable uuid |
Only loaded in live AI mode; MVP default is mock mode. |
node-cron 3.0.2–3.0.3 |
direct dep | depends on vulnerable uuid |
npm audit fix --force would jump to node-cron@4 (breaking). |
Recommendation: Not exploitable in current usage. Evaluate upgrading node-cron to v4 on a branch (verify the cron API), or accept and monitor. Do not run npm audit fix --force unreviewed (breaking major bump).
- Lint warnings (13 server / 1 client): mostly stale
eslint-disabledirectives that no longer suppress anything, plus two genuinely unused identifiers (faqService.jsargembedding,maintenance.test.jsimportFaqEntry). Cosmetic — clear witheslint --fixwhere safe. Not fixed here to keep the audit diff security-focused. - Consistency:
createQuerycoerced inputs toString/Boolean; the list/search paths did not (root cause of SEC-02) — now consistent. - Commit the seed fix:
seed/seed.jspathToFileURLchange is correct; commit it (fix(seed): …).
- (Prod) Set
NODE_ENV=production+ strong uniqueJWT_*secrets; adopthelmet+ CSP; move tokens to httpOnly cookies. (SEC-04, SEC-06, SEC-10) - (Med) Restrict
ban_reasonto admins/self. (SEC-09) - (Med) Block admin self-demotion / last-admin removal. (SEC-11)
- (Low) Reconcile accepted-answer state on merge; revert like points on prune; dedupe reports. (SEC-12, SEC-13, SEC-16)
- (Validation) Email-format + length validation on user input. (SEC-14)
- (Testing) Add client component tests (Vitest + RTL) and an E2E suite (Playwright) covering the
TASK.mddemo loop; add a load test for the vector-scan ceiling. - (Deps) Plan a
node-cron@4upgrade spike. (SEC-15)
Generated as part of a milestone-wise QA & security audit. All Critical/High/Medium findings were remediated and pinned with automated regression tests; remaining Low items are documented above with recommended fixes.