Skip to content

fix(kyc): enforce gating across all capital-movement routes#245

Merged
mikewheeleer merged 2 commits into
Liquifact:mainfrom
Mrwicks00:security/kyc-gate-all-funding
May 28, 2026
Merged

fix(kyc): enforce gating across all capital-movement routes#245
mikewheeleer merged 2 commits into
Liquifact:mainfrom
Mrwicks00:security/kyc-gate-all-funding

Conversation

@Mrwicks00
Copy link
Copy Markdown
Contributor

fix(kyc): enforce gating across all capital-movement routes

Closes #222

Summary

requireKycForFunding was documented to protect three endpoints but was only wired to POST /api/invest/fund-invoice. This PR audits every money-moving route and applies the gate consistently, and also fixes a smeId spoofing vulnerability in the middleware itself.


Problem

Two capital-movement routes were unprotected:

Route Status before
POST /api/invest/fund-invoice ✅ Gated
POST /api/invoices/:id/link-escrow ❌ No KYC gate
POST /api/invoices/:id/transitionfunded/settled ❌ No KYC gate

Additionally, the middleware's smeId resolution order was:

// VULNERABLE — body/params smeId could be spoofed
const smeId = req.user.smeId || req.body?.smeId || req.params?.smeId;

An attacker with a valid JWT (even for an unverified SME) could supply a different, verified SME's ID in the request body and pass the KYC gate for an entity they do not own.


Changes

src/middleware/kycGating.js

  • Security fix (smeId spoofing): smeId is now resolved exclusively from req.user.smeId — the JWT claim set by authenticateToken. req.body.smeId and req.params.smeId are intentionally ignored during the KYC identity check.
  • req.kyc now includes smeId so downstream handlers can reference it without re-reading req.user.
  • Expanded JSDoc listing every gated endpoint and the full security contract.
// BEFORE (vulnerable)
const smeId = req.user.smeId || req.body?.smeId || req.params?.smeId;

// AFTER (fixed — tied to authenticated principal only)
const smeId = req.user.smeId || null;

src/routes/invoiceStateRoutes.js

  • POST /:id/link-escrow — added requireKycForFunding directly. This endpoint initiates the escrow funding lifecycle and is a clear capital-movement entry point.
  • POST /:id/transition — added conditionalKycGate: KYC is enforced only when targetState ∈ { funded, settled }. Non-capital transitions (approved, rejected) are unaffected and flow through without a KYC check.
  • CAPITAL_MOVING_STATES set defined at module level for maintainability.

tests/kyc.gating.test.js

Full rewrite covering all acceptance criteria from issue #222:

Section What is tested
ConfigSchema KYC env var validation (regression)
checkKycHealth Disabled / healthy / degraded provider
Middleware — auth 401 for missing req.user / req.user.sub
Middleware — smeId 400 when JWT has no smeId; resolution from JWT only
Middleware — KYC gate 403 for pending/rejected; 200 for verified/exempted
Middleware — req.kyc smeId attached for downstream
POST /api/invest/fund-invoice 403 / 201 / 201 (pending / verified / exempted)
POST /api/invoices/:id/link-escrow 403 / 400 / passes gate
POST /api/invoices/:id/transition Capital states blocked; non-capital pass through
smeId spoofing — body Gate uses JWT smeId; body smeId ignored ✅
smeId spoofing — params Gate uses JWT smeId; URL param ignored ✅

docs/compliance.md


Security Notes

  • No secrets are introduced. All KYC provider credentials continue to be read from .env / deployment secrets only.
  • The smeId fix eliminates horizontal privilege escalation: an attacker who owns SME A cannot claim the KYC status of SME B by embedding SME B's ID in a request.
  • Tenant isolation remains enforced by extractTenant middleware upstream of the gate.
  • Non-capital endpoints (list/read/approve/reject) are intentionally not gated — the gate is scoped to operations that transfer or release capital.

Test Output

npm install && npm test -- tests/kyc.gating.test.js

⚠️ node_modules was not present in the local environment at time of push (native sqlite3 build issue with the dev environment). The test suite is structurally verified and designed to pass in CI where npm install succeeds.


Checklist

  • Branch: security/kyc-gate-all-funding
  • Commit: fix(kyc): enforce gating across all capital-movement routes
  • src/middleware/kycGating.js — smeId spoofing fix
  • src/routes/invoiceStateRoutes.js — gate on link-escrow and capital transitions
  • tests/kyc.gating.test.js — full rewrite with spoofing coverage
  • docs/compliance.md — updated gating table, anti-spoofing note, roadmap
  • No secrets in repo
  • No breaking changes to existing gated endpoint (fund-invoice)

- Closes Liquifact#222: requireKycForFunding was only wired to POST /api/invest/fund-invoice.
  Any other funding/settlement route could transfer capital without a KYC check.

Changes:
  src/middleware/kycGating.js
    - FIX: smeId is now resolved ONLY from req.user.smeId (the JWT principal).
      Previously the fallback chain req.user.smeId || req.body?.smeId || req.params?.smeId
      allowed an authenticated caller to supply a verified SME's ID they do not
      own and bypass the identity check. Body/param values are now intentionally
      ignored.
    - req.kyc now includes smeId so downstream handlers can reference it without
      re-reading req.user.
    - Expanded JSDoc to document every gated endpoint and the security contract.

  src/routes/invoiceStateRoutes.js
    - POST /:id/link-escrow now requires requireKycForFunding (initiates escrow
      funding lifecycle — capital-movement entry point).
    - POST /:id/transition uses a new conditionalKycGate that applies
      requireKycForFunding only when targetState ∈ {funded, settled};
      non-capital transitions (approved, rejected) are unaffected.
    - CAPITAL_MOVING_STATES set defined at module level for clarity.

  tests/kyc.gating.test.js
    - Full rewrite covering all gated routes and the anti-spoofing guarantee.
    - Sections: ConfigSchema regression, checkKycHealth, middleware unit tests
      (auth, smeId resolution, KYC status gate, req.kyc attachment),
      fund-invoice route, link-escrow route, transition route (conditional
      gating), and smeId spoofing via body/params.

  docs/compliance.md
    - Updated gated-endpoint inventory table.
    - Documented the anti-spoofing fix with before/after code.
    - Phase 1 roadmap updated to mark issue Liquifact#222 complete.
    - Version bumped to 1.1.0.
@drips-wave
Copy link
Copy Markdown

drips-wave Bot commented May 28, 2026

@Mrwicks00 Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

@Mrwicks00
Copy link
Copy Markdown
Contributor Author

@mikewheeleer

… lint rule

- requireKycForFunding: add @returns {Promise<void>}
- auditKycAccess: add @returns {Promise<void>}
- conditionalKycGate: add @returns {void}

These were flagged by CI lint on the changed files only.
@mikewheeleer mikewheeleer merged commit 158cd5d into Liquifact:main May 28, 2026
1 of 2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Enforce KYC gating on all capital-movement endpoints, not just /api/invest/fund-invoice

2 participants