Skip to content

Validate BRL tax IDs and bind KYC to the claimed CPF - #1395

Open
ebma wants to merge 10 commits into
stagingfrom
fix/api-cpf-claim-guard
Open

ebma wants to merge 10 commits into
stagingfrom
fix/api-cpf-claim-guard

Conversation

@ebma

@ebma ebma commented Sep 29, 2026 •

Copy link
Copy Markdown
Member

Summary

Lean mitigation for Brazilian tax-ID squatting, found while reviewing the gold app (#1388 follow-ups). The full fix (a CPF becomes exclusive only once the provider approves it) was deliberately not chosen; it stays open under the new RISK-026.

  • Validate before claiming. createSubaccount only checked accountType, so any signed-in session could bind any string: "abc" bound the hash of an empty string, a missing taxId threw a 500, and an unclaimed valid CPF became exclusive to whoever sent it first. It now requires a non-empty name of at most 255 characters and a checksum-valid CPF (INDIVIDUAL) or CNPJ (COMPANY), in every environment including sandbox, before any provider call or write.
  • Bind KYC to the claimed tax ID. newKyc forwarded the submission without comparing taxIdNumber with the CPF claimed on the subaccount, so an account could end up approved for one identity while Vortex stored another CPF. newKyc and the KYB level-1 API submission now answer 400 before any provider call unless the submitted tax ID hashes to the claimed one.
  • Operator runbook for releasing a squatted tax ID: docs/operations-brl-tax-id-claim-release.md (the financial_operations claim must go too, or the owner's retry still gets 409).
  • Docs and spec: public API docs and OpenAPI (validation, 400 cases, a checksum-valid example CPF instead of 12345678901), 05-integrations/brla.md (invariant 5 claimed CPF validation that did not exist; new invariant 48, threat rows), RISK-REGISTER.md (RISK-026).

Notes for review

  • An earlier revision also capped how many other profiles' tax IDs one principal could probe (429 after five). It was dropped: review found the createSubaccount 409 count could be bypassed with a path variant (/createSubaccount/), the cap also blocked the caller's own tax IDs, and it was per instance only. Whether a tax ID is registered to another profile therefore stays observable through the 403/409 answers, bounded only by the global per-IP rate limit. This residual is recorded under RISK-026.
  • Invariant 48 binds only the identity submitted through the API (newKyc, KYB level-1). The hosted KYB flow (initiateKybLevel1) and the provider's approval are not compared with the claimed tax ID, because Avenia subaccount creation sends only accountType and name. Also recorded under RISK-026.
  • Claiming unregistered CPFs is not rate-limited per principal: it is indistinguishable from a partner onboarding new customers, and open OTP signup makes a per-account cap easy to bypass. Squatting remains mitigated by validation, KYC binding and the runbook.
  • The KYB check compares the TIN with the claimed CNPJ even when countryTaxResidence is not BRA; a company with a foreign TIN now gets 400. Fail-closed on purpose (confirmed), because gating on the country would reopen the swap.
  • The dashboard KYC form only checks the CPF length; with this PR a mistyped CPF gets the API's 400 instead of silently claiming the wrong one. A client-side checksum there would be a nicer follow-up (gold gets one in Gold: faster landing, sturdier KYC polling, CPF check and key cleanup #1392).

Test plan

  • Validator: bad checksum, CPF/CNPJ vs account type, missing or non-string name/taxId, formatted valid CPF. Controller: malformed bodies never reach the provider, the operation claim or the DB.
  • newKyc / KYB: mismatching, non-string and formatted-equivalent tax IDs.
  • After dropping the limiter: API typecheck and the BRL/validator unit tests pass locally.
  • Full API suite including the BRL corridor integration tests (CI).

ebma and others added 6 commits September 29, 2026 16:08
…er call

createSubaccount only checked accountType, so a signed-in session could
reserve any unclaimed CPF: the claim, the provider subaccount and the
unique tax-hash row were all created before anything looked at taxId. A
non-string taxId also threw a TypeError (500) and "abc" normalized to ""
and bound sha256("").

Require a non-empty name (max 255, the company_name column width) and a
checksum-valid CPF for INDIVIDUAL or CNPJ for COMPANY, in every
environment, and reject with 400 ahead of the controller.
…subaccount

newKyc and the KYB API submission forwarded the request body to the
provider without comparing its tax id to the one reserved at
createSubaccount. The provider approves whoever the documents belong to,
so an account could end up Approved with a stored taxReference that
differs from the KYC'd identity.

Reject with 400 before any provider call when the normalized
taxIdNumber (individual) or taxIdentificationNumberTin (company) does
not hash to the record's taxReferenceHash. Formatted equivalents pass.
The tax-keyed BRL lookups answer 403 for a tax id held by another profile
and createSubaccount answers 409, which lets any signed-in session test
whether a CPF is registered. After five such distinct hits in 24 hours a
principal gets 429 for further tax ids. Own and unregistered tax ids never
count, so a partner onboarding many customers from one profile and status
polling are unaffected. State is in memory per API instance.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…he 429 limit

Replace the checksum-invalid CPF in the managed-profile example with a
valid synthetic one, describe the 400 cases (name/taxId validation,
taxIdNumber and taxIdentificationNumberTin mismatch) and add the shared
429 response for the six tax-keyed BR operations to the OpenAPI source,
the regenerated types and the fiat-corridors guide.
createSubaccount reserves a CPF/CNPJ for the first authenticated caller,
and the real owner then gets a 409 with no self-service recovery. The
runbook gives read-first, verify-then-change steps to release an
unapproved claim: the provider_customers and kyc_cases rows, the
tax-hash-keyed financial_operations claim that would otherwise block the
owner's retry, and the orphaned provider subaccount.
createSubaccount is a reserving flow, and invariant 5 and its checklist
line claimed CPF validation at ramp registration, which the code does
not do. Restate invariant 5 around validation before the claim, extend
invariants 18 and 36, add invariants 48-49 (claim bound to the verified
identity, distinct-tax-id limiter), squatting/enumeration/identity-swap
threat rows and checklist lines, register the accepted residual risk as
RISK-026 (full fix: exclusivity only on approval), and record the
limiter in the api-surface spec.
@ebma ebma mentioned this pull request Sep 29, 2026
6 of 9 tasks
@netlify

netlify Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

✅ Deploy Preview for vortexfi canceled.

Name Link
🔨 Latest commit c682747
🔍 Latest deploy log https://app.netlify.com/projects/vortexfi/deploys/6abe9a6fd475a50008c5620b

@netlify

netlify Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

✅ Deploy Preview for vrtx-dashboard canceled.

Name Link
🔨 Latest commit c682747
🔍 Latest deploy log https://app.netlify.com/projects/vrtx-dashboard/deploys/6abe9a6fd700990008353e3f

@netlify

netlify Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

✅ Deploy Preview for vortex-sandbox ready!

Name Link
🔨 Latest commit c682747
🔍 Latest deploy log https://app.netlify.com/projects/vortex-sandbox/deploys/6abe9a6f18d5520008d3c3b6
😎 Deploy Preview https://deploy-preview-1395--vortex-sandbox.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Concurrent requests can bypass the probe cap, unrelated conflicts can consume it, and accepted names are not normalized before provider submission.

Review effort: Balanced
Findings: 1 High severity · 2 Medium severity

Open (3)
What changed in this PR

Hardens BRL onboarding against invalid tax-ID claims, identity swaps, and enumeration while documenting residual squatting risk and operator recovery.

Changes:

  • Validates subaccount names and CPF/CNPJ checksums.
  • Binds KYC/KYB submissions to the claimed tax ID.
  • Adds a distinct-tax-ID probe limiter, tests, API documentation, and release runbook.
File Description
docs/​security-spec/​RISK-REGISTER.md Records residual BRL tax-ID risk.
docs/​security-spec/​07-operations/​api-surface.md Specifies limiter behavior and coverage.
docs/​security-spec/​05-integrations/​brla.md Defines validation and binding invariants.
docs/​README.md Indexes the operator runbook.
docs/​operations-brl-tax-id-claim-release.md Documents claim-release operations.
docs/​api/​pages/​14-managed-profiles.md Uses a checksum-valid CPF example.
docs/​api/​pages/​09-fiat-corridors.md Documents validation and rate limiting.
docs/​api/​openapi/​vortex.openapi.json Updates schemas and responses.
docs/​api/​openapi/​vortex.openapi.d.ts Regenerates OpenAPI declarations.
apps/​api/​src/​api/​routes/​v1/​brla.route.ts Mounts the limiter on tax-keyed routes.
apps/​api/​src/​api/​middlewares/​validators.ts Adds subaccount input validation.
apps/​api/​src/​api/​middlewares/​validators.test.ts Tests validation boundaries.
apps/​api/​src/​api/​middlewares/​distinctTaxIdLimiter.ts Implements per-principal probe limiting.
apps/​api/​src/​api/​middlewares/​distinctTaxIdLimiter.test.ts Tests limiter semantics and mounting.
apps/​api/​src/​api/​controllers/​brla.controller.ts Enforces KYC/KYB tax-ID binding.
apps/​api/​src/​api/​controllers/​brla.controller.test.ts Adds controller regression coverage.

💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +53 to +55
const hits = foreignHits.get(principal);
if (hits) dropExpired(hits, Date.now());
if (hits && !hits.has(taxIdHash) && hits.size >= MAX_FOREIGN_TAX_IDS) {
Comment on lines +16 to +20
function answeredForeignTaxId(req: Request, res: Response): boolean {
return (
res.statusCode === httpStatus.FORBIDDEN ||
(res.statusCode === httpStatus.CONFLICT && req.method === "POST" && req.path === "/createSubaccount")
);
Comment on lines +374 to +379
if (typeof name !== "string" || name.trim().length === 0 || name.trim().length > SUBACCOUNT_NAME_MAX_LENGTH) {
res.status(httpStatus.BAD_REQUEST).json({
error: `name must be a non-empty string of at most ${SUBACCOUNT_NAME_MAX_LENGTH} characters.`
});
return;
}
The in-memory limiter only partially bounded tax-id enumeration (per instance, reset on deploy, bypassable with fresh sign-ups and URL variants) while blocking a capped partner's own customers. Validation and KYC binding close the squatting issues; the enumeration residual stays under RISK-026.
@ebma ebma changed the title Validate BRL tax IDs, bind KYC to the claimed CPF and cap probes of other accounts' CPFs Validate BRL tax IDs and bind KYC to the claimed CPF Oct 1, 2026
The validator bounds the trimmed name to 255 characters, but the controller passed the raw value to Avenia, so a 255-character name with surrounding spaces exceeded the documented limit at the provider.
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.

2 participants