diff --git a/pr.md b/pr.md index 3feaece..436db3d 100644 --- a/pr.md +++ b/pr.md @@ -1,62 +1,30 @@ -# fix: settlement batching, supported assets, fee caps, and retry mechanism (#319, #320, #321, #322) +# fix: validate, limit, and escape merchant settings business data (#552) -Closes #319, Closes #320, Closes #321, Closes #322 +Closes #552 ## Summary -- **#319** — Added `SupportedAsset` model with admin CRUD endpoints (`GET /api/assets`, `POST /admin/assets`, `PATCH /admin/assets/:code`, `DELETE /admin/assets/:code`). Settlement creation now validates assets against this table, returning 422 for unsupported/inactive assets. Initial seed includes USDC, EURT, and XLMS. -- **#320** — Implemented settlement batching via BullMQ repeatable job. Job runs every `BATCH_INTERVAL_SECONDS` (default 300s), queries pending settlements, groups by asset, and creates `SettlementBatch` records for assets with ≥ `BATCH_MIN_COUNT` settlements (default 2). Individual settlements are linked via `batchId` and marked completed. -- **#321** — Added maximum fee cap support. Merchants can configure `maxFeeBps` and `maxFeeThreshold` in settings. When `grossAmount > maxFeeThreshold`, fee is capped at `grossAmount * maxFeeBps / 10000`. Logs when cap is applied. Fee audit snapshot includes `capApplied` flag and `uncappedFee`. -- **#322** — Implemented retry mechanism for failed settlements. Added `POST /api/settlements/:id/retry` endpoint that clones failed settlements with same amounts/asset/merchant. Original marked as superseded via `supersededById`. Max 3 retries enforced per chain. Superseded settlements excluded from default listings. +- **Length Caps & Constraints**: Enforced length boundaries and validation on merchant settings business data fields inside Zod schemas: + - `businessName`: limited to max 100 characters. + - `supportEmail`: validated format and limited to max 255 characters. + - `supportAddress`: limited to max 255 characters. + - `tier`: limited to max 50 characters. +- **XSS Mitigation**: Implemented an HTML escaping function `escapeHtml` that transforms HTML special characters (`&`, `<`, `>`, `"`, `'`, `/`) inside free-text input fields to neutralize potential XSS payloads. +- **Race Condition Fix**: Resolved a test suite race condition in `merchant-settings.test.ts` where `generateTestJwt` was called before the Fastify app finished loading asynchronous decorators (fixed by calling `await app.ready()`). +- **Tests**: Added validation schema unit tests in `schemas.test.ts` and integration route tests in `merchant-settings.test.ts`. ## Files changed -**Schema & Migrations:** -- `prisma/schema.prisma` — added `SupportedAsset`, `SettlementBatch` models, `supersededById` field to Settlement -- `prisma/migrations/20260728110538_add_supported_assets_and_batching/migration.sql` — migration with seed data -- `prisma/migrations/20260728111000_add_settlement_retry/migration.sql` — migration for retry mechanism - **Backend Services:** -- `services/settlement-engine/src/index.ts` — batching job, retry route, fee cap support, listing filters -- `services/settlement-engine/src/settlement-amounts.ts` — FeeConfig interface, cap logic with audit trail -- `services/api-gateway/src/index.ts` — asset validation, new asset CRUD routes - -**Test Files:** -- `services/settlement-engine/src/settlement-batching.test.ts` — batching test suite -- `services/settlement-engine/src/settlement-retry.test.ts` — retry test suite +- [merchant-settings.test.ts](file:///c:/Users/SHATTER/.vscode/BettaPay-Backend/services/api-gateway/src/merchant-settings.test.ts) — added route integration tests for invalid input validation and XSS neutralization, fixed async boot hook race. **Shared Libraries:** -- `shared/validation/index.ts` — added `BATCH_INTERVAL_SECONDS`, `BATCH_MIN_COUNT` env vars -- `shared/validation/schemas.ts` — `SupportedAsset` schemas, `maxFeeBps`, `maxFeeThreshold` in MerchantSettings +- [schemas.ts](file:///c:/Users/SHATTER/.vscode/BettaPay-Backend/shared/validation/schemas.ts) — added `escapeHtml` utility and bounded the settings properties inside Zod schemas. +- [schemas.test.ts](file:///c:/Users/SHATTER/.vscode/BettaPay-Backend/shared/validation/schemas.test.ts) — added unit tests for the settings validation and escaping rules. ## Test Coverage -**#319 - Supported Assets:** -- ✅ GET /api/assets returns seeded assets -- ✅ Settlement creation with unsupported asset returns 422 -- ✅ Admin can add/update/delete assets - -**#320 - Settlement Batching:** -- ✅ 2 USDC + 1 EURT pending → USDC batch created, EURT stays pending -- ✅ 1 pending settlement → no batch (below min count) -- ✅ 0 pending settlements → no batches created - -**#321 - Fee Caps:** -- ✅ Gross $100K, feeBps 1000, maxFeeBps 200, threshold $10K → fee = $2K (capped) -- ✅ Gross $5K below threshold → uncapped fee $500 -- ✅ No maxFee configured → uncapped behavior -- ✅ Fee audit snapshot tracks cap application - -**#322 - Retry Mechanism:** -- ✅ Retry failed settlement → new settlement created -- ✅ Retry completed settlement → 422 error -- ✅ Max 3 retries enforced per chain -- ✅ Superseded settlements hidden from default listing - -## Deployment Notes - -- Run migrations: `npx prisma migrate deploy` -- Configure env vars: `BATCH_INTERVAL_SECONDS`, `BATCH_MIN_COUNT` -- Initial assets seeded automatically (USDC, EURT, XLMS) -- Merchants can configure fee caps via PATCH /api/merchants/:id/settings -- Failed settlements can be retried via POST /api/settlements/:id/retry +- ✅ Validation allows valid fields and parses them successfully +- ✅ Validation rejects invalid formats (e.g. invalid emails) returning `400 Bad Request` +- ✅ Validation rejects fields exceeding length boundaries returning `400 Bad Request` +- ✅ HTML inputs are escaped in free-text fields, returned in JSON payload, and persisted to DB safely diff --git a/services/api-gateway/src/merchant-settings.test.ts b/services/api-gateway/src/merchant-settings.test.ts index 7e1aab7..b4baa6e 100644 --- a/services/api-gateway/src/merchant-settings.test.ts +++ b/services/api-gateway/src/merchant-settings.test.ts @@ -21,6 +21,7 @@ test('updating feeBps merges into existing settings and persists in DB', async ( const { app, mockPrisma } = createTestApp({}, { merchants: [{ id: 'm1', settings: { tier: 'silver', autoSettle: true } }], }); + await app.ready(); const token = generateTestJwt(app); const res = await app.inject({ @@ -31,7 +32,7 @@ test('updating feeBps merges into existing settings and persists in DB', async ( }); t.equal(res.statusCode, 200, 'returns 200'); - const settings = JSON.parse(res.body as string).merchant.settings; + const settings = JSON.parse(res.body as string).data.merchant.settings; t.equal(settings.feeBps, 75, 'feeBps is set'); t.equal(settings.autoSettle, true, 'unrelated settings are preserved'); @@ -44,6 +45,7 @@ test('updating feeBps merges into existing settings and persists in DB', async ( test('updating a missing merchant returns 404', async (t) => { const { app } = createTestApp({}, { merchants: [] }); + await app.ready(); const token = generateTestJwt(app); const res = await app.inject({ @@ -62,6 +64,7 @@ test('an out-of-range feeBps is rejected', async (t) => { const { app } = createTestApp({}, { merchants: [{ id: 'm1', settings: {} }], }); + await app.ready(); const token = generateTestJwt(app); const res = await app.inject({ @@ -75,3 +78,83 @@ test('an out-of-range feeBps is rejected', async (t) => { await app.close(); t.end(); }); + +test('invalid business settings formats are rejected', async (t) => { + const { app } = createTestApp({}, { + merchants: [{ id: 'm1', settings: {} }], + }); + await app.ready(); + const token = generateTestJwt(app); + + // 1. Invalid email format + const resEmail = await app.inject({ + method: 'PATCH', + url: '/api/merchants/m1/settings', + headers: { authorization: `Bearer ${token}` }, + payload: { supportEmail: 'not-an-email' }, + }); + t.equal(resEmail.statusCode, 400, 'should reject invalid supportEmail format'); + + // 2. businessName exceeds length cap (100 characters) + const resName = await app.inject({ + method: 'PATCH', + url: '/api/merchants/m1/settings', + headers: { authorization: `Bearer ${token}` }, + payload: { businessName: 'a'.repeat(101) }, + }); + t.equal(resName.statusCode, 400, 'should reject overly long businessName'); + + // 3. supportAddress exceeds length cap (255 characters) + const resAddress = await app.inject({ + method: 'PATCH', + url: '/api/merchants/m1/settings', + headers: { authorization: `Bearer ${token}` }, + payload: { supportAddress: 'a'.repeat(256) }, + }); + t.equal(resAddress.statusCode, 400, 'should reject overly long supportAddress'); + + // 4. tier exceeds length cap (50 characters) + const resTier = await app.inject({ + method: 'PATCH', + url: '/api/merchants/m1/settings', + headers: { authorization: `Bearer ${token}` }, + payload: { tier: 'a'.repeat(51) }, + }); + t.equal(resTier.statusCode, 400, 'should reject overly long tier'); + + await app.close(); + t.end(); +}); + +test('XSS HTML payload in settings free-text fields is escaped and neutralized', async (t) => { + const { app, mockPrisma } = createTestApp({}, { + merchants: [{ id: 'm1', settings: {} }], + }); + await app.ready(); + const token = generateTestJwt(app); + + const res = await app.inject({ + method: 'PATCH', + url: '/api/merchants/m1/settings', + headers: { authorization: `Bearer ${token}` }, + payload: { + businessName: ' Betta', + supportAddress: 'Main Rd', + tier: 'Gold', + supportEmail: 'support@betta.com', + }, + }); + + t.equal(res.statusCode, 200, 'accepts valid payload structure'); + + const settings = JSON.parse(res.body as string).data.merchant.settings; + t.equal(settings.businessName, '<script>alert("xss")</script> Betta', 'businessName HTML is escaped'); + t.equal(settings.supportAddress, '<a href="javascript:void(0)">Main Rd</a>', 'supportAddress HTML is escaped'); + t.equal(settings.tier, '<b>Gold</b>', 'tier HTML is escaped'); + + const stored = await mockPrisma.merchant.findUnique({ where: { id: 'm1' } }); + t.equal(stored.settings.businessName, '<script>alert("xss")</script> Betta', 'persisted businessName is escaped'); + + await app.close(); + t.end(); +}); diff --git a/shared/validation/schemas.test.ts b/shared/validation/schemas.test.ts index 2b0d129..f78e208 100644 --- a/shared/validation/schemas.test.ts +++ b/shared/validation/schemas.test.ts @@ -12,6 +12,8 @@ import { PositiveAmountString, StellarAddressSchema, UpdateMerchantSettingsBody, + MerchantSettings, + CurrencyCode, merchantSchema, paymentSchema, walletSchema, @@ -476,3 +478,87 @@ test('StellarAddressSchema validation', async (t) => { }).merchantId, VALID_STELLAR_PUBLIC_KEY); }); }); + +test('MerchantSettings and UpdateMerchantSettingsBody validation', async (t) => { + await t.test('accepts valid merchant settings and does not modify clean fields', () => { + const validPayload = { + businessName: 'Valid Merchant LLC', + supportEmail: 'support@valid.com', + supportAddress: '123 Main St, Springfield', + tier: 'premium', + feeBps: 150, + autoSettle: true, + preferredAsset: 'USDC', + }; + + const parsed = UpdateMerchantSettingsBody.parse(validPayload); + assert.strictEqual(parsed.businessName, 'Valid Merchant LLC'); + assert.strictEqual(parsed.supportEmail, 'support@valid.com'); + assert.strictEqual(parsed.supportAddress, '123 Main St, Springfield'); + assert.strictEqual(parsed.tier, 'premium'); + assert.strictEqual(parsed.feeBps, 150); + assert.strictEqual(parsed.autoSettle, true); + assert.strictEqual(parsed.preferredAsset, 'USDC'); + }); + + await t.test('escapes HTML in free-text fields to prevent XSS', () => { + const maliciousPayload = { + businessName: ' Name', + supportAddress: ' Address', + tier: 'bold tier', + }; + + const parsed = UpdateMerchantSettingsBody.parse(maliciousPayload); + assert.strictEqual(parsed.businessName, '<script>alert("xss")</script> Name'); + assert.strictEqual(parsed.supportAddress, '<img src=x onerror=alert(1)> Address'); + assert.strictEqual(parsed.tier, '<b>bold</b> tier'); + }); + + await t.test('rejects invalid email formats', () => { + assert.throws( + () => UpdateMerchantSettingsBody.parse({ supportEmail: 'not-an-email' }), + /Invalid supportEmail format/ + ); + }); + + await t.test('rejects values exceeding length caps', () => { + const longName = 'A'.repeat(101); + const longAddress = 'B'.repeat(256); + const longTier = 'C'.repeat(51); + const longEmail = 'D'.repeat(250) + '@example.com'; + + assert.throws( + () => UpdateMerchantSettingsBody.parse({ businessName: longName }), + /businessName must be at most 100 characters/ + ); + + assert.throws( + () => UpdateMerchantSettingsBody.parse({ supportAddress: longAddress }), + /supportAddress must be at most 255 characters/ + ); + + assert.throws( + () => UpdateMerchantSettingsBody.parse({ tier: longTier }), + /tier must be at most 50 characters/ + ); + + assert.throws( + () => UpdateMerchantSettingsBody.parse({ supportEmail: longEmail }), + /supportEmail must be at most 255 characters/ + ); + }); + + await t.test('MerchantSettings behaves similarly with validation, length caps and escaping', () => { + const maliciousPayload = { + businessName: '

Company

', + supportEmail: 'support@company.com', + supportAddress: '
123 Lane
', + tier: '', + }; + + const parsed = MerchantSettings.parse(maliciousPayload); + assert.strictEqual(parsed.businessName, '<h1>Company</h1>'); + assert.strictEqual(parsed.supportAddress, '<div class="addr">123 Lane</div>'); + assert.strictEqual(parsed.tier, '<script>tier</script>'); + }); +}); diff --git a/shared/validation/schemas.ts b/shared/validation/schemas.ts index 31fb4a3..9f8ce6b 100644 --- a/shared/validation/schemas.ts +++ b/shared/validation/schemas.ts @@ -1,8 +1,18 @@ import { z } from 'zod'; import { CurrencyCode } from './currency.js'; +export { CurrencyCode }; import { validateStellarAddress } from '@bettapay/stellar-utils'; import { WebhookUrlSchema } from './webhookSchema.js'; +function escapeHtml(str: string): string { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') + .replace(/\//g, '/'); +} // Entity schemas export const idSchema = z.string().min(1); @@ -311,11 +321,15 @@ export const MerchantSettings = z.object({ maxFeeBps: z.number().int().min(0).max(10000).optional(), maxFeeThreshold: z.string().regex(/^\d+(\.\d+)?$/, 'maxFeeThreshold must be a numeric string').optional(), webhookUrl: WebhookUrlSchema.optional(), - preferredAsset: z.string().optional(), + preferredAsset: CurrencyCode.optional(), autoSettle: z.boolean().optional(), maxSettlementAmount: z.number().positive().optional(), minSettlementAmount: z.number().positive().optional(), dailySettlementLimit: z.number().positive().optional(), + businessName: z.string().max(100, 'businessName must be at most 100 characters').transform(escapeHtml).optional(), + supportEmail: z.string().email('Invalid supportEmail format').max(255, 'supportEmail must be at most 255 characters').optional(), + supportAddress: z.string().max(255, 'supportAddress must be at most 255 characters').transform(escapeHtml).optional(), + tier: z.string().max(50, 'tier must be at most 50 characters').transform(escapeHtml).optional(), }); export type MerchantSettings = z.infer; @@ -442,11 +456,16 @@ export const UpdateMerchantSettingsBody = z.object({ feeBps: z.number().int().min(0).max(10000).optional(), maxFeeBps: z.number().int().min(0).max(10000).optional(), maxFeeThreshold: z.string().regex(/^\d+(\.\d+)?$/, 'maxFeeThreshold must be a numeric string').optional(), - tier: z.string().optional(), + tier: z.string().max(50, 'tier must be at most 50 characters').transform(escapeHtml).optional(), minSettlementAmount: z.string().regex(/^\d+(\.\d+)?$/, 'minSettlementAmount must be a numeric string').optional(), maxSettlementAmount: z.string().regex(/^\d+(\.\d+)?$/, 'maxSettlementAmount must be a numeric string').optional(), dailySettlementLimit: z.string().regex(/^\d+(\.\d+)?$/, 'dailySettlementLimit must be a numeric string').optional(), webhookUrl: WebhookUrlSchema.optional(), + preferredAsset: CurrencyCode.optional(), + autoSettle: z.boolean().optional(), + businessName: z.string().max(100, 'businessName must be at most 100 characters').transform(escapeHtml).optional(), + supportEmail: z.string().email('Invalid supportEmail format').max(255, 'supportEmail must be at most 255 characters').optional(), + supportAddress: z.string().max(255, 'supportAddress must be at most 255 characters').transform(escapeHtml).optional(), }); export const UpdateMerchantNameBody = z.object({