From c1a152d692181ce464272336f54c4ea54ac7b28c Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 29 Sep 2026 16:08:42 +0200 Subject: [PATCH 01/10] fix(api): validate createSubaccount name and tax id before any provider 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. --- .../api/controllers/brla.controller.test.ts | 57 ++++++++++++ .../src/api/middlewares/validators.test.ts | 86 ++++++++++++++++++- apps/api/src/api/middlewares/validators.ts | 26 +++++- 3 files changed, 165 insertions(+), 4 deletions(-) diff --git a/apps/api/src/api/controllers/brla.controller.test.ts b/apps/api/src/api/controllers/brla.controller.test.ts index 2f013b63d..02e7ad456 100644 --- a/apps/api/src/api/controllers/brla.controller.test.ts +++ b/apps/api/src/api/controllers/brla.controller.test.ts @@ -15,6 +15,7 @@ import QuoteTicket from "../../models/quoteTicket.model"; import User from "../../models/user.model"; import { hashTaxReference } from "../services/avenia/avenia-customer.service"; import { SupabaseAuthService } from "../services/auth"; +import { validateSubaccountCreation } from "../middlewares/validators"; import { createSubaccount, createKybDocument, @@ -1861,6 +1862,62 @@ describe("createSubaccount", () => { expect(subaccountInfoMock).toHaveBeenCalledWith("new-subaccount"); }); + // The route runs validateSubaccountCreation ahead of the controller; drive both in order so the + // assertions cover what a real request reaches. + async function submitThroughRoute(body: unknown, userId = "squatter-user") { + const req = { body, userId } as any; + const res = createResponse(); + let validated = false; + validateSubaccountCreation(req, res as any, () => { + validated = true; + }); + if (validated) await createSubaccount(req, res as any); + return res; + } + + it("never reaches the provider or the database for malformed input", async () => { + mockBrlaApi(); + const findOne = mock(async () => null); + ProviderCustomer.findOne = findOne as unknown as typeof ProviderCustomer.findOne; + + const malformed = [ + { accountType: AveniaAccountType.INDIVIDUAL, name: "Squatter", taxId: "12345678901" }, + { accountType: AveniaAccountType.INDIVIDUAL, name: "Squatter", taxId: "abc" }, + { accountType: AveniaAccountType.INDIVIDUAL, name: "Squatter" }, + { accountType: AveniaAccountType.INDIVIDUAL, name: "Squatter", taxId: 8786985906 }, + { accountType: AveniaAccountType.INDIVIDUAL, name: "Squatter", taxId: "11222333000181" }, + { accountType: AveniaAccountType.COMPANY, name: "Squatter Ltda", taxId: "08786985906" }, + { accountType: AveniaAccountType.INDIVIDUAL, name: " ", taxId: "08786985906" }, + { accountType: AveniaAccountType.INDIVIDUAL, taxId: "08786985906" }, + { accountType: AveniaAccountType.INDIVIDUAL, name: "Squatter", taxId: "08786985907" } + ]; + for (const body of malformed) { + const res = await submitThroughRoute(body); + expect(res.statusCode).toBe(httpStatus.BAD_REQUEST); + } + + expect(createAveniaSubaccountMock).not.toHaveBeenCalled(); + expect(FinancialOperation.findOrCreate).not.toHaveBeenCalled(); + expect(sequelize.transaction).not.toHaveBeenCalled(); + expect(findOne).not.toHaveBeenCalled(); + }); + + it("stores the normalized tax id when the client sends a formatted valid CPF", async () => { + mockBrlaApi(); + const providerCreateMock = mock(async (values: Record) => ({ ...values })); + ProviderCustomer.findOne = mock(async () => null) as typeof ProviderCustomer.findOne; + ProviderCustomer.create = providerCreateMock as unknown as typeof ProviderCustomer.create; + + const res = await submitThroughRoute({ ...validBody, taxId: "087.869.859-06" }, "new-user"); + + expect(res.statusCode).toBe(httpStatus.OK); + expect(createAveniaSubaccountMock).toHaveBeenCalledTimes(1); + expect(providerCreateMock.mock.calls[0]?.[0]).toMatchObject({ + taxReference: "08786985906", + taxReferenceHash: hashTaxReference("08786985906") + }); + }); + it("rejects overwrite when a started record belongs to another entity", async () => { mockBrlaApi(); createAveniaSubaccountMock.mockClear(); diff --git a/apps/api/src/api/middlewares/validators.test.ts b/apps/api/src/api/middlewares/validators.test.ts index 97e9e5f48..30ff2426c 100644 --- a/apps/api/src/api/middlewares/validators.test.ts +++ b/apps/api/src/api/middlewares/validators.test.ts @@ -1,4 +1,4 @@ -import { BrDocumentType, Networks, QuoteError, RampDirection } from "@vortexfi/shared"; +import { AveniaAccountType, BrDocumentType, Networks, QuoteError, RampDirection } from "@vortexfi/shared"; import { describe, expect, it, mock } from "bun:test"; import type { NextFunction, Request, Response } from "express"; import httpStatus from "http-status"; @@ -9,7 +9,8 @@ import { validateAveniaKybLevel1, validateAveniaKybUbo, validateCreateBestQuoteInput, - validateKycSubmission + validateKycSubmission, + validateSubaccountCreation } from "./validators"; function buildRes() { @@ -283,3 +284,84 @@ describe("validateKycSubmission", () => { expect(res.statusCode).toBeUndefined(); }); }); + +describe("validateSubaccountCreation", () => { + // Synthetic identifiers with valid check digits (never real people or companies). + const VALID_CPF = "52998224725"; + const VALID_CPF_FORMATTED = "529.982.247-25"; + const VALID_CNPJ = "11222333000181"; + const VALID_CNPJ_FORMATTED = "11.222.333/0001-81"; + + function validate(body: unknown) { + const req = { body } as unknown as Request; + const res = buildRes(); + const next = mock(() => undefined) as unknown as NextFunction; + + validateSubaccountCreation(req, res, next); + + return { next, res }; + } + + function expectRejected(body: unknown, error: string) { + const { next, res } = validate(body); + expect(res.statusCode).toBe(httpStatus.BAD_REQUEST); + expect(res.body).toEqual({ error }); + expect(next).not.toHaveBeenCalled(); + } + + const individual = { accountType: AveniaAccountType.INDIVIDUAL, name: "Ana Maria Silva", taxId: VALID_CPF }; + const company = { accountType: AveniaAccountType.COMPANY, name: "Acme Ltda", taxId: VALID_CNPJ }; + + it("accepts a valid CPF, plain or formatted, for an individual", () => { + for (const taxId of [VALID_CPF, VALID_CPF_FORMATTED, ` ${VALID_CPF} `]) { + const { next, res } = validate({ ...individual, taxId }); + expect(next).toHaveBeenCalledTimes(1); + expect(res.statusCode).toBeUndefined(); + } + }); + + it("accepts a valid CNPJ, plain or formatted, for a company", () => { + for (const taxId of [VALID_CNPJ, VALID_CNPJ_FORMATTED]) { + const { next, res } = validate({ ...company, taxId }); + expect(next).toHaveBeenCalledTimes(1); + expect(res.statusCode).toBeUndefined(); + } + }); + + it("rejects checksum-invalid and trivial identifiers", () => { + for (const taxId of ["52998224724", "12345678901", "11111111111", "abc", "", "529.982.247-2"]) { + expectRejected({ ...individual, taxId }, "taxId must be a valid CPF for INDIVIDUAL accounts."); + } + expectRejected({ ...company, taxId: "11222333000182" }, "taxId must be a valid CNPJ for COMPANY accounts."); + }); + + it("rejects a CPF for a company and a CNPJ for an individual", () => { + expectRejected({ ...company, taxId: VALID_CPF }, "taxId must be a valid CNPJ for COMPANY accounts."); + expectRejected({ ...individual, taxId: VALID_CNPJ }, "taxId must be a valid CPF for INDIVIDUAL accounts."); + }); + + it("rejects a missing or non-string taxId", () => { + for (const taxId of [undefined, null, 52998224725, [VALID_CPF], { value: VALID_CPF }]) { + expectRejected({ ...individual, taxId }, "taxId must be a valid CPF for INDIVIDUAL accounts."); + } + }); + + it("rejects a missing, non-string, blank or oversized name", () => { + const error = "name must be a non-empty string of at most 255 characters."; + for (const name of [undefined, null, 42, ["Ana"], "", " ", "x".repeat(256)]) { + expectRejected({ ...individual, name }, error); + } + const { next } = validate({ ...individual, name: "x".repeat(255) }); + expect(next).toHaveBeenCalledTimes(1); + }); + + it("rejects an unknown or missing accountType before looking at the other fields", () => { + for (const accountType of [undefined, "BUSINESS", 1]) { + expectRejected({ ...individual, accountType }, "Invalid accountType."); + } + }); + + it("rejects a missing body instead of throwing", () => { + expectRejected(undefined, "Invalid accountType."); + }); +}); diff --git a/apps/api/src/api/middlewares/validators.ts b/apps/api/src/api/middlewares/validators.ts index 5d1741135..fb699e916 100644 --- a/apps/api/src/api/middlewares/validators.ts +++ b/apps/api/src/api/middlewares/validators.ts @@ -1,4 +1,5 @@ import { + AveniaAccountType, BrDocumentType, BrKYCDataUploadRequest, BrKybLevel1Payload, @@ -12,6 +13,7 @@ import { getCaseSensitiveNetwork, isSupportedFiatCurrency, isValidAveniaAccountType, + isValidCnpj, isValidCpf, isValidCurrencyForDirection, isValidDirection, @@ -354,16 +356,36 @@ export const validateSiweValidate: RequestHandler = (req, res, next) => { next(); }; +// provider_customers.company_name is VARCHAR(255); the controller stores the trimmed name there after the provider call. +const SUBACCOUNT_NAME_MAX_LENGTH = 255; + +// Runs before any provider call or DB write: a subaccount reserves its tax id exclusively, so only a +// well-formed CPF (INDIVIDUAL) or CNPJ (COMPANY) may reach the controller. export const validateSubaccountCreation: RequestHandler = (req, res, next) => { - const { accountType } = req.body as CreateAveniaSubaccountRequest; + const { accountType, name, taxId } = (req.body ?? {}) as Partial>; - if (!accountType || !isValidAveniaAccountType(accountType)) { + if (typeof accountType !== "string" || !isValidAveniaAccountType(accountType)) { res.status(httpStatus.BAD_REQUEST).json({ error: "Invalid accountType." }); return; } + 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; + } + + const isCompany = accountType === AveniaAccountType.COMPANY; + if (typeof taxId !== "string" || !(isCompany ? isValidCnpj(taxId.trim()) : isValidCpf(taxId.trim()))) { + res.status(httpStatus.BAD_REQUEST).json({ + error: `taxId must be a valid ${isCompany ? "CNPJ" : "CPF"} for ${accountType} accounts.` + }); + return; + } + next(); }; From d63c1a7a4136738c2b3d8ebfbf3982d345c5c3fe Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 29 Sep 2026 16:10:46 +0200 Subject: [PATCH 02/10] fix(api): bind KYC and KYB submissions to the tax id claimed for the 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. --- .../api/controllers/brla.controller.test.ts | 93 ++++++++++++++++++- .../src/api/controllers/brla.controller.ts | 13 +++ 2 files changed, 104 insertions(+), 2 deletions(-) diff --git a/apps/api/src/api/controllers/brla.controller.test.ts b/apps/api/src/api/controllers/brla.controller.test.ts index 02e7ad456..9101f82c7 100644 --- a/apps/api/src/api/controllers/brla.controller.test.ts +++ b/apps/api/src/api/controllers/brla.controller.test.ts @@ -2161,6 +2161,52 @@ describe("newKyc", () => { expect(getInstance).not.toHaveBeenCalled(); }); + describe("tax id binding", () => { + function mockOwnedIndividual() { + CustomerEntity.findAll = mock(async () => [{ id: "entity-user-1" }]) as unknown as typeof CustomerEntity.findAll; + ProviderCustomer.findOne = mock(async () => ({ + customerEntityId: "entity-user-1", + customerType: "individual", + id: "customer-1", + provider: "avenia", + providerSubaccountId: "subaccount-1", + taxReferenceHash: hashTaxReference("08786985906") + })) as unknown as typeof ProviderCustomer.findOne; + const getInstance = mock(() => ({}) as BrlaApiService); + BrlaApiService.getInstance = getInstance; + // Anything past the binding check opens the KYC claim transaction; fail loudly if reached. + const transaction = mock(async () => { + throw new Error("reached the KYC claim"); + }); + sequelize.transaction = transaction as unknown as typeof sequelize.transaction; + return { getInstance, transaction }; + } + + it("rejects a taxIdNumber that differs from the claimed CPF before any provider call", async () => { + const { getInstance, transaction } = mockOwnedIndividual(); + + for (const taxIdNumber of ["52998224725", "", undefined, 8786985906]) { + const res = createResponse(); + await newKyc({ body: { subAccountId: "subaccount-1", taxIdNumber }, userId: "user-1" } as any, res as any); + + expect(res.statusCode).toBe(httpStatus.BAD_REQUEST); + expect(res.body).toEqual({ error: "taxIdNumber does not match the tax ID claimed for this subaccount." }); + } + expect(getInstance).not.toHaveBeenCalled(); + expect(transaction).not.toHaveBeenCalled(); + }); + + it("lets a formatted equivalent of the claimed CPF through to the submission", async () => { + const { transaction } = mockOwnedIndividual(); + + const res = createResponse(); + await newKyc({ body: { subAccountId: "subaccount-1", taxIdNumber: "087.869.859-06" }, userId: "user-1" } as any, res as any); + + expect(transaction).toHaveBeenCalled(); + expect(res.statusCode).not.toBe(httpStatus.BAD_REQUEST); + }); + }); + it("rejects an imported-method case before provider document or submission calls", async () => { CustomerEntity.findAll = mock(async () => [{ id: "entity-user-1" }]) as unknown as typeof CustomerEntity.findAll; ProviderCustomer.findOne = mock(async () => ({ @@ -2168,7 +2214,8 @@ describe("newKyc", () => { customerType: "individual", id: "customer-1", provider: "avenia", - providerSubaccountId: "subaccount-1" + providerSubaccountId: "subaccount-1", + taxReferenceHash: hashTaxReference("08786985906") })) as unknown as typeof ProviderCustomer.findOne; KycCase.findAll = mock(async () => [{ id: "case-1", verificationMethod: "sumsub_share_token" }]) as unknown as typeof KycCase.findAll; sequelize.transaction = mock(async callback => @@ -2186,7 +2233,7 @@ describe("newKyc", () => { ); const res = createResponse(); - await newKyc({ body: { subAccountId: "subaccount-1" }, userId: "user-1" } as any, res as any); + await newKyc({ body: { subAccountId: "subaccount-1", taxIdNumber: "08786985906" }, userId: "user-1" } as any, res as any); expect(res.statusCode).toBe(httpStatus.CONFLICT); expect(getUploadedDocuments).not.toHaveBeenCalled(); @@ -2203,6 +2250,7 @@ describe("newKyc", () => { provider: "avenia", providerSubaccountId: "subaccount-1", status: VerificationStatus.InReview, + taxReferenceHash: hashTaxReference("08786985906"), update: customerUpdate }; ProviderCustomer.findOne = mock(async () => customer) as unknown as typeof ProviderCustomer.findOne; @@ -2260,6 +2308,7 @@ describe("newKyc", () => { { body: { subAccountId: "subaccount-1", + taxIdNumber: "087.869.859-06", uploadedDocumentId: "document-1", uploadedSelfieId: "selfie-1" }, @@ -2354,6 +2403,7 @@ describe("Avenia API KYB", () => { providerSubaccountId: "subaccount-1", status: VerificationStatus.Pending, statusExternal: null, + taxReferenceHash: hashTaxReference(validSubmission.taxIdentificationNumberTin), update })) as unknown as typeof ProviderCustomer.findOne; return update; @@ -2487,6 +2537,45 @@ describe("Avenia API KYB", () => { expect(createUbo).not.toHaveBeenCalled(); }); + it("rejects a TIN that differs from the claimed CNPJ before any provider call", async () => { + const { customerUpdate, submit } = mockInitialSubmission(); + const getInstance = mock(() => ({ submitKybLevel1: submit }) as unknown as BrlaApiService); + BrlaApiService.getInstance = getInstance; + + const res = createResponse(); + await submitKybLevel1Api( + { + body: { ...validSubmission, taxIdentificationNumberTin: "11222333000181" }, + query: { subAccountId: "subaccount-1" }, + userId: "user-1" + } as any, + res as any + ); + + expect(res.statusCode).toBe(httpStatus.BAD_REQUEST); + expect(res.body).toEqual({ error: "taxIdentificationNumberTin does not match the tax ID claimed for this subaccount." }); + expect(getInstance).not.toHaveBeenCalled(); + expect(submit).not.toHaveBeenCalled(); + expect(customerUpdate).not.toHaveBeenCalled(); + }); + + it("accepts a formatted TIN equivalent to the claimed CNPJ", async () => { + const { submit } = mockInitialSubmission(); + + const res = createResponse(); + await submitKybLevel1Api( + { + body: { ...validSubmission, taxIdentificationNumberTin: "42.731.085/0001-67" }, + query: { subAccountId: "subaccount-1" }, + userId: "user-1" + } as any, + res as any + ); + + expect(res.statusCode).toBe(httpStatus.OK); + expect(submit).toHaveBeenCalledTimes(1); + }); + it("submits ready company documents and persists the pending attempt", async () => { const { caseUpdate, customerUpdate, submit } = mockInitialSubmission(); diff --git a/apps/api/src/api/controllers/brla.controller.ts b/apps/api/src/api/controllers/brla.controller.ts index 7fa04159d..dddfd13bf 100644 --- a/apps/api/src/api/controllers/brla.controller.ts +++ b/apps/api/src/api/controllers/brla.controller.ts @@ -906,6 +906,12 @@ export const newKyc = async ( res.status(httpStatus.BAD_REQUEST).json({ error: "Individual KYC requires an individual customer account." }); return; } + // The provider approves whoever the submitted documents belong to; the CPF claimed at + // createSubaccount must be that same identity, or the approval would attach to the wrong tax id. + if (typeof req.body.taxIdNumber !== "string" || hashTaxReference(req.body.taxIdNumber) !== record.taxReferenceHash) { + res.status(httpStatus.BAD_REQUEST).json({ error: "taxIdNumber does not match the tax ID claimed for this subaccount." }); + return; + } const response = await submitStandardAveniaKyc({ actorProfileId, @@ -1068,6 +1074,13 @@ export const submitKybLevel1Api = async ( ): Promise => { try { const record = await resolveAveniaKybAccount(req, req.query.subAccountId); + // Same binding as newKyc: the submitted TIN must be the CNPJ claimed for this subaccount. + if (hashTaxReference(req.body.taxIdentificationNumberTin) !== record.taxReferenceHash) { + res + .status(httpStatus.BAD_REQUEST) + .json({ error: "taxIdentificationNumberTin does not match the tax ID claimed for this subaccount." }); + return; + } const subAccountId = record.providerSubaccountId as string; const brlaApiService = BrlaApiService.getInstance(); if (record.status === VerificationStatus.Approved) { From d8af3035ff1c5f95e1f4049ebc5abdb1a2839fd5 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 29 Sep 2026 16:13:16 +0200 Subject: [PATCH 03/10] fix(api): limit probes of other profiles' tax ids on the brl routes 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 --- .../middlewares/distinctTaxIdLimiter.test.ts | 189 ++++++++++++++++++ .../api/middlewares/distinctTaxIdLimiter.ts | 78 ++++++++ apps/api/src/api/routes/v1/brla.route.ts | 10 + 3 files changed, 277 insertions(+) create mode 100644 apps/api/src/api/middlewares/distinctTaxIdLimiter.test.ts create mode 100644 apps/api/src/api/middlewares/distinctTaxIdLimiter.ts diff --git a/apps/api/src/api/middlewares/distinctTaxIdLimiter.test.ts b/apps/api/src/api/middlewares/distinctTaxIdLimiter.test.ts new file mode 100644 index 000000000..44a7fdada --- /dev/null +++ b/apps/api/src/api/middlewares/distinctTaxIdLimiter.test.ts @@ -0,0 +1,189 @@ +import { EventEmitter } from "node:events"; +import { isValidCnpj, isValidCpf } from "@vortexfi/shared"; +import { afterEach, describe, expect, it, mock, setSystemTime } from "bun:test"; +import type { NextFunction, Request, Response } from "express"; +import httpStatus from "http-status"; +import brlaRoutes from "../routes/v1/brla.route"; +import { createDistinctTaxIdLimiter, limitDistinctTaxIds } from "./distinctTaxIdLimiter"; + +// Synthetic identifiers with valid check digits (never real people or companies). +const CPFS = ["52998224725", "11144477735", "39053344705", "15350972057", "16899535009", "87435698032", "00783214090"]; +const CNPJ = "11222333000181"; +const { FORBIDDEN, CONFLICT, NOT_FOUND, OK, TOO_MANY_REQUESTS } = httpStatus; + +// Runs the limiter; when it passes the request on, the "controller" answers with `answer`. +function call( + limiter: ReturnType, + request: { body?: unknown; ip?: string; method?: string; path?: string; query?: unknown; userId?: string }, + answer: number = OK +) { + const req = { ip: "203.0.113.7", method: "GET", path: "/getUser", ...request } as unknown as Request; + const res = Object.assign(new EventEmitter(), { statusCode: 0, body: undefined as unknown }) as unknown as Response & { + body?: unknown; + }; + res.status = mock((code: number) => { + res.statusCode = code; + return res; + }) as Response["status"]; + res.json = mock((payload: unknown) => { + res.body = payload; + return res; + }) as Response["json"]; + const next = mock(() => { + res.statusCode = answer; + res.emit("finish"); + }) as unknown as NextFunction; + limiter(req, res, next); + return { limited: res.statusCode === TOO_MANY_REQUESTS, next, res }; +} + +const get = (taxId: unknown, userId = "user-1") => ({ method: "GET", query: { taxId }, userId }); +const createSubaccount = (taxId: string, userId = "user-1") => ({ + body: { taxId }, + method: "POST", + path: "/createSubaccount", + query: {}, + userId +}); +// Five distinct probes of other profiles' tax ids exhaust the budget. +const exhaust = (limiter: ReturnType, userId = "user-1") => { + for (const cpf of CPFS.slice(0, 5)) expect(call(limiter, get(cpf, userId), FORBIDDEN).limited).toBe(false); +}; + +afterEach(() => setSystemTime()); + +describe("distinctTaxIdLimiter", () => { + it("uses only checksum-valid identifiers in this file", () => { + expect(CPFS.every(isValidCpf)).toBe(true); + expect(isValidCnpj(CNPJ)).toBe(true); + }); + + it("never limits a profile's own or unknown tax ids, however many", () => { + const limiter = createDistinctTaxIdLimiter(); + // A partner serving many customers from one profile: each is new (404), then its own (200). + for (let i = 0; i < 200; i++) { + const cpf = CPFS[i % CPFS.length]; + expect(call(limiter, get(cpf), i < CPFS.length ? NOT_FOUND : OK).limited).toBe(false); + expect(call(limiter, createSubaccount(cpf), OK).limited).toBe(false); + } + }); + + it("rejects a sixth distinct tax id after five probes of other profiles' tax ids", () => { + const limiter = createDistinctTaxIdLimiter(); + exhaust(limiter); + + const sixth = call(limiter, get(CPFS[5])); + expect(sixth.limited).toBe(true); + expect(sixth.next).not.toHaveBeenCalled(); + expect(sixth.res.body).toEqual({ error: "Too many distinct tax IDs for this account. Try again later." }); + // Repeating a tax id already probed reveals nothing new and still passes. + expect(call(limiter, get(CPFS[0]), FORBIDDEN).limited).toBe(false); + }); + + it("counts a createSubaccount conflict but not a conflict on the lookups", () => { + const limiter = createDistinctTaxIdLimiter(); + for (const cpf of CPFS.slice(0, 5)) call(limiter, { ...get(cpf), path: "/getKycStatus" }, CONFLICT); + expect(call(limiter, get(CPFS[5])).limited).toBe(false); + + for (const cpf of CPFS.slice(0, 5)) call(limiter, createSubaccount(cpf), CONFLICT); + expect(call(limiter, get(CPFS[5])).limited).toBe(true); + }); + + it("treats formatted and plain forms of one tax id as the same probe", () => { + const limiter = createDistinctTaxIdLimiter(); + exhaust(limiter); + + expect(call(limiter, get("529.982.247-25"), FORBIDDEN).limited).toBe(false); + expect(call(limiter, get("111.444.777-35"), FORBIDDEN).limited).toBe(false); + }); + + it("counts CNPJs and CPFs in the same budget", () => { + const limiter = createDistinctTaxIdLimiter(); + for (const cpf of CPFS.slice(0, 4)) call(limiter, get(cpf), FORBIDDEN); + call(limiter, get(CNPJ), FORBIDDEN); + + expect(call(limiter, get(CPFS[4])).limited).toBe(true); + }); + + it("keeps principals independent", () => { + const limiter = createDistinctTaxIdLimiter(); + exhaust(limiter, "user-1"); + + expect(call(limiter, get(CPFS[5], "user-1")).limited).toBe(true); + expect(call(limiter, get(CPFS[5], "user-2")).limited).toBe(false); + }); + + it("falls back to the client IP for anonymous callers", () => { + const limiter = createDistinctTaxIdLimiter(); + const anonymous = (taxId: string, ip: string) => ({ ...get(taxId), ip, userId: undefined }); + for (const cpf of CPFS.slice(0, 5)) call(limiter, anonymous(cpf, "203.0.113.1"), FORBIDDEN); + + expect(call(limiter, anonymous(CPFS[5], "203.0.113.1")).limited).toBe(true); + expect(call(limiter, anonymous(CPFS[5], "203.0.113.2")).limited).toBe(false); + }); + + it("frees the budget once the window has passed", () => { + const limiter = createDistinctTaxIdLimiter(); + const start = new Date("2026-01-01T00:00:00Z"); + setSystemTime(start); + exhaust(limiter); + expect(call(limiter, get(CPFS[5])).limited).toBe(true); + + setSystemTime(new Date(start.getTime() + 24 * 60 * 60 * 1000 - 1)); + expect(call(limiter, get(CPFS[5])).limited).toBe(true); + + setSystemTime(new Date(start.getTime() + 24 * 60 * 60 * 1000)); + expect(call(limiter, get(CPFS[5])).limited).toBe(false); + }); + + it("ignores requests without a checksum-valid tax id", () => { + const limiter = createDistinctTaxIdLimiter(); + const junk = ["abc", "", "12345678901", "52998224724", CPFS[0].slice(0, 10), undefined, null, 52998224725, [CPFS[1]]]; + for (const taxId of junk) { + const { limited, next } = call(limiter, get(taxId), FORBIDDEN); + expect(limited).toBe(false); + expect(next).toHaveBeenCalledTimes(1); + } + + // Junk did not consume any of the budget. + exhaust(limiter); + expect(call(limiter, get(CPFS[5])).limited).toBe(true); + }); + + it("reads the body on POST and the query on GET and HEAD", () => { + const limiter = createDistinctTaxIdLimiter(); + for (const cpf of CPFS.slice(0, 5)) call(limiter, createSubaccount(cpf), CONFLICT); + + expect(call(limiter, createSubaccount(CPFS[5])).limited).toBe(true); + expect(call(limiter, get(CPFS[5])).limited).toBe(true); + expect(call(limiter, { ...get(CPFS[5]), method: "HEAD" }).limited).toBe(true); + }); + + it("cannot be bypassed by a body that shadows the query on a read route", () => { + const limiter = createDistinctTaxIdLimiter(); + const shadowed = (taxId: string) => ({ body: { taxId: CPFS[0] }, method: "GET", query: { taxId }, userId: "user-1" }); + for (const cpf of CPFS.slice(0, 5)) call(limiter, shadowed(cpf), FORBIDDEN); + + expect(call(limiter, shadowed(CPFS[5])).limited).toBe(true); + }); +}); + +describe("brla routes", () => { + it("run the limiter on every tax-keyed route", () => { + const routes = [ + ["get", "/getUser"], + ["get", "/getUserRemainingLimit"], + ["get", "/getKycStatus"], + ["get", "/getSelfieLivenessUrl"], + ["post", "/createSubaccount"], + ["post", "/getUploadUrls"] + ] as const; + const stack = (brlaRoutes as unknown as { stack: Array<{ route?: { path: string; methods: Record; stack: Array<{ handle: unknown }> } }> }) + .stack; + + for (const [method, path] of routes) { + const layer = stack.find(entry => entry.route?.path === path && entry.route.methods[method]); + expect(layer?.route?.stack.some(handler => handler.handle === limitDistinctTaxIds)).toBe(true); + } + }); +}); diff --git a/apps/api/src/api/middlewares/distinctTaxIdLimiter.ts b/apps/api/src/api/middlewares/distinctTaxIdLimiter.ts new file mode 100644 index 000000000..4f8aeda8a --- /dev/null +++ b/apps/api/src/api/middlewares/distinctTaxIdLimiter.ts @@ -0,0 +1,78 @@ +import { isValidCnpj, isValidCpf, normalizeTaxId } from "@vortexfi/shared"; +import type { Request, RequestHandler, Response } from "express"; +import httpStatus from "http-status"; +import logger from "../../config/logger"; +import { hashTaxReference } from "../services/avenia/avenia-customer.service"; +import { getEffectiveUserId } from "./effectiveUser"; + +const WINDOW_MS = 24 * 60 * 60 * 1000; +const MAX_FOREIGN_TAX_IDS = 5; +const SWEEP_ABOVE_PRINCIPALS = 10_000; + +// Only an answer about another profile's tax id leaks anything or blocks its owner: the lookups +// answer 403 for it, createSubaccount 409. Own tax ids (200) and unknown ones (404, which is also +// the "create it first" signal for a new customer) never count, so a partner serving many +// customers from one profile is not limited. +function answeredForeignTaxId(req: Request, res: Response): boolean { + return ( + res.statusCode === httpStatus.FORBIDDEN || + (res.statusCode === httpStatus.CONFLICT && req.method === "POST" && req.path === "/createSubaccount") + ); +} + +/** + * Limits how many DISTINCT tax ids owned by other profiles one principal may probe on the + * tax-keyed /brl routes per window, to curb CPF existence enumeration (403 vs. 404) and squatting + * probes (409). After that many hits the principal gets 429 for any new tax id until the oldest + * hit ages out. Only checksum-valid CPF/CNPJ are considered. + * + * The principal is the effective user (the managed child for delegated calls), else the client IP. + * ponytail: state is per API instance and in memory, so the effective cap is 5 x instances and + * resets on deploy; move it to the database if abuse persists. + */ +export function createDistinctTaxIdLimiter(): RequestHandler { + // principal -> (hash of another profile's tax id it probed -> when) + const foreignHits = new Map>(); + + const dropExpired = (hits: Map, now: number) => { + for (const [taxIdHash, at] of hits) if (now - at >= WINDOW_MS) hits.delete(taxIdHash); + }; + + return (req, res, next) => { + // Read the same place the controllers do: the POST routes use the body, the read routes (GET, + // and the HEAD Express routes to them) the query. A body on a GET must not shadow the query. + const raw: unknown = req.method === "POST" ? req.body?.taxId : req.query?.taxId; + const taxId = typeof raw === "string" ? normalizeTaxId(raw) : ""; + if (!isValidCpf(taxId) && !isValidCnpj(taxId)) { + next(); + return; + } + + const principal = getEffectiveUserId(req) ?? `ip:${req.ip}`; + const taxIdHash = hashTaxReference(taxId); + const hits = foreignHits.get(principal); + if (hits) dropExpired(hits, Date.now()); + if (hits && !hits.has(taxIdHash) && hits.size >= MAX_FOREIGN_TAX_IDS) { + logger.warn("Foreign tax id probe limit reached", { principal }); + res.status(httpStatus.TOO_MANY_REQUESTS).json({ error: "Too many distinct tax IDs for this account. Try again later." }); + return; + } + + res.on("finish", () => { + if (!answeredForeignTaxId(req, res)) return; + const now = Date.now(); + const entries = foreignHits.get(principal) ?? new Map(); + entries.set(taxIdHash, now); + foreignHits.set(principal, entries); + if (foreignHits.size > SWEEP_ABOVE_PRINCIPALS) { + for (const [key, stale] of foreignHits) { + dropExpired(stale, now); + if (stale.size === 0) foreignHits.delete(key); + } + } + }); + next(); + }; +} + +export const limitDistinctTaxIds = createDistinctTaxIdLimiter(); diff --git a/apps/api/src/api/routes/v1/brla.route.ts b/apps/api/src/api/routes/v1/brla.route.ts index 8e05bd369..ff253c6b0 100644 --- a/apps/api/src/api/routes/v1/brla.route.ts +++ b/apps/api/src/api/routes/v1/brla.route.ts @@ -1,6 +1,7 @@ import { RequestHandler, Router } from "express"; import * as brlaController from "../../controllers/brla.controller"; import { rejectImpersonation } from "../../middlewares/bearerPrincipal"; +import { limitDistinctTaxIds } from "../../middlewares/distinctTaxIdLimiter"; import { optionalPartnerOrUserAuth, requirePartnerOrUserAuth } from "../../middlewares/dualAuth"; import { authorizeManagedProfile } from "../../middlewares/managedProfileAuth"; import { @@ -20,10 +21,14 @@ const router: Router = Router({ mergeParams: true }); // /getUser, /getUserRemainingLimit, and /validatePixKey use optionalPartnerOrUserAuth so that SDK // clients without API keys can drive a BRL ramp pre-flight against fully-anonymous quotes. The // controllers themselves apply ownership scoping using `getEffectiveUserId`; +// +// The routes keyed by a `taxId` (getUser, getUserRemainingLimit, getKycStatus, getSelfieLivenessUrl, +// createSubaccount, getUploadUrls) run `limitDistinctTaxIds` after auth so the principal is resolved. router.get( "/getUser", optionalPartnerOrUserAuth(), authorizeManagedProfile(), + limitDistinctTaxIds, brlaController.getAveniaUser as unknown as RequestHandler ); @@ -31,6 +36,7 @@ router.get( "/getUserRemainingLimit", optionalPartnerOrUserAuth(), authorizeManagedProfile(), + limitDistinctTaxIds, brlaController.getAveniaUserRemainingLimit as unknown as RequestHandler ); @@ -38,6 +44,7 @@ router.get( "/getKycStatus", requirePartnerOrUserAuth(), authorizeManagedProfile(), + limitDistinctTaxIds, brlaController.fetchSubaccountKycStatus as unknown as RequestHandler ); @@ -46,6 +53,7 @@ router.get( requirePartnerOrUserAuth(), authorizeManagedProfile({ corridor: "BR" }), rejectImpersonation, + limitDistinctTaxIds, brlaController.getSelfieLivenessUrl as unknown as RequestHandler ); @@ -58,6 +66,7 @@ router authorizeManagedProfile({ corridor: "BR" }), rejectImpersonation, validateSubaccountCreation, + limitDistinctTaxIds, brlaController.createSubaccount as unknown as RequestHandler ); @@ -68,6 +77,7 @@ router authorizeManagedProfile({ corridor: "BR", customerType: "individual" }), rejectImpersonation, validateStartKyc2, + limitDistinctTaxIds, brlaController.getUploadUrls ); From 08c440b24bf1454f351b8b851dfb66efd2b715ff Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 29 Sep 2026 16:15:15 +0200 Subject: [PATCH 04/10] docs(api): document createSubaccount validation, tax id binding and the 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. --- docs/api/openapi/vortex.openapi.d.ts | 35 ++++++++++++++++----- docs/api/openapi/vortex.openapi.json | 45 +++++++++++++++++++++++---- docs/api/pages/09-fiat-corridors.md | 4 +++ docs/api/pages/14-managed-profiles.md | 2 +- 4 files changed, 72 insertions(+), 14 deletions(-) diff --git a/docs/api/openapi/vortex.openapi.d.ts b/docs/api/openapi/vortex.openapi.d.ts index 3a9a82016..c710ca7b7 100644 --- a/docs/api/openapi/vortex.openapi.d.ts +++ b/docs/api/openapi/vortex.openapi.d.ts @@ -109,7 +109,7 @@ export interface paths { put?: never; /** * Create user or retry KYC - * @description `companyName`, `startDate` and `cnpj` are only required when taxIdType is `CNPJ` + * @description Creates the provider subaccount for a Brazilian individual (`INDIVIDUAL`, CPF) or company (`COMPANY`, CNPJ). The tax ID is reserved for the calling account as soon as this succeeds, so `taxId` must be the CPF/CNPJ of the person or company being onboarded; it is validated (format and check digits) before anything is created. Repeating the call for a tax ID the account already owns returns the existing `subAccountId`. * * `quoteId` is optional: pass it in the normal ramp flow, or omit it for the quote-less KYB deep link where business verification starts before any quote exists. * @@ -2249,6 +2249,7 @@ export interface components { /** @enum {string} */ sourceOfFundsAndIncome: "business_loans" | "grants" | "inter_company_funds" | "investment_proceeds" | "legal_settlement" | "owners_capital" | "pension_retirement" | "sale_of_assets" | "sales_of_goods_and_services" | "third_party_funds" | "treasury_reserves"; taxIdentificationDocumentId: string; + /** @description The CNPJ the company subaccount was created with. Punctuation is ignored; a different value is rejected with `400`. */ taxIdentificationNumberTin: string; uboIds: string[]; /** Format: uri */ @@ -2365,11 +2366,14 @@ export interface components { CreateSubaccountRequest: { /** @enum {string} */ accountType: "INDIVIDUAL" | "COMPANY"; - /** @description Individual full name or company legal name. */ + /** @description Individual full name or company legal name (1 to 255 characters after trimming). */ name: string; quoteId?: string; sessionId?: string; - /** @description CPF for an individual or CNPJ for a company. */ + /** + * @description CPF for an `INDIVIDUAL` account or CNPJ for a `COMPANY` account. Check digits are validated; punctuation is optional (`529.982.247-25` and `52998224725` are equivalent). + * @example 529.982.247-25 + */ taxId: string; }; CreateSubaccountResponse: { @@ -2767,6 +2771,7 @@ export interface components { state: string; streetAddress: string; subAccountId: string; + /** @description The CPF the subaccount was created with. Punctuation is ignored; a different CPF is rejected with `400`. */ taxIdNumber: string; uploadedDocumentId: string; uploadedSelfieId: string; @@ -3536,6 +3541,15 @@ export interface components { }; }; }; + /** @description Too many tax IDs of other accounts. An account (or, for unauthenticated requests, a client IP) that queried 5 different CPF/CNPJ values belonging to other accounts within 24 hours (answered `403`, or `409` on `createSubaccount`) receives this for any further tax ID on the tax-ID-keyed BR operations (`createSubaccount`, `getUser`, `getUserRemainingLimit`, `getKycStatus`, `getUploadUrls`, `getSelfieLivenessUrl`). Tax IDs the account owns and tax IDs not yet registered never count, and repeating a tax ID is never limited. Retry after the 24-hour window has passed. */ + TooManyDistinctTaxIds: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BrErrorResponse"]; + }; + }; }; parameters: { /** @description Selects one active, directly managed child as the effective subject. Use the controlling manager's secret `X-API-Key`, or its Supabase Bearer session where that operation accepts Bearer authentication. Public keys and direct child credentials cannot use this selector; a direct child credential already acts as its own subject without the header. Invalid UUIDs return `400 INVALID_MANAGED_PROFILE_ID`, missing authentication returns `401 AUTHENTICATION_REQUIRED`, and unauthorized, deleted, malformed, or corridor-disallowed children return `403 MANAGED_PROFILE_ACCESS_DENIED`. */ @@ -3831,8 +3845,9 @@ export interface operations { }; /** * @description Bad Request. Possible reasons: - * - Missing required fields (cpf, cnpj, companyName, startDate) - * - Subaccount already created and KYC level > 0 + * - `accountType` is not `INDIVIDUAL` or `COMPANY` + * - `name` is missing, blank, or longer than 255 characters + * - `taxId` is missing, is not a string, or is not a valid CPF (`INDIVIDUAL`) / CNPJ (`COMPANY`); punctuation is optional, the check digits are verified * - Other invalid request details */ 400: { @@ -3845,6 +3860,7 @@ export interface operations { }; 401: components["responses"]["ManagedSelectorUnauthorized"]; 403: components["responses"]["BrlaManagedSelectorForbidden"]; + 429: components["responses"]["TooManyDistinctTaxIds"]; /** @description Internal Server Error. */ 500: { headers: { @@ -3909,6 +3925,7 @@ export interface operations { "application/json": components["schemas"]["BrErrorResponse"]; }; }; + 429: components["responses"]["TooManyDistinctTaxIds"]; /** @description Internal Server Error (e.g., no KYC events found when expected). */ 500: { headers: { @@ -3973,6 +3990,7 @@ export interface operations { "application/json": components["schemas"]["BrErrorResponse"]; }; }; + 429: components["responses"]["TooManyDistinctTaxIds"]; /** @description Internal server error. */ 500: { headers: { @@ -4038,6 +4056,7 @@ export interface operations { "application/json": components["schemas"]["BrErrorResponse"]; }; }; + 429: components["responses"]["TooManyDistinctTaxIds"]; /** @description Internal server error. */ 500: { headers: { @@ -4105,6 +4124,7 @@ export interface operations { "application/json": components["schemas"]["BrErrorResponse"]; }; }; + 429: components["responses"]["TooManyDistinctTaxIds"]; /** @description Internal Server Error. */ 500: { headers: { @@ -4165,6 +4185,7 @@ export interface operations { "application/json": components["schemas"]["BrErrorResponse"]; }; }; + 429: components["responses"]["TooManyDistinctTaxIds"]; /** @description Internal Server Error. */ 500: { headers: { @@ -4383,7 +4404,7 @@ export interface operations { "application/json": components["schemas"]["KycLevel1Response"]; }; }; - /** @description Invalid submission or document state. */ + /** @description Invalid submission or document state, including a `taxIdentificationNumberTin` that does not match the CNPJ the company subaccount was created with (punctuation is ignored). */ 400: { headers: { [name: string]: unknown; @@ -4738,7 +4759,7 @@ export interface operations { "application/json": components["schemas"]["KycLevel1Response"]; }; }; - /** @description Validation failure. */ + /** @description Validation failure, including a `taxIdNumber` that does not match the CPF the subaccount was created with (punctuation is ignored). Submit the same CPF used for `createSubaccount`. */ 400: { headers: { [name: string]: unknown; diff --git a/docs/api/openapi/vortex.openapi.json b/docs/api/openapi/vortex.openapi.json index 67ff4cf38..ad8571e65 100644 --- a/docs/api/openapi/vortex.openapi.json +++ b/docs/api/openapi/vortex.openapi.json @@ -80,6 +80,16 @@ } }, "description": "" + }, + "TooManyDistinctTaxIds": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BrErrorResponse" + } + } + }, + "description": "Too many tax IDs of other accounts. An account (or, for unauthenticated requests, a client IP) that queried 5 different CPF/CNPJ values belonging to other accounts within 24 hours (answered `403`, or `409` on `createSubaccount`) receives this for any further tax ID on the tax-ID-keyed BR operations (`createSubaccount`, `getUser`, `getUserRemainingLimit`, `getKycStatus`, `getUploadUrls`, `getSelfieLivenessUrl`). Tax IDs the account owns and tax IDs not yet registered never count, and repeating a tax ID is never limited. Retry after the 24-hour window has passed." } }, "schemas": { @@ -653,6 +663,7 @@ "type": "string" }, "taxIdentificationNumberTin": { + "description": "The CNPJ the company subaccount was created with. Punctuation is ignored; a different value is rejected with `400`.", "type": "string" }, "uboIds": { @@ -1004,7 +1015,9 @@ "type": "string" }, "name": { - "description": "Individual full name or company legal name.", + "description": "Individual full name or company legal name (1 to 255 characters after trimming).", + "maxLength": 255, + "minLength": 1, "type": "string" }, "quoteId": { @@ -1014,7 +1027,8 @@ "type": "string" }, "taxId": { - "description": "CPF for an individual or CNPJ for a company.", + "description": "CPF for an `INDIVIDUAL` account or CNPJ for a `COMPANY` account. Check digits are validated; punctuation is optional (`529.982.247-25` and `52998224725` are equivalent).", + "example": "529.982.247-25", "type": "string" } }, @@ -2238,6 +2252,7 @@ "type": "string" }, "taxIdNumber": { + "description": "The CPF the subaccount was created with. Punctuation is ignored; a different CPF is rejected with `400`.", "type": "string" }, "uploadedDocumentId": { @@ -4606,7 +4621,7 @@ "/v1/brl/createSubaccount": { "post": { "deprecated": false, - "description": "`companyName`, `startDate` and `cnpj` are only required when taxIdType is `CNPJ`\n\n`quoteId` is optional: pass it in the normal ramp flow, or omit it for the quote-less KYB deep link where business verification starts before any quote exists.\n\n**Auth:** secret `X-API-Key` or Supabase Bearer session.", + "description": "Creates the provider subaccount for a Brazilian individual (`INDIVIDUAL`, CPF) or company (`COMPANY`, CNPJ). The tax ID is reserved for the calling account as soon as this succeeds, so `taxId` must be the CPF/CNPJ of the person or company being onboarded; it is validated (format and check digits) before anything is created. Repeating the call for a tax ID the account already owns returns the existing `subAccountId`.\n\n`quoteId` is optional: pass it in the normal ramp flow, or omit it for the quote-less KYB deep link where business verification starts before any quote exists.\n\n**Auth:** secret `X-API-Key` or Supabase Bearer session.", "operationId": "createSubaccount", "parameters": [ { @@ -4643,7 +4658,7 @@ } } }, - "description": "Bad Request. Possible reasons:\n- Missing required fields (cpf, cnpj, companyName, startDate)\n- Subaccount already created and KYC level > 0\n- Other invalid request details", + "description": "Bad Request. Possible reasons:\n- `accountType` is not `INDIVIDUAL` or `COMPANY`\n- `name` is missing, blank, or longer than 255 characters\n- `taxId` is missing, is not a string, or is not a valid CPF (`INDIVIDUAL`) / CNPJ (`COMPANY`); punctuation is optional, the check digits are verified\n- Other invalid request details", "headers": {} }, "401": { @@ -4652,6 +4667,9 @@ "403": { "$ref": "#/components/responses/BrlaManagedSelectorForbidden" }, + "429": { + "$ref": "#/components/responses/TooManyDistinctTaxIds" + }, "500": { "content": { "application/json": { @@ -4746,6 +4764,9 @@ "description": "The canonical KYC state requires reconciliation.", "headers": {} }, + "429": { + "$ref": "#/components/responses/TooManyDistinctTaxIds" + }, "500": { "content": { "application/json": { @@ -4840,6 +4861,9 @@ "description": "The immutable KYC method or canonical case state conflicts with liveness creation.", "headers": {} }, + "429": { + "$ref": "#/components/responses/TooManyDistinctTaxIds" + }, "500": { "content": { "application/json": { @@ -4935,6 +4959,9 @@ "description": "The immutable KYC method or canonical case state conflicts with upload creation.", "headers": {} }, + "429": { + "$ref": "#/components/responses/TooManyDistinctTaxIds" + }, "500": { "content": { "application/json": { @@ -5030,6 +5057,9 @@ "description": "Subaccount not found.", "headers": {} }, + "429": { + "$ref": "#/components/responses/TooManyDistinctTaxIds" + }, "500": { "content": { "application/json": { @@ -5123,6 +5153,9 @@ "description": "Subaccount not found or limits not found.", "headers": {} }, + "429": { + "$ref": "#/components/responses/TooManyDistinctTaxIds" + }, "500": { "content": { "application/json": { @@ -5426,7 +5459,7 @@ } } }, - "description": "Invalid submission or document state." + "description": "Invalid submission or document state, including a `taxIdentificationNumberTin` that does not match the CNPJ the company subaccount was created with (punctuation is ignored)." }, "401": { "$ref": "#/components/responses/ManagedSelectorUnauthorized", @@ -5909,7 +5942,7 @@ } } }, - "description": "Validation failure.", + "description": "Validation failure, including a `taxIdNumber` that does not match the CPF the subaccount was created with (punctuation is ignored). Submit the same CPF used for `createSubaccount`.", "headers": {} }, "401": { diff --git a/docs/api/pages/09-fiat-corridors.md b/docs/api/pages/09-fiat-corridors.md index 5cfca6ca7..b7f269cd6 100644 --- a/docs/api/pages/09-fiat-corridors.md +++ b/docs/api/pages/09-fiat-corridors.md @@ -37,6 +37,10 @@ BRL routes settle over PIX and require user onboarding with Vortex's local payme Use `/v1/brl/*` for BRL account and verification operations. The previous `/v1/brla/*` prefix remains supported as an equivalent migration alias. +`POST /v1/brl/createSubaccount` reserves the tax ID for the calling account, so it validates the request before anything is created: `name` must be 1 to 255 characters and `taxId` must be a check-digit-valid CPF for `INDIVIDUAL` or CNPJ for `COMPANY` (punctuation optional). Anything else returns `400`. The submission that follows must carry the same tax ID: `taxIdNumber` on `newKyc` and `taxIdentificationNumberTin` on the business submission are compared with the tax ID the subaccount was created with (punctuation ignored), and a different value returns `400` before anything reaches the provider. + +To limit tax ID enumeration, an account (or the client IP for unauthenticated requests) that queried 5 distinct tax IDs belonging to other accounts within 24 hours (answered `403`, or `409` on `createSubaccount`) receives `429` for any further tax ID on `createSubaccount`, `getUser`, `getUserRemainingLimit`, `getKycStatus`, `getUploadUrls`, and `getSelfieLivenessUrl`. Tax IDs your account owns and tax IDs not yet registered never count, so onboarding many customers and status polling are unaffected. + Level 1 onboarding collects basic identity information and enables lower-limit BRL flows. Level 2 adds document and liveness verification and may be required for higher limits or stricter compliance rules. The user must have completed KYC on the same account whose key registers the ramp; otherwise the ramp may fail or require additional account-management steps. A normal partner key cannot select an arbitrary user. An enabled managed-profile manager may use a secret `sk_*` key or Supabase session with `X-Managed-Profile-Id` to drive supported BR KYC operations for its directly managed child when the manager has the `BR` corridor and the child's immutable type is allowed by both current manager policy and Vortex's BR capability matrix. A null manager customer-type policy adds no further restriction. A public `pk_*` key is insufficient. When possible, use the Vortex application or hosted widget to complete onboarding before ramp execution. Business users can be sent straight into verification with the [KYB Deep Link](https://api-docs.vortexfinance.co/kyb-deep-link). diff --git a/docs/api/pages/14-managed-profiles.md b/docs/api/pages/14-managed-profiles.md index 4335fb2a4..4e60de1a2 100644 --- a/docs/api/pages/14-managed-profiles.md +++ b/docs/api/pages/14-managed-profiles.md @@ -91,7 +91,7 @@ X-API-Key: sk_live_... X-Managed-Profile-Id: 00000000-0000-0000-0000-000000000002 Content-Type: application/json -{ "accountType": "INDIVIDUAL", "name": "Ana Maria Silva", "taxId": "12345678901" } +{ "accountType": "INDIVIDUAL", "name": "Ana Maria Silva", "taxId": "52998224725" } ``` ```http From 15f37c7f84b2a0cb7eb1718167368a2b1191b003 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 29 Sep 2026 16:18:12 +0200 Subject: [PATCH 05/10] docs(repo): add operator runbook for releasing a squatted BRL tax id 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. --- docs/README.md | 1 + docs/operations-brl-tax-id-claim-release.md | 120 ++++++++++++++++++++ 2 files changed, 121 insertions(+) create mode 100644 docs/operations-brl-tax-id-claim-release.md diff --git a/docs/README.md b/docs/README.md index c9fdfc620..a956d2bda 100644 --- a/docs/README.md +++ b/docs/README.md @@ -25,6 +25,7 @@ The smaller set of general project documents stays directly in `docs/`: | [`architecture-email-notifications.md`](architecture-email-notifications.md) | Current transactional/auth email architecture: queue, dispatch, producers | | [`architecture-identity-model.md`](architecture-identity-model.md) | Current cross-module identity and ownership architecture | | [`architecture-monerium-b2b-onramp.md`](architecture-monerium-b2b-onramp.md) | Current end-to-end architecture of the B2B EUR onramp: onboarding, deposit-to-payout, batching, fees, data model | +| [`operations-brl-tax-id-claim-release.md`](operations-brl-tax-id-claim-release.md) | Operator runbook for releasing a BRL tax ID squatted through `createSubaccount` (RISK-026) | | [`operations-demo-environment.md`](operations-demo-environment.md) | Setup and runbook for the sandbox sales-demo account | | [`operations-legacy-schema-cleanup.md`](operations-legacy-schema-cleanup.md) | Deployment gates and recovery runbook for irreversible migrations 060-061 | | [`operations-monerium-b2b-rollout.md`](operations-monerium-b2b-rollout.md) | Launch gates, deploy checklist, and terms inputs for the B2B onramp pilot | diff --git a/docs/operations-brl-tax-id-claim-release.md b/docs/operations-brl-tax-id-claim-release.md new file mode 100644 index 000000000..d67e79d76 --- /dev/null +++ b/docs/operations-brl-tax-id-claim-release.md @@ -0,0 +1,120 @@ +# Releasing A Squatted BRL Tax ID + +Status: current operator runbook. It backs RISK-026 in +[`security-spec/RISK-REGISTER.md`](security-spec/RISK-REGISTER.md); the behavior it works +around is specified in [`security-spec/05-integrations/brla.md`](security-spec/05-integrations/brla.md) +(invariants 48-49). + +## When to use it + +`POST /v1/brl/createSubaccount` reserves a CPF/CNPJ for the first authenticated caller. The +unique index `ux_provider_customers_tax_hash` makes that reservation exclusive at once, before +the provider has verified who the caller is. If someone claimed a tax ID that is not theirs, the +real owner's `createSubaccount` fails with `409 A subaccount already exists for this taxId` and +has no self-service recovery. Use this runbook when a person (or company) shows that a tax ID +they own is held by a profile that is not theirs. + +Do not use it when the holder's row is `approved`: the provider matched documents to that tax +ID, so the holder is most likely the genuine owner. Escalate to engineering and compliance. + +## Ground rules + +- Reads use the read-only connection. Changes need a write connection and a second person who + reviews the verified row IDs before the change runs. +- Record the tax ID only as its hash in tickets and logs. Never paste the raw CPF/CNPJ. +- Run the change in one transaction and check every affected-row count against the read steps + before committing. + +## 1. Identify the claim + +Compute the hash the same way `hashTaxReference` does (SHA-256 of the digits-only value) and look +up the holder: + +```sql +SELECT encode(sha256(convert_to('', 'UTF8')), 'hex') AS tax_hash; + +SELECT pc.id, pc.customer_type, pc.status, pc.status_external, pc.provider_subaccount_id, + pc.created_at, ce.profile_id AS holder_profile_id +FROM provider_customers pc +JOIN customer_entities ce ON ce.id = pc.customer_entity_id +WHERE pc.provider = 'avenia' AND pc.tax_reference_hash = ''; +``` + +Expect exactly one row and a status other than `approved`. Note `pc.id`, +`provider_subaccount_id` and `holder_profile_id`. + +## 2. Check what the holder has started + +```sql +SELECT id, type, status, status_external, provider_case_id, submitted_at, + verification_method, verification_submission->>'status' AS submission_status +FROM kyc_cases +WHERE provider = 'avenia' AND provider_customer_id = ''; + +SELECT id, current_phase, created_at FROM ramp_states WHERE user_id = ''; +``` + +- A case with `provider_case_id` set, `in_review` status, or a `submitted`/`confirmed` submission + means an attempt exists at the provider. Ask the provider about that attempt before going on; + do not release while it could still be approved for this tax ID. +- The ramp query is expected to return no rows: onboarding must be approved before a ramp can be + registered. Stop and escalate if it returns any. + +## 3. Find the exactly-once claim + +`createSubaccount` records a `financial_operations` row keyed by the tax hash. If it is left in +place, the real owner's retry hits it with a different request hash (the owner profile is part +of the request) and fails with `409 ... already claimed with different inputs`. + +```sql +SELECT id, status, external_id, created_at +FROM financial_operations +WHERE scope_type = 'profile' AND scope_id = '' + AND phase = 'createSubaccount' AND provider = 'avenia'; +``` + +Expect exactly one row whose `external_id` equals the `provider_subaccount_id` from step 1. Any +other result means the state is not the expected squat; stop and escalate. + +One variant is still a squat: step 1 returns no row, but this query returns a row. The holder's +provider call or local write did not finish, so only the claim remains and it blocks the real +owner the same way. If its `status` is `submitted` or `unknown`, a provider subaccount may exist +without any local record; ask the provider before deleting the row, and treat `external_id` (if +set) as the subaccount for step 4. Skip the `provider_customers` and `kyc_cases` deletes in +step 5. + +## 4. Decide on the provider subaccount + +Vortex has no call to close or delete a provider subaccount, and the create call carries only +account type and name, so the provider learns the tax ID only if a KYC attempt was submitted +(step 2). Ask the provider to close or flag the subaccount id from step 1, or record it as +abandoned in the operations log. Never reassign it to the real owner: it carries the holder's +unapproved state and data. The real owner's retry creates a fresh subaccount. + +## 5. Release + +Only after steps 1-4 match, in one transaction: + +```sql +BEGIN; +DELETE FROM kyc_cases + WHERE provider = 'avenia' AND provider_customer_id = '' AND status <> 'approved'; -- rows from step 2 +DELETE FROM provider_customers + WHERE id = '' AND provider = 'avenia' AND status <> 'approved'; -- exactly 1 +DELETE FROM financial_operations + WHERE id = '' AND scope_id = '' AND phase = 'createSubaccount'; -- exactly 1 +-- compare each reported count with the read steps, then COMMIT or ROLLBACK +``` + +The holder's `customer_entities` row is the profile's own entity and stays. Decide separately +whether the holder profile should be suspended under the abuse policy. + +## 6. Verify and follow up + +- Ask the real owner to retry onboarding. `createSubaccount` should return `200` with a new + `subAccountId`, and the tax hash should now resolve to a row owned by the owner's profile. +- If the owner gets `429`, their account queried more than five distinct tax IDs of other + profiles within 24 hours on that API instance; the counter resets on deploy or after the window + passes. +- Log the tax hash, the deleted row IDs, the approver and the provider ticket. A second release + within a quarter is the trigger in RISK-026 to make the reservation exclusive only on approval. From 23dbb9869b0ce2304ec5fa1c59096fddda5a9821 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 29 Sep 2026 16:19:15 +0200 Subject: [PATCH 06/10] docs(repo): sync the security spec with the tax-id claim guards 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. --- docs/security-spec/05-integrations/brla.md | 18 ++++++++++++++---- .../security-spec/07-operations/api-surface.md | 5 ++++- docs/security-spec/RISK-REGISTER.md | 1 + 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/docs/security-spec/05-integrations/brla.md b/docs/security-spec/05-integrations/brla.md index 17eaeb65b..cb1f33568 100644 --- a/docs/security-spec/05-integrations/brla.md +++ b/docs/security-spec/05-integrations/brla.md @@ -47,6 +47,8 @@ The catalog resolves every supported EVM source through `BrlOfframpBase`. Its so Avenia requires a subaccount per user, identified by tax ID (CPF for individuals, CNPJ for businesses). The system creates and manages subaccounts through canonical `provider_customers` rows (`provider = 'avenia'`) owned by the user's `customer_entities` row. `provider_subaccount_id` stores the Avenia subaccount identifier, while normalized tax-ID lookup uses `tax_reference_hash`. +`createSubaccount` is a **reserving flow**: the unique `ux_provider_customers_tax_hash` index makes the first authenticated caller the exclusive owner of a tax ID immediately, before the provider has verified who that caller is (invariants 5, 18, 48, 49; RISK-026). Possession of the CPF/CNPJ is proven only later, by provider approval. + `POST /v1/brl/createSubaccount` accepts an **optional** `quoteId`. In the normal ramp flow it is the quote that triggered onboarding; in quote-less onboarding paths such as the **KYB deep link** (`?kyb` / `?kybLocked` widget entry, where business verification starts before any quote exists) and authenticated dashboard sender onboarding, it is omitted. Quote provenance is not persisted because those write-only fields were dropped in the `provider_customers` cutover. The value is never used as an authorization input, so its absence does not weaken any access check: the ownership guard (below) and authenticated user context gate subaccount creation independently of whether a quote is present. ### Inbound verification webhook (`POST /v1/webhooks/avenia`) @@ -151,7 +153,7 @@ The invariant `transferAmount ≥ payoutAmount` must hold (transfer covers payou 2. **PIX payout amount MUST equal `quote.outputAmount`** — `createPayOutQuote.outputAmount` is derived from the immutable stored quote; the user receives exactly the quoted net BRL (after Avenia anchor fee). 3. **The on-chain BRLA transfer amount MUST equal `quote.metadata.nablaSwapEvm.outputAmountRaw`** — This guarantees the full Nabla output reaches Avenia; Avenia keeps the anchor fee and pays the user the net amount. 4. **`brlaPayoutOnBase` MUST NOT initiate the PIX payout until the Avenia balance reflects the deposit** — The balance poll prevents calling `createPixOutputTicket` against funds that have not yet been credited. -5. **User tax ID (CPF) MUST be validated** — CPF format validation at ramp registration, not at payout time. +5. **A tax ID MUST be checksum-validated before it is claimed** — `validateSubaccountCreation` runs ahead of the `createSubaccount` controller in every environment, including sandbox. It rejects with `400`, before any `financial_operations` claim, provider POST, or `provider_customers`/`customer_entities` write: an unknown `accountType`; a `name` that is missing, not a string, blank after trimming, or longer than 255 characters (the `provider_customers.company_name` width); and a `taxId` that is missing, not a string, or not a check-digit-valid CPF for `INDIVIDUAL` / CNPJ for `COMPANY` (punctuation optional, surrounding whitespace ignored). Ramp registration does not re-run the checksum: it derives the account from the authenticated profile and treats a supplied `taxId` only as an equality cross-check (invariant 16). 6. **Avenia subaccount creation MUST be idempotent** — A durable `financial_operations` claim keyed by the normalized tax-ID hash is persisted before the provider POST. Confirmed results can therefore repair a failed local write without another POST, while submitted/unknown outcomes stop with a reconciliation error because Avenia accepts no idempotency key. Persistence is additionally serialized by normalized tax ID across API instances. If an owned canonical row already has a provider subaccount ID, retries return it unchanged, repair a missing local KYC case, and MUST NOT call Avenia, replace the ID, or reset verification. Ramp registration snapshots the chosen subaccount ID into persisted block facts so execution and recovery cannot drift to a later mutable lookup. 7. **PIX payment confirmation MUST be verified before advancing on-ramp** — `brlaOnrampMint` polls the Base ephemeral balance; advancement only on confirmed BRLA arrival. 8. **Avenia API responses MUST be validated** — Status codes, ticket IDs, and amount confirmations must be checked. `AveniaTicketStatus.FAILED` must throw an unrecoverable error; `AveniaTicketStatus.PARTIAL_FAILED` must be handled as a ticket-specific partial failure and must not be polled indefinitely or treated as a generic success. @@ -164,7 +166,7 @@ The invariant `transferAmount ≥ payoutAmount` must hold (transfer covers payou 15. **BRL→EVM quote output precision MUST match the destination token** — For supported EVM destinations, `quote.outputAmount` MUST preserve the destination token's decimal precision, and `evmToEvm.outputAmountRaw` MUST represent the destination token's raw units. The Squid bridge input remains Base USDC raw (`evmToEvm.inputAmountRaw`), but final delivery uses destination-token decimals. 16. **BRL register paths MUST derive tax ID / subaccount from the effective user** — The catalog flow's `AveniaMint.register` and `AveniaOfframpPayout.register` hooks resolve the Avenia account via `resolveAveniaAccountForRamp(userId, additionalData.taxId)` and call the block-owned `createAveniaOnrampTicket` / `validateAveniaOfframpRecipient` logic in `phases/blocks/core/avenia-registration.ts`. That module owns pending-BRL aggregation, BRL/global limit enforcement, PIX-owner masked-tax-ID matching, trusted subaccount wallet resolution, and onramp ticket creation. A client-supplied `additionalData.taxId` is accepted only when it matches the derived value (enforced identically on the onramp and offramp paths); mismatches return `400`. `additionalData.receiverTaxId` may legitimately differ from the sender and is validated downstream against the PIX key owner. `RampService` only dispatches through the flow recorded in quote metadata and projects phase-owned facts/artifacts into legacy ramp state; it does not own Avenia registration operations. 17. **`/v1/brla/getUser` and `/v1/brla/getUserRemainingLimit` MUST scope reads to the effective user** — When a `taxId` query is provided, the matching Avenia `provider_customers` row MUST belong to a `customer_entities` row owned by `getEffectiveUserId(req)`. When `taxId` is omitted, the endpoint derives the user's Avenia account via the resolver and returns `400` for zero or multiple KYC-completed matches. The legacy partner-key exemption that allowed reading any taxId has been removed; bare partner keys without a profile binding and fully anonymous callers are rejected with `400`. -18. **`/v1/brl/createSubaccount` MUST require an authenticated principal and use only canonical identity** — The route uses `requirePartnerOrUserAuth()` and the controller requires an effective user. Bare partner keys and anonymous callers receive `400`; the Avenia API is not called and no `provider_customers` row is created. Existing-tax-ID conflict and reuse decisions inspect only canonical Avenia `provider_customers` ownership. The controller does not query or adopt rows from `tax_ids`. +18. **`/v1/brl/createSubaccount` MUST require an authenticated principal and use only canonical identity** — The route uses `requirePartnerOrUserAuth()` and the controller requires an effective user. Bare partner keys and anonymous callers receive `400`; the Avenia API is not called and no `provider_customers` row is created. Existing-tax-ID conflict and reuse decisions inspect only canonical Avenia `provider_customers` ownership. The controller does not query or adopt rows from `tax_ids`. The claim is validated first (invariant 5) and bounded per principal (invariant 49), but it remains first-come (invariant 48). 19. **BRL quote creation MUST remain anonymous-eligible while register/start remain user-gated** — `POST /v1/quotes` and `POST /v1/quotes/best` accept BRL corridors from anonymous callers and partner-key callers (with or without a `userId` binding). The Avenia `createPayInQuote` calls used by the BRL engines do not require a user-bound principal. The actual Avenia subaccount/taxId resolution still happens server-side at register time via `resolveAveniaAccountForRamp(effectiveUserId, additionalData.taxId)`. `POST /v1/ramp/register` requires Supabase or secret-key credentials, and `RampService.registerRamp` rejects provider-backed ramps without an effective user with `400 Invalid quote`. **An anonymous BRL quote may be claimed by an authenticated caller** (the normal web-app funnel: quote before login, register after) — claiming is not an escalation because the anonymous quote carries no owner and the Avenia identity is derived from the claimer's own KYC records, never from the quote or request body. 20. **`brlaPayoutOnBase` MUST verify the ephemeral's BRLA balance before the first broadcast of the presigned transfer** — The presigned payout is single-use (its nonce is consumed even on revert), so the handler calls `ensurePresignedTransferFunded` before `sendRawTransaction`: sender/token/amount are decoded from the signed raw tx and the ephemeral balance is polled (3-minute timeout); a shortfall raises a recoverable error instead of burning the nonce. The Avenia-side balance poll (invariant 4) runs after this on-chain transfer and does not replace it. See `03-ramp-engine/ramp-phase-flows.md` invariant 12. 21. **Avenia company KYB completion MUST be provider-confirmed and ownership-bound** — `POST /v1/brla/kyb/new-level-1/web-sdk` stores the returned Avenia `attemptId` as the owned business `kyc_cases.provider_case_id`. Every exact-attempt read, including authenticated status, dashboard reconciliation, and the notification fallback worker, MUST send both that attempt ID and the owning business account's `provider_subaccount_id` and MUST reject a mismatched response ID. `GET /v1/brla/kyb/attempt-status` accepts only a case owned by the effective user, persists normalized status on both the case and provider customer, and returns only `status`, `retryable`, optional `result`, and optional normalized `failureReason`; dashboard reconciliation uses the same locked provider-customer-before-case persistence path and cannot downgrade terminal state. The two paths MUST settle a terminal outcome identically: whichever wins the race also persists the normalized failure reason on both canonical rows and enqueues the outcome notification in the same transaction, idempotently keyed on the attempt id — the loser (the route answers `409` on a settled case; the worker skips terminal cases) can no longer write either. Client-side events cannot assert completion: only provider `COMPLETED` plus `APPROVED` may complete onboarding; `REJECTED`, `EXPIRED`, `PENDING`, and `PROCESSING` must not pass the parent verification gate. @@ -182,7 +184,7 @@ The invariant `transferAmount ≥ payoutAmount` must hold (transfer covers payou 33. **The webhook body MUST be runtime-validated before any property is read** — A valid signature proves only that Avenia sent the bytes. `JSON.parse` alone admits `null`, arrays, scalars, and attempts missing the fields an email is rendered from, so the receiver accepts Avenia's two documented envelopes (top-level `subAccountId` or nested `event.accountId`), normalizes them, and validates the account id plus `subscription` and, when one is present, the attempt (`id`, `status`, `updatedAt` as non-empty strings; `result` and `resultMessage` as strings when present) before the first property access or database lookup. Anything failing that returns a deterministic `400` and enqueues nothing. An unrecognised *value* of `status` or `result` is not a validation failure: it is a well-formed event with no email mapped to it, and is acknowledged `200` so Avenia does not retry it indefinitely. 34. **A provider-confirmed paid initial PIX ramp on a runtime-enabled flow MUST be recoverable without current managed-profile authorization** — The client start deadline and current managed corridor/type policy continue to govern public update/start calls. The unhandled-payment worker separately compares the ramp's exact persisted Avenia ticket with the provider's `PAID` tickets. Runtime-enabled initial ramps remain pollable through the worker's three-day age window when the ticket is absent, has an unknown/non-paid status, or carries a historical unhandled-payment alert flag. When a signed, still-`initial` ramp is paid, the worker starts the persisted flow under a row lock without applying the expired client deadline or re-authorizing the now-committed manager policy. Only successful recovery suppresses later worker cycles; a failed automatic attempt remains eligible and alerts operations. Moonbeam-dependent AssetHub flows are excluded under RISK-020: the worker does not poll, recover, or alert on them, and operations must reconcile them manually. Startup compatibility checks retain the ramp's persisted flow version while such a payable ticket exists. 35. **Ramp updates MUST NOT modify persisted Avenia recovery identity** — `POST /v1/ramp/update` accepts only the documented client-reported transaction-hash fields in `additionalData`. It rejects every other key with `400`, including the registration-owned `taxId`, `subAccountId`, `aveniaTicketId`, and nested `blockState`. The unhandled-payment worker therefore compares paid provider tickets against the immutable identity snapshotted by Avenia registration. -36. **KYC preflight MUST NOT reserve a client-asserted tax identity** — `POST /v1/brla/kyc/record-attempt` may validate authentication, managed BR authorization, quote ownership, and the BRL corridor for compatibility, but MUST NOT create a `provider_customers` or `kyc_cases` row from its client-supplied CPF/CNPJ. Quote ownership proves only quote ownership. The globally unique Avenia tax hash is persisted only by the authenticated subaccount creation flow that establishes the canonical provider account. +36. **KYC preflight MUST NOT reserve a client-asserted tax identity** — `POST /v1/brla/kyc/record-attempt` may validate authentication, managed BR authorization, quote ownership, and the BRL corridor for compatibility, but MUST NOT create a `provider_customers` or `kyc_cases` row from its client-supplied CPF/CNPJ. Quote ownership proves only quote ownership. The globally unique Avenia tax hash is persisted only by the authenticated subaccount creation flow that establishes the canonical provider account. That flow reserves first-come (invariant 48); removing the preflight reservation did not remove the squatting exposure, it narrowed it to the validated, rate-bounded `createSubaccount` path (RISK-026). 37. **Concurrent Avenia KYB case creation within one API process MUST converge on one operation** — `getOrCreateAveniaKybCase` coalesces in-flight creation by provider-customer ID before calling Sequelize, so simultaneous submissions handled by the same process receive the same canonical case. The entry is removed after success or failure so later reads and retries still consult the database. This is intentionally process-local and does not provide a cross-replica database uniqueness guarantee. 38. **UBO creation MUST fail closed after an ambiguous provider outcome** — Before sending an Avenia UBO creation request, Vortex locks the provider customer, requires one canonical KYB case, and records a `prepared` submission using one-way identity and full-payload fingerprints; raw UBO identity payloads and document IDs are not persisted in this state. A parsed provider response records `confirmed` and its UBO ID. Transport errors, timeouts, rate limits, conflicts, and provider failures record `ambiguous`, and subsequent requests for that identity return `409` without another provider POST until an operator reconciles the outcome. Deterministic client rejections record `failed` and may be corrected and retried. 39. **Active-attempt reconciliation MUST NOT overwrite terminal KYB state** — Reconciliation locks and rereads both the provider customer and KYB case before applying `pending` or `in_review`. If either row has become approved or rejected since the provider attempt list was fetched, the stale active response is ignored and terminal status and lifecycle metadata remain intact. @@ -194,6 +196,8 @@ The invariant `transferAmount ≥ payoutAmount` must hold (transfer covers payou 45. **The Avenia webhook MUST remain notification-only for imported KYC** — A signed event may enqueue an idempotent notification but MUST NOT approve or otherwise mutate verification state. Exact-attempt polling remains the authoritative persistence path. 46. **Consent evidence MUST remain explicit and provisional** — The request requires literal `consentAttested: true`; Vortex appends an entry containing actor, subject, timestamp, and server-controlled policy `sumsub-share-v1` to `verification_submission.consentAttestations` without the raw token. Replacing a provider-`401` failed claim with a new idempotency key MUST preserve prior entries and append the new attestation. This policy is enabled provisionally and MUST NOT be represented as replacing legal basis, applicant disclosure, biometric/special-category consent, or data-transfer obligations while legal/provider confirmations remain unresolved. 47. **Standard individual KYC submission MUST be durable, request-bound, authorized at claim time, and privacy-safe** — Vortex MUST persist the submission JSON on the locked canonical case before the Avenia Level 1 POST and atomically persist its complete attempt baseline and case `submitted_at` when claiming it as `submitted`. Deterministic provider client rejections create no attempt, so they record `failed` with a fixed `400` and may be corrected and retried, mirroring UBO creation. Timeouts, rate limits, conflicts, 5xx, and local-confirmation outcomes become `ambiguous`; reconciliation uses that timestamp, excludes baseline and attempts bound to other cases, fails closed when it is absent, and never sends a second POST for an active claim. Avenia's own status and detail MUST NOT be forwarded to the caller. Prepared, submitted, ambiguous, and confirmed reuse MUST match actor, subject, and the SHA-256 digest of the canonical flat payload before reconciliation or replay; mismatches return a fixed `409` without a provider POST. Unmanaged calls MUST have `actor === subject` with no managed selectors. Managed calls MUST identify the controlling manager separately, allow only `actor === controlling manager` or `actor === subject`, and lock and revalidate the controlling active manager, exact active manager/subject/relationship ID, `BR` permission, null-means-all individual policy, managed subject, and expected active individual entity during preparation and immediately before `prepared -> submitted`. Second-check revocation or a concurrent canonical approval MUST prevent the provider POST. Confirmation MUST bind its exact attempt ID even when approval won concurrently, without downgrading terminal state or timestamps. A provider-confirmed retryable terminal may replace the prior confirmed JSON with a prepared claim carrying the new request fingerprint; confirmation clears prior case approval/rejection timestamps and failure reasons before returning it to pending. A standard provider state of `COMPLETED` without `APPROVED` or `REJECTED` MUST fail closed before either direct-status or onboarding persistence. The standard identity payload MUST use the Avenia client's sensitive-body mode, and raw payloads, digest inputs, request logs, provider error details, and thrown errors MUST NOT contain identity values echoed by Avenia. +48. **A tax-ID claim MUST be bound to the identity later verified on that subaccount** — The unique tax hash is exclusive from the first `createSubaccount`, so nothing else may let the claimant verify a different identity under it. `newKyc` MUST return `400` before any provider call or KYC claim when the normalized `taxIdNumber` (non-string values included) does not hash, via `hashTaxReference`, to the resolved record's `tax_reference_hash`. `submitKybLevel1Api` MUST apply the same rule to `taxIdentificationNumberTin` before any provider read or write. Formatted and plain forms of one tax ID are equal. The claim is released only by an operator (`docs/operations-brl-tax-id-claim-release.md`); no request path releases another profile's unapproved claim. +49. **Tax-ID-keyed BRL routes MUST bound how many other profiles' tax IDs one principal can probe** — `createSubaccount`, `getUser`, `getUserRemainingLimit`, `getKycStatus`, `getUploadUrls`, and `getSelfieLivenessUrl` run `limitDistinctTaxIds` after authentication and managed-profile resolution (and after body validation on the POST routes). A checksum-valid CPF/CNPJ is recorded for the principal (`getEffectiveUserId(req)`, else `req.ip`) only when the answer reveals that another profile holds it: `403` on any of the six routes, or `409` on `createSubaccount`. Once a principal has 5 such distinct tax IDs within 24 hours (last-hit window), any further tax ID answers `429` before any lookup or write. The principal's own tax IDs (`200`) and unregistered ones (`404`, also the "create it first" signal for a new customer) never count, so a partner onboarding many customers from one profile and the widget's 2-second status polling are unaffected; consequently claiming unregistered tax IDs is not rate-bounded per principal (see the squatting row). The tax ID is read where the handler reads it (query for GET/HEAD, body for POST), so a body cannot shadow the query. Malformed values are ignored, not counted. State is in memory per API instance: the effective cap is 5 x instances and resets on deploy. The limiter does not change any status code, so the `404` (not created) vs `403` (other-owned) distinction that the KYC state machine and SDK rely on remains observable within the cap (RISK-026). ## Threat Vectors & Mitigations @@ -220,6 +224,9 @@ The invariant `transferAmount ≥ payoutAmount` must hold (transfer covers payou | **Company KYB status bypass or cross-user attempt lookup** | A browser asserts that hosted verification finished, or probes another user's Avenia attempt ID and receives provider submission metadata. | Initiation binds the attempt to the authenticated user's KYB case; every exact lookup checks that binding and scopes the provider request with the owning subaccount before the call, rejects a mismatched response ID, minimizes its response, and accepts only provider-confirmed `COMPLETED` + `APPROVED`. | | **Duplicate KYB attempt while provider processing is active** | A caller starts another API or hosted KYB attempt while Avenia is already processing one for the company. | Both creation paths list attempts for the ownership-verified subaccount. Hosted creation rejects an active attempt. API creation transactionally binds exactly one active attempt and returns it without POSTing; ambiguous multiple-active results fail closed. A provider `409` triggers the same scoped re-query and exact-one reconciliation. Terminal attempts are left to Avenia's retry rules. | | **Tax-ID reservation through KYC preflight** | An authenticated attacker owns a BRL quote but submits a victim's valid CPF/CNPJ to the initial-attempt endpoint, attempting to occupy the globally unique Avenia tax hash. | The endpoint retains its empty compatibility response and quote checks but performs no identity persistence. Only canonical subaccount creation may create the globally reserving provider-customer row. | +| **CPF/CNPJ squatting through subaccount creation** | An authenticated attacker (open email-OTP signup) submits a target's valid CPF/CNPJ to `createSubaccount` before the owner does. The unique tax hash gives the attacker exclusive ownership; the real owner gets `409` and cannot recover unaided. | Mitigated, not eliminated (RISK-026). Checksum, type-match, and `name` validation reject junk ids, including the `sha256("")` bind and the TypeError path (invariant 5); probing tax IDs already held by other profiles is capped per principal (invariant 49), but claiming unregistered ones is not, because it is indistinguishable from a partner onboarding new customers; the claimant cannot verify a different identity under the claim (invariant 48); an unapproved claim is releasable by the operator runbook. Residual: an attacker with many sessions or IPs can still claim many valid ids, and the claim stays exclusive until an operator releases it. The full fix (exclusive only once the provider approves) is not implemented. | +| **Tax-ID existence enumeration** | An attacker probes `getUser`, `getKycStatus`, `getUserRemainingLimit`, `getUploadUrls`, or `getSelfieLivenessUrl` with candidate ids and reads the differing answers for unknown vs. other-owned tax IDs. | The limiter covers all six routes and caps the other-owned tax IDs one principal can probe (the answers that leak), on top of the global 100 req/min per IP limit (invariant 49). Ownership checks are unchanged. Residual: status codes are deliberately not collapsed (`packages/kyc` treats `404` from `getUser` as "create the subaccount"; the SDK treats `404` from `getUserRemainingLimit` as "skip pre-flight"), and many principals or instances multiply the cap (RISK-026). | +| **KYC/KYB identity swap on a claimed tax ID** | An attacker claims CPF/CNPJ X, then submits KYC/KYB with different documents and a different tax ID Y so the provider approves Y while Vortex stores X. | `newKyc` and `submitKybLevel1Api` reject a mismatching `taxIdNumber` / `taxIdentificationNumberTin` with `400` before any provider call (invariant 48). The provider remains the authority on whether the documents match the submitted tax ID. | | **Share-token replay or ambiguous duplicate import** | A timeout or malformed provider response causes the caller to resend a bearer-like identity-transfer token, potentially creating multiple attempts or transferring data twice. | A durable pre-send claim and token digest serialize submission. Same-key/same-token retries may reconcile through provider reads without another POST or token send. Deterministic provider rejections created no attempt, so they are failed/retriable with a new key; other unresolved outcomes remain quarantined and are never automatically replayed. | | **Share-token disclosure** | Request/error logging, telemetry, provider errors, or support tooling captures the token and enables unauthorized identity-data transfer. | Sensitive-body provider mode, flat sanitized errors, strict observability exclusion, request-memory-only handling, and digest-only persistence prevent raw-token persistence or emission. | | **KYC completion spoofing** | A caller treats token possession, import acceptance, a Sumsub result, or a webhook as proof of approval. | The canonical case binds one exact Avenia attempt; only exact polling of Avenia `COMPLETED + APPROVED` can approve. `EXPIRED` remains non-approved and pending reconciliation, and the webhook is notification-only. | @@ -231,7 +238,10 @@ The invariant `transferAmount ≥ payoutAmount` must hold (transfer covers payou - [x] BRL↔AssetHub runtime-disabled. **PASS** — quote eligibility rejects both directions, registration/start return unavailable, and the phase processor holds persisted flow identities before a lock or executor. The catalog remains only for decoding and history compatibility. - [x] `brlaPayoutOnBase` PIX amount equals `quote.outputAmount`. **PASS** — `createPayOutQuote.outputAmount = amountForQuote = new Big(quote.outputAmount).round(2,0)`. - [x] On-chain BRLA transfer amount equals the subsidy-adjusted full swap output. **PASS** — `metadata.blocks.aveniaOfframpPayout.transferAmountRaw` is derived from the post-subsidy BRLA phase input and is used unchanged by the payout transaction preparer; the PIX amount remains immutable `quote.outputAmount`. -- [x] User CPF/tax ID is validated at ramp registration (not at payout). **PASS** — CPF validation present in registration flow. +- [x] A CPF/CNPJ is checksum-validated before it is claimed. **PASS** — `validateSubaccountCreation` rejects an invalid, wrongly typed, or non-string `taxId` and an invalid `name` with `400` before the controller in every environment; `validators.test.ts` and the `createSubaccount` controller tests assert that malformed bodies never reach the provider mock or the database. Ramp registration does not re-run the checksum (it derives the account from the profile). +- [x] `newKyc` and the KYB API submission are bound to the claimed tax ID. **PASS** — a `taxIdNumber` / `taxIdentificationNumberTin` that is not a string or does not match the claimed tax ID (punctuation ignored) returns `400` before any provider call; controller tests cover mismatch, non-string, and formatted-equivalent inputs. +- [x] Tax-ID-keyed BRL routes run the distinct-tax-ID limiter. **PASS** — `distinctTaxIdLimiter.test.ts` covers unlimited repeats, the sixth distinct id, principal isolation, IP fallback, window expiry, malformed values, and GET/POST/HEAD source selection, and asserts the limiter is mounted on all six routes. +- [ ] Tax-ID squatting and enumeration are fully closed. **OPEN (RISK-026)** — accepted lean mitigation only; a valid unclaimed tax ID can still be claimed exclusively by any signed-in session until an operator releases it. - [x] Avenia subaccount creation is idempotent. **PASS** — returns an owned canonical account unchanged; otherwise a durable provider-operation claim makes confirmed results replayable and ambiguous results reconciliation-only before the provider call can be repeated. - [x] Paid initial PIX ramps are recovered automatically. **PASS** — the unhandled-payment worker keeps absent, unknown, non-paid, and historically alerted initial tickets pollable through the three-day age window, starts the persisted flow only after Avenia reports the exact ticket `PAID`, suppresses successful recovery, and retries failed attempts while alerting operations. - [x] Ramp updates cannot replace Avenia recovery identity. **PASS** — `RampService.updateRamp` allowlists client-reported transaction hashes and rejects registration-owned identity and block state. diff --git a/docs/security-spec/07-operations/api-surface.md b/docs/security-spec/07-operations/api-surface.md index 8b1162035..a3b968b09 100644 --- a/docs/security-spec/07-operations/api-surface.md +++ b/docs/security-spec/07-operations/api-surface.md @@ -7,6 +7,7 @@ This spec covers the external-facing attack surface of the Vortex API (`apps/api **Express configuration** (`config/express.ts`): - CORS: Explicit origin whitelist — `app.vortexfinance.co`, `dashboard.vortexfinance.co`, `metrics.vortexfinance.co`, staging Netlify and localhost (non-production only, gated on `DEPLOYMENT_ENV`), plus optional comma-separated fixed origins from `DASHBOARD_ORIGINS` and `BROWSER_SDK_ORIGINS` (resolved once at boot, wildcard entries dropped) and the optional `DASHBOARD_PREVIEW_SITE` env var (a single Netlify site slug; enables the fixed-shape pattern `https://deploy-preview---.netlify.app` for dashboard deploy previews, non-production only; helpers in `config/corsOrigins.ts`) - Rate limiting: 100 requests per minute per IP (global, all endpoints) +- Distinct-tax-ID limiter (`middlewares/distinctTaxIdLimiter.ts`): on the tax-keyed `/v1/brl/*` routes only, a principal that probed 5 distinct tax IDs held by other profiles within 24 hours gets `429` for further tax IDs (in memory, per API instance) - Helmet: Standard HTTP security headers - Body parser: JSON with **20MB limit**, except auth-first `POST /v1/brl/kyc/import-token` (also available at legacy `/v1/brla/kyc/import-token`), whose route-local parser has a **16 KiB limit** - Cookie parser: Enabled (for Supabase auth tokens) @@ -77,6 +78,7 @@ This spec covers the external-facing attack surface of the Vortex API (`apps/api 25. **Headless profile lifecycle MUST fail closed** — Manager lifecycle routes derive the manager from a Supabase session or profile-bound secret credential and require its current manager configuration to be active. Creation requires an immutable provider contact email separate from the child's null login email; normalized contact emails are unique and permanently reserved within each manager. Child reads, credential management, and deletion are scoped by both manager and child profile IDs so foreign relationships are indistinguishable from missing rows. Only the manager-scoped child-credential route may issue credentials for a managed subject; generic profile-managed and admin partner-managed creation reject them. Credential creation and logical deletion lock the child profile and relationship in a common order; deletion is idempotent, revokes child credentials in the same transaction, and leaves retained provider, KYC, quote, ramp, and callback state intact. Managed profiles cannot create a second customer-entity type after provisioning. 26. **Managed selector handling MUST be explicit per route** — Recipient invite preview and acceptance reject `X-Managed-Profile-Id` rather than redeeming as a selected child; sender-side recipient routes are delegated only after managed-profile authorization. Direct child credentials are rejected from webhook and manager lifecycle routes. Managed children have one immutable active customer entity from provisioning, so `PUT /v1/onboarding/active-entity` is not a delegated child operation. The legacy Monerium and Mykobo routes are the accepted exception: they ignore the selector and remain scoped to the Supabase-authenticated manager. Managed clients must not send the header to those routes, and dashboard child mode disables those actions. 27. **Public onboarding discovery MUST keep OpenAPI authoritative for request schemas** — `GET /v1/onboarding/requirements` is unauthenticated and returns only the reviewed static Avenia/Alfredpay flow identity, document requirements, ordered non-GET API/hosted/upload actions, workflow value bindings, and documentation/OpenAPI links. Initial reads, readiness getters, redirect getters, and status polling MUST NOT be advertised; integration documentation and OpenAPI own those completion details. No top-level field catalog or independent request schema is returned. `fixedBody`, `fixedQuery`, and `derivedValues` may bind provider discriminators or prior step outputs only to body/query fields accepted by the referenced OpenAPI operation. The endpoint MUST NOT inspect profile state, return customer or provider identifiers, accept an owner selector, or advertise unsupported combinations such as AR business or Monerium flows. Every advertised API step, request-schema fragment, and workflow-binding target is checked against the reviewed OpenAPI document so stale mappings fail the documentation gate. +28. **Tax-ID-keyed BRL routes MUST bound other-owned tax IDs probed per principal** — `createSubaccount`, `getUser`, `getUserRemainingLimit`, `getKycStatus`, `getUploadUrls`, and `getSelfieLivenessUrl` run `limitDistinctTaxIds` after authentication and managed-profile resolution. The principal is `getEffectiveUserId(req)` (the managed child for delegated calls), else `req.ip`; a checksum-valid CPF/CNPJ counts only when answered `403` (or `409` on `createSubaccount`), and after 5 distinct ones within 24 hours further tax IDs get `429`. Own and unregistered tax IDs never count, repeated use of one tax ID is unlimited (status polling), malformed values are ignored, and the tax ID is read from the query for GET/HEAD and from the body for POST. State is per instance, so the effective cap is 5 x instances and resets on deploy. Details and the residual risk are in `05-integrations/brla.md` invariant 49 and RISK-026. ## Threat Vectors & Mitigations @@ -84,7 +86,7 @@ This spec covers the external-facing attack surface of the Vortex API (`apps/api | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **⚠️ Memory exhaustion via large request body** — Attacker sends a 20MB JSON payload repeatedly to exhaust server memory | Rate limiting (100 req/min) provides some protection, but 100 requests × 20MB = 2GB of memory pressure per minute per IP. **The 20MB limit should be reduced to 1-10MB.** | | **CORS bypass** — Attacker's site makes cross-origin requests to the API | Explicit origin whitelist prevents this. However, the whitelist includes `staging--pendulum-pay.netlify.app` — if the staging site is compromised or has XSS, it becomes a CORS-allowed origin in production. | -| **Rate limit bypass via IP rotation** — Attacker uses multiple IPs to exceed per-IP rate limits | No mitigation beyond the per-IP limit. No account-based rate limiting, no endpoint-specific limits, no progressive penalties. High-value endpoints (ramp creation, quote generation) get the same limit as read-only endpoints. | +| **Rate limit bypass via IP rotation** — Attacker uses multiple IPs to exceed per-IP rate limits | No mitigation beyond the per-IP limit, except the narrow distinct-tax-ID limiter on the tax-keyed `/v1/brl/*` routes, which is keyed on the effective user (IP for anonymous callers) and, being per instance, is also multiplied by IP or session rotation. Otherwise no account-based rate limiting, no endpoint-specific limits, no progressive penalties. High-value endpoints (ramp creation, quote generation) get the same limit as read-only endpoints. | | **Input validation bypass** — Validator doesn't check a field that the controller uses | Hand-written validators are prone to omissions. No schema library enforces completeness. New fields added to controllers may not get corresponding validators. | | **Mass assignment** — Extra fields in the request body are passed to database operations | Validators check for expected fields but don't strip unknown fields. If a controller passes `req.body` directly to a database query (e.g., Sequelize `create(req.body)`), extra fields could set unintended columns. | | **Error response information leak** — The `errors` array in error responses reveals internal validation logic or database field names | Error handler wraps errors in `APIError`. The `errors` array content depends on what validators put there. Validator messages reference field names from the API schema, not necessarily database internals, but should be audited. | @@ -123,6 +125,7 @@ This spec covers the external-facing attack surface of the Vortex API (`apps/api - [ ] Verify partner-facing API observability writes are best-effort and cannot alter response status, response body, or quote/ramp state. - [x] Verify active maintenance windows are enforced by the backend on quote creation and ramp register/update/start, not only by frontend UI state. - [x] `GET /v1/ramp/history` precedes the dynamic `/:id` route, requires an effective user, and returns only non-initial ramps whose `RampState.userId` matches that user. HTTP tests cover multiple destination wallets, cross-user isolation, user-scoped API keys, anonymous rejection, and the `403` for a partner-only secret key (no partner-wide fallback). +- [x] Tax-keyed BRL routes (`createSubaccount`, `getUser`, `getUserRemainingLimit`, `getKycStatus`, `getUploadUrls`, `getSelfieLivenessUrl`) run the distinct-tax-ID limiter. **PASS** — `distinctTaxIdLimiter.test.ts` asserts the middleware is mounted on all six routes and covers own and unregistered ids never counting, the sixth id after five other-owned hits, `409` counting only on `createSubaccount`, principal isolation, and window expiry. The per-instance in-memory ceiling is accepted under RISK-026. - [x] Unified credential creation enforces five active non-expired rows per profile under a profile lock, and revocation updates one whole credential by ID. - [x] Public/header/body and public/secret mismatches return `403 CREDENTIAL_MISMATCH`. - [x] Backend startup checks the full unified schema and requires the legacy `api_keys` table to be absent before listening. diff --git a/docs/security-spec/RISK-REGISTER.md b/docs/security-spec/RISK-REGISTER.md index 0d68a7221..17419697d 100644 --- a/docs/security-spec/RISK-REGISTER.md +++ b/docs/security-spec/RISK-REGISTER.md @@ -46,6 +46,7 @@ register and the owning module specification. | RISK-023 | Accepted | High | Payments Platform + Operations | The active Monerium EUR onramp attributes settlement from the linked owner's EURe balance increasing by the quoted post-fee amount. It does not correlate a provider issue order or mint transaction to the ramp, so an unrelated, duplicate, late, concurrent, or replacement-ramp credit can satisfy the delta. | Registration snapshots the owner balance after resolving one approved profile/Polygon EOA/IBAN match and rejects a second live ramp for the same owner (which would share the permit nonce and race for one credit); IBAN moves are refused while a ramp waits on the current wallet; execution transfers only the exact quoted amount; excess stays with the owner; caller-controlled provider identity is rejected. | Implement deterministic provider-order or mint-transaction correlation before increasing Monerium volume, operating concurrent/replacement ramps for one owner, or claiming payment-level attribution. | | RISK-024 | Accepted | High | Payments Platform + Product | The Monerium owner permit expires one week after preparation (the swap presign deadline) and can become stale if its nonce is consumed before SEPA settlement. There is no automatic reauthorization, refund, or recovery path, so late settlement can leave EURe in the owner's wallet and require manual resolution. | Payment instructions are withheld until the exact permit and downstream presigns validate; execution rechecks nonce, deadline, allowance, and balances and fails closed rather than broadening authorization. | Add a safe re-sign/recovery/refund flow and define the accepted SEPA settlement window before unattended operation at material volume. | | RISK-025 | Accepted | Medium | Payments Platform + Product | Monerium profiles onboarded through the OAuth application are invisible to the white-label application, so their EUR readiness and ramp registration depend on an access/refresh token pair that exists only in backend memory. A backend restart, token revocation, or refresh failure makes such a user unregisterable until they reconnect Monerium. | Reads and registration fail closed with `MONERIUM_REAUTHENTICATION_REQUIRED`; the dashboard and widget prompt a reconnect; no token is persisted, so nothing at rest can be stolen. | Add an encrypted refresh-token store or migrate OAuth profiles into the white-label application before relying on unattended re-registration or observing reconnect prompts at material volume. | +| RISK-026 | Accepted | Medium | Payments Platform + Product + Operations | Brazilian tax-ID squatting and enumeration. `createSubaccount` reserves a CPF/CNPJ exclusively for the first authenticated caller (unique `ux_provider_customers_tax_hash`) before the provider has verified who the caller is, and Supabase sign-up is open OTP. Any signed-in session can therefore claim a valid, unclaimed tax ID; the real owner then receives `409` and cannot recover without an operator. The tax-keyed lookup routes also answer differently for unknown and other-owned tax IDs, so they can be used to test whether a CPF is registered. The product owner accepted a lean mitigation instead of making the reservation exclusive only on provider approval. | Checksum, account-type and `name` validation before any provider call or write; `newKyc` / KYB submission bound to the claimed tax ID; a per-principal cap of 5 distinct other-owned tax IDs probed per 24 h (429) on the six tax-keyed `/v1/brl` routes, in memory per API instance (claiming unregistered tax IDs is deliberately not capped, so partners onboarding many customers from one profile are unaffected); the global 100 req/min per IP limiter; unchanged ownership checks and status codes; operator release runbook ([`operations-brl-tax-id-claim-release.md`](../operations-brl-tax-id-claim-release.md)). | Implement exclusivity only on provider approval (unapproved claims contestable or expiring) before BRL onboarding volume grows materially, when a second squat needs an operator release, when the 429 log shows probing, or when the limiter must be shared across instances (database-backed). | ## Review cadence From 8328767efeb1f05aeb2c124172da7d724cc0092e Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 30 Sep 2026 12:32:02 +0200 Subject: [PATCH 07/10] revert(api): drop the distinct tax id limiter from the brl routes 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. --- .../middlewares/distinctTaxIdLimiter.test.ts | 189 ------------------ .../api/middlewares/distinctTaxIdLimiter.ts | 78 -------- apps/api/src/api/routes/v1/brla.route.ts | 10 - 3 files changed, 277 deletions(-) delete mode 100644 apps/api/src/api/middlewares/distinctTaxIdLimiter.test.ts delete mode 100644 apps/api/src/api/middlewares/distinctTaxIdLimiter.ts diff --git a/apps/api/src/api/middlewares/distinctTaxIdLimiter.test.ts b/apps/api/src/api/middlewares/distinctTaxIdLimiter.test.ts deleted file mode 100644 index 44a7fdada..000000000 --- a/apps/api/src/api/middlewares/distinctTaxIdLimiter.test.ts +++ /dev/null @@ -1,189 +0,0 @@ -import { EventEmitter } from "node:events"; -import { isValidCnpj, isValidCpf } from "@vortexfi/shared"; -import { afterEach, describe, expect, it, mock, setSystemTime } from "bun:test"; -import type { NextFunction, Request, Response } from "express"; -import httpStatus from "http-status"; -import brlaRoutes from "../routes/v1/brla.route"; -import { createDistinctTaxIdLimiter, limitDistinctTaxIds } from "./distinctTaxIdLimiter"; - -// Synthetic identifiers with valid check digits (never real people or companies). -const CPFS = ["52998224725", "11144477735", "39053344705", "15350972057", "16899535009", "87435698032", "00783214090"]; -const CNPJ = "11222333000181"; -const { FORBIDDEN, CONFLICT, NOT_FOUND, OK, TOO_MANY_REQUESTS } = httpStatus; - -// Runs the limiter; when it passes the request on, the "controller" answers with `answer`. -function call( - limiter: ReturnType, - request: { body?: unknown; ip?: string; method?: string; path?: string; query?: unknown; userId?: string }, - answer: number = OK -) { - const req = { ip: "203.0.113.7", method: "GET", path: "/getUser", ...request } as unknown as Request; - const res = Object.assign(new EventEmitter(), { statusCode: 0, body: undefined as unknown }) as unknown as Response & { - body?: unknown; - }; - res.status = mock((code: number) => { - res.statusCode = code; - return res; - }) as Response["status"]; - res.json = mock((payload: unknown) => { - res.body = payload; - return res; - }) as Response["json"]; - const next = mock(() => { - res.statusCode = answer; - res.emit("finish"); - }) as unknown as NextFunction; - limiter(req, res, next); - return { limited: res.statusCode === TOO_MANY_REQUESTS, next, res }; -} - -const get = (taxId: unknown, userId = "user-1") => ({ method: "GET", query: { taxId }, userId }); -const createSubaccount = (taxId: string, userId = "user-1") => ({ - body: { taxId }, - method: "POST", - path: "/createSubaccount", - query: {}, - userId -}); -// Five distinct probes of other profiles' tax ids exhaust the budget. -const exhaust = (limiter: ReturnType, userId = "user-1") => { - for (const cpf of CPFS.slice(0, 5)) expect(call(limiter, get(cpf, userId), FORBIDDEN).limited).toBe(false); -}; - -afterEach(() => setSystemTime()); - -describe("distinctTaxIdLimiter", () => { - it("uses only checksum-valid identifiers in this file", () => { - expect(CPFS.every(isValidCpf)).toBe(true); - expect(isValidCnpj(CNPJ)).toBe(true); - }); - - it("never limits a profile's own or unknown tax ids, however many", () => { - const limiter = createDistinctTaxIdLimiter(); - // A partner serving many customers from one profile: each is new (404), then its own (200). - for (let i = 0; i < 200; i++) { - const cpf = CPFS[i % CPFS.length]; - expect(call(limiter, get(cpf), i < CPFS.length ? NOT_FOUND : OK).limited).toBe(false); - expect(call(limiter, createSubaccount(cpf), OK).limited).toBe(false); - } - }); - - it("rejects a sixth distinct tax id after five probes of other profiles' tax ids", () => { - const limiter = createDistinctTaxIdLimiter(); - exhaust(limiter); - - const sixth = call(limiter, get(CPFS[5])); - expect(sixth.limited).toBe(true); - expect(sixth.next).not.toHaveBeenCalled(); - expect(sixth.res.body).toEqual({ error: "Too many distinct tax IDs for this account. Try again later." }); - // Repeating a tax id already probed reveals nothing new and still passes. - expect(call(limiter, get(CPFS[0]), FORBIDDEN).limited).toBe(false); - }); - - it("counts a createSubaccount conflict but not a conflict on the lookups", () => { - const limiter = createDistinctTaxIdLimiter(); - for (const cpf of CPFS.slice(0, 5)) call(limiter, { ...get(cpf), path: "/getKycStatus" }, CONFLICT); - expect(call(limiter, get(CPFS[5])).limited).toBe(false); - - for (const cpf of CPFS.slice(0, 5)) call(limiter, createSubaccount(cpf), CONFLICT); - expect(call(limiter, get(CPFS[5])).limited).toBe(true); - }); - - it("treats formatted and plain forms of one tax id as the same probe", () => { - const limiter = createDistinctTaxIdLimiter(); - exhaust(limiter); - - expect(call(limiter, get("529.982.247-25"), FORBIDDEN).limited).toBe(false); - expect(call(limiter, get("111.444.777-35"), FORBIDDEN).limited).toBe(false); - }); - - it("counts CNPJs and CPFs in the same budget", () => { - const limiter = createDistinctTaxIdLimiter(); - for (const cpf of CPFS.slice(0, 4)) call(limiter, get(cpf), FORBIDDEN); - call(limiter, get(CNPJ), FORBIDDEN); - - expect(call(limiter, get(CPFS[4])).limited).toBe(true); - }); - - it("keeps principals independent", () => { - const limiter = createDistinctTaxIdLimiter(); - exhaust(limiter, "user-1"); - - expect(call(limiter, get(CPFS[5], "user-1")).limited).toBe(true); - expect(call(limiter, get(CPFS[5], "user-2")).limited).toBe(false); - }); - - it("falls back to the client IP for anonymous callers", () => { - const limiter = createDistinctTaxIdLimiter(); - const anonymous = (taxId: string, ip: string) => ({ ...get(taxId), ip, userId: undefined }); - for (const cpf of CPFS.slice(0, 5)) call(limiter, anonymous(cpf, "203.0.113.1"), FORBIDDEN); - - expect(call(limiter, anonymous(CPFS[5], "203.0.113.1")).limited).toBe(true); - expect(call(limiter, anonymous(CPFS[5], "203.0.113.2")).limited).toBe(false); - }); - - it("frees the budget once the window has passed", () => { - const limiter = createDistinctTaxIdLimiter(); - const start = new Date("2026-01-01T00:00:00Z"); - setSystemTime(start); - exhaust(limiter); - expect(call(limiter, get(CPFS[5])).limited).toBe(true); - - setSystemTime(new Date(start.getTime() + 24 * 60 * 60 * 1000 - 1)); - expect(call(limiter, get(CPFS[5])).limited).toBe(true); - - setSystemTime(new Date(start.getTime() + 24 * 60 * 60 * 1000)); - expect(call(limiter, get(CPFS[5])).limited).toBe(false); - }); - - it("ignores requests without a checksum-valid tax id", () => { - const limiter = createDistinctTaxIdLimiter(); - const junk = ["abc", "", "12345678901", "52998224724", CPFS[0].slice(0, 10), undefined, null, 52998224725, [CPFS[1]]]; - for (const taxId of junk) { - const { limited, next } = call(limiter, get(taxId), FORBIDDEN); - expect(limited).toBe(false); - expect(next).toHaveBeenCalledTimes(1); - } - - // Junk did not consume any of the budget. - exhaust(limiter); - expect(call(limiter, get(CPFS[5])).limited).toBe(true); - }); - - it("reads the body on POST and the query on GET and HEAD", () => { - const limiter = createDistinctTaxIdLimiter(); - for (const cpf of CPFS.slice(0, 5)) call(limiter, createSubaccount(cpf), CONFLICT); - - expect(call(limiter, createSubaccount(CPFS[5])).limited).toBe(true); - expect(call(limiter, get(CPFS[5])).limited).toBe(true); - expect(call(limiter, { ...get(CPFS[5]), method: "HEAD" }).limited).toBe(true); - }); - - it("cannot be bypassed by a body that shadows the query on a read route", () => { - const limiter = createDistinctTaxIdLimiter(); - const shadowed = (taxId: string) => ({ body: { taxId: CPFS[0] }, method: "GET", query: { taxId }, userId: "user-1" }); - for (const cpf of CPFS.slice(0, 5)) call(limiter, shadowed(cpf), FORBIDDEN); - - expect(call(limiter, shadowed(CPFS[5])).limited).toBe(true); - }); -}); - -describe("brla routes", () => { - it("run the limiter on every tax-keyed route", () => { - const routes = [ - ["get", "/getUser"], - ["get", "/getUserRemainingLimit"], - ["get", "/getKycStatus"], - ["get", "/getSelfieLivenessUrl"], - ["post", "/createSubaccount"], - ["post", "/getUploadUrls"] - ] as const; - const stack = (brlaRoutes as unknown as { stack: Array<{ route?: { path: string; methods: Record; stack: Array<{ handle: unknown }> } }> }) - .stack; - - for (const [method, path] of routes) { - const layer = stack.find(entry => entry.route?.path === path && entry.route.methods[method]); - expect(layer?.route?.stack.some(handler => handler.handle === limitDistinctTaxIds)).toBe(true); - } - }); -}); diff --git a/apps/api/src/api/middlewares/distinctTaxIdLimiter.ts b/apps/api/src/api/middlewares/distinctTaxIdLimiter.ts deleted file mode 100644 index 4f8aeda8a..000000000 --- a/apps/api/src/api/middlewares/distinctTaxIdLimiter.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { isValidCnpj, isValidCpf, normalizeTaxId } from "@vortexfi/shared"; -import type { Request, RequestHandler, Response } from "express"; -import httpStatus from "http-status"; -import logger from "../../config/logger"; -import { hashTaxReference } from "../services/avenia/avenia-customer.service"; -import { getEffectiveUserId } from "./effectiveUser"; - -const WINDOW_MS = 24 * 60 * 60 * 1000; -const MAX_FOREIGN_TAX_IDS = 5; -const SWEEP_ABOVE_PRINCIPALS = 10_000; - -// Only an answer about another profile's tax id leaks anything or blocks its owner: the lookups -// answer 403 for it, createSubaccount 409. Own tax ids (200) and unknown ones (404, which is also -// the "create it first" signal for a new customer) never count, so a partner serving many -// customers from one profile is not limited. -function answeredForeignTaxId(req: Request, res: Response): boolean { - return ( - res.statusCode === httpStatus.FORBIDDEN || - (res.statusCode === httpStatus.CONFLICT && req.method === "POST" && req.path === "/createSubaccount") - ); -} - -/** - * Limits how many DISTINCT tax ids owned by other profiles one principal may probe on the - * tax-keyed /brl routes per window, to curb CPF existence enumeration (403 vs. 404) and squatting - * probes (409). After that many hits the principal gets 429 for any new tax id until the oldest - * hit ages out. Only checksum-valid CPF/CNPJ are considered. - * - * The principal is the effective user (the managed child for delegated calls), else the client IP. - * ponytail: state is per API instance and in memory, so the effective cap is 5 x instances and - * resets on deploy; move it to the database if abuse persists. - */ -export function createDistinctTaxIdLimiter(): RequestHandler { - // principal -> (hash of another profile's tax id it probed -> when) - const foreignHits = new Map>(); - - const dropExpired = (hits: Map, now: number) => { - for (const [taxIdHash, at] of hits) if (now - at >= WINDOW_MS) hits.delete(taxIdHash); - }; - - return (req, res, next) => { - // Read the same place the controllers do: the POST routes use the body, the read routes (GET, - // and the HEAD Express routes to them) the query. A body on a GET must not shadow the query. - const raw: unknown = req.method === "POST" ? req.body?.taxId : req.query?.taxId; - const taxId = typeof raw === "string" ? normalizeTaxId(raw) : ""; - if (!isValidCpf(taxId) && !isValidCnpj(taxId)) { - next(); - return; - } - - const principal = getEffectiveUserId(req) ?? `ip:${req.ip}`; - const taxIdHash = hashTaxReference(taxId); - const hits = foreignHits.get(principal); - if (hits) dropExpired(hits, Date.now()); - if (hits && !hits.has(taxIdHash) && hits.size >= MAX_FOREIGN_TAX_IDS) { - logger.warn("Foreign tax id probe limit reached", { principal }); - res.status(httpStatus.TOO_MANY_REQUESTS).json({ error: "Too many distinct tax IDs for this account. Try again later." }); - return; - } - - res.on("finish", () => { - if (!answeredForeignTaxId(req, res)) return; - const now = Date.now(); - const entries = foreignHits.get(principal) ?? new Map(); - entries.set(taxIdHash, now); - foreignHits.set(principal, entries); - if (foreignHits.size > SWEEP_ABOVE_PRINCIPALS) { - for (const [key, stale] of foreignHits) { - dropExpired(stale, now); - if (stale.size === 0) foreignHits.delete(key); - } - } - }); - next(); - }; -} - -export const limitDistinctTaxIds = createDistinctTaxIdLimiter(); diff --git a/apps/api/src/api/routes/v1/brla.route.ts b/apps/api/src/api/routes/v1/brla.route.ts index ff253c6b0..8e05bd369 100644 --- a/apps/api/src/api/routes/v1/brla.route.ts +++ b/apps/api/src/api/routes/v1/brla.route.ts @@ -1,7 +1,6 @@ import { RequestHandler, Router } from "express"; import * as brlaController from "../../controllers/brla.controller"; import { rejectImpersonation } from "../../middlewares/bearerPrincipal"; -import { limitDistinctTaxIds } from "../../middlewares/distinctTaxIdLimiter"; import { optionalPartnerOrUserAuth, requirePartnerOrUserAuth } from "../../middlewares/dualAuth"; import { authorizeManagedProfile } from "../../middlewares/managedProfileAuth"; import { @@ -21,14 +20,10 @@ const router: Router = Router({ mergeParams: true }); // /getUser, /getUserRemainingLimit, and /validatePixKey use optionalPartnerOrUserAuth so that SDK // clients without API keys can drive a BRL ramp pre-flight against fully-anonymous quotes. The // controllers themselves apply ownership scoping using `getEffectiveUserId`; -// -// The routes keyed by a `taxId` (getUser, getUserRemainingLimit, getKycStatus, getSelfieLivenessUrl, -// createSubaccount, getUploadUrls) run `limitDistinctTaxIds` after auth so the principal is resolved. router.get( "/getUser", optionalPartnerOrUserAuth(), authorizeManagedProfile(), - limitDistinctTaxIds, brlaController.getAveniaUser as unknown as RequestHandler ); @@ -36,7 +31,6 @@ router.get( "/getUserRemainingLimit", optionalPartnerOrUserAuth(), authorizeManagedProfile(), - limitDistinctTaxIds, brlaController.getAveniaUserRemainingLimit as unknown as RequestHandler ); @@ -44,7 +38,6 @@ router.get( "/getKycStatus", requirePartnerOrUserAuth(), authorizeManagedProfile(), - limitDistinctTaxIds, brlaController.fetchSubaccountKycStatus as unknown as RequestHandler ); @@ -53,7 +46,6 @@ router.get( requirePartnerOrUserAuth(), authorizeManagedProfile({ corridor: "BR" }), rejectImpersonation, - limitDistinctTaxIds, brlaController.getSelfieLivenessUrl as unknown as RequestHandler ); @@ -66,7 +58,6 @@ router authorizeManagedProfile({ corridor: "BR" }), rejectImpersonation, validateSubaccountCreation, - limitDistinctTaxIds, brlaController.createSubaccount as unknown as RequestHandler ); @@ -77,7 +68,6 @@ router authorizeManagedProfile({ corridor: "BR", customerType: "individual" }), rejectImpersonation, validateStartKyc2, - limitDistinctTaxIds, brlaController.getUploadUrls ); From 71743d5210d91f6703ea09cec88794b3f9ab9f81 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 30 Sep 2026 12:34:49 +0200 Subject: [PATCH 08/10] docs(api): drop the 429 tax id limit from the brl operations --- docs/api/openapi/vortex.openapi.d.ts | 15 --------------- docs/api/openapi/vortex.openapi.json | 28 ---------------------------- docs/api/pages/09-fiat-corridors.md | 2 -- 3 files changed, 45 deletions(-) diff --git a/docs/api/openapi/vortex.openapi.d.ts b/docs/api/openapi/vortex.openapi.d.ts index c710ca7b7..b7529567d 100644 --- a/docs/api/openapi/vortex.openapi.d.ts +++ b/docs/api/openapi/vortex.openapi.d.ts @@ -3541,15 +3541,6 @@ export interface components { }; }; }; - /** @description Too many tax IDs of other accounts. An account (or, for unauthenticated requests, a client IP) that queried 5 different CPF/CNPJ values belonging to other accounts within 24 hours (answered `403`, or `409` on `createSubaccount`) receives this for any further tax ID on the tax-ID-keyed BR operations (`createSubaccount`, `getUser`, `getUserRemainingLimit`, `getKycStatus`, `getUploadUrls`, `getSelfieLivenessUrl`). Tax IDs the account owns and tax IDs not yet registered never count, and repeating a tax ID is never limited. Retry after the 24-hour window has passed. */ - TooManyDistinctTaxIds: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["BrErrorResponse"]; - }; - }; }; parameters: { /** @description Selects one active, directly managed child as the effective subject. Use the controlling manager's secret `X-API-Key`, or its Supabase Bearer session where that operation accepts Bearer authentication. Public keys and direct child credentials cannot use this selector; a direct child credential already acts as its own subject without the header. Invalid UUIDs return `400 INVALID_MANAGED_PROFILE_ID`, missing authentication returns `401 AUTHENTICATION_REQUIRED`, and unauthorized, deleted, malformed, or corridor-disallowed children return `403 MANAGED_PROFILE_ACCESS_DENIED`. */ @@ -3860,7 +3851,6 @@ export interface operations { }; 401: components["responses"]["ManagedSelectorUnauthorized"]; 403: components["responses"]["BrlaManagedSelectorForbidden"]; - 429: components["responses"]["TooManyDistinctTaxIds"]; /** @description Internal Server Error. */ 500: { headers: { @@ -3925,7 +3915,6 @@ export interface operations { "application/json": components["schemas"]["BrErrorResponse"]; }; }; - 429: components["responses"]["TooManyDistinctTaxIds"]; /** @description Internal Server Error (e.g., no KYC events found when expected). */ 500: { headers: { @@ -3990,7 +3979,6 @@ export interface operations { "application/json": components["schemas"]["BrErrorResponse"]; }; }; - 429: components["responses"]["TooManyDistinctTaxIds"]; /** @description Internal server error. */ 500: { headers: { @@ -4056,7 +4044,6 @@ export interface operations { "application/json": components["schemas"]["BrErrorResponse"]; }; }; - 429: components["responses"]["TooManyDistinctTaxIds"]; /** @description Internal server error. */ 500: { headers: { @@ -4124,7 +4111,6 @@ export interface operations { "application/json": components["schemas"]["BrErrorResponse"]; }; }; - 429: components["responses"]["TooManyDistinctTaxIds"]; /** @description Internal Server Error. */ 500: { headers: { @@ -4185,7 +4171,6 @@ export interface operations { "application/json": components["schemas"]["BrErrorResponse"]; }; }; - 429: components["responses"]["TooManyDistinctTaxIds"]; /** @description Internal Server Error. */ 500: { headers: { diff --git a/docs/api/openapi/vortex.openapi.json b/docs/api/openapi/vortex.openapi.json index ad8571e65..d3f0757f7 100644 --- a/docs/api/openapi/vortex.openapi.json +++ b/docs/api/openapi/vortex.openapi.json @@ -80,16 +80,6 @@ } }, "description": "" - }, - "TooManyDistinctTaxIds": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BrErrorResponse" - } - } - }, - "description": "Too many tax IDs of other accounts. An account (or, for unauthenticated requests, a client IP) that queried 5 different CPF/CNPJ values belonging to other accounts within 24 hours (answered `403`, or `409` on `createSubaccount`) receives this for any further tax ID on the tax-ID-keyed BR operations (`createSubaccount`, `getUser`, `getUserRemainingLimit`, `getKycStatus`, `getUploadUrls`, `getSelfieLivenessUrl`). Tax IDs the account owns and tax IDs not yet registered never count, and repeating a tax ID is never limited. Retry after the 24-hour window has passed." } }, "schemas": { @@ -4667,9 +4657,6 @@ "403": { "$ref": "#/components/responses/BrlaManagedSelectorForbidden" }, - "429": { - "$ref": "#/components/responses/TooManyDistinctTaxIds" - }, "500": { "content": { "application/json": { @@ -4764,9 +4751,6 @@ "description": "The canonical KYC state requires reconciliation.", "headers": {} }, - "429": { - "$ref": "#/components/responses/TooManyDistinctTaxIds" - }, "500": { "content": { "application/json": { @@ -4861,9 +4845,6 @@ "description": "The immutable KYC method or canonical case state conflicts with liveness creation.", "headers": {} }, - "429": { - "$ref": "#/components/responses/TooManyDistinctTaxIds" - }, "500": { "content": { "application/json": { @@ -4959,9 +4940,6 @@ "description": "The immutable KYC method or canonical case state conflicts with upload creation.", "headers": {} }, - "429": { - "$ref": "#/components/responses/TooManyDistinctTaxIds" - }, "500": { "content": { "application/json": { @@ -5057,9 +5035,6 @@ "description": "Subaccount not found.", "headers": {} }, - "429": { - "$ref": "#/components/responses/TooManyDistinctTaxIds" - }, "500": { "content": { "application/json": { @@ -5153,9 +5128,6 @@ "description": "Subaccount not found or limits not found.", "headers": {} }, - "429": { - "$ref": "#/components/responses/TooManyDistinctTaxIds" - }, "500": { "content": { "application/json": { diff --git a/docs/api/pages/09-fiat-corridors.md b/docs/api/pages/09-fiat-corridors.md index b7f269cd6..100265516 100644 --- a/docs/api/pages/09-fiat-corridors.md +++ b/docs/api/pages/09-fiat-corridors.md @@ -39,8 +39,6 @@ Use `/v1/brl/*` for BRL account and verification operations. The previous `/v1/b `POST /v1/brl/createSubaccount` reserves the tax ID for the calling account, so it validates the request before anything is created: `name` must be 1 to 255 characters and `taxId` must be a check-digit-valid CPF for `INDIVIDUAL` or CNPJ for `COMPANY` (punctuation optional). Anything else returns `400`. The submission that follows must carry the same tax ID: `taxIdNumber` on `newKyc` and `taxIdentificationNumberTin` on the business submission are compared with the tax ID the subaccount was created with (punctuation ignored), and a different value returns `400` before anything reaches the provider. -To limit tax ID enumeration, an account (or the client IP for unauthenticated requests) that queried 5 distinct tax IDs belonging to other accounts within 24 hours (answered `403`, or `409` on `createSubaccount`) receives `429` for any further tax ID on `createSubaccount`, `getUser`, `getUserRemainingLimit`, `getKycStatus`, `getUploadUrls`, and `getSelfieLivenessUrl`. Tax IDs your account owns and tax IDs not yet registered never count, so onboarding many customers and status polling are unaffected. - Level 1 onboarding collects basic identity information and enables lower-limit BRL flows. Level 2 adds document and liveness verification and may be required for higher limits or stricter compliance rules. The user must have completed KYC on the same account whose key registers the ramp; otherwise the ramp may fail or require additional account-management steps. A normal partner key cannot select an arbitrary user. An enabled managed-profile manager may use a secret `sk_*` key or Supabase session with `X-Managed-Profile-Id` to drive supported BR KYC operations for its directly managed child when the manager has the `BR` corridor and the child's immutable type is allowed by both current manager policy and Vortex's BR capability matrix. A null manager customer-type policy adds no further restriction. A public `pk_*` key is insufficient. When possible, use the Vortex application or hosted widget to complete onboarding before ramp execution. Business users can be sent straight into verification with the [KYB Deep Link](https://api-docs.vortexfinance.co/kyb-deep-link). From f29ac8e6d0076b5539107a5f10bf0fd10dc08535 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 30 Sep 2026 16:02:01 +0200 Subject: [PATCH 09/10] docs(repo): drop the tax id limiter from the spec and narrow invariant 48 --- docs/operations-brl-tax-id-claim-release.md | 5 +---- docs/security-spec/05-integrations/brla.md | 15 ++++++--------- docs/security-spec/07-operations/api-surface.md | 5 +---- docs/security-spec/RISK-REGISTER.md | 2 +- 4 files changed, 9 insertions(+), 18 deletions(-) diff --git a/docs/operations-brl-tax-id-claim-release.md b/docs/operations-brl-tax-id-claim-release.md index d67e79d76..937749783 100644 --- a/docs/operations-brl-tax-id-claim-release.md +++ b/docs/operations-brl-tax-id-claim-release.md @@ -3,7 +3,7 @@ Status: current operator runbook. It backs RISK-026 in [`security-spec/RISK-REGISTER.md`](security-spec/RISK-REGISTER.md); the behavior it works around is specified in [`security-spec/05-integrations/brla.md`](security-spec/05-integrations/brla.md) -(invariants 48-49). +(invariant 48). ## When to use it @@ -113,8 +113,5 @@ whether the holder profile should be suspended under the abuse policy. - Ask the real owner to retry onboarding. `createSubaccount` should return `200` with a new `subAccountId`, and the tax hash should now resolve to a row owned by the owner's profile. -- If the owner gets `429`, their account queried more than five distinct tax IDs of other - profiles within 24 hours on that API instance; the counter resets on deploy or after the window - passes. - Log the tax hash, the deleted row IDs, the approver and the provider ticket. A second release within a quarter is the trigger in RISK-026 to make the reservation exclusive only on approval. diff --git a/docs/security-spec/05-integrations/brla.md b/docs/security-spec/05-integrations/brla.md index cb1f33568..1f647081a 100644 --- a/docs/security-spec/05-integrations/brla.md +++ b/docs/security-spec/05-integrations/brla.md @@ -47,7 +47,7 @@ The catalog resolves every supported EVM source through `BrlOfframpBase`. Its so Avenia requires a subaccount per user, identified by tax ID (CPF for individuals, CNPJ for businesses). The system creates and manages subaccounts through canonical `provider_customers` rows (`provider = 'avenia'`) owned by the user's `customer_entities` row. `provider_subaccount_id` stores the Avenia subaccount identifier, while normalized tax-ID lookup uses `tax_reference_hash`. -`createSubaccount` is a **reserving flow**: the unique `ux_provider_customers_tax_hash` index makes the first authenticated caller the exclusive owner of a tax ID immediately, before the provider has verified who that caller is (invariants 5, 18, 48, 49; RISK-026). Possession of the CPF/CNPJ is proven only later, by provider approval. +`createSubaccount` is a **reserving flow**: the unique `ux_provider_customers_tax_hash` index makes the first authenticated caller the exclusive owner of a tax ID immediately, before the provider has verified who that caller is (invariants 5, 18, 48; RISK-026). Possession of the CPF/CNPJ is proven only later, by provider approval. `POST /v1/brl/createSubaccount` accepts an **optional** `quoteId`. In the normal ramp flow it is the quote that triggered onboarding; in quote-less onboarding paths such as the **KYB deep link** (`?kyb` / `?kybLocked` widget entry, where business verification starts before any quote exists) and authenticated dashboard sender onboarding, it is omitted. Quote provenance is not persisted because those write-only fields were dropped in the `provider_customers` cutover. The value is never used as an authorization input, so its absence does not weaken any access check: the ownership guard (below) and authenticated user context gate subaccount creation independently of whether a quote is present. @@ -166,7 +166,7 @@ The invariant `transferAmount ≥ payoutAmount` must hold (transfer covers payou 15. **BRL→EVM quote output precision MUST match the destination token** — For supported EVM destinations, `quote.outputAmount` MUST preserve the destination token's decimal precision, and `evmToEvm.outputAmountRaw` MUST represent the destination token's raw units. The Squid bridge input remains Base USDC raw (`evmToEvm.inputAmountRaw`), but final delivery uses destination-token decimals. 16. **BRL register paths MUST derive tax ID / subaccount from the effective user** — The catalog flow's `AveniaMint.register` and `AveniaOfframpPayout.register` hooks resolve the Avenia account via `resolveAveniaAccountForRamp(userId, additionalData.taxId)` and call the block-owned `createAveniaOnrampTicket` / `validateAveniaOfframpRecipient` logic in `phases/blocks/core/avenia-registration.ts`. That module owns pending-BRL aggregation, BRL/global limit enforcement, PIX-owner masked-tax-ID matching, trusted subaccount wallet resolution, and onramp ticket creation. A client-supplied `additionalData.taxId` is accepted only when it matches the derived value (enforced identically on the onramp and offramp paths); mismatches return `400`. `additionalData.receiverTaxId` may legitimately differ from the sender and is validated downstream against the PIX key owner. `RampService` only dispatches through the flow recorded in quote metadata and projects phase-owned facts/artifacts into legacy ramp state; it does not own Avenia registration operations. 17. **`/v1/brla/getUser` and `/v1/brla/getUserRemainingLimit` MUST scope reads to the effective user** — When a `taxId` query is provided, the matching Avenia `provider_customers` row MUST belong to a `customer_entities` row owned by `getEffectiveUserId(req)`. When `taxId` is omitted, the endpoint derives the user's Avenia account via the resolver and returns `400` for zero or multiple KYC-completed matches. The legacy partner-key exemption that allowed reading any taxId has been removed; bare partner keys without a profile binding and fully anonymous callers are rejected with `400`. -18. **`/v1/brl/createSubaccount` MUST require an authenticated principal and use only canonical identity** — The route uses `requirePartnerOrUserAuth()` and the controller requires an effective user. Bare partner keys and anonymous callers receive `400`; the Avenia API is not called and no `provider_customers` row is created. Existing-tax-ID conflict and reuse decisions inspect only canonical Avenia `provider_customers` ownership. The controller does not query or adopt rows from `tax_ids`. The claim is validated first (invariant 5) and bounded per principal (invariant 49), but it remains first-come (invariant 48). +18. **`/v1/brl/createSubaccount` MUST require an authenticated principal and use only canonical identity** — The route uses `requirePartnerOrUserAuth()` and the controller requires an effective user. Bare partner keys and anonymous callers receive `400`; the Avenia API is not called and no `provider_customers` row is created. Existing-tax-ID conflict and reuse decisions inspect only canonical Avenia `provider_customers` ownership. The controller does not query or adopt rows from `tax_ids`. The claim is validated first (invariant 5), but it remains first-come (invariant 48). 19. **BRL quote creation MUST remain anonymous-eligible while register/start remain user-gated** — `POST /v1/quotes` and `POST /v1/quotes/best` accept BRL corridors from anonymous callers and partner-key callers (with or without a `userId` binding). The Avenia `createPayInQuote` calls used by the BRL engines do not require a user-bound principal. The actual Avenia subaccount/taxId resolution still happens server-side at register time via `resolveAveniaAccountForRamp(effectiveUserId, additionalData.taxId)`. `POST /v1/ramp/register` requires Supabase or secret-key credentials, and `RampService.registerRamp` rejects provider-backed ramps without an effective user with `400 Invalid quote`. **An anonymous BRL quote may be claimed by an authenticated caller** (the normal web-app funnel: quote before login, register after) — claiming is not an escalation because the anonymous quote carries no owner and the Avenia identity is derived from the claimer's own KYC records, never from the quote or request body. 20. **`brlaPayoutOnBase` MUST verify the ephemeral's BRLA balance before the first broadcast of the presigned transfer** — The presigned payout is single-use (its nonce is consumed even on revert), so the handler calls `ensurePresignedTransferFunded` before `sendRawTransaction`: sender/token/amount are decoded from the signed raw tx and the ephemeral balance is polled (3-minute timeout); a shortfall raises a recoverable error instead of burning the nonce. The Avenia-side balance poll (invariant 4) runs after this on-chain transfer and does not replace it. See `03-ramp-engine/ramp-phase-flows.md` invariant 12. 21. **Avenia company KYB completion MUST be provider-confirmed and ownership-bound** — `POST /v1/brla/kyb/new-level-1/web-sdk` stores the returned Avenia `attemptId` as the owned business `kyc_cases.provider_case_id`. Every exact-attempt read, including authenticated status, dashboard reconciliation, and the notification fallback worker, MUST send both that attempt ID and the owning business account's `provider_subaccount_id` and MUST reject a mismatched response ID. `GET /v1/brla/kyb/attempt-status` accepts only a case owned by the effective user, persists normalized status on both the case and provider customer, and returns only `status`, `retryable`, optional `result`, and optional normalized `failureReason`; dashboard reconciliation uses the same locked provider-customer-before-case persistence path and cannot downgrade terminal state. The two paths MUST settle a terminal outcome identically: whichever wins the race also persists the normalized failure reason on both canonical rows and enqueues the outcome notification in the same transaction, idempotently keyed on the attempt id — the loser (the route answers `409` on a settled case; the worker skips terminal cases) can no longer write either. Client-side events cannot assert completion: only provider `COMPLETED` plus `APPROVED` may complete onboarding; `REJECTED`, `EXPIRED`, `PENDING`, and `PROCESSING` must not pass the parent verification gate. @@ -184,7 +184,7 @@ The invariant `transferAmount ≥ payoutAmount` must hold (transfer covers payou 33. **The webhook body MUST be runtime-validated before any property is read** — A valid signature proves only that Avenia sent the bytes. `JSON.parse` alone admits `null`, arrays, scalars, and attempts missing the fields an email is rendered from, so the receiver accepts Avenia's two documented envelopes (top-level `subAccountId` or nested `event.accountId`), normalizes them, and validates the account id plus `subscription` and, when one is present, the attempt (`id`, `status`, `updatedAt` as non-empty strings; `result` and `resultMessage` as strings when present) before the first property access or database lookup. Anything failing that returns a deterministic `400` and enqueues nothing. An unrecognised *value* of `status` or `result` is not a validation failure: it is a well-formed event with no email mapped to it, and is acknowledged `200` so Avenia does not retry it indefinitely. 34. **A provider-confirmed paid initial PIX ramp on a runtime-enabled flow MUST be recoverable without current managed-profile authorization** — The client start deadline and current managed corridor/type policy continue to govern public update/start calls. The unhandled-payment worker separately compares the ramp's exact persisted Avenia ticket with the provider's `PAID` tickets. Runtime-enabled initial ramps remain pollable through the worker's three-day age window when the ticket is absent, has an unknown/non-paid status, or carries a historical unhandled-payment alert flag. When a signed, still-`initial` ramp is paid, the worker starts the persisted flow under a row lock without applying the expired client deadline or re-authorizing the now-committed manager policy. Only successful recovery suppresses later worker cycles; a failed automatic attempt remains eligible and alerts operations. Moonbeam-dependent AssetHub flows are excluded under RISK-020: the worker does not poll, recover, or alert on them, and operations must reconcile them manually. Startup compatibility checks retain the ramp's persisted flow version while such a payable ticket exists. 35. **Ramp updates MUST NOT modify persisted Avenia recovery identity** — `POST /v1/ramp/update` accepts only the documented client-reported transaction-hash fields in `additionalData`. It rejects every other key with `400`, including the registration-owned `taxId`, `subAccountId`, `aveniaTicketId`, and nested `blockState`. The unhandled-payment worker therefore compares paid provider tickets against the immutable identity snapshotted by Avenia registration. -36. **KYC preflight MUST NOT reserve a client-asserted tax identity** — `POST /v1/brla/kyc/record-attempt` may validate authentication, managed BR authorization, quote ownership, and the BRL corridor for compatibility, but MUST NOT create a `provider_customers` or `kyc_cases` row from its client-supplied CPF/CNPJ. Quote ownership proves only quote ownership. The globally unique Avenia tax hash is persisted only by the authenticated subaccount creation flow that establishes the canonical provider account. That flow reserves first-come (invariant 48); removing the preflight reservation did not remove the squatting exposure, it narrowed it to the validated, rate-bounded `createSubaccount` path (RISK-026). +36. **KYC preflight MUST NOT reserve a client-asserted tax identity** — `POST /v1/brla/kyc/record-attempt` may validate authentication, managed BR authorization, quote ownership, and the BRL corridor for compatibility, but MUST NOT create a `provider_customers` or `kyc_cases` row from its client-supplied CPF/CNPJ. Quote ownership proves only quote ownership. The globally unique Avenia tax hash is persisted only by the authenticated subaccount creation flow that establishes the canonical provider account. That flow reserves first-come (invariant 48); removing the preflight reservation did not remove the squatting exposure, it narrowed it to the validated `createSubaccount` path (RISK-026). 37. **Concurrent Avenia KYB case creation within one API process MUST converge on one operation** — `getOrCreateAveniaKybCase` coalesces in-flight creation by provider-customer ID before calling Sequelize, so simultaneous submissions handled by the same process receive the same canonical case. The entry is removed after success or failure so later reads and retries still consult the database. This is intentionally process-local and does not provide a cross-replica database uniqueness guarantee. 38. **UBO creation MUST fail closed after an ambiguous provider outcome** — Before sending an Avenia UBO creation request, Vortex locks the provider customer, requires one canonical KYB case, and records a `prepared` submission using one-way identity and full-payload fingerprints; raw UBO identity payloads and document IDs are not persisted in this state. A parsed provider response records `confirmed` and its UBO ID. Transport errors, timeouts, rate limits, conflicts, and provider failures record `ambiguous`, and subsequent requests for that identity return `409` without another provider POST until an operator reconciles the outcome. Deterministic client rejections record `failed` and may be corrected and retried. 39. **Active-attempt reconciliation MUST NOT overwrite terminal KYB state** — Reconciliation locks and rereads both the provider customer and KYB case before applying `pending` or `in_review`. If either row has become approved or rejected since the provider attempt list was fetched, the stale active response is ignored and terminal status and lifecycle metadata remain intact. @@ -196,8 +196,7 @@ The invariant `transferAmount ≥ payoutAmount` must hold (transfer covers payou 45. **The Avenia webhook MUST remain notification-only for imported KYC** — A signed event may enqueue an idempotent notification but MUST NOT approve or otherwise mutate verification state. Exact-attempt polling remains the authoritative persistence path. 46. **Consent evidence MUST remain explicit and provisional** — The request requires literal `consentAttested: true`; Vortex appends an entry containing actor, subject, timestamp, and server-controlled policy `sumsub-share-v1` to `verification_submission.consentAttestations` without the raw token. Replacing a provider-`401` failed claim with a new idempotency key MUST preserve prior entries and append the new attestation. This policy is enabled provisionally and MUST NOT be represented as replacing legal basis, applicant disclosure, biometric/special-category consent, or data-transfer obligations while legal/provider confirmations remain unresolved. 47. **Standard individual KYC submission MUST be durable, request-bound, authorized at claim time, and privacy-safe** — Vortex MUST persist the submission JSON on the locked canonical case before the Avenia Level 1 POST and atomically persist its complete attempt baseline and case `submitted_at` when claiming it as `submitted`. Deterministic provider client rejections create no attempt, so they record `failed` with a fixed `400` and may be corrected and retried, mirroring UBO creation. Timeouts, rate limits, conflicts, 5xx, and local-confirmation outcomes become `ambiguous`; reconciliation uses that timestamp, excludes baseline and attempts bound to other cases, fails closed when it is absent, and never sends a second POST for an active claim. Avenia's own status and detail MUST NOT be forwarded to the caller. Prepared, submitted, ambiguous, and confirmed reuse MUST match actor, subject, and the SHA-256 digest of the canonical flat payload before reconciliation or replay; mismatches return a fixed `409` without a provider POST. Unmanaged calls MUST have `actor === subject` with no managed selectors. Managed calls MUST identify the controlling manager separately, allow only `actor === controlling manager` or `actor === subject`, and lock and revalidate the controlling active manager, exact active manager/subject/relationship ID, `BR` permission, null-means-all individual policy, managed subject, and expected active individual entity during preparation and immediately before `prepared -> submitted`. Second-check revocation or a concurrent canonical approval MUST prevent the provider POST. Confirmation MUST bind its exact attempt ID even when approval won concurrently, without downgrading terminal state or timestamps. A provider-confirmed retryable terminal may replace the prior confirmed JSON with a prepared claim carrying the new request fingerprint; confirmation clears prior case approval/rejection timestamps and failure reasons before returning it to pending. A standard provider state of `COMPLETED` without `APPROVED` or `REJECTED` MUST fail closed before either direct-status or onboarding persistence. The standard identity payload MUST use the Avenia client's sensitive-body mode, and raw payloads, digest inputs, request logs, provider error details, and thrown errors MUST NOT contain identity values echoed by Avenia. -48. **A tax-ID claim MUST be bound to the identity later verified on that subaccount** — The unique tax hash is exclusive from the first `createSubaccount`, so nothing else may let the claimant verify a different identity under it. `newKyc` MUST return `400` before any provider call or KYC claim when the normalized `taxIdNumber` (non-string values included) does not hash, via `hashTaxReference`, to the resolved record's `tax_reference_hash`. `submitKybLevel1Api` MUST apply the same rule to `taxIdentificationNumberTin` before any provider read or write. Formatted and plain forms of one tax ID are equal. The claim is released only by an operator (`docs/operations-brl-tax-id-claim-release.md`); no request path releases another profile's unapproved claim. -49. **Tax-ID-keyed BRL routes MUST bound how many other profiles' tax IDs one principal can probe** — `createSubaccount`, `getUser`, `getUserRemainingLimit`, `getKycStatus`, `getUploadUrls`, and `getSelfieLivenessUrl` run `limitDistinctTaxIds` after authentication and managed-profile resolution (and after body validation on the POST routes). A checksum-valid CPF/CNPJ is recorded for the principal (`getEffectiveUserId(req)`, else `req.ip`) only when the answer reveals that another profile holds it: `403` on any of the six routes, or `409` on `createSubaccount`. Once a principal has 5 such distinct tax IDs within 24 hours (last-hit window), any further tax ID answers `429` before any lookup or write. The principal's own tax IDs (`200`) and unregistered ones (`404`, also the "create it first" signal for a new customer) never count, so a partner onboarding many customers from one profile and the widget's 2-second status polling are unaffected; consequently claiming unregistered tax IDs is not rate-bounded per principal (see the squatting row). The tax ID is read where the handler reads it (query for GET/HEAD, body for POST), so a body cannot shadow the query. Malformed values are ignored, not counted. State is in memory per API instance: the effective cap is 5 x instances and resets on deploy. The limiter does not change any status code, so the `404` (not created) vs `403` (other-owned) distinction that the KYC state machine and SDK rely on remains observable within the cap (RISK-026). +48. **A tax-ID claim MUST be bound to the identity later verified on that subaccount** — The unique tax hash is exclusive from the first `createSubaccount`, so the identity the claimant submits through the API MUST be that tax ID. `newKyc` MUST return `400` before any provider call or KYC claim when the normalized `taxIdNumber` (non-string values included) does not hash, via `hashTaxReference`, to the resolved record's `tax_reference_hash`. `submitKybLevel1Api` MUST apply the same rule to `taxIdentificationNumberTin` before any provider read or write. Formatted and plain forms of one tax ID are equal. The claim is released only by an operator (`docs/operations-brl-tax-id-claim-release.md`); no request path releases another profile's unapproved claim. This binds only the API-submitted identity: Avenia subaccount creation sends only `accountType` and `name`, so the hosted KYB flow (`initiateKybLevel1`) and provider approval are not compared with the claimed tax ID, and `assertAveniaImportedTaxIdentity` (Sumsub token import) passes when the provider returns no tax ID (RISK-026). ## Threat Vectors & Mitigations @@ -224,9 +223,8 @@ The invariant `transferAmount ≥ payoutAmount` must hold (transfer covers payou | **Company KYB status bypass or cross-user attempt lookup** | A browser asserts that hosted verification finished, or probes another user's Avenia attempt ID and receives provider submission metadata. | Initiation binds the attempt to the authenticated user's KYB case; every exact lookup checks that binding and scopes the provider request with the owning subaccount before the call, rejects a mismatched response ID, minimizes its response, and accepts only provider-confirmed `COMPLETED` + `APPROVED`. | | **Duplicate KYB attempt while provider processing is active** | A caller starts another API or hosted KYB attempt while Avenia is already processing one for the company. | Both creation paths list attempts for the ownership-verified subaccount. Hosted creation rejects an active attempt. API creation transactionally binds exactly one active attempt and returns it without POSTing; ambiguous multiple-active results fail closed. A provider `409` triggers the same scoped re-query and exact-one reconciliation. Terminal attempts are left to Avenia's retry rules. | | **Tax-ID reservation through KYC preflight** | An authenticated attacker owns a BRL quote but submits a victim's valid CPF/CNPJ to the initial-attempt endpoint, attempting to occupy the globally unique Avenia tax hash. | The endpoint retains its empty compatibility response and quote checks but performs no identity persistence. Only canonical subaccount creation may create the globally reserving provider-customer row. | -| **CPF/CNPJ squatting through subaccount creation** | An authenticated attacker (open email-OTP signup) submits a target's valid CPF/CNPJ to `createSubaccount` before the owner does. The unique tax hash gives the attacker exclusive ownership; the real owner gets `409` and cannot recover unaided. | Mitigated, not eliminated (RISK-026). Checksum, type-match, and `name` validation reject junk ids, including the `sha256("")` bind and the TypeError path (invariant 5); probing tax IDs already held by other profiles is capped per principal (invariant 49), but claiming unregistered ones is not, because it is indistinguishable from a partner onboarding new customers; the claimant cannot verify a different identity under the claim (invariant 48); an unapproved claim is releasable by the operator runbook. Residual: an attacker with many sessions or IPs can still claim many valid ids, and the claim stays exclusive until an operator releases it. The full fix (exclusive only once the provider approves) is not implemented. | -| **Tax-ID existence enumeration** | An attacker probes `getUser`, `getKycStatus`, `getUserRemainingLimit`, `getUploadUrls`, or `getSelfieLivenessUrl` with candidate ids and reads the differing answers for unknown vs. other-owned tax IDs. | The limiter covers all six routes and caps the other-owned tax IDs one principal can probe (the answers that leak), on top of the global 100 req/min per IP limit (invariant 49). Ownership checks are unchanged. Residual: status codes are deliberately not collapsed (`packages/kyc` treats `404` from `getUser` as "create the subaccount"; the SDK treats `404` from `getUserRemainingLimit` as "skip pre-flight"), and many principals or instances multiply the cap (RISK-026). | -| **KYC/KYB identity swap on a claimed tax ID** | An attacker claims CPF/CNPJ X, then submits KYC/KYB with different documents and a different tax ID Y so the provider approves Y while Vortex stores X. | `newKyc` and `submitKybLevel1Api` reject a mismatching `taxIdNumber` / `taxIdentificationNumberTin` with `400` before any provider call (invariant 48). The provider remains the authority on whether the documents match the submitted tax ID. | +| **CPF/CNPJ squatting through subaccount creation** | An authenticated attacker (open email-OTP signup) submits a target's valid CPF/CNPJ to `createSubaccount` before the owner does. The unique tax hash gives the attacker exclusive ownership; the real owner gets `409` and cannot recover unaided. | Mitigated, not eliminated (RISK-026). Checksum, type-match, and `name` validation reject junk ids, including the `sha256("")` bind and the TypeError path (invariant 5); claiming is not capped per principal, because it is indistinguishable from a partner onboarding new customers; the identity submitted through `newKyc` / the KYB API must match the claim (invariant 48; the hosted KYB flow is not bound); an unapproved claim is releasable by the operator runbook. Residual: an attacker with many sessions or IPs can still claim many valid ids, and the claim stays exclusive until an operator releases it. The full fix (exclusive only once the provider approves) is not implemented. | +| **Tax-ID existence enumeration** | An attacker probes `getUser`, `getKycStatus`, `getUserRemainingLimit`, `getUploadUrls`, or `getSelfieLivenessUrl` with candidate ids and reads the differing answers for unknown vs. other-owned tax IDs. | Ownership checks are unchanged; probing is bounded only by the global 100 req/min per IP limit. Residual (RISK-026): status codes are deliberately not collapsed (`packages/kyc` treats `404` from `getUser` as "create the subaccount"; the SDK treats `404` from `getUserRemainingLimit` as "skip pre-flight"), so whether a tax ID is registered to another profile stays observable, and many principals or IPs multiply the per-IP limit.| **KYC/KYB identity swap on a claimed tax ID** | An attacker claims CPF/CNPJ X, then submits KYC/KYB with different documents and a different tax ID Y so the provider approves Y while Vortex stores X. | `newKyc` and `submitKybLevel1Api` reject a mismatching `taxIdNumber` / `taxIdentificationNumberTin` with `400` before any provider call (invariant 48). The provider remains the authority on whether the documents match the submitted tax ID. Residual: the hosted KYB flow and provider approval are not compared with the claimed tax ID (invariant 48, RISK-026). | | **Share-token replay or ambiguous duplicate import** | A timeout or malformed provider response causes the caller to resend a bearer-like identity-transfer token, potentially creating multiple attempts or transferring data twice. | A durable pre-send claim and token digest serialize submission. Same-key/same-token retries may reconcile through provider reads without another POST or token send. Deterministic provider rejections created no attempt, so they are failed/retriable with a new key; other unresolved outcomes remain quarantined and are never automatically replayed. | | **Share-token disclosure** | Request/error logging, telemetry, provider errors, or support tooling captures the token and enables unauthorized identity-data transfer. | Sensitive-body provider mode, flat sanitized errors, strict observability exclusion, request-memory-only handling, and digest-only persistence prevent raw-token persistence or emission. | | **KYC completion spoofing** | A caller treats token possession, import acceptance, a Sumsub result, or a webhook as proof of approval. | The canonical case binds one exact Avenia attempt; only exact polling of Avenia `COMPLETED + APPROVED` can approve. `EXPIRED` remains non-approved and pending reconciliation, and the webhook is notification-only. | @@ -240,7 +238,6 @@ The invariant `transferAmount ≥ payoutAmount` must hold (transfer covers payou - [x] On-chain BRLA transfer amount equals the subsidy-adjusted full swap output. **PASS** — `metadata.blocks.aveniaOfframpPayout.transferAmountRaw` is derived from the post-subsidy BRLA phase input and is used unchanged by the payout transaction preparer; the PIX amount remains immutable `quote.outputAmount`. - [x] A CPF/CNPJ is checksum-validated before it is claimed. **PASS** — `validateSubaccountCreation` rejects an invalid, wrongly typed, or non-string `taxId` and an invalid `name` with `400` before the controller in every environment; `validators.test.ts` and the `createSubaccount` controller tests assert that malformed bodies never reach the provider mock or the database. Ramp registration does not re-run the checksum (it derives the account from the profile). - [x] `newKyc` and the KYB API submission are bound to the claimed tax ID. **PASS** — a `taxIdNumber` / `taxIdentificationNumberTin` that is not a string or does not match the claimed tax ID (punctuation ignored) returns `400` before any provider call; controller tests cover mismatch, non-string, and formatted-equivalent inputs. -- [x] Tax-ID-keyed BRL routes run the distinct-tax-ID limiter. **PASS** — `distinctTaxIdLimiter.test.ts` covers unlimited repeats, the sixth distinct id, principal isolation, IP fallback, window expiry, malformed values, and GET/POST/HEAD source selection, and asserts the limiter is mounted on all six routes. - [ ] Tax-ID squatting and enumeration are fully closed. **OPEN (RISK-026)** — accepted lean mitigation only; a valid unclaimed tax ID can still be claimed exclusively by any signed-in session until an operator releases it. - [x] Avenia subaccount creation is idempotent. **PASS** — returns an owned canonical account unchanged; otherwise a durable provider-operation claim makes confirmed results replayable and ambiguous results reconciliation-only before the provider call can be repeated. - [x] Paid initial PIX ramps are recovered automatically. **PASS** — the unhandled-payment worker keeps absent, unknown, non-paid, and historically alerted initial tickets pollable through the three-day age window, starts the persisted flow only after Avenia reports the exact ticket `PAID`, suppresses successful recovery, and retries failed attempts while alerting operations. diff --git a/docs/security-spec/07-operations/api-surface.md b/docs/security-spec/07-operations/api-surface.md index a3b968b09..8b1162035 100644 --- a/docs/security-spec/07-operations/api-surface.md +++ b/docs/security-spec/07-operations/api-surface.md @@ -7,7 +7,6 @@ This spec covers the external-facing attack surface of the Vortex API (`apps/api **Express configuration** (`config/express.ts`): - CORS: Explicit origin whitelist — `app.vortexfinance.co`, `dashboard.vortexfinance.co`, `metrics.vortexfinance.co`, staging Netlify and localhost (non-production only, gated on `DEPLOYMENT_ENV`), plus optional comma-separated fixed origins from `DASHBOARD_ORIGINS` and `BROWSER_SDK_ORIGINS` (resolved once at boot, wildcard entries dropped) and the optional `DASHBOARD_PREVIEW_SITE` env var (a single Netlify site slug; enables the fixed-shape pattern `https://deploy-preview---.netlify.app` for dashboard deploy previews, non-production only; helpers in `config/corsOrigins.ts`) - Rate limiting: 100 requests per minute per IP (global, all endpoints) -- Distinct-tax-ID limiter (`middlewares/distinctTaxIdLimiter.ts`): on the tax-keyed `/v1/brl/*` routes only, a principal that probed 5 distinct tax IDs held by other profiles within 24 hours gets `429` for further tax IDs (in memory, per API instance) - Helmet: Standard HTTP security headers - Body parser: JSON with **20MB limit**, except auth-first `POST /v1/brl/kyc/import-token` (also available at legacy `/v1/brla/kyc/import-token`), whose route-local parser has a **16 KiB limit** - Cookie parser: Enabled (for Supabase auth tokens) @@ -78,7 +77,6 @@ This spec covers the external-facing attack surface of the Vortex API (`apps/api 25. **Headless profile lifecycle MUST fail closed** — Manager lifecycle routes derive the manager from a Supabase session or profile-bound secret credential and require its current manager configuration to be active. Creation requires an immutable provider contact email separate from the child's null login email; normalized contact emails are unique and permanently reserved within each manager. Child reads, credential management, and deletion are scoped by both manager and child profile IDs so foreign relationships are indistinguishable from missing rows. Only the manager-scoped child-credential route may issue credentials for a managed subject; generic profile-managed and admin partner-managed creation reject them. Credential creation and logical deletion lock the child profile and relationship in a common order; deletion is idempotent, revokes child credentials in the same transaction, and leaves retained provider, KYC, quote, ramp, and callback state intact. Managed profiles cannot create a second customer-entity type after provisioning. 26. **Managed selector handling MUST be explicit per route** — Recipient invite preview and acceptance reject `X-Managed-Profile-Id` rather than redeeming as a selected child; sender-side recipient routes are delegated only after managed-profile authorization. Direct child credentials are rejected from webhook and manager lifecycle routes. Managed children have one immutable active customer entity from provisioning, so `PUT /v1/onboarding/active-entity` is not a delegated child operation. The legacy Monerium and Mykobo routes are the accepted exception: they ignore the selector and remain scoped to the Supabase-authenticated manager. Managed clients must not send the header to those routes, and dashboard child mode disables those actions. 27. **Public onboarding discovery MUST keep OpenAPI authoritative for request schemas** — `GET /v1/onboarding/requirements` is unauthenticated and returns only the reviewed static Avenia/Alfredpay flow identity, document requirements, ordered non-GET API/hosted/upload actions, workflow value bindings, and documentation/OpenAPI links. Initial reads, readiness getters, redirect getters, and status polling MUST NOT be advertised; integration documentation and OpenAPI own those completion details. No top-level field catalog or independent request schema is returned. `fixedBody`, `fixedQuery`, and `derivedValues` may bind provider discriminators or prior step outputs only to body/query fields accepted by the referenced OpenAPI operation. The endpoint MUST NOT inspect profile state, return customer or provider identifiers, accept an owner selector, or advertise unsupported combinations such as AR business or Monerium flows. Every advertised API step, request-schema fragment, and workflow-binding target is checked against the reviewed OpenAPI document so stale mappings fail the documentation gate. -28. **Tax-ID-keyed BRL routes MUST bound other-owned tax IDs probed per principal** — `createSubaccount`, `getUser`, `getUserRemainingLimit`, `getKycStatus`, `getUploadUrls`, and `getSelfieLivenessUrl` run `limitDistinctTaxIds` after authentication and managed-profile resolution. The principal is `getEffectiveUserId(req)` (the managed child for delegated calls), else `req.ip`; a checksum-valid CPF/CNPJ counts only when answered `403` (or `409` on `createSubaccount`), and after 5 distinct ones within 24 hours further tax IDs get `429`. Own and unregistered tax IDs never count, repeated use of one tax ID is unlimited (status polling), malformed values are ignored, and the tax ID is read from the query for GET/HEAD and from the body for POST. State is per instance, so the effective cap is 5 x instances and resets on deploy. Details and the residual risk are in `05-integrations/brla.md` invariant 49 and RISK-026. ## Threat Vectors & Mitigations @@ -86,7 +84,7 @@ This spec covers the external-facing attack surface of the Vortex API (`apps/api | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **⚠️ Memory exhaustion via large request body** — Attacker sends a 20MB JSON payload repeatedly to exhaust server memory | Rate limiting (100 req/min) provides some protection, but 100 requests × 20MB = 2GB of memory pressure per minute per IP. **The 20MB limit should be reduced to 1-10MB.** | | **CORS bypass** — Attacker's site makes cross-origin requests to the API | Explicit origin whitelist prevents this. However, the whitelist includes `staging--pendulum-pay.netlify.app` — if the staging site is compromised or has XSS, it becomes a CORS-allowed origin in production. | -| **Rate limit bypass via IP rotation** — Attacker uses multiple IPs to exceed per-IP rate limits | No mitigation beyond the per-IP limit, except the narrow distinct-tax-ID limiter on the tax-keyed `/v1/brl/*` routes, which is keyed on the effective user (IP for anonymous callers) and, being per instance, is also multiplied by IP or session rotation. Otherwise no account-based rate limiting, no endpoint-specific limits, no progressive penalties. High-value endpoints (ramp creation, quote generation) get the same limit as read-only endpoints. | +| **Rate limit bypass via IP rotation** — Attacker uses multiple IPs to exceed per-IP rate limits | No mitigation beyond the per-IP limit. No account-based rate limiting, no endpoint-specific limits, no progressive penalties. High-value endpoints (ramp creation, quote generation) get the same limit as read-only endpoints. | | **Input validation bypass** — Validator doesn't check a field that the controller uses | Hand-written validators are prone to omissions. No schema library enforces completeness. New fields added to controllers may not get corresponding validators. | | **Mass assignment** — Extra fields in the request body are passed to database operations | Validators check for expected fields but don't strip unknown fields. If a controller passes `req.body` directly to a database query (e.g., Sequelize `create(req.body)`), extra fields could set unintended columns. | | **Error response information leak** — The `errors` array in error responses reveals internal validation logic or database field names | Error handler wraps errors in `APIError`. The `errors` array content depends on what validators put there. Validator messages reference field names from the API schema, not necessarily database internals, but should be audited. | @@ -125,7 +123,6 @@ This spec covers the external-facing attack surface of the Vortex API (`apps/api - [ ] Verify partner-facing API observability writes are best-effort and cannot alter response status, response body, or quote/ramp state. - [x] Verify active maintenance windows are enforced by the backend on quote creation and ramp register/update/start, not only by frontend UI state. - [x] `GET /v1/ramp/history` precedes the dynamic `/:id` route, requires an effective user, and returns only non-initial ramps whose `RampState.userId` matches that user. HTTP tests cover multiple destination wallets, cross-user isolation, user-scoped API keys, anonymous rejection, and the `403` for a partner-only secret key (no partner-wide fallback). -- [x] Tax-keyed BRL routes (`createSubaccount`, `getUser`, `getUserRemainingLimit`, `getKycStatus`, `getUploadUrls`, `getSelfieLivenessUrl`) run the distinct-tax-ID limiter. **PASS** — `distinctTaxIdLimiter.test.ts` asserts the middleware is mounted on all six routes and covers own and unregistered ids never counting, the sixth id after five other-owned hits, `409` counting only on `createSubaccount`, principal isolation, and window expiry. The per-instance in-memory ceiling is accepted under RISK-026. - [x] Unified credential creation enforces five active non-expired rows per profile under a profile lock, and revocation updates one whole credential by ID. - [x] Public/header/body and public/secret mismatches return `403 CREDENTIAL_MISMATCH`. - [x] Backend startup checks the full unified schema and requires the legacy `api_keys` table to be absent before listening. diff --git a/docs/security-spec/RISK-REGISTER.md b/docs/security-spec/RISK-REGISTER.md index 17419697d..f4ccaef16 100644 --- a/docs/security-spec/RISK-REGISTER.md +++ b/docs/security-spec/RISK-REGISTER.md @@ -46,7 +46,7 @@ register and the owning module specification. | RISK-023 | Accepted | High | Payments Platform + Operations | The active Monerium EUR onramp attributes settlement from the linked owner's EURe balance increasing by the quoted post-fee amount. It does not correlate a provider issue order or mint transaction to the ramp, so an unrelated, duplicate, late, concurrent, or replacement-ramp credit can satisfy the delta. | Registration snapshots the owner balance after resolving one approved profile/Polygon EOA/IBAN match and rejects a second live ramp for the same owner (which would share the permit nonce and race for one credit); IBAN moves are refused while a ramp waits on the current wallet; execution transfers only the exact quoted amount; excess stays with the owner; caller-controlled provider identity is rejected. | Implement deterministic provider-order or mint-transaction correlation before increasing Monerium volume, operating concurrent/replacement ramps for one owner, or claiming payment-level attribution. | | RISK-024 | Accepted | High | Payments Platform + Product | The Monerium owner permit expires one week after preparation (the swap presign deadline) and can become stale if its nonce is consumed before SEPA settlement. There is no automatic reauthorization, refund, or recovery path, so late settlement can leave EURe in the owner's wallet and require manual resolution. | Payment instructions are withheld until the exact permit and downstream presigns validate; execution rechecks nonce, deadline, allowance, and balances and fails closed rather than broadening authorization. | Add a safe re-sign/recovery/refund flow and define the accepted SEPA settlement window before unattended operation at material volume. | | RISK-025 | Accepted | Medium | Payments Platform + Product | Monerium profiles onboarded through the OAuth application are invisible to the white-label application, so their EUR readiness and ramp registration depend on an access/refresh token pair that exists only in backend memory. A backend restart, token revocation, or refresh failure makes such a user unregisterable until they reconnect Monerium. | Reads and registration fail closed with `MONERIUM_REAUTHENTICATION_REQUIRED`; the dashboard and widget prompt a reconnect; no token is persisted, so nothing at rest can be stolen. | Add an encrypted refresh-token store or migrate OAuth profiles into the white-label application before relying on unattended re-registration or observing reconnect prompts at material volume. | -| RISK-026 | Accepted | Medium | Payments Platform + Product + Operations | Brazilian tax-ID squatting and enumeration. `createSubaccount` reserves a CPF/CNPJ exclusively for the first authenticated caller (unique `ux_provider_customers_tax_hash`) before the provider has verified who the caller is, and Supabase sign-up is open OTP. Any signed-in session can therefore claim a valid, unclaimed tax ID; the real owner then receives `409` and cannot recover without an operator. The tax-keyed lookup routes also answer differently for unknown and other-owned tax IDs, so they can be used to test whether a CPF is registered. The product owner accepted a lean mitigation instead of making the reservation exclusive only on provider approval. | Checksum, account-type and `name` validation before any provider call or write; `newKyc` / KYB submission bound to the claimed tax ID; a per-principal cap of 5 distinct other-owned tax IDs probed per 24 h (429) on the six tax-keyed `/v1/brl` routes, in memory per API instance (claiming unregistered tax IDs is deliberately not capped, so partners onboarding many customers from one profile are unaffected); the global 100 req/min per IP limiter; unchanged ownership checks and status codes; operator release runbook ([`operations-brl-tax-id-claim-release.md`](../operations-brl-tax-id-claim-release.md)). | Implement exclusivity only on provider approval (unapproved claims contestable or expiring) before BRL onboarding volume grows materially, when a second squat needs an operator release, when the 429 log shows probing, or when the limiter must be shared across instances (database-backed). | +| RISK-026 | Accepted | Medium | Payments Platform + Product + Operations | Brazilian tax-ID squatting and enumeration. `createSubaccount` reserves a CPF/CNPJ exclusively for the first authenticated caller (unique `ux_provider_customers_tax_hash`) before the provider has verified who the caller is, and Supabase sign-up is open OTP. Any signed-in session can therefore claim a valid, unclaimed tax ID; the real owner then receives `409` and cannot recover without an operator. The tax-keyed lookup routes also answer differently for unknown and other-owned tax IDs, so they can be used to test whether a CPF is registered. The product owner accepted a lean mitigation instead of making the reservation exclusive only on provider approval. | Checksum, account-type and `name` validation before any provider call or write; `newKyc` / KYB submission bound to the claimed tax ID (the hosted KYB flow and provider approval are not bound); the global 100 req/min per IP limiter, which is the only bound on tax-ID existence probing; unchanged ownership checks and status codes; operator release runbook ([`operations-brl-tax-id-claim-release.md`](../operations-brl-tax-id-claim-release.md)). | Implement exclusivity only on provider approval (unapproved claims contestable or expiring) before BRL onboarding volume grows materially, when a second squat needs an operator release, or when tax-ID enumeration is observed (then answer other-owned and unregistered tax IDs identically, or cap probes per principal in the database). | ## Review cadence From c682747fc3ce93cfca011906e7cfa31d33483ceb Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 1 Oct 2026 19:37:46 +0200 Subject: [PATCH 10/10] fix(api): send avenia the trimmed subaccount name 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. --- .../src/api/controllers/brla.controller.test.ts | 14 ++++++++++++++ apps/api/src/api/controllers/brla.controller.ts | 8 +++++--- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/apps/api/src/api/controllers/brla.controller.test.ts b/apps/api/src/api/controllers/brla.controller.test.ts index 9101f82c7..26d60a8c9 100644 --- a/apps/api/src/api/controllers/brla.controller.test.ts +++ b/apps/api/src/api/controllers/brla.controller.test.ts @@ -1918,6 +1918,20 @@ describe("createSubaccount", () => { }); }); + it("sends the provider the trimmed name the validator measured", async () => { + mockBrlaApi(); + ProviderCustomer.findOne = mock(async () => null) as typeof ProviderCustomer.findOne; + ProviderCustomer.create = mock(async (values: Record) => ({ + ...values + })) as unknown as typeof ProviderCustomer.create; + const name = "a".repeat(255); + + const res = await submitThroughRoute({ ...validBody, name: ` ${name} ` }, "new-user"); + + expect(res.statusCode).toBe(httpStatus.OK); + expect(createAveniaSubaccountMock).toHaveBeenCalledWith(AveniaAccountType.INDIVIDUAL, name); + }); + it("rejects overwrite when a started record belongs to another entity", async () => { mockBrlaApi(); createAveniaSubaccountMock.mockClear(); diff --git a/apps/api/src/api/controllers/brla.controller.ts b/apps/api/src/api/controllers/brla.controller.ts index dddfd13bf..4021432ae 100644 --- a/apps/api/src/api/controllers/brla.controller.ts +++ b/apps/api/src/api/controllers/brla.controller.ts @@ -371,7 +371,9 @@ export const createSubaccount = async ( res: Response ): Promise => { try { - const { name, taxId, accountType: requestAccountType } = req.body; + const { taxId, accountType: requestAccountType } = req.body; + // validateSubaccountCreation bounded the trimmed name, so every use below sends that same value. + const name = req.body.name.trim(); const effectiveUserId = getEffectiveUserId(req); // Reject callers that do not resolve to a user (anonymous requests @@ -449,7 +451,7 @@ export const createSubaccount = async ( provider: "avenia", request: { accountType, - name: name.trim(), + name, ownerProfileId: effectiveUserId, taxReferenceHash }, @@ -459,7 +461,7 @@ export const createSubaccount = async ( let companyName: string | null = null; if (accountType === AveniaAccountType.COMPANY) { - companyName = name.trim(); + companyName = name; try { const account = await brlaApiService.subaccountInfo(id); companyName = account?.accountInfo.name?.trim() || account?.accountInfo.fullName?.trim() || companyName;