Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 22 additions & 0 deletions backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
40 changes: 38 additions & 2 deletions backend/src/auditLog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,10 @@ interface AuditLogFilters {
action?: string;
path?: string;
statusCode?: number;
from?: string;
to?: string;
limit?: number;
offset?: number;
}

const entries: AuditLogEntry[] = [];
Expand Down Expand Up @@ -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
Expand All @@ -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() {
Expand Down
45 changes: 31 additions & 14 deletions backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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, {
Expand All @@ -3120,6 +3125,8 @@ app.get('/admin/audit/logs', validateApiKey, (req: Request, res: Response) => {
hasNextPage,
extras: {
logs: data,
page,
total: countAuditLogs(filters),
metrics: getAuditLogMetrics(),
},
});
Expand All @@ -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, {
Expand All @@ -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(),
},
Expand Down
11 changes: 11 additions & 0 deletions backend/src/types/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
66 changes: 66 additions & 0 deletions backend/src/webhookDelivery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`;
}
Expand Down Expand Up @@ -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<WebhookSignedEnvelope>;
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');
}
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/Tabs.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
8 changes: 4 additions & 4 deletions frontend/src/components/ui/Table.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand All @@ -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; }
Expand All @@ -57,5 +57,5 @@
}

.table-container::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.15);
background: var(--border-subtle);
}
6 changes: 4 additions & 2 deletions frontend/src/lib/optimisticVaultCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,8 +175,10 @@ export function applyOptimisticVaultPatch(
const walletDelta = action === "deposit" ? -amount : amount;
const vaultDelta = action === "deposit" ? amount : -amount;

queryClient.setQueryData<number>(keys.balanceKey, (current = 0) =>
Math.max(current + walletDelta, 0),
queryClient.setQueryData<number | undefined>(keys.balanceKey, (current) =>
typeof current === "number"
? Math.max(current + walletDelta, 0)
: current,
);
queryClient.setQueryData<PortfolioHolding[] | undefined>(
keys.holdingsKey,
Expand Down
Loading
Loading