diff --git a/backend/.env.example b/backend/.env.example index a6563d32..e19613cd 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -73,6 +73,7 @@ ADMIN_AUDIT_LOG_STORAGE=hybrid WEBHOOK_VERIFICATION_TIMEOUT_MS=5000 WEBHOOK_CHALLENGE_TTL_SECONDS=900 WEBHOOK_ALLOW_UNVERIFIED=false +WEBHOOK_SIGNATURE_MAX_SKEW_MS=300000 # Prisma runtime connection settings PRISMA_POOL_SIZE=10 diff --git a/backend/README.md b/backend/README.md index 0a055356..e44ee0b1 100644 --- a/backend/README.md +++ b/backend/README.md @@ -290,6 +290,28 @@ export or re-register the live mappings before deploying/restarting; otherwise the persistent tables will start empty and only new registrations will be preserved. +## Webhook Failure Behavior + +Incoming webhook deliveries must identify a configured endpoint and contain a +valid schema version, event type, delivery ID, and ISO-8601 `sentAt` timestamp. +The HMAC-SHA256 signature is checked before replay state is recorded. Invalid +signatures, malformed envelopes, unknown endpoints, missing secrets, stale +timestamps, and repeated delivery IDs are rejected without application +processing. + +Outbound delivery attempts use exponential backoff with jitter. After +`WEBHOOK_MAX_ATTEMPTS` failures, the delivery is marked failed and copied to +the webhook dead-letter queue. Operators can inspect it through +`GET /admin/webhooks/dead-letter` and explicitly retry it with +`POST /admin/webhooks/dead-letter/:id/retry`. A retry creates a new delivery +attempt while retaining the original failure record for auditability. + +The replay timestamp window is controlled by +`WEBHOOK_SIGNATURE_MAX_SKEW_MS` (default: 300000 ms). Consumers should return +a non-2xx response for invalid webhook requests; the sender treats non-2xx and +network/time-out failures as retryable until the dead-letter threshold is +reached. + ## Issues Addressed ### Issue #145: Rate Limiting diff --git a/backend/src/auditLog.ts b/backend/src/auditLog.ts index 837f63b5..42d462b7 100644 --- a/backend/src/auditLog.ts +++ b/backend/src/auditLog.ts @@ -31,7 +31,10 @@ interface AuditLogFilters { action?: string; path?: string; statusCode?: number; + from?: string; + to?: string; limit?: number; + offset?: number; } const entries: AuditLogEntry[] = []; @@ -71,6 +74,23 @@ export function createAdminAuditMiddleware() { } export function getAuditLogs(filters: AuditLogFilters = {}): AuditLogEntry[] { + const normalizedLimit = Math.max(1, Math.min(filters.limit ?? 100, 500)); + const normalizedOffset = Math.max(0, filters.offset ?? 0); + return filterAuditLogs(filters) + .sort((left, right) => { + const timestampOrder = right.timestamp.localeCompare(left.timestamp); + return timestampOrder !== 0 + ? timestampOrder + : right.id.localeCompare(left.id); + }) + .slice(normalizedOffset, normalizedOffset + normalizedLimit); +} + +export function countAuditLogs(filters: AuditLogFilters = {}): number { + return filterAuditLogs(filters).length; +} + +function filterAuditLogs(filters: AuditLogFilters): AuditLogEntry[] { const statusFilter = typeof filters.statusCode === 'number' && Number.isFinite(filters.statusCode) ? filters.statusCode @@ -93,11 +113,27 @@ export function getAuditLogs(filters: AuditLogFilters = {}): AuditLogEntry[] { return false; } + if (filters.from && entry.timestamp < normalizeAuditDate(filters.from, false)) { + return false; + } + + if (filters.to && entry.timestamp > normalizeAuditDate(filters.to, true)) { + return false; + } + return true; }); - const normalizedLimit = Math.max(1, Math.min(filters.limit ?? 100, 500)); - return filtered.slice(0, normalizedLimit); + return filtered; +} + +function normalizeAuditDate(value: string, endOfDay: boolean): string { + if (/^\d{4}-\d{2}-\d{2}$/.test(value)) { + return `${value}T${endOfDay ? '23:59:59.999' : '00:00:00.000'}Z`; + } + + const timestamp = Date.parse(value); + return Number.isNaN(timestamp) ? value : new Date(timestamp).toISOString(); } export function getAuditLogMetrics() { diff --git a/backend/src/index.ts b/backend/src/index.ts index 27da7a57..d51720ac 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -23,7 +23,8 @@ import { loadConfig as loadRateLimiterConfig, } from './rateLimiter'; import { idempotencyStore } from './idempotency'; -import { createAdminAuditMiddleware, getAuditLogs, getAuditLogMetrics } from './auditLog'; +import { createAdminAuditMiddleware, getAuditLogs, countAuditLogs, getAuditLogMetrics } from './auditLog'; +import { AuditLogQuerySchema } from './middleware/validate'; import { recordAdminAuditLog } from './adminAudit'; import { recordAdminConfigChange, listAdminConfigChanges, getActorFromRequest @@ -3101,17 +3102,21 @@ app.post('/api/v1/webhooks/verify', validate({ body: WebhookVerifyBodySchema }), /** * GET /admin/audit/logs - list admin activity logs */ -app.get('/admin/audit/logs', validateApiKey, (req: Request, res: Response) => { +app.get('/admin/audit/logs', validateApiKey, validate({ query: AuditLogQuerySchema }), (req: Request, res: Response) => { const statusCode = req.query.statusCode ? parseInt(String(req.query.statusCode), 10) : undefined; const limit = parseLimited(req.query.limit, 100, 1, 500); - - const logs = getAuditLogs({ + const page = parseLimited(req.query.page, 1, 1, 1000000); + const offset = (page - 1) * limit; + const filters = { actor: req.query.actor ? String(req.query.actor) : undefined, action: req.query.action ? String(req.query.action) : undefined, path: req.query.path ? String(req.query.path) : undefined, statusCode, - limit: limit + 1, - }); + from: req.query.from ? String(req.query.from) : undefined, + to: req.query.to ? String(req.query.to) : undefined, + }; + + const logs = getAuditLogs({ ...filters, limit: limit + 1, offset }); const { data, hasNextPage } = paginateByLimit(logs, limit); sendStandardListEnvelope(res, { @@ -3120,6 +3125,8 @@ app.get('/admin/audit/logs', validateApiKey, (req: Request, res: Response) => { hasNextPage, extras: { logs: data, + page, + total: countAuditLogs(filters), metrics: getAuditLogMetrics(), }, }); @@ -3128,18 +3135,26 @@ app.get('/admin/audit/logs', validateApiKey, (req: Request, res: Response) => { /** * GET /admin/audit-logs - list admin audit entries (Issue #253) */ -app.get('/admin/audit-logs', validateApiKey, async (req: Request, res: Response) => { +app.get('/admin/audit-logs', validateApiKey, validate({ query: AuditLogQuerySchema }), async (req: Request, res: Response) => { const limit = parseLimited(req.query.limit, 50, 1, 200); - const statusCode = req.query.statusCode - ? parseLimited(req.query.statusCode, 0, 100, 599) + const page = parseLimited(req.query.page, 1, 1, 1000000); + const offset = (page - 1) * limit; + const statusValue = req.query.statusCode ?? req.query.status; + const statusCode = statusValue + ? parseLimited(statusValue, 0, 100, 599) : undefined; - - const rows = getAuditLogs({ - action: typeof req.query.action === 'string' ? req.query.action : undefined, + const filters = { + action: typeof req.query.action === 'string' + ? req.query.action + : typeof req.query.type === 'string' ? req.query.type : undefined, actor: typeof req.query.actor === 'string' ? req.query.actor : undefined, + path: typeof req.query.path === 'string' ? req.query.path : undefined, statusCode, - limit: limit + 1, - }); + from: typeof req.query.from === 'string' ? req.query.from : undefined, + to: typeof req.query.to === 'string' ? req.query.to : undefined, + }; + + const rows = getAuditLogs({ ...filters, limit: limit + 1, offset }); const { data, hasNextPage } = paginateByLimit(rows, limit); void recordAdminAuditLog(req, 'audit-logs.read', 200, { @@ -3154,6 +3169,8 @@ app.get('/admin/audit-logs', validateApiKey, async (req: Request, res: Response) extras: { meta: { count: data.length, + total: countAuditLogs(filters), + page, limit, timestamp: new Date().toISOString(), }, diff --git a/backend/src/types/validation.ts b/backend/src/types/validation.ts index a9c10678..20041be1 100644 --- a/backend/src/types/validation.ts +++ b/backend/src/types/validation.ts @@ -41,6 +41,17 @@ export const TransactionListQuerySchema = PaginationQuerySchema.extend({ to: z.string().optional(), }).passthrough(); +export const AuditLogQuerySchema = PaginationQuerySchema.extend({ + actor: z.string().optional(), + action: z.string().optional(), + type: z.string().optional(), + path: z.string().optional(), + status: z.string().regex(/^\d+$/, 'status must be an HTTP status code').optional(), + statusCode: z.string().regex(/^\d+$/, 'statusCode must be an HTTP status code').optional(), + from: z.string().min(1).optional(), + to: z.string().min(1).optional(), +}).passthrough(); + export const WebhookListQuerySchema = PaginationQuerySchema.extend({ includeDeleted: z.enum(['true', 'false']).optional(), endpointId: z.string().optional(), diff --git a/backend/src/webhookDelivery.ts b/backend/src/webhookDelivery.ts index 9704497b..a348389c 100644 --- a/backend/src/webhookDelivery.ts +++ b/backend/src/webhookDelivery.ts @@ -539,6 +539,19 @@ export interface WebhookSignedEnvelope { deliveryId: string; } +export type IncomingWebhookVerificationResult = + | { verified: true; envelope: WebhookSignedEnvelope } + | { + verified: false; + reason: + | 'unknown-endpoint' + | 'missing-secret' + | 'invalid-envelope' + | 'invalid-signature' + | 'stale-event' + | 'replayed-event'; + }; + function createReplayCacheKey(endpointId: string, deliveryId: string): string { return `${endpointId}:${deliveryId}`; } @@ -601,6 +614,59 @@ export function verifyWebhookSignature( return crypto.timingSafeEqual(providedBuffer, expectedBuffer); } +export function verifyIncomingWebhookPayload( + endpointId: string, + envelope: unknown, + signature: unknown, +): IncomingWebhookVerificationResult { + const endpoint = endpoints.get(endpointId); + if (!endpoint || endpoint.deletedAt) { + return { verified: false, reason: 'unknown-endpoint' }; + } + if (!endpoint.secret) { + return { verified: false, reason: 'missing-secret' }; + } + if (!envelope || typeof envelope !== 'object' || Array.isArray(envelope)) { + return { verified: false, reason: 'invalid-envelope' }; + } + + const candidate = envelope as Partial; + if ( + candidate.schemaVersion !== WEBHOOK_SCHEMA_VERSION || + typeof candidate.eventType !== 'string' || + !WEBHOOK_EVENT_TYPES.includes(candidate.eventType as WebhookEventType) || + typeof candidate.sentAt !== 'string' || + typeof candidate.deliveryId !== 'string' || + candidate.deliveryId.length === 0 || + !candidate.payload || + typeof candidate.payload !== 'object' || + Array.isArray(candidate.payload) + ) { + return { verified: false, reason: 'invalid-envelope' }; + } + + if ( + typeof signature !== 'string' || + !verifyWebhookSignature(endpoint.secret, envelope, signature) + ) { + return { verified: false, reason: 'invalid-signature' }; + } + + const sentAtMs = Date.parse(candidate.sentAt); + if ( + Number.isNaN(sentAtMs) || + Math.abs(Date.now() - sentAtMs) > webhookSignatureMaxSkewMs + ) { + return { verified: false, reason: 'stale-event' }; + } + + if (!markWebhookDeliverySeen(endpointId, candidate.deliveryId, candidate.sentAt)) { + return { verified: false, reason: 'replayed-event' }; + } + + return { verified: true, envelope: candidate as WebhookSignedEnvelope }; +} + function encodeDeliveryCursor(delivery: WebhookDeliveryRecord): string { return Buffer.from(JSON.stringify({ createdAt: delivery.createdAt, id: delivery.id })).toString('base64url'); } diff --git a/frontend/src/components/Tabs.css b/frontend/src/components/Tabs.css index 59336228..83c22ba7 100644 --- a/frontend/src/components/Tabs.css +++ b/frontend/src/components/Tabs.css @@ -36,7 +36,7 @@ .tabs-trigger:hover { color: var(--text-primary); - background: rgba(255, 255, 255, 0.03); + background: var(--bg-surface-hover); } .tabs-trigger.active { diff --git a/frontend/src/components/ui/Table.css b/frontend/src/components/ui/Table.css index 167bb136..4452ccf9 100644 --- a/frontend/src/components/ui/Table.css +++ b/frontend/src/components/ui/Table.css @@ -3,7 +3,7 @@ overflow-x: auto; border-radius: var(--radius-md); border: 1px solid var(--border-glass); - background: rgba(0, 0, 0, 0.1); + background: var(--bg-control); } .ui-table { @@ -21,7 +21,7 @@ letter-spacing: 0.05em; font-size: var(--text-xs); border-bottom: 1px solid var(--border-glass); - background: rgba(255, 255, 255, 0.02); + background: var(--bg-muted); } .ui-td { @@ -36,7 +36,7 @@ } .ui-tr:hover .ui-td { - background: rgba(255, 255, 255, 0.03); + background: var(--bg-surface-hover); } .ui-th.align-center, .ui-td.align-center { text-align: center; } @@ -57,5 +57,5 @@ } .table-container::-webkit-scrollbar-thumb:hover { - background: rgba(255, 255, 255, 0.15); + background: var(--border-subtle); } diff --git a/frontend/src/lib/optimisticVaultCache.ts b/frontend/src/lib/optimisticVaultCache.ts index b51402d6..9bc0b2bb 100644 --- a/frontend/src/lib/optimisticVaultCache.ts +++ b/frontend/src/lib/optimisticVaultCache.ts @@ -175,8 +175,10 @@ export function applyOptimisticVaultPatch( const walletDelta = action === "deposit" ? -amount : amount; const vaultDelta = action === "deposit" ? amount : -amount; - queryClient.setQueryData(keys.balanceKey, (current = 0) => - Math.max(current + walletDelta, 0), + queryClient.setQueryData(keys.balanceKey, (current) => + typeof current === "number" + ? Math.max(current + walletDelta, 0) + : current, ); queryClient.setQueryData( keys.holdingsKey, diff --git a/frontend/src/styles/theme.css b/frontend/src/styles/theme.css index 49104696..8494fca5 100644 --- a/frontend/src/styles/theme.css +++ b/frontend/src/styles/theme.css @@ -14,12 +14,14 @@ --bg-elevated: rgba(24, 26, 36, 0.92); --bg-overlay: rgba(10, 11, 16, 0.8); --bg-muted: rgba(0, 0, 0, 0.2); + --bg-control: rgba(0, 0, 0, 0.24); --bg-gradient-1: rgba(112, 0, 255, 0.08); --bg-gradient-2: rgba(0, 240, 255, 0.05); --border-glass: rgba(255, 255, 255, 0.08); --border-glass-glow: rgba(0, 240, 255, 0.3); --border-subtle: rgba(255, 255, 255, 0.12); + --border-control: rgba(255, 255, 255, 0.18); --accent-cyan: #00f0ff; --accent-cyan-dim: rgba(0, 240, 255, 0.2); @@ -35,6 +37,7 @@ --text-primary: #ffffff; --text-secondary: #a8b8cc; --text-tertiary: #8494a7; + --text-placeholder: #9aaabd; --text-inverse: #0f172a; --text-warning: #f59e0b; --text-success: #22c55e; @@ -66,12 +69,14 @@ --bg-elevated: #ffffff; --bg-overlay: rgba(255, 255, 255, 0.8); --bg-muted: rgba(15, 23, 42, 0.05); + --bg-control: rgba(255, 255, 255, 0.92); --bg-gradient-1: rgba(126, 34, 206, 0.04); --bg-gradient-2: rgba(2, 132, 199, 0.03); --border-glass: rgba(0, 0, 0, 0.08); --border-glass-glow: rgba(0, 136, 204, 0.2); --border-subtle: rgba(15, 23, 42, 0.12); + --border-control: rgba(15, 23, 42, 0.2); --accent-cyan: #0284c7; --accent-cyan-dim: rgba(2, 132, 199, 0.1); @@ -87,6 +92,7 @@ --text-primary: #0f172a; --text-secondary: #40505f; --text-tertiary: #5a6a7d; + --text-placeholder: #526273; --text-inverse: #ffffff; --text-warning: #b45309; --text-success: #15803d; @@ -130,6 +136,26 @@ body { color: var(--text-primary); } +button, +input, +select, +textarea { + color: var(--text-primary); +} + +input, +select, +textarea { + background-color: var(--bg-control); + border-color: var(--border-control); +} + +input::placeholder, +textarea::placeholder { + color: var(--text-placeholder); + opacity: 1; +} + [data-sonner-toaster] { font-family: var(--font-sans); }