From e6fda3ebf434e2978137d23ddeb64d82ed6eacdd Mon Sep 17 00:00:00 2001 From: bitcoindev817-hue Date: Sat, 17 Oct 2026 22:07:58 +0100 Subject: [PATCH] feat: auth-gate webhook verify, constrain cache delete regex, validate admin id routes, and sanitize logs - Issue #1608: Auth-gate /webhooks/verify endpoints with dedicated limiter and secret protection - Issue #1609: Constrain regex pattern and add dry-run to DELETE /admin/cache - Issue #1610: Validate params on admin id routes and handle malformed URI decoding - Issue #1612: Fully mask secrets in diagnostics bundle and redact wallet addresses in logs Closes #1608, Closes #1609, Closes #1610, Closes #1612 --- backend/jest.config.js | 1 + backend/prisma/schema.prisma | 25 +- .../src/__tests__/adminCacheAndParams.test.ts | 154 ++++ .../src/__tests__/diagnosticsBundle.test.ts | 13 +- backend/src/__tests__/setup.ts | 2 +- .../__tests__/webhookVerifyEndpoint.test.ts | 87 +++ backend/src/auditRedaction.ts | 13 +- backend/src/auth.ts | 16 +- backend/src/diagnosticsBundle.ts | 3 +- backend/src/eventOutbox.ts | 26 +- backend/src/idempotency.ts | 685 ------------------ backend/src/idempotencyStore.ts | 4 +- backend/src/index.ts | 170 +++-- backend/src/middleware/apiError.ts | 28 - backend/src/middleware/apiKeyAuth.ts | 422 ++++++++++- backend/src/middleware/tenantBoundary.ts | 3 +- backend/src/middleware/walletQueryGuard.ts | 4 +- backend/src/pagination.ts | 1 - backend/src/rateLimiter.ts | 7 + backend/src/routes/vaults.ts | 154 ---- backend/src/tracing.ts | 1 - backend/src/types/validation.ts | 43 +- backend/src/vaultEndpoints.ts | 1 - backend/src/walletUtils.ts | 11 + 24 files changed, 895 insertions(+), 979 deletions(-) create mode 100644 backend/src/__tests__/adminCacheAndParams.test.ts create mode 100644 backend/src/__tests__/webhookVerifyEndpoint.test.ts diff --git a/backend/jest.config.js b/backend/jest.config.js index 3946f79f8..06c81c4cf 100644 --- a/backend/jest.config.js +++ b/backend/jest.config.js @@ -42,6 +42,7 @@ module.exports = { moduleNameMapper: { '^@yieldvault/api-schemas$': '/../packages/api-schemas/src/index.ts', '^@yieldvault/api-schemas/(.*)$': '/../packages/api-schemas/src/$1', + '^zod$': '/node_modules/zod', '^@prisma/instrumentation$': '/src/__tests__/mocks/prismainstrumentation.js', '^@opentelemetry/(.*)$': '/src/__tests__/mocks/opentelemetry.js', // Redirect the sorobanClient module that vaultEndpoints imports (./sorobanClient) diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 151c82c85..6def17653 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -143,22 +143,6 @@ model AdminAuditLog { @@index([actor]) } -model ApiKey { - id String @id @default(uuid()) - tenantId String - hashedKey String @unique - scopes String // JSON-serialised string[] - expiresAt DateTime? - lastUsedAt DateTime? - revoked Boolean @default(false) - revokedAt DateTime? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - @@index([tenantId]) - @@index([hashedKey]) - @@index([expiresAt]) -} model ApiKeyAuditEvent { id String @id @default(uuid()) @@ -543,19 +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 000000000..73842def6 --- /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 987f5fa61..36d58067f 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 901b8efa8..804863db8 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 000000000..1e71b6604 --- /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 0f7706c26..80b6dcce9 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 46c4b510a..4250ece57 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 b2406a2dd..8d0761e2c 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 7b7fe922a..891f0327e 100644 --- a/backend/src/eventOutbox.ts +++ b/backend/src/eventOutbox.ts @@ -36,9 +36,14 @@ import { * Resolving the delegate at call time (with a clear error if unavailable) * makes the failure mode explicit and robust across runtime paths. */ -function getEventOutboxDelegate() { +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; if (!delegate) { throw new Error( @@ -136,8 +141,9 @@ function getInstanceId(): string { * the failure surfaces as a `Cannot read properties of undefined` TypeError * deep inside fire-and-forget writes, where callers only log it. */ -export function assertEventOutboxModelAvailable(client: unknown = prisma): void { - const delegate = (client as { eventOutbox?: { create?: unknown } } | null | undefined)?.eventOutbox; +export function assertEventOutboxModelAvailable(client?: unknown): void { + const target = (client ?? getPrismaClient()) as { eventOutbox?: { create?: unknown } } | null | undefined; + const delegate = target?.eventOutbox; if (!delegate || typeof delegate.create !== 'function') { throw new Error( 'Prisma client is missing the eventOutbox model. Run `prisma generate` and ensure the EventOutbox migration is applied.', @@ -170,14 +176,9 @@ class EventOutboxService { * Returns the created outbox record. */ async writeEvent(input: OutboxWriteInput): Promise { - if (!prisma.eventOutbox) { - throw new Error('prisma.eventOutbox is not initialized. Ensure Prisma schema includes eventOutbox model.'); - } - assertEventOutboxModelAvailable(); const now = new Date(); const vaultId = input.vaultId ?? input.aggregateId; - const record = await prisma.eventOutbox.create({ const record = await getEventOutboxDelegate().create({ data: { id: `obx-${crypto.randomUUID()}`, @@ -222,7 +223,6 @@ class EventOutboxService { // We select the head-of-line event per vault to enforce strict // per-vault ordering (a vault's next event is only processed after // its predecessor has been relayed or dead-lettered). - const candidates = await prisma.eventOutbox.findMany({ // 1. Find eligible entries: pending or failed entries whose lock is expired const candidates = await getEventOutboxDelegate().findMany({ where: { @@ -265,8 +265,6 @@ class EventOutboxService { // 2. Lock the claimed entries by updating lockedAt/lockedBy in bulk const candidateIds = orderedCandidates.map((e) => e.id); - await prisma.eventOutbox.updateMany({ - const candidateIds = candidates.map((e) => e.id); await getEventOutboxDelegate().updateMany({ where: { id: { in: candidateIds }, @@ -318,12 +316,11 @@ class EventOutboxService { payload, ); - await getEventOutboxDelegate().update({ // Do not write the mark-as-sent update after abort; the event will be // re-relayed after restart (at-least-once delivery). if (signal?.aborted) break; - await prisma.eventOutbox.update({ + await getEventOutboxDelegate().update({ where: { id: entry.id }, data: { status: 'relayed', @@ -488,7 +485,6 @@ class EventOutboxService { */ async replayOnStartup(): Promise { assertEventOutboxModelAvailable(); - const pendingCount = await prisma.eventOutbox.count({ const pendingCount = await getEventOutboxDelegate().count({ where: { status: { in: ['pending', 'failed'] } }, }); @@ -738,7 +734,7 @@ class EventOutboxService { * concurrent writers. */ private async nextSequenceForVault(vaultId: string): Promise { - const result = await prisma.$transaction(async (tx) => { + const result = await getPrismaClient().$transaction(async (tx) => { const latest = await tx.eventOutbox.findFirst({ where: { vaultId }, orderBy: { sequence: 'desc' }, diff --git a/backend/src/idempotency.ts b/backend/src/idempotency.ts index cf111e0d6..fcc20199c 100644 --- a/backend/src/idempotency.ts +++ b/backend/src/idempotency.ts @@ -22,13 +22,9 @@ */ import crypto from 'crypto'; -import NodeCache from 'node-cache'; import { prisma } from './prisma'; -import { redisClientManager } from './rateLimiter'; import { logger } from './middleware/structuredLogging'; import type { Request, Response, NextFunction } from 'express'; -import NodeCache from 'node-cache'; -import { redisClientManager } from './rateLimiter'; /** * The replay cache used by money-moving operations (deposits, withdrawals, @@ -396,306 +392,6 @@ declare global { } } -// ─── Error Classes ────────────────────────────────────────────────────── - -export class IdempotencyConflictError extends Error { - constructor(message = 'Idempotency key already used for a different request body') { - super(message); - this.name = 'IdempotencyConflictError'; - } -} - -// ─── Internal Types ───────────────────────────────────────────────────── - -export interface IdempotentOperationResult { - statusCode: number; - body: T; -} - -interface StoredResponse extends IdempotentOperationResult { - fingerprint: string; - metadata: IdempotencyKeyMetadata; -} - -interface PendingOperation { - fingerprint: string; - promise: Promise>; - metadata: IdempotencyKeyMetadata; -} - -export interface IdempotencyKeyInfo { - key: string; - metadata: IdempotencyKeyMetadata; -} - -export interface IdempotencyMetrics { - hits: number; - conflicts: number; - evictions: number; - activeKeys: number; - pendingKeys: number; -} - -// ─── Redis key prefix ─────────────────────────────────────────────────── - -const REDIS_PREFIX = 'idempotency:'; - -// ─── Fingerprint helper ───────────────────────────────────────────────── - -export function getIdempotencyHashThreshold(): number { - return parseInt(process.env.IDEMPOTENCY_HASH_THRESHOLD_BYTES || '4096', 10); -} - -export function buildIdempotencyFingerprint(payload: unknown): string { - const stable = stableStringify(payload); - const byteLength = Buffer.byteLength(stable, 'utf-8'); - if (byteLength > getIdempotencyHashThreshold()) { - return `hashv1:${crypto.createHash('sha256').update(stable).digest('hex')}`; - } - return stable; -} - -function stableStringify(value: unknown): string { - if (value === null) return 'null'; - if (value instanceof Date) return JSON.stringify(value.toISOString()); - if (typeof value !== 'object') return JSON.stringify(value); - - if (Array.isArray(value)) { - return `[${value.map((item) => stableStringify(item)).join(',')}]`; - } - - const record = value as Record; - const keys = Object.keys(record).sort(); - const serialized = keys.map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`); - return `{${serialized.join(',')}}`; -} - -// ─── IdempotencyStore ─────────────────────────────────────────────────── - -export class IdempotencyStore { - private readonly completedResponses: NodeCache; - private readonly pendingResponses = new Map>(); - - private _hits = 0; - private _conflicts = 0; - private _evictions = 0; - - constructor(private readonly ttlMs = 24 * 60 * 60 * 1000) { - const ttlSeconds = Math.max(1, Math.ceil(this.ttlMs / 1000)); - this.completedResponses = new NodeCache({ stdTTL: ttlSeconds, checkperiod: ttlSeconds }); - this.completedResponses.on('expired', (_key: string, _value: unknown) => { - this._evictions++; - }); - } - - private redisKey(key: string): string { - return `${REDIS_PREFIX}${key}`; - } - - private get redis() { - const client = redisClientManager.getClient(); - return redisClientManager.isReady() && client ? client : null; - } - - private async redisGet(key: string): Promise | null> { - const r = this.redis; - if (!r) return null; - try { - const raw = await r.get(this.redisKey(key)); - return raw ? (JSON.parse(raw) as StoredResponse) : null; - } catch { - return null; - } - } - - private async redisSet(key: string, value: StoredResponse): Promise { - const r = this.redis; - if (!r) return; - try { - const ttlSeconds = Math.max(1, Math.ceil(this.ttlMs / 1000)); - await r.set(this.redisKey(key), JSON.stringify(value), 'EX', ttlSeconds); - } catch (err) { - console.log(JSON.stringify({ level: 'warn', event: 'idempotency_redis_write_fail', key, reason: (err as Error).message })); - } - } - - private async redisDel(key: string): Promise { - const r = this.redis; - if (!r) return false; - try { - return (await r.del(this.redisKey(key))) > 0; - } catch { - return false; - } - } - - async execute( - key: string, - fingerprint: string, - operation: () => Promise> - ): Promise<{ result: IdempotentOperationResult; replayed: boolean }> { - const now = new Date().toISOString(); - - let completed = await this.redisGet(key); - if (!completed) { - completed = this.completedResponses.get>(key) ?? null; - } - - if (completed) { - if (completed.fingerprint !== fingerprint) { - this._conflicts++; - throw new IdempotencyConflictError(); - } - this._hits++; - completed.metadata.lastAccessedAt = now; - completed.metadata.replayCount++; - await this.redisSet(key, completed); - this.completedResponses.set(key, completed); - return { result: { statusCode: completed.statusCode, body: completed.body }, replayed: true }; - } - - const pendingOperation = this.pendingResponses.get(key) as PendingOperation | undefined; - if (pendingOperation) { - if (pendingOperation.fingerprint !== fingerprint) { - this._conflicts++; - throw new IdempotencyConflictError(); - } - this._hits++; - pendingOperation.metadata.lastAccessedAt = now; - pendingOperation.metadata.replayCount++; - const replayed = await pendingOperation.promise; - return { result: { statusCode: replayed.statusCode, body: replayed.body }, replayed: true }; - } - - const metadata: IdempotencyKeyMetadata = { createdAt: now, lastAccessedAt: now, replayCount: 0, status: 'pending' }; - - const operationPromise = (async () => { - const result = await operation(); - const stored: StoredResponse = { - ...result, - fingerprint, - metadata: { ...metadata, status: 'completed', lastAccessedAt: new Date().toISOString() }, - }; - await this.redisSet(key, stored); - this.completedResponses.set(key, stored, this.ttlMs / 1000); - return stored; - })(); - - this.pendingResponses.set(key, { fingerprint, promise: operationPromise, metadata }); - - try { - const stored = await operationPromise; - return { result: { statusCode: stored.statusCode, body: stored.body }, replayed: false }; - } finally { - this.pendingResponses.delete(key); - } - } - - inspectKeys(prefix?: string): IdempotencyKeyInfo[] { - const results: IdempotencyKeyInfo[] = []; - for (const key of this.completedResponses.keys()) { - if (prefix && !key.startsWith(prefix)) continue; - const entry = this.completedResponses.get>(key); - if (entry) results.push({ key, metadata: { ...entry.metadata } }); - } - for (const [key, pending] of this.pendingResponses.entries()) { - if (prefix && !key.startsWith(prefix)) continue; - if (!results.some((r) => r.key === key)) { - results.push({ key, metadata: { ...pending.metadata } }); - } - } - return results; - } - - async deleteKey(key: string): Promise { - const deletedLocal = this.completedResponses.del(key) > 0; - const deletedPending = this.pendingResponses.delete(key); - const deletedRedis = await this.redisDel(key); - if (deletedLocal || deletedPending || deletedRedis) { - this._evictions++; - return true; - } - return false; - } - - clear(): void { - const count = this.completedResponses.keys().length + this.pendingResponses.size; - this._evictions += count; - this.completedResponses.flushAll(); - this.pendingResponses.clear(); - } - - getMetrics(): IdempotencyMetrics { - return { - hits: this._hits, - conflicts: this._conflicts, - evictions: this._evictions, - activeKeys: this.completedResponses.keys().length, - pendingKeys: this.pendingResponses.size, - }; - } - - async pruneStaleKeys( - retentionMs: number, - dryRun = false, - ): Promise<{ pruned: number; localPruned: number; redisPruned: number }> { - const cutoff = Date.now() - retentionMs; - let localPruned = 0; - let redisPruned = 0; - - for (const key of this.completedResponses.keys()) { - const entry = this.completedResponses.get>(key); - if (!entry) continue; - const createdAt = Date.parse(entry.metadata.createdAt); - if (Number.isNaN(createdAt) || createdAt >= cutoff) continue; - if (!dryRun) { - this.completedResponses.del(key); - this._evictions++; - } - localPruned++; - } - - const r = this.redis; - if (r) { - let cursor = '0'; - do { - const [nextCursor, keys] = await r.scan(cursor, 'MATCH', `${REDIS_PREFIX}*`, 'COUNT', 100); - cursor = nextCursor; - for (const redisKey of keys) { - try { - const raw = await r.get(redisKey); - if (!raw) continue; - const entry = JSON.parse(raw) as StoredResponse; - const createdAt = Date.parse(entry.metadata?.createdAt ?? ''); - const ttl = await r.ttl(redisKey); - const isStale = (!Number.isNaN(createdAt) && createdAt < cutoff) || ttl === 0; - if (!isStale) continue; - if (!dryRun) { - await r.del(redisKey); - this._evictions++; - } - redisPruned++; - } catch { - if (!dryRun) { - await r.del(redisKey); - this._evictions++; - } - redisPruned++; - } - } - } while (cursor !== '0'); - } - - return { pruned: localPruned + redisPruned, localPruned, redisPruned }; - } -} - -// ─── Singleton ────────────────────────────────────────────────────────── - -export const idempotencyStore = new IdempotencyStore( - parseInt(process.env.IDEMPOTENCY_KEY_TTL_MS || '86400000', 10) -); - // ─── Cleanup ──────────────────────────────────────────────────────────────── /** @@ -739,384 +435,3 @@ export function startIdempotencyCleanupTask(intervalMs = 3600000): NodeJS.Timer }); }, intervalMs); } - -// ─── In-flight store (Redis + NodeCache) ───────────────────────────────────── -// -// Issue #811: multi-instance deployments lost idempotency guarantees on pod -// recycle because responses were stored only in-process. This revision -// persists completed responses to Redis using SET … EX so all replicas share -// the same store, falling back to NodeCache when Redis is unavailable -// (fail-open, with a warning). -// -// The Prisma helpers above (enforceIdempotency / *IdempotencyRecord) are the -// newer, DB-backed design but are not yet wired into any route. The mutation -// endpoints (deposit/withdrawal/transfer), the admin key routes and the -// retention sweeper all still call the store below, so both surfaces live here -// until those call sites are migrated. Restoring this store is what keeps -// `idempotencyStore`, `IdempotencyStore` and `IdempotencyConflictError` -// resolvable for those consumers. -// ─── Response Store (Redis + NodeCache) ───────────────────────────────────── -// -// Shared response store used by vault endpoints and the transfer orchestrator. -// Completed responses are persisted to Redis (when available) with a NodeCache -// in-process fallback. Retention sweeps live in idempotencyRetention.ts. - -// ─── Public Types ───────────────────────────────────────────────────────────── - -export interface IdempotentOperationResult { - statusCode: number; - body: T; -} - -/** Metadata attached to every idempotency key entry. */ -export interface IdempotencyEntryMetadata { -export interface IdempotencyStoreKeyMetadata { - /** ISO-8601 timestamp when the key was first stored. */ - createdAt: string; - /** ISO-8601 timestamp of the most recent access (read or write). */ - lastAccessedAt: string; - /** Number of times this key has been replayed (returned cached result). */ - replayCount: number; - /** Current state of the entry. */ - status: 'pending' | 'completed'; -} - -/** Summary returned by GET /admin/idempotency/keys. */ -export interface IdempotencyKeyInfo { - key: string; - metadata: IdempotencyEntryMetadata; - metadata: IdempotencyStoreKeyMetadata; -} - -/** Snapshot of store-wide observability counters. */ -export interface IdempotencyMetrics { - hits: number; - conflicts: number; - evictions: number; - activeKeys: number; - pendingKeys: number; -} - -interface StoredResponse extends IdempotentOperationResult { - fingerprint: string; - metadata: IdempotencyEntryMetadata; -// ─── Internal Types ─────────────────────────────────────────────────────────── - -interface StoredResponse extends IdempotentOperationResult { - fingerprint: string; - metadata: IdempotencyStoreKeyMetadata; -} - -interface PendingOperation { - fingerprint: string; - promise: Promise>; - metadata: IdempotencyEntryMetadata; -} - - metadata: IdempotencyStoreKeyMetadata; -} - -// ─── Errors ─────────────────────────────────────────────────────────────────── - -export class IdempotencyConflictError extends Error { - constructor(message = 'Idempotency key already used for a different request body') { - super(message); - this.name = 'IdempotencyConflictError'; - } -} - -const REDIS_PREFIX = 'idempotency:'; - -// ─── Redis key prefix ───────────────────────────────────────────────────────── - -const REDIS_PREFIX = 'idempotency:'; - -// ─── Store ──────────────────────────────────────────────────────────────────── - -export class IdempotencyStore { - /** Fallback in-process store used when Redis is unavailable. */ - private readonly localCache: NodeCache; - private readonly pendingResponses = new Map>(); - - // Observability counters - private _hits = 0; - private _conflicts = 0; - private _evictions = 0; - - constructor(private readonly ttlMs = 24 * 60 * 60 * 1000) { - const ttlSeconds = Math.max(1, Math.ceil(this.ttlMs / 1000)); - this.localCache = new NodeCache({ stdTTL: ttlSeconds, checkperiod: ttlSeconds }); - this.localCache.on('expired', () => { this._evictions++; }); - } - - // ─── Redis helpers ───────────────────────────────────────────────────────── - - private redisKey(key: string): string { - return `${REDIS_PREFIX}${key}`; - } - - private get redis() { - const client = redisClientManager.getClient(); - return redisClientManager.isReady() && client ? client : null; - } - - private async redisGet(key: string): Promise | null> { - const r = this.redis; - if (!r) return null; - try { - const raw = await r.get(this.redisKey(key)); - return raw ? (JSON.parse(raw) as StoredResponse) : null; - } catch { - return null; - } - } - - private async redisSet(key: string, value: StoredResponse): Promise { - const r = this.redis; - if (!r) return; - try { - const ttlSeconds = Math.max(1, Math.ceil(this.ttlMs / 1000)); - await r.set(this.redisKey(key), JSON.stringify(value), 'EX', ttlSeconds); - } catch (err) { - console.log(JSON.stringify({ level: 'warn', event: 'idempotency_redis_write_fail', key, reason: (err as Error).message })); - } - } - - private async redisDel(key: string): Promise { - const r = this.redis; - if (!r) return false; - try { - return (await r.del(this.redisKey(key))) > 0; - } catch { - return false; - } - } - - // ─── Core execute ────────────────────────────────────────────────────────── - - async execute( - key: string, - fingerprint: string, - operation: () => Promise> - ): Promise<{ result: IdempotentOperationResult; replayed: boolean }> { - const now = new Date().toISOString(); - - // 1. Check Redis first, then local cache - let completed = await this.redisGet(key); - if (!completed) { - completed = this.localCache.get>(key) ?? null; - } - - if (completed) { - if (completed.fingerprint !== fingerprint) { - this._conflicts++; - throw new IdempotencyConflictError(); - } - this._hits++; - completed.metadata.lastAccessedAt = now; - completed.metadata.replayCount++; - // Refresh in both backends; errors are non-fatal - await this.redisSet(key, completed); - this.localCache.set(key, completed); - return { result: { statusCode: completed.statusCode, body: completed.body }, replayed: true }; - } - - // 2. Currently in-flight - const pendingOperation = this.pendingResponses.get(key) as PendingOperation | undefined; - if (pendingOperation) { - if (pendingOperation.fingerprint !== fingerprint) { - this._conflicts++; - throw new IdempotencyConflictError(); - } - this._hits++; - pendingOperation.metadata.lastAccessedAt = now; - pendingOperation.metadata.replayCount++; - const replayed = await pendingOperation.promise; - return { result: { statusCode: replayed.statusCode, body: replayed.body }, replayed: true }; - } - - // 3. First execution - const metadata: IdempotencyEntryMetadata = { createdAt: now, lastAccessedAt: now, replayCount: 0, status: 'pending' }; - const metadata: IdempotencyStoreKeyMetadata = { createdAt: now, lastAccessedAt: now, replayCount: 0, status: 'pending' }; - - const operationPromise = (async () => { - const result = await operation(); - const stored: StoredResponse = { - ...result, - fingerprint, - metadata: { ...metadata, status: 'completed', lastAccessedAt: new Date().toISOString() }, - }; - // Persist to Redis (primary) and local cache (fallback/fast-path) - await this.redisSet(key, stored); - this.localCache.set(key, stored, this.ttlMs / 1000); - return stored; - })(); - - this.pendingResponses.set(key, { fingerprint, promise: operationPromise, metadata }); - - try { - const stored = await operationPromise; - return { result: { statusCode: stored.statusCode, body: stored.body }, replayed: false }; - } finally { - this.pendingResponses.delete(key); - } - } - - // ─── Inspection ──────────────────────────────────────────────────────────── - - inspectKeys(prefix?: string): IdempotencyKeyInfo[] { - const results: IdempotencyKeyInfo[] = []; - for (const key of this.localCache.keys()) { - if (prefix && !key.startsWith(prefix)) continue; - const entry = this.localCache.get>(key); - if (entry) results.push({ key, metadata: { ...entry.metadata } }); - } - for (const [key, pending] of this.pendingResponses.entries()) { - if (prefix && !key.startsWith(prefix)) continue; - if (!results.some((r) => r.key === key)) { - results.push({ key, metadata: { ...pending.metadata } }); - } - } - return results; - } - - // ─── Targeted deletion ───────────────────────────────────────────────────── - - async deleteKey(key: string): Promise { - const deletedLocal = this.localCache.del(key) > 0; - const deletedPending = this.pendingResponses.delete(key); - const deletedRedis = await this.redisDel(key); - if (deletedLocal || deletedPending || deletedRedis) { - this._evictions++; - return true; - } - return false; - } - - // ─── Global clear (admin only) ───────────────────────────────────────────── - - clear(): void { - const count = this.localCache.keys().length + this.pendingResponses.size; - this._evictions += count; - this.localCache.flushAll(); - this.pendingResponses.clear(); - // Note: Redis keys are prefixed with REDIS_PREFIX; a full Redis FLUSHDB is intentionally - // not issued here to avoid clearing unrelated keys. Use deleteKey() per-key when needed. - } - - // ─── Observability ───────────────────────────────────────────────────────── - - getMetrics(): IdempotencyMetrics { - return { - hits: this._hits, - conflicts: this._conflicts, - evictions: this._evictions, - activeKeys: this.localCache.keys().length, - pendingKeys: this.pendingResponses.size, - }; - } - - // ─── Retention cleanup ───────────────────────────────────────────────────── - - async pruneStaleKeys( - retentionMs: number, - dryRun = false, - ): Promise<{ pruned: number; localPruned: number; redisPruned: number }> { - /** - * Removes entries older than `retentionMs` from the local cache and Redis. - * With `dryRun`, only counts the entries that would be removed: nothing is - * deleted and the eviction counter is left untouched (Issue #1375). - */ - - async pruneStaleKeys( - retentionMs: number, - dryRun = false, - ): Promise<{ pruned: number; localPruned: number; redisPruned: number; dryRun: boolean }> { - const cutoff = Date.now() - retentionMs; - let localPruned = 0; - let redisPruned = 0; - - for (const key of this.localCache.keys()) { - const entry = this.localCache.get>(key); - if (!entry) continue; - const createdAt = Date.parse(entry.metadata.createdAt); - if (Number.isNaN(createdAt) || createdAt >= cutoff) continue; - if (!dryRun) { - this.localCache.del(key); - this._evictions++; - } - localPruned++; - } - - const r = this.redis; - if (r) { - let cursor = '0'; - do { - const [nextCursor, keys] = await r.scan(cursor, 'MATCH', `${REDIS_PREFIX}*`, 'COUNT', 100); - cursor = nextCursor; - for (const redisKey of keys) { - try { - const raw = await r.get(redisKey); - if (!raw) continue; - const entry = JSON.parse(raw) as StoredResponse; - const createdAt = Date.parse(entry.metadata?.createdAt ?? ''); - const ttl = await r.ttl(redisKey); - const isStale = (!Number.isNaN(createdAt) && createdAt < cutoff) || ttl === 0; - if (!isStale) continue; - if (!dryRun) { - await r.del(redisKey); - this._evictions++; - } - redisPruned++; - } catch { - if (!dryRun) { - await r.del(redisKey); - this._evictions++; - } - redisPruned++; - } - } - } while (cursor !== '0'); - } - - return { pruned: localPruned + redisPruned, localPruned, redisPruned }; - return { pruned: localPruned + redisPruned, localPruned, redisPruned, dryRun }; - } -} - -// ─── Singleton ──────────────────────────────────────────────────────────────── - -export const idempotencyStore = new IdempotencyStore( - parseInt(process.env.IDEMPOTENCY_KEY_TTL_MS || '86400000', 10) -); - -// ─── Fingerprint helper ─────────────────────────────────────────────────────── - -export function getIdempotencyHashThreshold(): number { - return parseInt(process.env.IDEMPOTENCY_HASH_THRESHOLD_BYTES || '4096', 10); -} - -export function buildIdempotencyFingerprint(payload: unknown): string { - const stable = stableStringify(payload); - const byteLength = Buffer.byteLength(stable, 'utf-8'); - if (byteLength > getIdempotencyHashThreshold()) { - return `hashv1:${crypto.createHash('sha256').update(stable).digest('hex')}`; - } - return stable; -} - -function stableStringify(value: unknown): string { - if (value === null) return 'null'; - if (value instanceof Date) return JSON.stringify(value.toISOString()); - if (typeof value !== 'object') return JSON.stringify(value); - - if (Array.isArray(value)) { - return `[${value.map((item) => stableStringify(item)).join(',')}]`; - } - - const record = value as Record; - const keys = Object.keys(record).sort(); - const serialized = keys.map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`); - return `{${serialized.join(',')}}`; -} - diff --git a/backend/src/idempotencyStore.ts b/backend/src/idempotencyStore.ts index 41e4e6d3e..414d6623f 100644 --- a/backend/src/idempotencyStore.ts +++ b/backend/src/idempotencyStore.ts @@ -271,7 +271,7 @@ export class IdempotencyStore { async pruneStaleKeys( retentionMs: number, dryRun = false, - ): Promise<{ pruned: number; localPruned: number; redisPruned: number }> { + ): Promise<{ pruned: number; localPruned: number; redisPruned: number; dryRun?: boolean }> { const cutoff = Date.now() - retentionMs; let localPruned = 0; let redisPruned = 0; @@ -319,7 +319,7 @@ export class IdempotencyStore { } while (cursor !== '0'); } - return { pruned: localPruned + redisPruned, localPruned, redisPruned }; + return { pruned: localPruned + redisPruned, localPruned, redisPruned, dryRun }; } } diff --git a/backend/src/index.ts b/backend/src/index.ts index 751630bd1..d528b16ab 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -30,6 +30,7 @@ import { writesLimiter, readsLimiter, adminLimiter, + webhookVerifyLimiter, identityRateLimiter, getRateLimitMonitorSnapshot, loadConfig as loadRateLimiterConfig, @@ -73,7 +74,7 @@ import { geofencingMiddleware } from './middleware/geofencing'; import { cacheMiddleware, invalidateCache, getCacheStats, 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 } 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, CanonicalIdParamSchema, WalletParamSchema, ArtifactIdParamSchema, KeyParamSchema, CacheDeleteQuerySchema } from './middleware/validate'; import { tieredJsonBodyParser } from './middleware/payloadLimit'; import { requireSignedWalletAction } from './middleware/walletSignedAction'; import { timeoutMiddleware, createTimeoutFor } from './middleware/timeoutMiddleware'; @@ -1178,8 +1179,31 @@ app.get('/api/vault/apy', (_req: Request, res: Response) => { res.redirect(301, '/api/v1/vault/apy'); }); -// /webhooks/verify → /api/v1/webhooks/verify -app.post('/webhooks/verify', validate({ body: WebhookVerifyBodySchema }), (req: Request, res: Response) => { +/** + * 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({ @@ -1210,7 +1234,16 @@ app.post('/webhooks/verify', validate({ body: WebhookVerifyBodySchema }), (req: 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/*, @@ -1826,7 +1859,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({ @@ -1922,12 +1955,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) { @@ -1939,6 +1988,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) { @@ -1951,6 +2016,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, @@ -2178,7 +2255,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({ @@ -2306,7 +2383,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); @@ -2547,7 +2624,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(); @@ -2691,7 +2768,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) { @@ -2713,7 +2790,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) { @@ -3171,7 +3248,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) { @@ -3234,40 +3311,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 @@ -3536,7 +3588,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({ @@ -3552,7 +3604,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)); @@ -3580,7 +3632,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({ @@ -4191,7 +4243,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({ @@ -4214,7 +4266,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({ @@ -4310,7 +4362,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)) { @@ -4511,8 +4563,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/apiError.ts b/backend/src/middleware/apiError.ts index 8c397b3b0..2d168e163 100644 --- a/backend/src/middleware/apiError.ts +++ b/backend/src/middleware/apiError.ts @@ -79,21 +79,6 @@ export function buildApiErrorBody(options: BuildApiErrorBodyOptions): ApiErrorBo ...(options.correlationId ? { correlationId: options.correlationId } : {}), ...(options.traceId ? { traceId: options.traceId } : {}), }; - /** - * Short, stable headline for the failure. Part of the published error - * contract (see `ErrorEnvelope` in openapi.json) alongside `message`. - */ - summary?: string; - /** - * Field-level failures, surfaced at the top level for clients that read - * `errors` directly. `details` carries the same payload for the generic - * envelope. - */ - errors?: unknown[]; - /** Request path, echoed on routing failures so clients can log it directly. */ - path?: string; - summary?: string; - errors?: unknown; } export function sendApiError( @@ -123,19 +108,6 @@ export function sendApiError( ...(traceId ? { traceId } : {}), }) ); - res.status(options.status).json({ - error: options.error ?? statusLabel(options.status), - status: options.status, - code: options.code, - message: options.message, - retryable: options.retryable ?? options.status >= 500, - ...(options.summary !== undefined ? { summary: options.summary } : {}), - ...(options.errors !== undefined ? { errors: options.errors } : {}), - ...(options.path !== undefined ? { path: options.path } : {}), - ...(options.details !== undefined ? { details: options.details } : {}), - ...(correlationId ? { correlationId } : {}), - ...(traceId ? { traceId } : {}), - }); } export function apiErrorContractMiddleware( diff --git a/backend/src/middleware/apiKeyAuth.ts b/backend/src/middleware/apiKeyAuth.ts index cdb3d5cd9..656da9796 100644 --- a/backend/src/middleware/apiKeyAuth.ts +++ b/backend/src/middleware/apiKeyAuth.ts @@ -1 +1,421 @@ -aW1wb3J0IHR5cGUgeyBSZXF1ZXN0LCBSZXNwb25zZSwgTmV4dEZ1bmN0aW9uIH0gZnJvbSAnZXhwcmVzcyc7CmltcG9ydCBjcnlwdG8gZnJvbSAnY3J5cHRvJzsKaW1wb3J0IHsgcHJpc21hIH0gZnJvbSAnLi4vcHJpc21hJzsKaW1wb3J0IHsgc2VuZEFwaUVycm9yIH0gZnJvbSAnLi9hcGlFcnJvcic7CgpleHBvcnQgdHlwZSBBcGlLZXlSb2xlID0gJ3ZpZXdlcicgfCAnb3BlcmF0b3InIHwgJ2FkbWluJyB8ICdzdXBlci1hZG1pbic7CgppbnRlcmZhY2UgQXBpS2V5TWV0YWRhdGEgewogIHJvbGU6IEFwaUtleVJvbGU7CiAgY3JlYXRlZEF0OiBEYXRlOwogIHJvdGF0ZWRBdD86IERhdGU7CiAgcmV2b2tlZEF0PzogRGF0ZTsKICB0ZW5hbnRJZDogc3RyaW5nOwogIHNjb3Blczogc3RyaW5nW107CiAgZXhwaXJlc0F0PzogRGF0ZSB8IG51bGw7CiAgbGFzdFVzZWRBdD86IERhdGUgfCBudWxsOwp9CgpkZWNsYXJlIGdsb2JhbCB7CiAgbmFtZXNwYWNlIEV4cHJlc3MgewogICAgaW50ZXJmYWNlIFJlcXVlc3QgewogICAgICBhdXRoQXBpS2V5SGFzaD86IHN0cmluZzsKICAgICAgYXV0aEFwaUtleVJvbGU/OiBBcGlLZXlSb2xlOwogICAgICBhdXRoQXBpS2V5VGVuYW50SWQ/OiBzdHJpbmc7CiAgICAgIGF1dGhBcGlLZXlTY29wZXM/OiBzdHJpbmdbXTsKICAgICAgYXV0aEFwaUtleUxhc3RVc2VkQXQ/OiBEYXRlIHwgbnVsbDsKICAgICAgYXV0aEFwaUtleUxlZ2FjeT86IGJvb2xlYW47CiAgICB9CiAgfQp9Cgpjb25zdCBST0xFX1BSRUNFREVOQ0U6IFJlY29yZDxBcGlLZXlSb2xlLCBudW1iZXI+ID0gewogIHZpZXdlcjogMCwKICBvcGVyYXRvcjogMSwKICBhZG1pbjogMiwKICAnc3VwZXItYWRtaW4nOiAzLAp9OwoKZXhwb3J0IGZ1bmN0aW9uIG5vcm1hbGl6ZUFwaUtleVJvbGUocmF3OiB1bmtub3duKTogQXBpS2V5Um9sZSB8IG51bGwgewogIGlmICh0eXBlb2YgcmF3ICE9PSAnc3RyaW5nJykgewogICAgcmV0dXJuIG51bGw7CiAgfQogIGNvbnN0IHZhbHVlID0gcmF3LnRyaW0oKS50b0xvd2VyQ2FzZSgpOwogIGlmICh2YWx1ZSA9PT0gJ3ZpZXdlcicgfHwgdmFsdWUgPT09ICdvcGVyYXRvcicgfHwgdmFsdWUgPT09ICdhZG1pbicgfHwgdmFsdWUgPT09ICdzdXBlci1hZG1pbicpIHsKICAgIHJldHVybiB2YWx1ZSBhcyBBcGlLZXlSb2xlOwogIH0KICByZXR1cm4gbnVsbDsKfQoKZnVuY3Rpb24gcGFyc2VTY29wZXMocmF3OiB1bmtub3duKTogc3RyaW5nW10gewogIGlmIChBcnJheS5pc0FycmF5KHJhdykpIHJldHVybiByYXcgYXMgc3RyaW5nW107CiAgaWYgKHR5cGVvZiByYXcgPT09ICdzdHJpbmcnKSB7CiAgICB0cnkgewogICAgICBjb25zdCBwYXJzZWQgPSBKU09OLnBhcnNlKHJhdyk7CiAgICAgIHJldHVybiBBcnJheS5pc0FycmF5KHBhcnNlZCkgPyBwYXJzZWQgOiBbXTsKICAgIH0gY2F0Y2ggewogICAgICByZXR1cm4gW107CiAgICB9CiAgfQogIHJldHVybiBbXTsKfQoKZXhwb3J0IGludGVyZmFjZSBQZXJzaXN0ZWRBcGlLZXlTdGF0dXMgewogIGlzQWN0aXZlOiBib29sZWFuOwogIGRlbGV0ZWRBdD86IERhdGUgfCBudWxsOwogIGV4cGlyZXNBdD86IERhdGUgfCBudWxsOwogIHRlbmFudD86IHsgZGVsZXRlZEF0PzogRGF0ZSB8IG51bGwgfTsKfQoKZXhwb3J0IGZ1bmN0aW9uIGlzUGVyc2lzdGVkQXBpS2V5VXNhYmxlKAogIGFwaUtleTogUGVyc2lzdGVkQXBpS2V5U3RhdHVzIHwgbnVsbApdOiBhcGlLZXkgaXMgUGVyc2lzdGVkQXBpS2V5U3RhdHVzIHsKICByZXR1cm4gQm9vbGVhbihhcGlLZXkgJiYgYXBpS2V5LmlzQWN0aXZlICYmICFhcGlLZXkuZGVsZXRlZEF0ICYmICFhcGlLZXkudGVuYW50Py5kZWxldGVkQXQpOwp9CgpleHBvcnQgZnVuY3Rpb24gaXNQZXJzaXN0ZWRBcGlLZXlFeHBpcmVkKAogIGFwaUtleTogUGVyc2lzdGVkQXBpS2V5U3RhdHVzIHwgbnVsbApdOiBib29sZWFuIHsKICByZXR1cm4gQm9vbGVhbihhcGlLZXkgJiYgYXBpS2V5LmV4cGlyZXNBdCAmJiBhcGlLZXkuZXhwaXJlc0F0LmdldFRpbWUoKSA8PSBEYXRlLm5vdygpKTsKfQoKZXhwb3J0IGZ1bmN0aW9uIGV4dHJhY3RUZW5hbnRJZEZyb21SZXF1ZXN0KHJlcTogUmVxdWVzdCk6IHN0cmluZyB8IG51bGwgewogIGNvbnN0IGZyb21QYXJhbXMgPSByZXEucGFyYW1zPy50ZW5hbnRJZDsKICBpZiAodHlwZW9mIGZyb21QYXJhbXMgPT09ICdzdHJpbmcnICYmIGZyb21QYXJhbXMudHJpbSgpKSB7CiAgICByZXR1cm4gZnJvbVBhcmFtcy50cmltKCk7CiAgfQogIGNvbnN0IGZyb21RdWVyeSA9IHJlcS5xdWVyeT8udGVuYW50SWQ7CiAgaWYgKHR5cGVvZiBmcm9tUXVlcnkgPT09ICdzdHJpbmcnICYmIGZyb21RdWVyeS50cmltKCkpIHsKICAgIHJldHVybiBmcm9tUXVlcnkudHJpbSgpOwogIH0KICBjb25zdCBmcm9tSGVhZGVyID0gcmVxLmdldD8uKCd4LXRlbmFudC1pZCcpOwogIGlmICh0eXBlb2YgZnJvbUhlYWRlciA9PT0gJ3N0cmluZycgJiYgZnJvbUhlYWRlci50cmltKCkpIHsKICAgIHJldHVybiBmcm9tSGVhZGVyLnRyaW0oKTsKICB9CiAgcmV0dXJuIG51bGw7Cn0KCmV4cG9ydCBmdW5jdGlvbiBleHRyYWN0UmVxdWlyZWRTY29wZXMocmVxOiBSZXF1ZXN0KTogc3RyaW5nW10gewogIGNvbnN0IHJhdyA9IChyZXEgYXMgUmVxdWVzdCAmIHsgcmVxdWlyZWRTY29wZXM/OiB1bmtub3duIH0pLnJlcXVpcmVkU2NvcGVzOwogIGlmIChBcnJheS5pc0FycmF5KHJhdykpIHsKICAgIHJldHVybiByYXcuZmlsdGVyKChzKTogcyBpcyBzdHJpbmcgPT4gdHlwZW9mIHMgPT09ICdzdHJpbmcnKTsKICB9CiAgaWYgKHR5cGVvZiByYXcgPT09ICdzdHJpbmcnICYmIHJhdy50cmltKCkpIHsKICAgIHJldHVybiByYXcuc3BsaXQoJywnKS5tYXAoKHMpID0+IHMudHJpbSgpKS5maWx0ZXIoQm9vbGVhbik7CiAgfQogIHJldHVybiBbXTsKfQoKZXhwb3J0IGZ1bmN0aW9uIGhhc1JlcXVpcmVkU2NvcGVzKGF2YWlsYWJsZTogc3RyaW5nW10sIHJlcXVpcmVkOiBzdHJpbmdbXSk6IGJvb2xlYW4gewogIGlmIChyZXF1aXJlZC5sZW5ndGggPT09IDApIHJldHVybiB0cnVlOwogIGlmIChhdmFpbGFibGUuaW5jbHVkZXMoJyonKSkgcmV0dXJuIHRydWU7CiAgcmV0dXJuIHJlcXVpcmVkLmV2ZXJ5KChzY29wZSkgPT4gYXZhaWxhYmxlLmluY2x1ZGVzKHNjb3BlKSk7Cn0KCmZ1bmN0aW9uIHNlbmRVbmF1dGhvcml6ZWQoCiAgcmVxOiBSZXF1ZXN0LAogIHJlczogUmVzcG9uc2UsCiAgY29kZTogc3RyaW5nLAogIG1lc3NhZ2U6IHN0cmluZywKICBzY2hlbWU6IHN0cmluZyA9ICdCZWFyZXInCik6IHZvaWQgewogIHJlcy5zZXRIZWFkZXIoJ1dXVy1BdXRoZW50aWNhdGUnLCBzY2hlbWUpOwogIHNlbmRBcGlFcnJvcihyZXEsIHJlcywgewogICAgc3RhdHVzOiA0MDEsCiAgICBjb2RlLAogICAgbWVzc2FnZSwKICAgIHJldHJhYmxlOiBmYWxzZSwKICB9KTsKfQoKY29uc3QgTEVHQUNZX0VOVl9LRVkgPSBwcm9jZXNzLmVudi5BUElfS0VZOwpjb25zdCBMRUdBQ1lfRU5WX1RFTkFOVCA9IHByb2Nlc3MuZW52LkFQSV9LRVlfVEVOQU5UX0lEIHx8ICdkZWZhdWx0JzsKY29uc3QgTEVHQUNZX0VOVl9TQ09QRVMgPSAocHJvY2Vzcy5lbnYuQVBJX0tFWV9TQ09QRVMgfHwgJyonKQogIC5zcGxpdCgnLCcpCiAgLm1hcCgocykgPT4gcy50cmltKCkpCiAgLmZpbHRlcihCb29sZWFuKTsKCmZ1bmN0aW9uIGNoZWNrTGVnYWN5RW52S2V5KHByb3ZpZGVkS2V5OiBzdHJpbmcpOiBib29sZWFuIHsKICBpZiAoIUxFR0FDWV9FTlZfS0VZKSByZXR1cm4gZmFsc2U7CiAgY29uc3QgYSA9IEJ1ZmZlci5mcm9tKHByb3ZpZGVkS2V5KTsKICBjb25zdCBiID0gQnVmZmVyLmZyb20oTEVHQUNZX0VOVl9LRVkpOwogIGlmIChhLmxlbmd0aCAhPT0gYi5sZW5ndGgpIHJldHVybiBmYWxzZTsKICByZXR1cm4gY3J5cHRvLnRpbWluZ1NhZmVFcXVhbChhLCBiKTsKfQoKZXhwb3J0IGFzeW5jIGZ1bmN0aW9uIHZhbGlkYXRlQXBpS2V5KAogIHJlcTogUmVxdWVzdCwKICByZXM6IFJlc3BvbnNlLAogIG5leHQ6IE5leHRGdW5jdGlvbgopOiBQcm9taXNlPHZvaWQ+IHsKICBjb25zdCBhdXRoSGVhZGVyID0gcmVxLmdldD8uKCdBdXRob3JpemF0aW9uJykgfHwgJyc7CiAgY29uc3QgbWF0Y2ggPSBhdXRoSGVhZGVyLm1hdGNoKC9eQXBpS2V5XHMrKC4rKSQvaSk7CiAgaWYgKCFtYXRjaCkgewogICAgc2VuZEFwaUVycm9yKHJlcSwgcmVzLCB7CiAgICAgIHN0YXR1czogNDAxLAogICAgICBjb2RlOiAnQVVUSF9BUElfS0VZX01JU1NJTkcnLAogICAgICBtZXNzYWdlOiAnTWlzc2luZyBvciBpbnZhbGlkIEFQSSBrZXknLAogICAgICByZXRyeWFibGU6IGZhbHNlLAogICAgfSk7CiAgICByZXR1cm47CiAgfQogIGNvbnN0IHByb3ZpZGVkS2V5ID0gbWF0Y2hbMV07CiAgY29uc3QgaGFzaGVkID0gY3J5cHRvLmNyZWF0ZUhhc2goJ3NoYTI1NicpLnVwZGF0ZShwcm92aWRlZEtleSkuZGlnZXN0KCdoZXgnKTsKICBjb25zdCByZXF1ZXN0ZWRUZW5hbnRJZCA9IGV4dHJhY3RUZW5hbnRJZEZyb21SZXF1ZXN0KHJlcSk7CiAgY29uc3QgcmVxdWlyZWRTY29wZXMgPSBleHRyYWN0UmVxdWlyZWRTY29wZXMocmVxKTsKCiAgLy8gVHJ5IFByaXNtYSBmaXJzdDsgZmFsbCBiYWNrIHRvIGluLW1lbW9yeSBzdG9yZSB3aGVuIHRoZSB0YWJsZSBkb2Vzbid0IGV4aXN0CiAgLy8gKGUuZy4gaW4gdGVzdHMgd2l0aCBhbiB1bi1taWdyYXRlZCBTUUxpdGUgZGF0YWJhc2UpLgogIGxldCBhcGlLZXk6IHsKICAgIGlkPzogc3RyaW5nOwogICAgaGFzaGVkS2V5OiBzdHJpbmc7CiAgICByb2xlOiBzdHJpbmc7CiAgICB0ZW5hbnRJZDogc3RyaW5nOwogICAgc2NvcGVzOiB1bmtub3duOwogICAgaXNBY3RpdmU6IGJvb2xlYW47CiAgICBkZWxldGVkQXQ/OiBEYXRlIHwgbnVsbDsKICAgIGV4cGlyZXNBdD86IERhdGUgfCBudWxsOwogICAgdGVuYW50PzogeyBkZWxldGVkQXQ/OiBEYXRlIHwgbnVsbCB9OwogIH0gfCBudWxsID0gbnVsbDsKICB0cnkgewogICAgYXBpS2V5ID0gYXdhaXQgcHJpc21hLmFwaUtleS5maW5kVW5pcXVlKHsKICAgICAgd2hlcmU6IHsgaGFzaGVkS2V5OiBoYXNoZWQgfSwKICAgICAgaW5jbHVkZTogeyB0ZW5hbnQ6IHsgc2VsZWN0OiB7IGRlbGV0ZWRBdDogdHJ1ZSB9IH0gfSwKICAgIH0pOwogIH0gY2F0Y2ggKGVycikgewogICAgLy8gT25seSBmYWxsIGJhY2sgd2hlbiB0aGUgQXBpS2V5IHRhYmxlIGhhc24ndCBiZWVuIGNyZWF0ZWQgKHRlc3RzIC8gZnJlc2ggREIpLgogICAgY29uc3QgaXNN aXNzaW5nVGFibGUgPQogICAgICBlcnIgaW5zdGFuY2VvZiBFcnJvciAmJgogICAgICAoZXJyLm1lc3NhZ2UuaW5jbHVkZXMoJ2RvZXMgbm90IGV4aXN0IGluIHRoZSBjdXJyZW50IGRhdGFiYXNlJykgfHwKICAgICAgICAoZXJyIGFzIHsgY29kZT86IHN0cmluZyB9KS5jb2RlID09PSAnUDIwMjEnKTsKICAgIGlmICghaXNN aXNzaW5nVGFibGUpIHRocm93IGVycjsKICB9CgogIGlmIChhcGlLZXkpIHsKICAgIGlmICghaXNQZXJzaXN0ZWRBcGlLZXlVc2FibGUoYXBpS2V5KSkgewogICAgICAvLyBBIHBlcnNpc3RlZC1idXQtZGlzYWJsZWQgY3JlZGVudGlhbCBtdXN0IGZhaWwgY2xvc2VkLiBOZXZlciBsZXQgaXQgZmFsbAogICAgICAvLyB0aHJvdWdoIHRvIHRoZSBsZWdhY3kgaW4tbWVtb3J5IHN0b3JlIHdoZXJlIGFuIG9sZCBkdXBsaWNhdGUgY291bGQgbGl2ZS4KICAgICAgc2VuZEFwaUVycm9yKHJlcSwgcmVzLCB7CiAgICAgICAgc3RhdHVzOiA0MDEsCiAgICAgICAgY29kZTogJ0FVVEhfQVBJX0tFWV9JTlZBTElEJywKICAgICAgICBtZXNzYWdlOiAnSW52YWxpZCBBUEkga2V5JywKICAgICAgICByZXRyeWFibGU6IGZhbHNlLAogICAgICB9KTsKICAgICAgcmV0dXJuOwogICAgfQoKICAgIGlmIChpc1BlcnNpc3RlZEFwaUtleUV4cGlyZWQoYXBpS2V5KSkgewogICAgICBzZW5kVW5hdXRob3JpemVkKHJlcSwgcmVzLCAnQVBJX0tFWV9FWFBJUkVEJywgJ0FQSSBrZXkgaGFzIGV4cGlyZWQnKTsKICAgICAgcmV0dXJuOwogICAgfQoKICAgIC8vIFRlbmFudCBzY29waW5nOiBpZiB0aGUgcmVxdWVzdCB0YXJnZXRzIGEgdGVuYW50LCB0aGUga2V5IG11c3QgYmVsb25nIHRvIGl0LgogICAgaWYgKHJlcXVlc3RlZFRlbmFudElkICYmIHJlcXVlc3RlZFRlbmFudElkICE9PSBhcGlLZXkudGVuYW50SWQpIHsKICAgICAgc2VuZEFwaUVycm9yKHJlcSwgcmVzLCB7CiAgICAgICAgc3RhdHVzOiA0MDEsCiAgICAgICAgY29kZTogJ0FVVEhfQVBJX0tFWV9JTlZBTElEJywKICAgICAgICBtZXNzYWdlOiAnQVBJIGtleSBpcyBub3QgdmFsaWQgZm9yIHRoaXMgdGVuYW50JywKICAgICAgICByZXRyeWFibGU6IGZhbHNlLAogICAgICB9KTsKICAgICAgcmV0dXJuOwogICAgfQoKICAgIGNvbnN0IGF2YWlsYWJsZVNjb3BlcyA9IHBhcnNlU2NvcGVzKGFwaUtleS5zY29wZXMpOwogICAgaWYgKCFoYXNSZXF1aXJlZFNjb3BlcyhhdmFpbGFibGVTY29wZXMsIHJlcXVpcmVkU2NvcGVzKSkgewogICAgICBzZW5kVW5hdXRob3JpemVkKAogICAgICAgIHJlcSwKICAgICAgICByZXMsCiAgICAgICAgJ1NDT1BFX0lOU1VGRklDSUVOVCcsCiAgICAgICAgJ0FQSSBrZXkgbWlzc2VzIHJlcXVpcmVkIHNjb3BlKHMpJywKICAgICAgICAnQmVhcmVyIGVycm9yPSJpbnN1ZmZpY2llbnRfc2NvcGUiJwogICAgICApOwogICAgICByZXR1cm47CiAgICB9CgogICAgcmVxLmF1dGhBcGlLZXlIYXNoID0gYXBpS2V5Lmhhc2hlZEtleTsKICAgIHJlcS5hdXRoQXBpS2V5Um9sZSA9IGFwaUtleS5yb2xlIGFzIEFwaUtleVJvbGU7CiAgICByZXEuYXV0aEFwaUtleVRlbmFudElkID0gYXBpS2V5LnRlbmFudElkOwogICAgcmVxLmF1dGhBcGlLZXlTY29wZXMgPSBhdmFpbGFibGVTY29wZXM7CiAgICByZXEuYXV0aEFwaUtleUxlZ2FjeSA9IGZhbHNlOwoKICAgIC8vIEJlc3QtZWZmb3J0IGxhc3QtdXNlZCB0cmFja2luZyBmb3IgYXVkaXQuIE5ldmVyIGZhaWwgYXV0aCBiZWNhdXNlIG9mIGl0LgogICAgdHJ5IHsKICAgICAgY29uc3Qgbm93ID0gbmV3IERhdGUoKTsKICAgICAgYXdhaXQgcHJpc21hLmFwaUtleS51cGRhdGUoewogICAgICAgIHdoZXJlOiB7IGhhc2hlZEtleTogYXBpS2V5Lmhhc2hlZEtleSB9LAogICAgICAgIGRhdGE6IHsgbGFzdFVzZWRBdDogbm93IH0sCiAgICAgIH0pOwogICAgICByZXEuYXV0aEFwaUtleUxhc3RVc2VkQXQgPSBub3c7CiAgICB9IGNhdGNoIHsKICAgICAgLy8gaWdub3JlIGxhc3QtdXNlZCB1cGRhdGUgZmFpbHVyZXMKICAgIH0KCiAgICBuZXh0KCk7CiAgICByZXR1cm47CiAgfQoKICAvLyBGYWxsYmFjazogY2hlY2sgdGhlIGluLW1lbW9yeSBzdG9yZSAodXNlZCBieSB0ZXN0cyBhbmQgbGVnYWN5IGNhbGxlcnMpCiAgY29uc3QgbWV0YSA9IElOX01FTU9SWV9LRVlTLmdldChoYXNoZWQpOwogIGlmIChtZXRhICYmICFtZXRhLnJldm9rZWRBdCkgewogICAgaWYgKG1ldGEuZXhwaXJlc0F0ICYmIG1ldGEuZXhwaXJlc0F0LmdldFRpbWUoKSA8PSBEYXRlLm5vdygpKSB7CiAgICAgIHNlbmRVbmF1dGhvcml6ZWQocmVxLCByZXMsICdBUElfS0VZX0VYUElSRUQnLCAnQVBJIGtleSBoYXMgZXhwaXJlZCcpOwogICAgICByZXR1cm47CiAgICB9CiAgICBpZiAocmVxdWVzdGVkVGVuYW50SWQgJiYgcmVxdWVzdGVkVGVuYW50SWQgIT09IG1ldGEudGVuYW50SWQpIHsKICAgICAgc2VuZEFwaUVycm9yKHJlcSwgcmVzLCB7CiAgICAgICAgc3RhdHVzOiA0MDEsCiAgICAgICAgY29kZTogJ0FVVEhfQVBJX0tFWV9JTlZBTElEJywKICAgICAgICBtZXNzYWdlOiAnQVBJIGtleSBpcyBub3QgdmFsaWQgZm9yIHRoaXMgdGVuYW50JywKICAgICAgICByZXRyeWFibGU6IGZhbHNlLAogICAgICB9KTsKICAgICAgcmV0dXJuOwogICAgfQogICAgaWYgKCFoYXNSZXF1aXJlZFNjb3BlcyhtZXRhLnNjb3BlcywgcmVxdWlyZWRTY29wZXMpKSB7CiAgICAgIHNlbmRVbmF1dGhvcml6ZWQoCiAgICAgICAgcmVxLAogICAgICAgIHJlcywKICAgICAgICAnU0NPUEVfSU5TVUZGSUNJRU5UJywKICAgICAgICAnQVBJIGtleSBtaXNzZXMgcmVxdWlyZWQgc2NvcGUocyknLAogICAgICAgICdCZWFyZXIgZXJyb3I9Imluc3VmZmljaWVudF9zY29wZSInCiAgICAgICk7CiAgICAgIHJldHVybjsKICAgIH0KICAgIHJlcS5hdXRoQXBpS2V5SGFzaCA9IGhhc2hlZDsKICAgIHJlcS5hdXRoQXBpS2V5Um9sZSA9IG1ldGEucm9sZTsKICAgIHJlcS5hdXRoQXBpS2V5VGVuYW50SWQgPSBtZXRhLnRlbmFudElkOwogICAgcmVxLmF1dGhBcGlLZXlTY29wZXMgPSBtZXRhLnNjb3BlczsKICAgIHJlcS5hdXRoQXBpS2V5TGVnYWN5ID0gZmFsc2U7CiAgICBtZXRhLmxhc3RVc2VkQXQgPSBuZXcgRGF0ZSgpOwogICAgcmVxLmF1dGhBcGlLZXlMYXN0VXNlZEF0ID0gbWV0YS5sYXN0VXNlZEF0OwogICAgbmV4dCgpOwogICAgcmV0dXJuOwogIH0KCiAgLy8gTGVnYWN5IGZhbGxiYWNrOiBzaW5nbGUgZ2xvYmFsIGVudiB2YXIgQVBJX0tFWS4gRGVwcmVjYXRlZC4KICBpZiAoY2hlY2tMZWdhY3lFbnZLZXkocHJvdmlkZWRLZXkpKSB7CiAgICBjb25zb2xlLndhcm4oCiAgICAgICdbZGVwcmVjYXRpb25dIEFQSV9LRVkgZW52IHZhciBmYWxsYmFjayBpcyBkZXByZWNhdGVkLiBNaWdyYXRlIHRvIHBlci10ZW5hbnQgQVBJIGtleXMgaW4gdGhlIGFwaUtleSB0YWJsZS4nCiAgICApOwogICAgaWYgKHJlcXVlc3RlZFRlbmFudElkICYmIHJlcXVlc3RlZFRlbmFudElkICE9PSBMRUdBQ1lfRU5WX1RFTkFOVCkgewogICAgICBzZW5kQXBpRXJyb3IocmVxLCByZXMsIHsKICAgICAgICBzdGF0dXM6IDQwMSwKICAgICAgICBjb2RlOiAnQVVUSF9BUElfS0VZX0lOVkFMSUQnLAogICAgICAgIG1lc3NhZ2U6ICdBUElrZXkgaXMgbm90IHZhbGlkIGZvciB0aGlzIHRlbmFudCcsCiAgICAgICAgcmV0cnlhYmxlOiBmYWxzZSwKICAgICAgfSk7CiAgICAgIHJldHVybjsKICAgIH0KICAgIGlmICghaGFzUmVxdWlyZWRTY29wZXMoTEVHQUNZX0VOVl9TQ09QRVMsIHJlcXVpcmVkU2NvcGVzKSkgewogICAgICBzZW5kVW5hdXRob3JpemVkKAogICAgICAgIHJlcSwKICAgICAgICByZXMsCiAgICAgICAgJ1NDT1BFX0lOU1VGRklDSUVOVCcsCiAgICAgICAgJ0FQSSBrZXkgbWlzc2VzIHJlcXVpcmVkIHNjb3BlKHMpJywKICAgICAgICAnQmVhcmVyIGVycm9yPSJpbnN1ZmZpY2llbnRfc2NvcGUiJwogICAgICApOwogICAgICByZXR1cm47CiAgICB9CiAgICByZXEuYXV0aEFwaUtleUhhc2ggPSBoYXNoZWQ7CiAgICByZXEuYXV0aEFwaUtleVJvbGUgPSAnYWRtaW4nOwogICAgcmVxLmF1dGhBcGlLZXlUZW5hbnRJZCA9IExFR0FDWV9FTlZfVEVOQU5UOwogICAgcmVxLmF1dGhBcGlLZXlTY29wZXMgPSBMRUdBQ1lfRU5WX1NDT1BFUzsKICAgIHJlcS5hdXRoQXBpS2V5TGVnYWN5ID0gdHJ1ZTsKICAgIG5leHQoKTsKICAgIHJldHVybjsKICB9CgogIHNlbmRBcGlFcnJvcihyZXEsIHJlcywgewogICAgc3RhdHVzOiA0MDEsCiAgICBjb2RlOiAnQVVUSF9BUElfS0VZX0lOVkFMSUQnLAogICAgbWVzc2FnZTogJ0ludmFsaWQgQVBJIGtleScsCiAgICByZXRyeWFibGU6IGZhbHNlLAogIH0pOwp9CgpleHBvcnQgZnVuY3Rpb24gaGFzaEFwaUtleShrZXk6IHN0cmluZyk6IHN0cmluZyB7CiAgcmV0dXJuIGNyeXB0by5jcmVhdGVIYXNoKCdzaGEyNTYnKS51cGRhdGUoa2V5KS5kaWdlc3QoJ2hleCcpOwp9CgovLyBMZWdhY3kgaGVscGVycyByZXRhaW5lZCBmb3IgY29tcGF0aWJpbGl0eSDigJMgb3BlcmF0ZSBvbiBpbi1tZW1vcnkgbWFwIG9ubHkuCmNvbnN0IElOX01FTU9SWV9LRVlTID0gbmV3IE1hcDxzdHJpbmcsIEFwaUtleU1ldGFkYXRhPigpOwoKZXhwb3J0IGZ1bmN0aW9uIHJlZ2lzdGVyQXBpS2V5KAogIGtleTogc3RyaW5nLAogIG9wdGlvbnM/OiB7IHJvbGU/OiBBcGlLZXlSb2xlOyB0ZW5hbnRJZD86IHN0cmluZzsgc2NvcGVzPzogc3RyaW5nW107IGV4cGlyZXNBdD86IERhdGUgfCBudWxsIH0KKTogc3RyaW5nIHsKICBjb25zdCBoYXNoID0gaGFzaEFwaUtleShrZXkpOwogIElOX01FTU9SWV9LRVlTLnNldChoYXNoLCB7CiAgICByb2xlOiBvcHRpb25zPy5yb2xlIHx8ICdhZG1pbicsCiAgICBjcmVhdGVkQXQ6IG5ldyBEYXRlKCksCiAgICB0ZW5hbnRJZDogb3B0aW9ucz8udGVuYW50SWQgfHwgJ3Vua25vd24nLAogICAgc2NvcGVzOiBvcHRpb25zPy5zY29wZXMgfHwgW10sCiAgICBleHBpcmVzQXQ6IG9wdGlvbnM/LmV4cGlyZXNBdCA/PyBudWxsLAogIH0pOwogIHJldHVybiBoYXNoOwp9CgpleHBvcnQgZnVuY3Rpb24gcmV2b2tlQXBpS2V5KGhhc2g6IHN0cmluZyk6IGJvb2xlYW4gewogIGNvbnN0IG1ldGEgPSBJTl9NRU1PUllfS0VZUy5nZXQoaGFzaCk7CiAgaWYgKG1ldGEpIHsKICAgIG1ldGEucmV2b2tlZEF0ID0gbmV3IERhdGUoKTsKICAgIElOX01FTU9SWV9LRVlTLnNldChoYXNoLCBtZXRhKTsKICB9CiAgcmV0dXJuIElOX01FTU9SWV9LRVlTLmRlbGV0ZShoYXNoKTsKfQoKZXhwb3J0IGZ1bmN0aW9uIHJvdGF0ZUFwaUtleSgKICBvbGRIYXNoOiBzdHJpbmcsCiAgbmV3S2V5OiBzdHJpbmcsCiAgb3B0aW9uczogeyByb2xlPzogQXBpS2V5Um9sZTsgdGVuYW50SWQ/OiBzdHJpbmc7IHNjb3Blcz86IHN0cmluZ1tdIH0gPSB7fQopOiBzdHJpbmcgfCBudWxsIHsKICBjb25zdCBtZXRhID0gSU5fTUVNT1JZX0tFWVMuZ2V0KG9sZEhhc2gpOwogIGlmICghbWV0YSkgcmV0dXJuIG51bGw7CiAgSU5fTUVNT1JZX0tFWVMuZGVsZXRlKG9sZEhhc2gpOwogIGNvbnN0IG5ld0hhc2ggPSBoYXNoQXBpS2V5KG5ld0tleSk7CiAgSU5fTUVNT1JZX0tFWVMuc2V0KG5ld0hhc2gsIHsKICAgIHJvbGU6IG9wdGlvbnMucm9sZSB8fCBtZXRhLnJvbGUsCiAgICBjcmVhdGVkQXQ6IG1ldGEuY3JlYXRlZEF0LAogICAgcm90YXRlZEF0OiBuZXcgRGF0ZSgpLAogICAgdGVuYW50SWQ6IG9wdGlvbnMudGVuYW50SWQgfHwgbWV0YS50ZW5hbnRJZCwKICAgIHNjb3Blczogb3B0aW9ucy5zY29wZXMgfHwgbWV0YS5zY29wZXMsCiAgICBleHBpcmVzQXQ6IG1ldGEuZXhwaXJlc0F0ID8/IG51bGwsCiAgfSk7CiAgcmV0dXJuIG5ld0hhc2g7Cn0KCmV4cG9ydCBmdW5jdGlvbiByZXN0b3JlQXBpS2V5KGhhc2g6IHN0cmluZyk6IGJvb2xlYW4gewogIGNvbnN0IG1ldGEgPSBJTl9NRU1PUllfS0VZUy5nZXQoaGFzaCk7CiAgaWYgKCFtZXRhKSByZXR1cm4gZmFsc2U7CiAgbWV0YS5yZXZva2VkQXQgPSB1bmRlZmluZWQ7CiAgSU5fTUVNT1JZX0tFWVMuc2V0KGhhc2gsIG1ldGEpOwogIHJldHVybiB0cnVlOwp9CgpleHBvcnQgZnVuY3Rpb24gZ2V0QXBpS2V5TWV0YWRhdGEoaGFzaDogc3RyaW5nKSB7CiAgY29uc3QgbWV0YSA9IElOX01FTU9SWV9LRVlTLmdldChoYXNoKTsKICBpZiAoIW1ldGEpIHJldHVybiBudWxsOwogIHJldHVybiB7CiAgICBoYXNoLAogICAgcm9sZTogbWV0YS5yb2xlLAogICAgY3JlYXRlZEF0OiBtZXRhLmNyZWF0ZWRBdC50b0lTT1N0cmluZygpLAogICAgLi4uKG1ldGEucm90YXRlZEF0ID8geyByb3RhdGVkQXQ6IG1ldGEucm90YXRlZEF0LnRvSVNPU3RyaW5nKCkgfSA6IHt9KSwKICAgIC4uLihtZXRhLnJldm9rZWRBdCA/IHsgcmV2b2tlZEF0OiBtZXRhLnJldm9rZWRBdC50b0lTT1N0cmluZygpIH0gOiB7fSksCiAgICB0ZW5hbnRJZDogbWV0YS50ZW5hbnRJZCwKICAgIHNjb3BlczogbWV0YS5zY29wZXMsCiAgICAuLi4obWV0YS5leHBpcmVzQXQgPyB7IGV4cGlyZXNBdDogbWV0YS5leHBpcmVzQXQudG9JU09TdHJpbmcoKSB9IDoge30pLAogICAgLi4uKG1ldGEubGFzdFVzZWRBdCA/IHsgbGFzdFVzZWRBdDogbWV0YS5sYXN0VXNlZEF0LnRvSVNPU3RyaW5nKCkgfSA6IHt9KSwKICB9Owp9CgpleHBvcnQgZnVuY3Rpb24gYXV0aGVudGljYXRlQXBpS2V5VmFsdWUodmFsdWU6IHN0cmluZykgewogIGNvbnN0IGhhc2ggPSBoYXNoQXBpS2V5KHZhbHVlKTsKICBjb25zdCBtZXRhID0gSU5fTUVNT1JZX0tFWVMuZ2V0KGhhc2gpOwogIGlmICghbWV0YSB8fCBtZXRhLnJldm9rZWRBdCkgcmV0dXJuIG51bGw7CiAgaWYgKG1ldGEuZXhwaXJlc0F0ICYmIG1ldGEuZXhwaXJlc0F0LmdldFRpbWUoKSA8PSBEYXRlLm5vdygpKSByZXR1cm4gbnVsbDsKICByZXR1cm4geyBoYXNoLCByb2xlOiBtZXRhLnJvbGUsIHRlbmFudElkOiBtZXRhLnRlbmFudElkLCBzY29wZXM6IG1ldGEuc2NvcGVzIH07Cn0KCmV4cG9ydCBmdW5jdGlvbiBoYXNSZXF1aXJlZEFwaUtleVJvbGUocmVxOiBSZXF1ZXN0LCByZXF1aXJlZFJvbGU6IEFwaUtleVJvbGUpOiBib29sZWFuIHsKICBjb25zdCBjdXJyZW50Um9sZSA9IHJlcS5hdXRoQXBpS2V5Um9sZSB8fCAnYWRtaW4nOwogIHJldHVybiBST0xFX1BSRUNFREVOQ0VbY3VycmVudFJvbGVdID49IFJPTEVfUFJFQ0VERU5DRVtyZXF1aXJlZFJvbGVdOwp9Cg== \ No newline at end of file +import type { Request, Response, NextFunction } from 'express'; +import crypto from 'crypto'; +import { prisma } from '../prisma'; +import { sendApiError } from './apiError'; + +export type ApiKeyRole = 'viewer' | 'operator' | 'admin' | 'super-admin'; + +interface ApiKeyMetadata { + role: ApiKeyRole; + createdAt: Date; + rotatedAt?: Date; + revokedAt?: Date; + tenantId: string; + scopes: string[]; + expiresAt?: Date | null; + lastUsedAt?: Date | null; +} + +declare global { + namespace Express { + interface Request { + authApiKeyHash?: string; + authApiKeyRole?: ApiKeyRole; + authApiKeyTenantId?: string; + authApiKeyScopes?: string[]; + authApiKeyLastUsedAt?: Date | null; + authApiKeyLegacy?: boolean; + } + } +} + +const ROLE_PRECEDENCE: Record = { + viewer: 0, + operator: 1, + admin: 2, + 'super-admin': 3, +}; + +export function normalizeApiKeyRole(raw: unknown): ApiKeyRole | null { + if (typeof raw !== 'string') { + return null; + } + const value = raw.trim().toLowerCase(); + if (value === 'viewer' || value === 'operator' || value === 'admin' || value === 'super-admin') { + return value as ApiKeyRole; + } + return null; +} + +function parseScopes(raw: unknown): string[] { + if (Array.isArray(raw)) return raw as string[]; + if (typeof raw === 'string') { + try { + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } + } + return []; +} + +export interface PersistedApiKeyStatus { + isActive: boolean; + deletedAt?: Date | null; + expiresAt?: Date | null; + tenant?: { deletedAt?: Date | null }; +} + +export function isPersistedApiKeyUsable( + apiKey: PersistedApiKeyStatus | null +): apiKey is PersistedApiKeyStatus { + return Boolean(apiKey && apiKey.isActive && !apiKey.deletedAt && !apiKey.tenant?.deletedAt); +} + +export function isPersistedApiKeyExpired( + apiKey: PersistedApiKeyStatus | null +): boolean { + return Boolean(apiKey && apiKey.expiresAt && apiKey.expiresAt.getTime() <= Date.now()); +} + +export function extractTenantIdFromRequest(req: Request): string | null { + const fromParams = req.params?.tenantId; + if (typeof fromParams === 'string' && fromParams.trim()) { + return fromParams.trim(); + } + const fromQuery = req.query?.tenantId; + if (typeof fromQuery === 'string' && fromQuery.trim()) { + return fromQuery.trim(); + } + const fromHeader = req.get?.('x-tenant-id'); + if (typeof fromHeader === 'string' && fromHeader.trim()) { + return fromHeader.trim(); + } + return null; +} + +export function extractRequiredScopes(req: Request): string[] { + const raw = (req as Request & { requiredScopes?: unknown }).requiredScopes; + if (Array.isArray(raw)) { + return raw.filter((s): s is string => typeof s === 'string'); + } + if (typeof raw === 'string' && raw.trim()) { + return raw.split(',').map((s) => s.trim()).filter(Boolean); + } + return []; +} + +export function hasRequiredScopes(available: string[], required: string[]): boolean { + if (required.length === 0) return true; + if (available.includes('*')) return true; + return required.every((scope) => available.includes(scope)); +} + +function sendUnauthorized( + req: Request, + res: Response, + code: string, + message: string, + scheme: string = 'Bearer' +): void { + res.setHeader('WWW-Authenticate', scheme); + sendApiError(req, res, { + status: 401, + code, + message, + retrable: false, + }); +} + +const LEGACY_ENV_KEY = process.env.API_KEY; +const LEGACY_ENV_TENANT = process.env.API_KEY_TENANT_ID || 'default'; +const LEGACY_ENV_SCOPES = (process.env.API_KEY_SCOPES || '*') + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + +function checkLegacyEnvKey(providedKey: string): boolean { + if (!LEGACY_ENV_KEY) return false; + const a = Buffer.from(providedKey); + const b = Buffer.from(LEGACY_ENV_KEY); + if (a.length !== b.length) return false; + return crypto.timingSafeEqual(a, b); +} + +export async function validateApiKey( + req: Request, + res: Response, + next: NextFunction +): Promise { + const authHeader = req.get?.('Authorization') || ''; + const match = authHeader.match(/^ApiKey\s+(.+)$/i); + if (!match) { + sendApiError(req, res, { + status: 401, + code: 'AUTH_API_KEY_MISSING', + message: 'Missing or invalid API key', + retryable: false, + }); + return; + } + const providedKey = match[1]; + const hashed = crypto.createHash('sha256').update(providedKey).digest('hex'); + const requestedTenantId = extractTenantIdFromRequest(req); + const requiredScopes = extractRequiredScopes(req); + + // Try Prisma first; fall back to in-memory store when the table doesn't exist + // (e.g. in tests with an un-migrated SQLite database). + let apiKey: { + id?: string; + hashedKey: string; + role: string; + tenantId: string; + scopes: unknown; + isActive: boolean; + deletedAt?: Date | null; + expiresAt?: Date | null; + tenant?: { deletedAt?: Date | null }; + } | null = null; + try { + apiKey = await prisma.apiKey.findUnique({ + where: { hashedKey: hashed }, + include: { tenant: { select: { deletedAt: true } } }, + }); + } catch (err) { + // Only fall back when the ApiKey table hasn't been created (tests / fresh DB). + const isMissingTable = + err instanceof Error && + (err.message.includes('does not exist in the current database') || + (err as { code?: string }).code === 'P2021'); + if (!isMissingTable) throw err; + } + + if (apiKey) { + if (!isPersistedApiKeyUsable(apiKey)) { + // A persisted-but-disabled credential must fail closed. Never let it fall + // through to the legacy in-memory store where an old duplicate could live. + sendApiError(req, res, { + status: 401, + code: 'AUTH_API_KEY_INVALID', + message: 'Invalid API key', + retryable: false, + }); + return; + } + + if (isPersistedApiKeyExpired(apiKey)) { + sendUnauthorized(req, res, 'API_KEY_EXPIRED', 'API key has expired'); + return; + } + + // Tenant scoping: if the request targets a tenant, the key must belong to it. + if (requestedTenantId && requestedTenantId !== apiKey.tenantId) { + sendApiError(req, res, { + status: 401, + code: 'AUTH_API_KEY_INVALID', + message: 'API key is not valid for this tenant', + retryable: false, + }); + return; + } + + const availableScopes = parseScopes(apiKey.scopes); + if (!hasRequiredScopes(availableScopes, requiredScopes)) { + sendUnauthorized( + req, + res, + 'SCOPE_INSUFFICIENT', + 'API key misses required scope(s)', + 'Bearer error="insufficient_scope"' + ); + return; + } + + req.authApiKeyHash = apiKey.hashedKey; + req.authApiKeyRole = apiKey.role as ApiKeyRole; + req.authApiKeyTenantId = apiKey.tenantId; + req.authApiKeyScopes = availableScopes; + req.authApiKeyLegacy = false; + + // Best-effort last-used tracking for audit. Never fail auth because of it. + try { + const now = new Date(); + await prisma.apiKey.update({ + where: { hashedKey: apiKey.hashedKey }, + data: { lastUsedAt: now }, + }); + req.authApiKeyLastUsedAt = now; + } catch { + // ignore last-used update failures + } + + next(); + return; + } + + // Fallback: check the in-memory store (used by tests and legacy callers) + const meta = IN_MEMORY_KEYS.get(hashed); + if (meta && !meta.revokedAt) { + if (meta.expiresAt && meta.expiresAt.getTime() <= Date.now()) { + sendUnauthorized(req, res, 'API_KEY_EXPIRED', 'API key has expired'); + return; + } + if (requestedTenantId && requestedTenantId !== meta.tenantId) { + sendApiError(req, res, { + status: 401, + code: 'AUTH_API_KEY_INVALID', + message: 'API key is not valid for this tenant', + retryable: false, + }); + return; + } + if (!hasRequiredScopes(meta.scopes, requiredScopes)) { + sendUnauthorized( + req, + res, + 'SCOPE_INSUFFICIENT', + 'API key misses required scope(s)', + 'Bearer error="insufficient_scope"' + ); + return; + } + req.authApiKeyHash = hashed; + req.authApiKeyRole = meta.role; + req.authApiKeyTenantId = meta.tenantId; + req.authApiKeyScopes = meta.scopes; + req.authApiKeyLegacy = false; + meta.lastUsedAt = new Date(); + req.authApiKeyLastUsedAt = meta.lastUsedAt; + next(); + return; + } + + // Legacy fallback: single global env var API_KEY. Deprecated. + if (checkLegacyEnvKey(providedKey)) { + console.warn( + '[deprecation] API_KEY env var fallback is deprecated. Migrate to per-tenant API keys in the apiKey table.' + ); + if (requestedTenantId && requestedTenantId !== LEGACY_ENV_TENANT) { + sendApiError(req, res, { + status: 401, + code: 'AUTH_API_KEY_INVALID', + message: 'APIkey is not valid for this tenant', + retryable: false, + }); + return; + } + if (!hasRequiredScopes(LEGACY_ENV_SCOPES, requiredScopes)) { + sendUnauthorized( + req, + res, + 'SCOPE_INSUFFICIENT', + 'API key misses required scope(s)', + 'Bearer error="insufficient_scope"' + ); + return; + } + req.authApiKeyHash = hashed; + req.authApiKeyRole = 'admin'; + req.authApiKeyTenantId = LEGACY_ENV_TENANT; + req.authApiKeyScopes = LEGACY_ENV_SCOPES; + req.authApiKeyLegacy = true; + next(); + return; + } + + sendApiError(req, res, { + status: 401, + code: 'AUTH_API_KEY_INVALID', + message: 'Invalid API key', + retryable: false, + }); +} + +export function hashApiKey(key: string): string { + return crypto.createHash('sha256').update(key).digest('hex'); +} + +// Legacy helpers retained for compatibility – operate on in-memory map only. +const IN_MEMORY_KEYS = new Map(); + +export function registerApiKey( + key: string, + options?: { role?: ApiKeyRole; tenantId?: string; scopes?: string[]; expiresAt?: Date | null } +): string { + const hash = hashApiKey(key); + IN_MEMORY_KEYS.set(hash, { + role: options?.role || 'admin', + createdAt: new Date(), + tenantId: options?.tenantId || 'unknown', + scopes: options?.scopes || [], + expiresAt: options?.expiresAt ?? null, + }); + return hash; +} + +export function revokeApiKey(hash: string): boolean { + const meta = IN_MEMORY_KEYS.get(hash); + if (meta) { + meta.revokedAt = new Date(); + IN_MEMORY_KEYS.set(hash, meta); + } + return IN_MEMORY_KEYS.delete(hash); +} + +export function rotateApiKey( + oldHash: string, + newKey: string, + options: { role?: ApiKeyRole; tenantId?: string; scopes?: string[] } = {} +): string | null { + const meta = IN_MEMORY_KEYS.get(oldHash); + if (!meta) return null; + IN_MEMORY_KEYS.delete(oldHash); + const newHash = hashApiKey(newKey); + IN_MEMORY_KEYS.set(newHash, { + role: options.role || meta.role, + createdAt: meta.createdAt, + rotatedAt: new Date(), + tenantId: options.tenantId || meta.tenantId, + scopes: options.scopes || meta.scopes, + expiresAt: meta.expiresAt ?? null, + }); + return newHash; +} + +export function restoreApiKey(hash: string): boolean { + const meta = IN_MEMORY_KEYS.get(hash); + if (!meta) return false; + meta.revokedAt = undefined; + IN_MEMORY_KEYS.set(hash, meta); + return true; +} + +export function getApiKeyMetadata(hash: string) { + const meta = IN_MEMORY_KEYS.get(hash); + if (!meta) return null; + return { + hash, + role: meta.role, + createdAt: meta.createdAt.toISOString(), + ...(meta.rotatedAt ? { rotatedAt: meta.rotatedAt.toISOString() } : {}), + ...(meta.revokedAt ? { revokedAt: meta.revokedAt.toISOString() } : {}), + tenantId: meta.tenantId, + scopes: meta.scopes, + ...(meta.expiresAt ? { expiresAt: meta.expiresAt.toISOString() } : {}), + ...(meta.lastUsedAt ? { lastUsedAt: meta.lastUsedAt.toISOString() } : {}), + }; +} + +export function authenticateApiKeyValue(value: string) { + const hash = hashApiKey(value); + const meta = IN_MEMORY_KEYS.get(hash); + if (!meta || meta.revokedAt) return null; + if (meta.expiresAt && meta.expiresAt.getTime() <= Date.now()) return null; + return { hash, role: meta.role, tenantId: meta.tenantId, scopes: meta.scopes }; +} + +export function hasRequiredApiKeyRole(req: Request, requiredRole: ApiKeyRole): boolean { + const currentRole = req.authApiKeyRole || 'admin'; + return ROLE_PRECEDENCE[currentRole] >= ROLE_PRECEDENCE[requiredRole]; +} diff --git a/backend/src/middleware/tenantBoundary.ts b/backend/src/middleware/tenantBoundary.ts index 4ba5fab85..2a6f73fba 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'; // ─── Extended Express Request Interface ────────────────────────────────────── @@ -177,7 +178,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 f66db5d0a..d14bba4db 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/pagination.ts b/backend/src/pagination.ts index 51d4b1ee9..8e2d17db3 100644 --- a/backend/src/pagination.ts +++ b/backend/src/pagination.ts @@ -218,7 +218,6 @@ export function parsePaginationQuery( query.page = 1; } } - } // Parse sortBy if (req.query.sortBy !== undefined && typeof req.query.sortBy === 'string') { diff --git a/backend/src/rateLimiter.ts b/backend/src/rateLimiter.ts index 89e67710b..aad3ede1d 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 de9854efa..2c4289277 100644 --- a/backend/src/routes/vaults.ts +++ b/backend/src/routes/vaults.ts @@ -1,157 +1,3 @@ -/** - * @file routes/vaults.ts - * `GET /api/v1/vaults` — paginated, public listing of active vaults. - * - * Pagination limits (Issue #1430) - * ------------------------------- - * This is an unauthenticated read that maps a caller-supplied `limit` - * straight onto Prisma's `take`, so it is exactly the shape of request that - * let `?limit=100000` OOM the API container. The route therefore sits behind - * `enforcePaginationLimits()`, which: - * - * - defaults `limit` to 20, - * - rejects `limit > 50` with `400` / `code: 'LIMIT_EXCEEDED'` *before* - * any database call (rejecting rather than clamping is deliberate: a - * silent clamp hides a broken or probing client behind a response that - * looks like a complete page), - * - clamps `page` into `1..1000` because the effective offset handed to - * the database is `(page - 1) * limit`. - * - * `take` is always `limit + 1` — one extra row is fetched to learn whether a - * next page exists, so the largest possible read is `MAX_PAGE_SIZE + 1`. - */ - -import { Router, Request, Response } from 'express'; -import { readsLimiter } from '../rateLimiter'; -import { createTimeoutFor } from '../middleware/timeoutMiddleware'; -import { - enforcePaginationLimits, - DEFAULT_PAGE_SIZE, - MAX_PAGE_SIZE, -} from '../middleware/paginationGuard'; -import { - createPaginatedResponse, - createPaginationEnvelope, - encodeCursor, - type PaginatedResponse, -} from '../pagination'; -import { getPrismaClient } from '../prismaClient'; -import { withSpan } from '../tracing'; -import { logger } from '../middleware/structuredLogging'; - -const router = Router(); - -/** Public projection of a vault row. `tenantId` is never exposed. */ -export interface VaultListItem { - id: string; - aum: number; - tvlUsd: string | null; - createdAt: string; - updatedAt: string; -} - -interface VaultRow { - id: string; - aum: number; - tvlUsd: string | null; - createdAt: Date; - updatedAt: Date; -} - -function toVaultListItem(row: VaultRow): VaultListItem { - return { - id: row.id, - aum: row.aum, - tvlUsd: row.tvlUsd, - createdAt: row.createdAt.toISOString(), - updatedAt: row.updatedAt.toISOString(), - }; -} - -/** - * Reads one page of active vaults. - * - * Exported separately from the route so unit tests can exercise the query - * construction without an HTTP round trip. - */ -export async function buildVaultsResponse( - limit: number, - page: number -): Promise> { - const prisma = getPrismaClient(); - const take = Math.min(limit, MAX_PAGE_SIZE) + 1; - const skip = (Math.max(1, page) - 1) * limit; - - const where = { deletedAt: null }; - - const [total, rows] = await Promise.all([ - prisma.vault.count({ where }), - prisma.vault.findMany({ - where, - // `id` breaks ties so pages stay stable when `createdAt` collides. - orderBy: [{ createdAt: 'desc' }, { id: 'asc' }], - take, - skip, - }), - ]); - - const hasNextPage = rows.length > limit; - const pageRows = hasNextPage ? rows.slice(0, limit) : rows; - const data = pageRows.map((row) => toVaultListItem(row as unknown as VaultRow)); - - const pagination = createPaginationEnvelope({ - count: data.length, - limit, - total, - currentPage: page, - totalPages: Math.max(1, Math.ceil(total / limit)), - hasNextPage, - hasPrevPage: page > 1, - nextCursor: - hasNextPage && data.length > 0 ? encodeCursor(data[data.length - 1].id) : null, - }); - - return createPaginatedResponse(data, pagination); -} - -/** - * GET /api/v1/vaults - * - * Query parameters: - * - `limit`: items per page, 1..50 (default 20). `> 50` → 400 LIMIT_EXCEEDED. - * - `page`: 1-based page number, clamped to 1..1000. - */ -router.get( - '/', - readsLimiter, - enforcePaginationLimits({ defaultLimit: DEFAULT_PAGE_SIZE, maxLimit: MAX_PAGE_SIZE }), - createTimeoutFor.read(), - async (req: Request, res: Response) => { - const { limit, page } = req.resolvedPagination ?? { - limit: DEFAULT_PAGE_SIZE, - page: 1, - }; - - return withSpan('vaults.list', async (span) => { - span.setAttributes({ 'vaults.limit': limit, 'vaults.page': page }); - - try { - const response = await buildVaultsResponse(limit, page); - res.status(200).json(response); - } catch (error) { - logger.log('error', 'Failed to list vaults', { - error: error instanceof Error ? error.message : String(error), - }); - res.status(500).json({ - error: 'Internal Server Error', - status: 500, - code: 'VAULTS_LIST_FAILED', - message: 'Failed to fetch vaults', - }); - } - }); - }, -); import { Router, Request, Response, NextFunction } from 'express'; import type { Prisma } from '@prisma/client'; import { getPrismaClient } from '../prismaClient'; diff --git a/backend/src/tracing.ts b/backend/src/tracing.ts index 0f3f7db24..4881786f5 100644 --- a/backend/src/tracing.ts +++ b/backend/src/tracing.ts @@ -83,7 +83,6 @@ export function initTracing(): void { const exporter = new OTLPTraceExporter({ url: `${OTLP_ENDPOINT}/v1/traces` }); sdk = new NodeSDK({ - resource: buildResource({ resource: new Resource({ [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 afdfc311b..4ac1c96af 100644 --- a/backend/src/types/validation.ts +++ b/backend/src/types/validation.ts @@ -73,7 +73,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/vaultEndpoints.ts b/backend/src/vaultEndpoints.ts index 9a25673d6..f7af3e7cd 100644 --- a/backend/src/vaultEndpoints.ts +++ b/backend/src/vaultEndpoints.ts @@ -956,7 +956,6 @@ router.get('/receipts', readsLimiter, async (req: Request, res: Response) => { req.query.limit === undefined ? undefined : parseInt(req.query.limit as string, 10), RECEIPTS_PAGINATION_CONFIG ); - const limit = Math.min(parseInt((req.query.limit as string) || '50', 10), 100); const cursor = req.query.cursor as string | undefined; const where = wallet ? { user: wallet } : {}; diff --git a/backend/src/walletUtils.ts b/backend/src/walletUtils.ts index e94d31f0b..a64da6c00 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); +} +