Scope: the FaceTrust AI API running locally (
localhost:3000), backed by real Postgres/Redis, real face-engine, and real nin_bvn_simulator instances — not a static code review. Every finding below was demonstrated with an actual request/response against the live service, not inferred from reading the source. Conducted as authorized testing against infrastructure the same team owns and operates.Summary
The most significant finding — a complete bypass of the brute-force protection on identity-linking face comparisons — was found, fixed, and the fix re-verified live in this same session. Two findings (CORS policy, operator-login lockout strategy) are configuration/design trade-offs that need a real answer (allowed frontend origins; whether username-only rate-limiting is acceptable) rather than a code fix I should make unilaterally, so they’re documented with concrete remediation options instead.
High: verify-face brute-force protection was fully bypassable
Where:POST /identity/{nin,bvn}/verify-face
The endpoint was rate-limited (verifyFaceLimiter in middleware/rateLimit.ts), but keyed by validationToken — the short-lived token minted by POST /identity/{nin,bvn}/validate. Since validate itself had no rate limit at all, an attacker could reset their attempt budget on demand:
face_mismatch (never rate_limited), proving unlimited face-comparison attempts against a single NIN/BVN were possible. This directly defeated the control the spec calls for (story 48: “repeated verify-face attempts against one NIN/BVN are throttled as a brute-force-identity-theft control”).
Fix: added a second, durable counter (src/lib/verifyFaceAttempts.ts) keyed by a hash of the NIN/BVN number itself, not the token — it survives token rotation because it’s bound to the thing actually being attacked, not the credential used to attack it. Same INCR/EXPIRE pattern already used for OTP attempt-limiting in modules/auth/service.ts. The original token-keyed limiter stays in place as a cheap first-pass throttle; the number-keyed counter is now the actual authoritative control.
Re-verified after the fix: 7 rounds of “re-validate → 1 attempt,” each with a brand-new validationToken — attempts 1-5 returned face_mismatch, attempts 6-7 correctly returned 429 rate_limited, even though every single attempt used a token that had never been used before.
Low: oversized file upload crashed to a generic 500
Where: anymultipart/form-data endpoint (verify-face, /identify, /verify/self)
middleware/upload.ts caps uploads at 10MB via multer, but multer’s own MulterError (thrown when the limit is exceeded) wasn’t recognized by the global error handler — it fell through to the generic catch-all, returning 500 internal_error instead of a proper 413. Not itself exploitable (the error handler already never leaks stack traces to the client either way — see the Informational section), but it’s an incorrect status code, and it would have shown up as a false “unhandled server error” in the error-rate alert (HighHttpErrorRate in observability/prometheus/alert_rules.yml) for what’s actually a well-formed client mistake.
Fix: middleware/errorHandler.ts now recognizes multer.MulterError explicitly and maps its code to the right status (LIMIT_FILE_SIZE/LIMIT_FILE_COUNT → 413, LIMIT_UNEXPECTED_FILE → 400).
Re-verified: an 11MB upload now returns 413 {"error":"upload_limit_file_size","message":"File too large"} instead of a 500.
Medium: CORS allows any origin, on every method
Where: every endpoint (app.ts’s cors() middleware, default config)
Authorization: Bearer <token> headers, which a malicious page on another origin cannot silently attach (same-origin policy keeps localStorage/JS-held tokens out of reach unless that other frontend has its own XSS hole) — but it’s still a real hardening gap and a bad default to carry into production: it means there is currently no origin allowlist to fall back on as a second layer if a frontend ever does something wrong with where it stores a token.
Not fixed — this needs an actual list of legitimate frontend origins (mobile app doesn’t use CORS at all; the B2C web client and merchant desk web client both do) to configure cors({ origin: [...] }) correctly. Flagging with a recommendation rather than guessing at origins and getting it wrong.
Medium: operator-login rate limiting enables a no-credentials lockout DoS
Where:POST /auth/operator/login, operatorLoginLimiter in middleware/rateLimit.ts
The limiter is keyed purely by operatorCode (10 attempts / 5 min), with no distinction between IP addresses or between failed and successful attempts. Demonstrated live: 11 wrong-password attempts against admin001 triggered the rate limit — and the next legitimate login attempt with the correct password was also blocked until the window expired. Since operator codes are often predictable (admin001, teller003, sequential per-org schemes), anyone who knows or guesses a real operator’s code can lock them out of the system for 5 minutes at a time, indefinitely, without ever needing a valid password.
Not fixed — the direct fix (key by operatorCode + IP instead of operatorCode alone) reduces this but doesn’t eliminate it against a distributed attacker, and changes operational behavior (a shared-NAT branch office hammering one wrong operator code would no longer protect its other operators) — a decision worth making deliberately rather than as a pentest-driven quick patch. Documenting with the concrete remediation option rather than applying it unilaterally.
Informational: dependency vulnerabilities (all in unused code paths)
npm audit reports 50 advisories (1 critical, 5 high, 44 moderate) — but every single one traces back to one of two places, neither of which is exercised by this service’s actual behavior:
@opentelemetry/auto-instrumentations-nodepulls in instrumentation packages for dependencies this service doesn’t use at all —amqplib,aws-lambda,mongodb,mysql2,kafkajs, etc. — and the vulnerable code (@opentelemetry/core’s baggage-propagation memory issue, the Jaeger propagator DoS) lives inside those unused instrumentations’ shared dependency tree, not in the HTTP/Express/pg/ioredis paths this app actually runs.vitest/vite/esbuild(critical: arbitrary file read via Vitest’s UI server) are dev/test-only tooling, never included in a production build, and the vulnerable Vitest UI server is never started anywhere in this codebase.
npm audit fix has no fix available without --force, which would pull in breaking major-version bumps (@opentelemetry/auto-instrumentations-node 0.51→0.79, @opentelemetry/sdk-node 0.55→0.221, vitest 2→4) — not something to apply without testing the OTel pipeline and test suite afterward. Recommendation: trim getNodeAutoInstrumentations() in src/instrumentation.ts to only the instrumentations actually relevant here (http, express, pg, ioredis) via its enabled: false per-package options, which shrinks both the attack surface and most of this list at once; schedule the major-version bumps as a separate, tested change.
What held up
Substantial testing across auth, authorization, injection, and business-logic categories found the following controls working exactly as designed, with no exploitable gap:- IDOR — every cross-user attempt (profile, PATCH, DELETE, history, history export, preferences) correctly returned
403, scoped by comparing the URL’s:idagainst the authenticated token’s subject. - JWT integrity — signature tampering (flipping
subjectType/subin the payload while keeping the old signature) and the classicalg: noneforgery both correctly rejected with401.jsonwebtoken’s defaultverify()behavior (HMAC-only inference from a string secret) already blocksalg: nonewithout extra config. - Refresh token rotation — reusing an already-rotated refresh token, or guessing a random UUID, both fail cleanly; single-use + revocation-on-rotation confirmed live.
- Cross-user token binding —
validation_token,link_token, andproof_tokenare all checked against the issuing user’s session server-side (not just possession of the token). Demonstrated: user B could not use user A’svalidationTokento verify-face, could not steal A’slinkTokento complete a link on B’s account, and could not use A’sproofTokento authorize a change to B’s profile. - Role-based access control — consumer tokens against every operator/admin-only route (
/operators,/identify,/disputes,/analytics/*,/settings/attestation-required) correctly rejected. An active (password-reset-complete) non-admin operator correctly could not create/reset/patch operators or self-escalate their own role. - SQL injection — not exploitable.
vectorRepository.tsuses$queryRawUnsafe/$executeRawUnsafe(Prisma’s “unsafe” only refers to the query string being programmatically built, not fixed at compile time), but every actual value — including the embedding vector literal — is passed as a bound$1/$2parameter, never string-concatenated into the SQL text. Confirmed by code review and black-box injection attempts (',; DROP TABLE, NoSQL-style operator objects) against every text input, all rejected at the zod schema layer before reaching a query. - Mass assignment —
PATCH /users/{id}uses a.strict()zod schema; attempting to smugglerole/status/id/namefields through the profile-update endpoint is rejected outright, not silently dropped. - Error handling — the global handler never returns a stack trace, internal error message, or library-specific detail to the client; every unhandled exception becomes a flat
{"error":"internal_error","message":"An unexpected error occurred"}, with the real detail only reaching the server-side logger. - OTP brute force — two independent layers (a 5-attempt Redis counter tied to the specific code, and a 5-per-5-min IP-independent rate limiter) both fire correctly; verified live with 7 wrong-code attempts.
- Secrets hygiene —
.envis git-ignored and confirmed not tracked in this repository. - Immutable settings —
PATCH /settings/attestation-requiredhas no code path that can succeed; it unconditionally throws, which was the explicit design intent (see Architecture).
Known, already-documented caveats (not new findings)
- Liveness stub (
LIVENESS_MODE=stubdefault) always passes — this is a deliberate, loudly-logged placeholder documented in Architecture → The liveness gap and face-engine’s own docs, not a discovery of this audit. - Dev-placeholder secrets (
JWT_SECRET,PII_ENCRYPTION_KEYin.env.example) are named...-change-mespecifically so they’re never mistaken for production-ready — restated here as a pre-production checklist item alongside the liveness stub, not a new gap. - Multer has no
fileFilter— any file type/content up to 10MB reaches face-engine before content-based rejection (undecodable_image) happens downstream. Not exploitable for code execution (face-engine only ever decodes bytes as an image, never executes anything), but adding a MIME-type allowlist at the API boundary is a reasonable defense-in-depth layer worth adding alongside the fixes above.