diff --git a/backend/jest.config.js b/backend/jest.config.js index 6817fc76..184e0766 100644 --- a/backend/jest.config.js +++ b/backend/jest.config.js @@ -40,6 +40,9 @@ module.exports = { setupFiles: ['/src/__tests__/preload.js'], moduleDirectories: ['node_modules', '/node_modules'], moduleNameMapper: { + '^@yieldvault/api-schemas$': '/../packages/api-schemas/src/index.ts', + '^@yieldvault/api-schemas/(.*)$': '/../packages/api-schemas/src/$1', + '^zod$': '/node_modules/zod', '^@yieldvault/api-schemas$': '/../packages/api-schemas/dist/index.js', '^@yieldvault/api-schemas/(.*)$': '/../packages/api-schemas/dist/$1', '^@prisma/instrumentation$': '/src/__tests__/mocks/prismainstrumentation.js', diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index bd26e320..74c2e476 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -143,6 +143,7 @@ model AdminAuditLog { @@index([actor]) } + model ApiKeyAuditEvent { id String @id @default(uuid()) actor String @@ -526,20 +527,24 @@ model ApiKey { id String @id @default(uuid()) tenantId String hashedKey String @unique - role String + role String? scopes String @default("[]") createdAt DateTime @default(now()) + updatedAt DateTime? @updatedAt expiresAt DateTime? lastUsedAt DateTime? + revoked Boolean @default(false) + revokedAt DateTime? isActive Boolean @default(true) deletedAt DateTime? deletedBy String? deletionReason String? - tenant Tenant @relation(fields: [tenantId], references: [id]) + tenant Tenant? @relation(fields: [tenantId], references: [id]) @@index([tenantId]) @@index([tenantId, deletedAt]) + @@index([hashedKey]) @@index([role]) @@index([deletedAt]) } diff --git a/backend/src/__tests__/adminCacheAndParams.test.ts b/backend/src/__tests__/adminCacheAndParams.test.ts new file mode 100644 index 00000000..73842def --- /dev/null +++ b/backend/src/__tests__/adminCacheAndParams.test.ts @@ -0,0 +1,154 @@ +import request from 'supertest'; +import app from '../index'; +import { registerApiKey } from '../middleware/apiKeyAuth'; + +describe('Admin cache regex and id validation (Issues #1609 & #1610)', () => { + const adminKey = 'admin-cache-params-test-key'; + const authHeader = { Authorization: `ApiKey ${adminKey}` }; + + beforeAll(() => { + registerApiKey(adminKey); + }); + + describe('Issue #1609: DELETE /admin/cache regex constraints', () => { + it('rejects ReDoS vulnerable patterns with 400 quickly (<100ms)', async () => { + const start = Date.now(); + const res = await request(app) + .delete('/admin/cache') + .query({ pattern: '(a+)+$' }) + .set(authHeader); + const elapsed = Date.now() - start; + + expect(res.status).toBe(400); + expect(elapsed).toBeLessThan(100); + expect(res.body.error).toBe('Bad Request'); + }); + + it('rejects regex pattern exceeding 100 characters with 400', async () => { + const longPattern = 'a'.repeat(101); + const res = await request(app) + .delete('/admin/cache') + .query({ pattern: longPattern }) + .set(authHeader); + + expect(res.status).toBe(400); + expect(res.body.error).toBe('Bad Request'); + }); + + it('rejects invalid regex syntax with 400', async () => { + const res = await request(app) + .delete('/admin/cache') + .query({ pattern: '[unclosed-bracket' }) + .set(authHeader); + + expect(res.status).toBe(400); + expect(res.body.error).toBe('Bad Request'); + }); + + it('supports dryRun=true to preview matches without deleting', async () => { + const res = await request(app) + .delete('/admin/cache') + .query({ pattern: '^vault:', dryRun: 'true' }) + .set(authHeader); + + expect(res.status).toBe(200); + expect(res.body.dryRun).toBe(true); + expect(res.body.wouldInvalidate).toBe(true); + expect(res.body.stats).toBeDefined(); + }); + }); + + describe('Issue #1610: Admin id-based routes validation and URI decode error handling', () => { + it('returns 400 (not 500) for malformed URI-encoded idempotency key', async () => { + const res = await request(app) + .delete('/admin/idempotency/keys/%E0%A4%A') + .set(authHeader); + + expect(res.status).toBe(400); + expect(res.body.error).toBe('Bad Request'); + expect(res.body.message).toMatch(/failed to decode|malformed uri/i); + }); + + it('rejects invalid/malformed id parameters with 400 across admin id routes', async () => { + const badId = 'invalid id with spaces & symbols!'; + + // DELETE /admin/webhooks/:id + const resWebhook = await request(app) + .delete(`/admin/webhooks/${encodeURIComponent(badId)}`) + .set(authHeader); + expect(resWebhook.status).toBe(400); + + // POST /admin/webhooks/:id/verify + const resVerify = await request(app) + .post(`/admin/webhooks/${encodeURIComponent(badId)}/verify`) + .set(authHeader); + expect(resVerify.status).toBe(400); + + // POST /admin/webhooks/dead-letter/:id/retry + const resDeadLetter = await request(app) + .post(`/admin/webhooks/dead-letter/${encodeURIComponent(badId)}/retry`) + .set(authHeader); + expect(resDeadLetter.status).toBe(400); + + // GET /admin/receipts/:id + const resReceipt = await request(app) + .get(`/admin/receipts/${encodeURIComponent(badId)}`) + .set(authHeader); + expect(resReceipt.status).toBe(400); + + // GET /admin/receipts/:id/verify + const resReceiptVerify = await request(app) + .get(`/admin/receipts/${encodeURIComponent(badId)}/verify`) + .set(authHeader); + expect(resReceiptVerify.status).toBe(400); + + // DELETE /admin/feature-flags/overrides/:id + const resFeatureFlag = await request(app) + .delete(`/admin/feature-flags/overrides/${encodeURIComponent(badId)}`) + .set(authHeader); + expect(resFeatureFlag.status).toBe(400); + + // POST /admin/emails/replay/:id + const resEmailReplay = await request(app) + .post(`/admin/emails/replay/${encodeURIComponent(badId)}`) + .set(authHeader); + expect(resEmailReplay.status).toBe(400); + + // DELETE /admin/wallet-aliases/:canonicalId + const resWalletAlias = await request(app) + .delete(`/admin/wallet-aliases/${encodeURIComponent(badId)}`) + .set(authHeader); + expect(resWalletAlias.status).toBe(400); + + // GET /admin/exports/bulk/jobs/:id + const resBulkJob = await request(app) + .get(`/admin/exports/bulk/jobs/${encodeURIComponent(badId)}`) + .set(authHeader); + expect(resBulkJob.status).toBe(400); + + // GET /admin/exports/bulk/artifacts/:artifactId + const resArtifact = await request(app) + .get(`/admin/exports/bulk/artifacts/${encodeURIComponent(badId)}`) + .set(authHeader); + expect(resArtifact.status).toBe(400); + + // GET /admin/jobs/dead-letters/:id + const resDeadLetterJob = await request(app) + .get(`/admin/jobs/dead-letters/${encodeURIComponent(badId)}`) + .set(authHeader); + expect(resDeadLetterJob.status).toBe(400); + + // POST /admin/jobs/dead-letters/:id/retry + const resRetryJob = await request(app) + .post(`/admin/jobs/dead-letters/${encodeURIComponent(badId)}/retry`) + .set(authHeader); + expect(resRetryJob.status).toBe(400); + + // DELETE /admin/jobs/dead-letters/:id + const resDeleteJob = await request(app) + .delete(`/admin/jobs/dead-letters/${encodeURIComponent(badId)}`) + .set(authHeader); + expect(resDeleteJob.status).toBe(400); + }); + }); +}); diff --git a/backend/src/__tests__/diagnosticsBundle.test.ts b/backend/src/__tests__/diagnosticsBundle.test.ts index 987f5fa6..36d58067 100644 --- a/backend/src/__tests__/diagnosticsBundle.test.ts +++ b/backend/src/__tests__/diagnosticsBundle.test.ts @@ -105,9 +105,11 @@ describe('diagnosticsBundleHandler', () => { expect(deps).toHaveProperty('jobs'); }); - it('redacts sensitive environment variables', async () => { - // Set a sensitive env var to test redaction + it('redacts sensitive environment variables and fully masks secrets without partial disclosure', async () => { + // Set sensitive env vars to test redaction process.env.NODE_ENV = 'test'; + process.env.JWT_SECRET = 'super-secret-jwt-token-key-12345'; + process.env.RATE_LIMIT_REDIS_URL = 'redis://user:secretpassword123@localhost:6379'; process.env.CIRCUIT_BREAKER_FAILURE_THRESHOLD = '5'; const req = mockReq(); @@ -117,6 +119,13 @@ describe('diagnosticsBundleHandler', () => { const config = res.body.config; expect(config.NODE_ENV).toBe('test'); + // JWT_SECRET should never appear in diagnostics bundle + expect(config).not.toHaveProperty('JWT_SECRET'); + // Sensitive keys under allowed prefixes must be fully masked + if (config.RATE_LIMIT_REDIS_URL) { + expect(config.RATE_LIMIT_REDIS_URL).toBe('***REDACTED***'); + expect(config.RATE_LIMIT_REDIS_URL).not.toContain('secretpassword'); + } // Non-allowed keys should not appear expect(config).not.toHaveProperty('HOME'); expect(config).not.toHaveProperty('PATH'); diff --git a/backend/src/__tests__/setup.ts b/backend/src/__tests__/setup.ts index 0b36ba6d..175b72a0 100644 --- a/backend/src/__tests__/setup.ts +++ b/backend/src/__tests__/setup.ts @@ -83,7 +83,7 @@ class PatchedPrismaClient extends OriginalPrismaClient { PrismaClientModule.PrismaClient = PatchedPrismaClient; // Register default admin API keys for integration tests that use test-admin-key. -const { registerApiKey } = require('../middleware/apiKeyAuth') as typeof import('../middleware/apiKeyAuth'); +const { registerApiKey } = require('../middleware/apiKeyAuth'); const defaultAdminKey = process.env.ADMIN_API_KEY || 'test-admin-key'; registerApiKey(defaultAdminKey); registerApiKey('super-admin-test-key', { role: 'super-admin' }); diff --git a/backend/src/__tests__/webhookVerifyEndpoint.test.ts b/backend/src/__tests__/webhookVerifyEndpoint.test.ts new file mode 100644 index 00000000..1e71b660 --- /dev/null +++ b/backend/src/__tests__/webhookVerifyEndpoint.test.ts @@ -0,0 +1,87 @@ +import request from 'supertest'; +import app from '../index'; +import { registerApiKey } from '../middleware/apiKeyAuth'; +import { logger } from '../middleware/structuredLogging'; + +describe('POST /webhooks/verify and /api/v1/webhooks/verify (Issue #1608)', () => { + const adminKey = 'webhook-verify-admin-key'; + const authHeader = { Authorization: `ApiKey ${adminKey}` }; + + beforeAll(() => { + registerApiKey(adminKey, { role: 'admin', scopes: ['webhooks', 'admin'] }); + }); + + it('rejects unauthenticated requests with 401', async () => { + const res1 = await request(app) + .post('/webhooks/verify') + .send({ secret: 'my-secret', payload: { event: 'test' } }); + + expect(res1.status).toBe(401); + + const res2 = await request(app) + .post('/api/v1/webhooks/verify') + .send({ secret: 'my-secret', payload: { event: 'test' } }); + + expect(res2.status).toBe(401); + }); + + it('computes HMAC signature and verifies correctly for authenticated admin', async () => { + const secret = 'super-secret-hmac-key'; + const payload = { event: 'deposit.completed', amount: '100' }; + + const res = await request(app) + .post('/api/v1/webhooks/verify') + .set(authHeader) + .send({ secret, payload }); + + expect(res.status).toBe(200); + expect(res.body.algorithm).toBe('HMAC-SHA256'); + expect(res.body.signature).toBeDefined(); + expect(typeof res.body.signature).toBe('string'); + }); + + it('collapses into single handler with identical behavior across both paths', async () => { + const secret = 'shared-secret-123'; + const payload = { action: 'ping' }; + + const resUnversioned = await request(app) + .post('/webhooks/verify') + .set(authHeader) + .send({ secret, payload }); + + const resVersioned = await request(app) + .post('/api/v1/webhooks/verify') + .set(authHeader) + .send({ secret, payload }); + + expect(resUnversioned.status).toBe(200); + expect(resVersioned.status).toBe(200); + expect(resUnversioned.body.signature).toBe(resVersioned.body.signature); + }); + + it('never leaks caller-supplied secret into logs', async () => { + const secret = 'secret-canary-never-log-this-value-xyz987'; + const payload = { canary: true }; + + const logSpy = jest.spyOn(logger, 'log'); + const consoleSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + + await request(app) + .post('/api/v1/webhooks/verify') + .set(authHeader) + .send({ secret, payload }); + + for (const call of logSpy.mock.calls) { + const serialized = JSON.stringify(call); + expect(serialized).not.toContain(secret); + } + + for (const call of consoleSpy.mock.calls) { + const serialized = JSON.stringify(call); + expect(serialized).not.toContain(secret); + } + + logSpy.mockRestore(); + consoleSpy.mockRestore(); + }); +}); diff --git a/backend/src/auditRedaction.ts b/backend/src/auditRedaction.ts index 0f7706c2..80b6dcce 100644 --- a/backend/src/auditRedaction.ts +++ b/backend/src/auditRedaction.ts @@ -1,8 +1,13 @@ +import { formatWalletForLog, isValidStellarAddress } from './walletUtils'; + const REDACTED_VALUE = '[REDACTED]'; const SENSITIVE_KEY_PATTERN = /(pass(word)?|secret|token|api[-_]?key|authorization|cookie|session|private[-_]?key|seed|mnemonic|credential)/i; +const WALLET_KEY_PATTERN = + /(wallet[-_]?address|wallet)/i; + const SENSITIVE_VALUE_PATTERN = /(bearer\s+[a-z0-9._-]+|sk_(live|test)_[a-z0-9]+|xox[baprs]-[a-z0-9-]+|ghp_[a-z0-9]+)/i; @@ -10,7 +15,7 @@ function isPlainObject(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } -function redactPrimitive(value: unknown): unknown { +function redactPrimitive(value: unknown, key?: string): unknown { if (typeof value !== 'string') { return value; } @@ -19,6 +24,10 @@ function redactPrimitive(value: unknown): unknown { return REDACTED_VALUE; } + if ((key && WALLET_KEY_PATTERN.test(key)) || isValidStellarAddress(value)) { + return formatWalletForLog(value); + } + return value; } @@ -44,7 +53,7 @@ export function redactSensitiveLogAttributes(input: T): T { continue; } - output[key] = redactPrimitive(value); + output[key] = redactPrimitive(value, key); } return output as T; diff --git a/backend/src/auth.ts b/backend/src/auth.ts index 46c4b510..4250ece5 100644 --- a/backend/src/auth.ts +++ b/backend/src/auth.ts @@ -40,7 +40,7 @@ import type { Request, Response, NextFunction } from 'express'; import { logger } from './middleware/structuredLogging'; import { sendApiError } from './middleware/apiError'; import Redis from 'ioredis'; -import { normalizeWalletAddress } from './walletUtils'; +import { normalizeWalletAddress, formatWalletForLog } from './walletUtils'; import { walletAliasMappingService } from './walletAliasService'; import { walletNonceService, @@ -500,7 +500,7 @@ export async function revokeAccessToken( logger.log('info', 'Access token revoked', { reason, - wallet: payload.sub.slice(0, 8) + '…', + wallet: formatWalletForLog(payload.sub), jti: payload.jti, }); } @@ -521,7 +521,7 @@ export async function revokeAllAccessTokens( logger.log('info', 'All access tokens revoked for wallet', { reason, - wallet: normalizedAddress.slice(0, 8) + '…', + wallet: formatWalletForLog(normalizedAddress), revokedCount: removed, }); @@ -566,7 +566,7 @@ export async function revokeCurrentSession(refreshToken: string): Promise logger.log('info', 'Current session revoked', { familyId: entry.familyId, - wallet: entry.walletAddress.slice(0, 8) + '…', + wallet: formatWalletForLog(entry.walletAddress), }); } @@ -598,7 +598,7 @@ export async function revokeAllSessions(walletAddress: string): Promise inMem.revokedFamilies.add(familyId); } logger.log('info', 'All sessions revoked for wallet', { - wallet: normalizedAddress.slice(0, 8) + '…', + wallet: formatWalletForLog(normalizedAddress), revokedCount, }); return revokedCount; @@ -607,7 +607,7 @@ export async function revokeAllSessions(walletAddress: string): Promise // Redis path: we cannot efficiently scan all tokens for a wallet without a // secondary index. Return 1 to indicate the operation was attempted. logger.log('info', 'logout-all requested (Redis: family-level revocation only)', { - wallet: normalizedAddress.slice(0, 8) + '…', + wallet: formatWalletForLog(normalizedAddress), }); return 1; } @@ -671,7 +671,7 @@ export async function rotateRefreshToken(oldRefreshToken: string): Promise { const tokens = await issueTokenPair(canonicalWallet); logger.log('info', 'JWT tokens issued on login', { - wallet: canonicalWallet.slice(0, 8) + '…', + wallet: formatWalletForLog(canonicalWallet), identitySource, }); diff --git a/backend/src/diagnosticsBundle.ts b/backend/src/diagnosticsBundle.ts index b2406a2d..8d0761e2 100644 --- a/backend/src/diagnosticsBundle.ts +++ b/backend/src/diagnosticsBundle.ts @@ -44,8 +44,7 @@ function isSensitiveKey(key: string): boolean { function redactValue(key: string, value: string): string { if (!isSensitiveKey(key)) return value; - if (value.length <= 8) return '***REDACTED***'; - return value.slice(0, 4) + '***REDACTED***' + value.slice(-4); + return '***REDACTED***'; } function getSanitizedEnvConfig(): Record { diff --git a/backend/src/eventOutbox.ts b/backend/src/eventOutbox.ts index 8887e165..2774b39b 100644 --- a/backend/src/eventOutbox.ts +++ b/backend/src/eventOutbox.ts @@ -47,6 +47,15 @@ import { * `Cannot read properties of undefined` errors deep inside fire-and-forget * writes. Resolving at call time makes the failure mode explicit. */ +function getPrismaClient() { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { prisma } = require('./prisma') as typeof import('./prisma'); + return prisma; +} + +function getEventOutboxDelegate() { + const prisma = getPrismaClient(); + const delegate = prisma?.eventOutbox; function getPrisma() { // eslint-disable-next-line @typescript-eslint/no-var-requires const { prisma } = require('./prisma') as typeof import('./prisma'); @@ -199,6 +208,8 @@ export function buildOutboxLeaseToken( * deep inside fire-and-forget writes, where callers only log it. */ export function assertEventOutboxModelAvailable(client?: unknown): void { + const target = (client ?? getPrismaClient()) as { eventOutbox?: { create?: unknown } } | null | undefined; + const delegate = target?.eventOutbox; // eslint-disable-next-line @typescript-eslint/no-var-requires const { prisma } = require('./prisma') as typeof import('./prisma'); const c = client ?? prisma; @@ -955,6 +966,7 @@ class EventOutboxService { * Sequences are per-vault and increment by exactly 1. */ private async nextSequenceForVault(vaultId: string): Promise { + const result = await getPrismaClient().$transaction(async (tx) => { const latest = await getEventOutboxDelegate().findFirst({ where: { aggregateId: vaultId }, orderBy: { sequence: 'desc' }, diff --git a/backend/src/idempotencyStore.ts b/backend/src/idempotencyStore.ts index 4f91d88c..330ba7f6 100644 --- a/backend/src/idempotencyStore.ts +++ b/backend/src/idempotencyStore.ts @@ -271,6 +271,7 @@ export class IdempotencyStore { async pruneStaleKeys( retentionMs: number, dryRun = false, + ): Promise<{ pruned: number; localPruned: number; redisPruned: number; dryRun?: boolean }> { ): Promise<{ pruned: number; localPruned: number; redisPruned: number; dryRun: boolean }> { const cutoff = Date.now() - retentionMs; let localPruned = 0; diff --git a/backend/src/index.ts b/backend/src/index.ts index 9523e765..02de2d72 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -20,6 +20,7 @@ import { writesLimiter, readsLimiter, adminLimiter, + webhookVerifyLimiter, identityRateLimiter, getRateLimitMonitorSnapshot, loadConfig as loadRateLimiterConfig, @@ -65,6 +66,7 @@ import { geofencingMiddleware } from './middleware/geofencing'; import { registerInvalidationHook } from './middleware/cache'; import { invalidateVaultCaches } from './vaultDataCache'; import { getRedisCacheHealth, redisCacheClient } from './redisCache'; +import { validate, LoginSchema, NonceRequestSchema, RefreshSchema, WebhookRegisterSchema, WebhookUpdateSchema, ApyBackfillBodySchema, MaintenanceToggleSchema, MaintenanceWindowBodySchema, FeatureFlagOverrideSchema, CacheInvalidateSchema, EventReplayBodySchema, WithdrawalLimitOverrideSchema, AllowlistWalletBodySchema, ImpersonationSessionBodySchema, ApiKeyRegisterSchema, ApiKeyRotateSchema, ApiKeyRevokeSchema, WebhookVerifyBodySchema, BulkExportBodySchema, TransactionBackfillBodySchema, GovernanceSnapshotExportSchema, ReportExportBodySchema, ChecksumVerifyBodySchema, DeadLetterResolveSchema, DeadLetterIdsSchema, DeadLetterProcessSchema, ScopedTokenCreateSchema, PaginationQuerySchema, WebhookListQuerySchema, IdParamSchema, WindowIdParamSchema, CreateVaultBodySchema, CanonicalIdParamSchema, WalletParamSchema, ArtifactIdParamSchema, KeyParamSchema, CacheDeleteQuerySchema } from './middleware/validate'; import { validate, LoginSchema, NonceRequestSchema, RefreshSchema, WebhookRegisterSchema, WebhookUpdateSchema, ApyBackfillBodySchema, MaintenanceToggleSchema, MaintenanceWindowBodySchema, FeatureFlagOverrideSchema, CacheInvalidateSchema, EventReplayBodySchema, WithdrawalLimitOverrideSchema, AllowlistWalletBodySchema, ImpersonationSessionBodySchema, ApiKeyRegisterSchema, ApiKeyRotateSchema, ApiKeyRevokeSchema, WebhookVerifyBodySchema, BulkExportBodySchema, TransactionBackfillBodySchema, GovernanceSnapshotExportSchema, ReportExportBodySchema, ChecksumVerifyBodySchema, DeadLetterResolveSchema, DeadLetterIdsSchema, DeadLetterProcessSchema, ScopedTokenCreateSchema, PaginationQuerySchema, WebhookListQuerySchema, IdParamSchema, WindowIdParamSchema, CreateVaultBodySchema, TransferResolveBodySchema, TransferResolveParamsSchema } from './middleware/validate'; import { redisCacheClient } from './redisCache'; import { validate, LoginSchema, NonceRequestSchema, RefreshSchema } from './middleware/validate'; @@ -1111,6 +1113,87 @@ app.post('/auth/logout-all', (req: Request, res: Response) => { res.redirect(301, '/api/v1/auth/logout-all'); }); +// /api/vault/* → /api/v1/vault/* +app.get('/api/vault/summary', (_req: Request, res: Response) => { + res.setHeader('deprecation', 'true'); + res.redirect(301, '/api/v1/vault/summary'); +}); +app.get('/api/vault/transactions/export', (_req: Request, res: Response) => { + res.redirect(301, '/api/v1/vault/transactions/export'); +}); +app.get('/api/vault/metrics', (_req: Request, res: Response) => { + res.redirect(301, '/api/v1/vault/metrics'); +}); +app.get('/api/vault/apy', (_req: Request, res: Response) => { + res.redirect(301, '/api/v1/vault/apy'); +}); + +/** + * Shared handler for POST /webhooks/verify and /api/v1/webhooks/verify (#1608). + * Requires admin ApiKey with webhook permission. Anonymous gets 401 via validateApiKey. + * Uses dedicated webhookVerifyLimiter rate-limit tier. + * Never logs secrets. + */ +const handleWebhookVerify = (req: Request, res: Response) => { + const scopes = (req as any).authApiKeyScopes || []; + const role = (req as any).authApiKeyRole || 'admin'; + const hasWebhookPerm = + scopes.includes('*') || + scopes.includes('webhooks') || + scopes.includes('admin') || + role === 'admin' || + role === 'super-admin'; + + if (!hasWebhookPerm) { + res.status(403).json({ + error: 'Forbidden', + status: 403, + message: 'API key lacks required webhooks scope', + }); + return; + } + + const { secret, payload, signature } = req.body || {}; + if (typeof secret !== 'string' || !secret.trim()) { + res.status(400).json({ + error: 'Bad Request', + status: 400, + message: 'secret is required and must be a non-empty string', + }); + return; + } + + if (typeof payload === 'undefined') { + res.status(400).json({ + error: 'Bad Request', + status: 400, + message: 'payload is required', + }); + return; + } + + const computedSignature = createWebhookSignature(secret, payload); + const verified = + typeof signature === 'string' && signature.length > 0 + ? verifyWebhookSignature(secret, payload, signature) + : null; + + res.status(200).json({ + algorithm: 'HMAC-SHA256', + signature: computedSignature, + verified, + }); +}; + +// /webhooks/verify → /api/v1/webhooks/verify (#1608) +app.post( + '/webhooks/verify', + webhookVerifyLimiter, + validateApiKey, + validate({ body: WebhookVerifyBodySchema }), + handleWebhookVerify, +); + // ─── Backward-compatibility redirects for list/router-mounted paths ────────── // Generic catch-all redirects for unversioned /vault/*, /referrals/*, // /transactions/*, /portfolio/* paths → /api/v1 equivalents. @@ -1581,7 +1664,7 @@ app.post('/admin/feature-flags/overrides', validateApiKey, validate({ body: Feat * DELETE /admin/feature-flags/overrides/:id - Delete a feature flag override * Requires API key authentication */ -app.delete('/admin/feature-flags/overrides/:id', validateApiKey, async (req: Request, res: Response) => { +app.delete('/admin/feature-flags/overrides/:id', validateApiKey, validate({ params: IdParamSchema }), async (req: Request, res: Response) => { const { id } = req.params; if (!id) { res.status(400).json({ @@ -1677,12 +1760,28 @@ app.get('/admin/cache/redis-status', validateApiKey, async (_req: Request, res: }); }); +function isSafeRegexPattern(pattern: string): boolean { + if (pattern.length > 100) { + return false; + } + const nestedQuantifiers = /(\([^)]*[+*?]\)[+*?{])|(\([^)]*\|[^)]*\)[+*?{])/; + if (nestedQuantifiers.test(pattern)) { + return false; + } + const repeatedQuantifiers = /\([^)]*(\+).*?\)(\+|\*|\{[0-9]+,\})/; + if (repeatedQuantifiers.test(pattern)) { + return false; + } + return true; +} + /** - * DELETE /admin/cache - Clear entire cache or by regex pattern (R8) + * DELETE /admin/cache - Clear entire cache or by regex pattern (R8, Issue #1609) * ?pattern= removes only matching entries + * Supports ?dryRun=true preview * Requires API key authentication */ -app.delete('/admin/cache', validateApiKey, (req: Request, res: Response) => { +app.delete('/admin/cache', validateApiKey, validate({ query: CacheDeleteQuerySchema }), (req: Request, res: Response) => { const pattern = typeof req.query.pattern === 'string' ? req.query.pattern : undefined; if (pattern !== undefined) { @@ -1694,6 +1793,22 @@ app.delete('/admin/cache', validateApiKey, (req: Request, res: Response) => { }); return; } + if (pattern.length > 100) { + res.status(400).json({ + error: 'Bad Request', + status: 400, + message: 'pattern query parameter exceeds maximum length of 100 characters', + }); + return; + } + if (!isSafeRegexPattern(pattern)) { + res.status(400).json({ + error: 'Bad Request', + status: 400, + message: `Unsafe regex pattern "${pattern}": potential catastrophic backtracking detected`, + }); + return; + } try { new RegExp(pattern); } catch (e) { @@ -1706,6 +1821,18 @@ app.delete('/admin/cache', validateApiKey, (req: Request, res: Response) => { } } + if (isDryRunRequest(req)) { + res.json({ + dryRun: true, + message: 'Cache invalidation dry-run preview', + pattern: pattern ?? null, + wouldInvalidate: true, + stats: getCacheStats(), + timestamp: new Date().toISOString(), + }); + return; + } + const removed = invalidateCache(pattern); res.json({ removed, @@ -1933,7 +2060,7 @@ app.get('/admin/emails/queue', validateApiKey, async (req: Request, res: Respons * POST /admin/emails/replay/:id * Requeues a failed/dead-letter email for another send attempt. */ -app.post('/admin/emails/replay/:id', validateApiKey, async (req: Request, res: Response) => { +app.post('/admin/emails/replay/:id', validateApiKey, validate({ params: IdParamSchema }), async (req: Request, res: Response) => { try { const email = await emailQueueService.replayEmail(req.params.id); res.status(200).json({ @@ -2077,7 +2204,7 @@ app.get('/admin/wallet-aliases', validateApiKey, async (_req: Request, res: Resp * Deletes a canonical wallet alias group and all linked aliases. * Requires API key authentication. */ -app.delete('/admin/wallet-aliases/:canonicalId', validateApiKey, async (req: Request, res: Response) => { +app.delete('/admin/wallet-aliases/:canonicalId', validateApiKey, validate({ params: CanonicalIdParamSchema }), async (req: Request, res: Response) => { const { canonicalId } = req.params; const existing = await walletAliasMappingService.getIdentityLinks(canonicalId); @@ -2318,7 +2445,7 @@ app.delete('/admin/impersonate/sessions/:id', validateApiKey, async (req: Reques * GET /admin/impersonate/:wallet - inspect vault state as a specific wallet * Requires super-admin API key and a valid non-expired impersonation session. */ -app.get('/admin/impersonate/:wallet', validateApiKey, async (req: Request, res: Response) => { +app.get('/admin/impersonate/:wallet', validateApiKey, validate({ params: WalletParamSchema }), async (req: Request, res: Response) => { const wallet = String(req.params.wallet || '').trim(); const actingAdminAddress = resolveActingAdminAddress(req); const sessionId = String(req.get('x-impersonation-session-id') || '').trim(); @@ -2462,7 +2589,7 @@ app.get('/admin/receipts', validateApiKey, async (req: Request, res: Response) = * Retrieves a specific admin action receipt. * Requires API key authentication. */ -app.get('/admin/receipts/:id', validateApiKey, async (req: Request, res: Response) => { +app.get('/admin/receipts/:id', validateApiKey, validate({ params: IdParamSchema }), async (req: Request, res: Response) => { try { const receipt = await getAdminReceipt(req.params.id); if (!receipt) { @@ -2484,7 +2611,7 @@ app.get('/admin/receipts/:id', validateApiKey, async (req: Request, res: Respons * Verifies the integrity of an admin action receipt. * Requires API key authentication. */ -app.get('/admin/receipts/:id/verify', validateApiKey, async (req: Request, res: Response) => { +app.get('/admin/receipts/:id/verify', validateApiKey, validate({ params: IdParamSchema }), async (req: Request, res: Response) => { try { const receipt = await getAdminReceipt(req.params.id); if (!receipt) { @@ -2942,7 +3069,7 @@ app.get('/admin/webhooks/dead-letter', validateApiKey, validate({ query: Webhook /** * POST /admin/webhooks/dead-letter/:id/retry - re-queue a dead-letter delivery */ -app.post('/admin/webhooks/dead-letter/:id/retry', validateApiKey, async (req: Request, res: Response) => { +app.post('/admin/webhooks/dead-letter/:id/retry', validateApiKey, validate({ params: IdParamSchema }), async (req: Request, res: Response) => { try { const entry = await retryWebhookDeadLetter(req.params.id); if (!entry) { @@ -3005,40 +3132,15 @@ app.get('/admin/webhooks/deliveries', validateApiKey, validate({ query: Paginati }); /** - * POST /api/v1/webhooks/verify - verify webhook secret/signature pairing before go-live + * POST /api/v1/webhooks/verify - verify webhook secret/signature pairing before go-live (#1608) */ -app.post('/api/v1/webhooks/verify', validate({ body: WebhookVerifyBodySchema }), (req: Request, res: Response) => { - const { secret, payload, signature } = req.body || {}; - if (typeof secret !== 'string' || !secret.trim()) { - res.status(400).json({ - error: 'Bad Request', - status: 400, - message: 'secret is required and must be a non-empty string', - }); - return; - } - - if (typeof payload === 'undefined') { - res.status(400).json({ - error: 'Bad Request', - status: 400, - message: 'payload is required', - }); - return; - } - - const computedSignature = createWebhookSignature(secret, payload); - const verified = - typeof signature === 'string' && signature.length > 0 - ? verifyWebhookSignature(secret, payload, signature) - : null; - - res.status(200).json({ - algorithm: 'HMAC-SHA256', - signature: computedSignature, - verified, - }); -}); +app.post( + '/api/v1/webhooks/verify', + webhookVerifyLimiter, + validateApiKey, + validate({ body: WebhookVerifyBodySchema }), + handleWebhookVerify, +); /** * GET /admin/audit/logs - list admin activity logs @@ -3312,7 +3414,7 @@ app.get('/admin/exports/bulk/jobs', validateApiKey, async (req: Request, res: Re /** * GET /admin/exports/bulk/jobs/:id - get bulk export job status */ -app.get('/admin/exports/bulk/jobs/:id', validateApiKey, async (req: Request, res: Response) => { +app.get('/admin/exports/bulk/jobs/:id', validateApiKey, validate({ params: IdParamSchema }), async (req: Request, res: Response) => { const job = await getBulkExportJob(String(req.params.id)); if (!job) { res.status(404).json({ @@ -3328,7 +3430,7 @@ app.get('/admin/exports/bulk/jobs/:id', validateApiKey, async (req: Request, res /** * POST /admin/exports/bulk/jobs/:id/cancel - cancel a pending/processing bulk export job */ -app.post('/admin/exports/bulk/jobs/:id/cancel', validateApiKey, async (req: Request, res: Response) => { +app.post('/admin/exports/bulk/jobs/:id/cancel', validateApiKey, validate({ params: IdParamSchema }), async (req: Request, res: Response) => { const cancelled = await cancelBulkExportJob(String(req.params.id)); if (!cancelled) { const job = await getBulkExportJob(String(req.params.id)); @@ -3356,7 +3458,7 @@ app.post('/admin/exports/bulk/jobs/:id/cancel', validateApiKey, async (req: Requ /** * GET /admin/exports/bulk/artifacts/:artifactId - download a completed bulk export artifact */ -app.get('/admin/exports/bulk/artifacts/:artifactId', validateApiKey, (req: Request, res: Response) => { +app.get('/admin/exports/bulk/artifacts/:artifactId', validateApiKey, validate({ params: ArtifactIdParamSchema }), (req: Request, res: Response) => { const artifact = getBulkExportArtifact(String(req.params.artifactId)); if (!artifact) { res.status(404).json({ @@ -3967,7 +4069,7 @@ app.get('/admin/jobs/dead-letters', validateApiKey, (req: Request, res: Response * Retrieves a single dead-letter record by ID. * Requires API key authentication. */ -app.get('/admin/jobs/dead-letters/:id', validateApiKey, (req: Request, res: Response) => { +app.get('/admin/jobs/dead-letters/:id', validateApiKey, validate({ params: IdParamSchema }), (req: Request, res: Response) => { const record = getDeadLetterRecord(req.params.id); if (!record) { res.status(404).json({ @@ -3990,7 +4092,7 @@ app.get('/admin/jobs/dead-letters/:id', validateApiKey, (req: Request, res: Resp * Supports dryRun flag. * Requires API key authentication. */ -app.post('/admin/jobs/dead-letters/:id/retry', validateApiKey, async (req: Request, res: Response) => { +app.post('/admin/jobs/dead-letters/:id/retry', validateApiKey, validate({ params: IdParamSchema }), async (req: Request, res: Response) => { const record = getDeadLetterRecord(req.params.id); if (!record) { res.status(404).json({ @@ -4086,7 +4188,7 @@ app.post('/admin/jobs/dead-letters/:id/resolve', validateApiKey, validate({ para * Discards a dead-letter record from the queue. * Requires API key authentication. */ -app.delete('/admin/jobs/dead-letters/:id', validateApiKey, (req: Request, res: Response) => { +app.delete('/admin/jobs/dead-letters/:id', validateApiKey, validate({ params: IdParamSchema }), (req: Request, res: Response) => { const actor = resolveActingAdminAddress(req); if (isDryRunRequest(req)) { @@ -4287,8 +4389,18 @@ app.get('/admin/idempotency/keys', validateApiKey, (req: Request, res: Response) * Removes a single idempotency key from the store. * Requires API key authentication. */ -app.delete('/admin/idempotency/keys/:key', validateApiKey, (req: Request, res: Response) => { - const key = decodeURIComponent(req.params.key); +app.delete('/admin/idempotency/keys/:key', validateApiKey, validate({ params: KeyParamSchema }), (req: Request, res: Response) => { + let key: string; + try { + key = decodeURIComponent(req.params.key); + } catch (err) { + res.status(400).json({ + error: 'Bad Request', + status: 400, + message: 'Malformed URI encoding in key parameter', + }); + return; + } if (isDryRunRequest(req)) { const exists = idempotencyStore.inspectKeys().some((entry) => entry.key === key); res.status(exists ? 200 : 404).json({ diff --git a/backend/src/middleware/tenantBoundary.ts b/backend/src/middleware/tenantBoundary.ts index df7b8fa0..b456a82e 100644 --- a/backend/src/middleware/tenantBoundary.ts +++ b/backend/src/middleware/tenantBoundary.ts @@ -17,6 +17,7 @@ import type { Request, Response, NextFunction } from 'express'; import { logger } from './structuredLogging'; import { prisma } from '../prisma'; +import { formatWalletForLog } from '../walletUtils'; import type { ApiKeyRole } from './apiKeyAuth'; // ─── Extended Express Request Interface ────────────────────────────────────── @@ -179,7 +180,7 @@ export function validateTenantOwnership( if (req.tenantId !== requestedTenantId) { logger.log('warn', 'Tenant boundary violation detected', { action: 'tenant_boundary_violation', - actor: req.authApiKeyHash || req.walletAddress, + actor: req.authApiKeyHash || (req.walletAddress ? formatWalletForLog(req.walletAddress) : undefined), tenantId: req.tenantId, requestedTenantId, resource: resourceName, diff --git a/backend/src/middleware/walletQueryGuard.ts b/backend/src/middleware/walletQueryGuard.ts index f66db5d0..d14bba4d 100644 --- a/backend/src/middleware/walletQueryGuard.ts +++ b/backend/src/middleware/walletQueryGuard.ts @@ -10,7 +10,7 @@ */ import type { Request, Response, NextFunction } from 'express'; -import { normalizeWalletAddress } from '../walletUtils'; +import { normalizeWalletAddress, formatWalletForLog } from '../walletUtils'; import { getAuthenticatedWalletAddress } from './tenantGuard'; import { hasPermission, Permission } from './rbac'; import { logger } from './structuredLogging'; @@ -111,7 +111,7 @@ export function walletQueryGuard(options: WalletQueryGuardOptions = {}) { const isApiKey = /^ApiKey\s+/i.test(authHeader); if (isApiKey && allowAdminBypass && hasPermission(req, adminBypassPermission)) { req.walletScope = { walletAddress: requestedWallet, isAdminBypass: true }; - logger.log('debug', 'walletQueryGuard: admin bypass', { walletAddress: requestedWallet }); + logger.log('debug', 'walletQueryGuard: admin bypass', { walletAddress: formatWalletForLog(requestedWallet) }); next(); return; } diff --git a/backend/src/rateLimiter.ts b/backend/src/rateLimiter.ts index 89e67710..aad3ede1 100644 --- a/backend/src/rateLimiter.ts +++ b/backend/src/rateLimiter.ts @@ -521,6 +521,13 @@ export const adminLimiter: RequestHandler = createLimiter({ windowMs: config.admin.windowMs, }); +/** Dedicated rate-limit tier for webhook verification endpoints (#1608). */ +export const webhookVerifyLimiter: RequestHandler = createLimiter({ + tier: 'webhook-verify', + max: parseInt(process.env.RATE_LIMIT_WEBHOOK_VERIFY_MAX || '30', 10), + windowMs: parseInt(process.env.RATE_LIMIT_WEBHOOK_VERIFY_WINDOW_MS || '60000', 10), +}); + /** * Dedicated per-wallet rate limiter for deposit and withdrawal mutations. * Configured via DEPOSITS_RATE_LIMIT_MAX / DEPOSITS_RATE_LIMIT_WINDOW_MS. diff --git a/backend/src/routes/vaults.ts b/backend/src/routes/vaults.ts index 7e49815f..2498050b 100644 --- a/backend/src/routes/vaults.ts +++ b/backend/src/routes/vaults.ts @@ -1,3 +1,77 @@ +import { Router, Request, Response, NextFunction } from 'express'; +import type { Prisma } from '@prisma/client'; +import { getPrismaClient } from '../prismaClient'; +import { validateApiKey } from '../middleware/apiKeyAuth'; +import { + createPaginatedResponse, + createPaginationEnvelope, + parsePaginationQuery, +} from '../pagination'; +import { buildVaultFilterHash, vaultCountCache } from '../vaultCountCache'; + +const router = Router(); + +/** GET /vaults: fresh page rows with a five-second cached, tenant-scoped total. */ +router.get('/', validateApiKey, async (req: Request, res: Response, next: NextFunction) => { + try { + const rawTenant = req.query.tenantId; + if (rawTenant !== undefined && (typeof rawTenant !== 'string' || !rawTenant.trim())) { + res.status(400).json({ error: 'tenantId must be a non-empty string' }); + return; + } + const isAdmin = ['admin', 'super-admin'].includes(req.authApiKeyRole || ''); + const tenantId = isAdmin ? (rawTenant as string | undefined) : req.authApiKeyTenantId; + if ( + !isAdmin && + (!tenantId || tenantId === 'unknown' || (rawTenant && rawTenant !== tenantId)) + ) { + res.status(403).json({ error: 'Vault access requires a matching authenticated tenant' }); + return; + } + + const where: Prisma.VaultWhereInput = { deletedAt: null, ...(tenantId ? { tenantId } : {}) }; + const query = parsePaginationQuery(req, { defaultSortBy: 'createdAt' }); + const limit = query.limit || 20; + const page = query.page || 1; + const sortBy = query.sortBy || 'createdAt'; + if (!['createdAt', 'updatedAt', 'id', 'aum'].includes(sortBy) || query.cursor) { + res + .status(400) + .json({ error: 'Use page pagination and sortBy createdAt, updatedAt, id or aum' }); + return; + } + const skip = (page - 1) * limit; + if (!Number.isSafeInteger(skip) || skip > 2147483647) { + res.status(400).json({ error: 'Page offset is too large' }); + return; + } + const prisma = getPrismaClient(); + const orderBy: Prisma.VaultOrderByWithRelationInput[] = [{ [sortBy]: query.sortOrder }]; + if (sortBy !== 'id') orderBy.push({ id: query.sortOrder }); + const [data, count] = await Promise.all([ + prisma.vault.findMany({ where, skip, take: limit, orderBy }), + vaultCountCache.get(buildVaultFilterHash(where), () => prisma.vault.count({ where })), + ]); + res.setHeader('X-Cache', count.status); + res.setHeader('Cache-Control', 'no-store'); + res.json( + createPaginatedResponse( + data, + createPaginationEnvelope({ + count: data.length, + limit, + total: count.total, + currentPage: page, + totalPages: Math.max(1, Math.ceil(count.total / limit)), + hasNextPage: skip + data.length < count.total, + hasPrevPage: page > 1, + }) + ) + ); + } catch (error) { + next(error); + } +}); /** * @file routes/vaults.ts * `GET /api/v1/vaults` — paginated, public listing of active vaults. diff --git a/backend/src/tracing.ts b/backend/src/tracing.ts index fc29dc01..ce69cd8e 100644 --- a/backend/src/tracing.ts +++ b/backend/src/tracing.ts @@ -82,6 +82,7 @@ export function initTracing(): void { const exporter = new OTLPTraceExporter({ url: `${OTLP_ENDPOINT}/v1/traces` }); sdk = new NodeSDK({ + resource: new Resource({ resource: buildResource({ [ATTR_SERVICE_NAME]: SERVICE_NAME, [ATTR_SERVICE_VERSION]: process.env.npm_package_version || '1.0.0', diff --git a/backend/src/types/validation.ts b/backend/src/types/validation.ts index 24370b7c..f5ea8f42 100644 --- a/backend/src/types/validation.ts +++ b/backend/src/types/validation.ts @@ -72,7 +72,48 @@ export const WebhookListQuerySchema = PaginationQuerySchema.extend({ }).passthrough(); export const IdParamSchema = z.object({ - id: z.string().min(1, 'id is required'), + id: z + .string() + .trim() + .min(1, 'id is required') + .max(128, 'id is too long') + .regex(/^[a-zA-Z0-9_-]+$/, 'id contains invalid characters'), +}); + +export const CanonicalIdParamSchema = z.object({ + canonicalId: z + .string() + .trim() + .min(1, 'canonicalId is required') + .max(128, 'canonicalId is too long') + .regex(/^[a-zA-Z0-9_-]+$/, 'canonicalId contains invalid characters'), +}); + +export const WalletParamSchema = z.object({ + wallet: walletAddressField, +}); + +export const ArtifactIdParamSchema = z.object({ + artifactId: z + .string() + .trim() + .min(1, 'artifactId is required') + .max(128, 'artifactId is too long') + .regex(/^[a-zA-Z0-9_-]+$/, 'artifactId contains invalid characters'), +}); + +export const KeyParamSchema = z.object({ + key: z + .string() + .trim() + .min(1, 'key is required') + .max(256, 'key is too long') + .regex(/^[a-zA-Z0-9_%-]+$/, 'key contains invalid characters'), +}); + +export const CacheDeleteQuerySchema = z.object({ + pattern: z.string().trim().max(100, 'Pattern exceeds maximum length of 100 characters').optional(), + dryRun: z.union([z.boolean(), z.enum(['true', 'false', '1', '0'])]).optional(), }); export const WindowIdParamSchema = z.object({ diff --git a/backend/src/walletUtils.ts b/backend/src/walletUtils.ts index e94d31f0..a64da6c0 100644 --- a/backend/src/walletUtils.ts +++ b/backend/src/walletUtils.ts @@ -19,3 +19,14 @@ export function isValidStellarAddress(address: string | undefined | null): boole const normalized = normalizeWalletAddress(address); return /^G[A-Z0-9]{55,63}$/.test(normalized); } + +/** + * Truncates and masks a wallet address for safe logging without raw PII disclosure. + */ +export function formatWalletForLog(address: string | undefined | null): string { + if (!address) return ''; + const norm = address.trim(); + if (norm.length <= 12) return norm.slice(0, 4) + '...'; + return norm.slice(0, 6) + '...' + norm.slice(-4); +} +