diff --git a/listener/.env.example b/listener/.env.example index 8c4461ca..e8bdc40d 100644 --- a/listener/.env.example +++ b/listener/.env.example @@ -17,6 +17,7 @@ RECONNECT_DELAY_MS=5000 # Events API Configuration EVENTS_API_PORT=8787 EVENTS_API_CORS_ORIGIN=http://localhost:5173 +WEBHOOK_SECRETS=[{"id":"default","secret":"whsec_your_secret_here"}] # Discord Webhook Configuration (optional) DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/YOUR_WEBHOOK_ID/YOUR_WEBHOOK_TOKEN diff --git a/listener/src/api/events-server.test.ts b/listener/src/api/events-server.test.ts index 489aec47..d18e837a 100644 --- a/listener/src/api/events-server.test.ts +++ b/listener/src/api/events-server.test.ts @@ -1,4 +1,17 @@ import http from 'http'; +import crypto from 'crypto'; +import { createEventsServer, checkStellarRpc, checkDiscord } from './events-server'; +import { eventRegistry } from '../store/event-registry'; + +const mockGetHealth = jest.fn(); + +jest.mock('@stellar/stellar-sdk', () => ({ + rpc: { + Server: jest.fn().mockImplementation(() => ({ + getHealth: mockGetHealth, + })), + }, +})); import { createEventsServer } from './events-server'; import { preferenceStore } from '../store/preference-store'; @@ -118,3 +131,150 @@ describe('Preference API endpoints', () => { }); }); }); + +function computeSignature(payload: string, secret: string): string { + const sig = crypto.createHmac('sha256', secret).update(payload, 'utf8').digest('hex'); + return `sha256=${sig}`; +} + +function makePostRequest( + server: http.Server, + path: string, + body: string, + headers: Record +): Promise<{ status: number; body: unknown }> { + return new Promise((resolve, reject) => { + const addr = server.address() as { port: number }; + const req = http.request( + { + host: '127.0.0.1', + port: addr.port, + path, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...headers, + }, + }, + (res) => { + let data = ''; + res.on('data', (chunk) => (data += chunk)); + res.on('end', () => { + try { + resolve({ status: res.statusCode!, body: JSON.parse(data) }); + } catch { + resolve({ status: res.statusCode!, body: data }); + } + }); + } + ); + req.on('error', reject); + req.write(body); + req.end(); + }); +} + +describe('POST /api/webhooks', () => { + let server: http.Server; + const secrets = [ + { id: 'key-1', secret: 'whsec_test_secret' }, + { id: 'key-2', secret: 'whsec_other_secret' }, + ]; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterEach(async () => { + if (server) await closeServer(server); + }); + + it('accepts a webhook with a valid signature', async () => { + const payload = JSON.stringify({ event: 'test', data: { foo: 'bar' } }); + const signature = computeSignature(payload, 'whsec_test_secret'); + + server = await startServer({ ...BASE_OPTIONS, webhookSecrets: secrets }); + const { status, body } = await makePostRequest(server, '/api/webhooks', payload, { + 'X-Webhook-Signature': signature, + 'X-Webhook-Key-Id': 'key-1', + }); + + expect(status).toBe(202); + expect((body as any).status).toBe('accepted'); + }); + + it('rejects a webhook with an invalid signature', async () => { + const payload = JSON.stringify({ event: 'test' }); + + server = await startServer({ ...BASE_OPTIONS, webhookSecrets: secrets }); + const { status, body } = await makePostRequest(server, '/api/webhooks', payload, { + 'X-Webhook-Signature': 'sha256=invalid', + 'X-Webhook-Key-Id': 'key-1', + }); + + expect(status).toBe(401); + expect((body as any).error).toBe('Invalid signature'); + }); + + it('rejects when signature header is missing', async () => { + const payload = JSON.stringify({ event: 'test' }); + + server = await startServer({ ...BASE_OPTIONS, webhookSecrets: secrets }); + const { status, body } = await makePostRequest(server, '/api/webhooks', payload, { + 'X-Webhook-Key-Id': 'key-1', + }); + + expect(status).toBe(401); + expect((body as any).error).toBe('Missing signature header'); + }); + + it('rejects when key-id header is missing', async () => { + const payload = JSON.stringify({ event: 'test' }); + const signature = computeSignature(payload, 'whsec_test_secret'); + + server = await startServer({ ...BASE_OPTIONS, webhookSecrets: secrets }); + const { status, body } = await makePostRequest(server, '/api/webhooks', payload, { + 'X-Webhook-Signature': signature, + }); + + expect(status).toBe(401); + expect((body as any).error).toBe('Missing key-id header'); + }); + + it('rejects when key-id is unknown', async () => { + const payload = JSON.stringify({ event: 'test' }); + const signature = computeSignature(payload, 'whsec_test_secret'); + + server = await startServer({ ...BASE_OPTIONS, webhookSecrets: secrets }); + const { status, body } = await makePostRequest(server, '/api/webhooks', payload, { + 'X-Webhook-Signature': signature, + 'X-Webhook-Key-Id': 'unknown-key', + }); + + expect(status).toBe(401); + expect((body as any).error).toBe('Unknown key-id'); + }); + + it('rejects when no webhook secrets are configured', async () => { + const payload = JSON.stringify({ event: 'test' }); + const signature = computeSignature(payload, 'whsec_test_secret'); + + server = await startServer(BASE_OPTIONS); + const { status, body } = await makePostRequest(server, '/api/webhooks', payload, { + 'X-Webhook-Signature': signature, + 'X-Webhook-Key-Id': 'key-1', + }); + + expect(status).toBe(401); + expect((body as any).error).toBe('Unknown key-id'); + }); + + it('returns 404 for POST to other paths', async () => { + const payload = JSON.stringify({ event: 'test' }); + + server = await startServer(BASE_OPTIONS); + const { status, body } = await makePostRequest(server, '/api/events', payload, {}); + + expect(status).toBe(404); + }); +}); diff --git a/listener/src/api/events-server.ts b/listener/src/api/events-server.ts index 754d4608..98e58123 100644 --- a/listener/src/api/events-server.ts +++ b/listener/src/api/events-server.ts @@ -5,6 +5,14 @@ import { preferenceStore } from '../store/preference-store'; import { PreferencesUpdateInput } from '../types/preferences'; import logger from '../utils/logger'; import { generateRequestId } from '../utils/request-id'; +import { + verifySignature, + extractSignature, + extractKeyId, + getSecretForKey, + collectRawBody, +} from '../services/webhook-verifier'; +import { WebhookSecret } from '../types'; import { RateLimitConfig } from '../types'; import { RateLimiter } from './rate-limiter'; @@ -13,6 +21,7 @@ export interface EventsServerOptions { corsOrigin?: string; stellarRpcUrl: string; discordWebhookUrl?: string; + webhookSecrets?: WebhookSecret[]; notificationAPI?: NotificationAPI | null; rateLimit?: RateLimitConfig; } @@ -147,6 +156,119 @@ export function createEventsServer(options: EventsServerOptions): http.Server { ? eventRegistry.getEvents(limit) : eventRegistry.getEvents(); + logger.info('Handling GET /api/events', { + requestId, + limit: limit ?? 'all', + }); + + const events = + limit !== undefined && !Number.isNaN(limit) + ? eventRegistry.getEvents(limit) + : eventRegistry.getEvents(); + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + count: eventRegistry.count(), + events, + }) + ); + + logger.info('GET /api/events complete', { + requestId, + returned: events.length, + durationMs: Date.now() - startTime, + }); + return; + } + + // Schedule notification endpoint + if (req.method === 'POST' && req.url === '/api/schedule') { + if (!options.notificationAPI) { + res.writeHead(503, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Scheduler not enabled' })); + return; + } + + let body = ''; + req.on('data', (chunk) => { + body += chunk.toString(); + }); + + if (req.method === 'POST' && req.url === '/api/webhooks') { + collectRawBody(req).then((rawBody) => { + const signatureHeader = extractSignature(req.headers); + const keyId = extractKeyId(req.headers); + + if (!signatureHeader) { + logger.warn('Webhook missing signature header', { requestId }); + res.writeHead(401, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Missing signature header' })); + return; + } + + if (!keyId) { + logger.warn('Webhook missing key-id header', { requestId }); + res.writeHead(401, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Missing key-id header' })); + return; + } + + const secrets = options.webhookSecrets ?? []; + const secret = getSecretForKey(secrets, keyId); + + if (!secret) { + logger.warn('Webhook unknown key-id', { requestId, keyId }); + res.writeHead(401, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Unknown key-id' })); + return; + } + + const isValid = verifySignature(rawBody, signatureHeader, secret); + + if (!isValid) { + logger.warn('Webhook invalid signature', { requestId, keyId }); + res.writeHead(401, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid signature' })); + return; + } + + logger.info('Webhook received and verified', { requestId, keyId }); + + res.writeHead(202, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ status: 'accepted' })); + }).catch((err) => { + logger.error('Failed to read webhook body', { requestId, error: err }); + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Failed to read request body' })); + }); + // Schedule notification endpoint + if (req.method === 'POST' && req.url === '/api/schedule') { + if (!options.notificationAPI) { + res.writeHead(503, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Scheduler not enabled' })); + req.on('end', async () => { + try { + const data = JSON.parse(body); + + // Validate required fields + if (!data.executeAt || !data.payload || !data.targetRecipient) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Missing required fields: executeAt, payload, targetRecipient' })); + return; + } + + const notificationId = await options.notificationAPI!.scheduleNotification({ + payload: data.payload, + notificationType: data.notificationType || NotificationType.DISCORD, + targetRecipient: data.targetRecipient, + executeAt: new Date(data.executeAt), + maxRetries: data.maxRetries, + priority: data.priority, + eventId: data.eventId, + contractAddress: data.contractAddress, + metadata: data.metadata, + }); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ count: eventRegistry.count(), events })); return; diff --git a/listener/src/config.test.ts b/listener/src/config.test.ts index 3ffb85b5..f6bda6f0 100644 --- a/listener/src/config.test.ts +++ b/listener/src/config.test.ts @@ -92,4 +92,49 @@ describe('Config validation', () => { deduplicationMaxSize: 250, }); }); + + describe('WEBHOOK_SECRETS', () => { + it('defaults to an empty array when not set', () => { + delete process.env.WEBHOOK_SECRETS; + const config = loadConfig(); + expect(config.webhookSecrets).toEqual([]); + }); + + it('parses valid webhook secrets', () => { + process.env.WEBHOOK_SECRETS = JSON.stringify([ + { id: 'key-1', secret: 'whsec_abc' }, + { id: 'key-2', secret: 'whsec_def' }, + ]); + + const config = loadConfig(); + expect(config.webhookSecrets).toEqual([ + { id: 'key-1', secret: 'whsec_abc' }, + { id: 'key-2', secret: 'whsec_def' }, + ]); + }); + + it('throws ConfigError for invalid JSON', () => { + process.env.WEBHOOK_SECRETS = 'not-json'; + expect(() => loadConfig()).toThrow(ConfigError); + expect(() => loadConfig()).toThrow('WEBHOOK_SECRETS must be valid JSON'); + }); + + it('throws ConfigError when item is missing id', () => { + process.env.WEBHOOK_SECRETS = JSON.stringify([{ secret: 'whsec_abc' }]); + expect(() => loadConfig()).toThrow(ConfigError); + expect(() => loadConfig()).toThrow('WEBHOOK_SECRETS[0].id must be a non-empty string'); + }); + + it('throws ConfigError when item is missing secret', () => { + process.env.WEBHOOK_SECRETS = JSON.stringify([{ id: 'key-1' }]); + expect(() => loadConfig()).toThrow(ConfigError); + expect(() => loadConfig()).toThrow('WEBHOOK_SECRETS[0].secret must be a non-empty string'); + }); + + it('throws ConfigError when value is not an array', () => { + process.env.WEBHOOK_SECRETS = '"string-value"'; + expect(() => loadConfig()).toThrow(ConfigError); + expect(() => loadConfig()).toThrow('WEBHOOK_SECRETS must be a JSON array'); + }); + }); }); diff --git a/listener/src/config.ts b/listener/src/config.ts index 13a86102..67038d5c 100644 --- a/listener/src/config.ts +++ b/listener/src/config.ts @@ -1,4 +1,4 @@ -import { Config, ContractConfig, DiscordConfig } from './types'; +import { Config, ContractConfig, DiscordConfig, WebhookSecret } from './types'; export class ConfigError extends Error { constructor(message: string) { @@ -85,9 +85,37 @@ function loadDiscordConfig(): DiscordConfig | undefined { }; } +function validateWebhookSecrets(value: unknown): WebhookSecret[] { + if (!Array.isArray(value)) { + throw new ConfigError('WEBHOOK_SECRETS must be a JSON array of secret objects.'); + } + + return value.map((item, index) => { + if (typeof item !== 'object' || item === null) { + throw new ConfigError( + `WEBHOOK_SECRETS[${index}] must be an object with id and secret.` + ); + } + + const id = (item as any).id; + const secret = (item as any).secret; + + if (typeof id !== 'string' || !id.trim()) { + throw new ConfigError(`WEBHOOK_SECRETS[${index}].id must be a non-empty string.`); + } + + if (typeof secret !== 'string' || !secret.trim()) { + throw new ConfigError(`WEBHOOK_SECRETS[${index}].secret must be a non-empty string.`); + } + + return { id: id.trim(), secret: secret.trim() }; + }); +} + export function loadConfig(): Config { const discord = loadDiscordConfig(); const rawContractAddresses = parseJsonEnv('CONTRACT_ADDRESSES', '[]'); + const rawWebhookSecrets = parseJsonEnv('WEBHOOK_SECRETS', '[]'); const clientOverrides = parseJsonEnv>( 'RATE_LIMIT_CLIENT_OVERRIDES', '{}' @@ -109,6 +137,7 @@ export function loadConfig(): Config { baseDelayMs: parseIntegerEnv('RETRY_BASE_DELAY_MS', '5000'), maxRetries: parseIntegerEnv('RETRY_MAX_RETRIES', '5'), }, + webhookSecrets: validateWebhookSecrets(rawWebhookSecrets), scheduler: { enabled: trimEnv('SCHEDULER_ENABLED') !== 'false', pollIntervalMs: parseIntegerEnv('SCHEDULER_POLL_INTERVAL_MS', '10000'), diff --git a/listener/src/services/webhook-verifier.test.ts b/listener/src/services/webhook-verifier.test.ts new file mode 100644 index 00000000..47633070 --- /dev/null +++ b/listener/src/services/webhook-verifier.test.ts @@ -0,0 +1,110 @@ +import crypto from 'crypto'; +import { + verifySignature, + extractSignature, + extractKeyId, + getSecretForKey, +} from './webhook-verifier'; + +function computeSignature(payload: string, secret: string): string { + const sig = crypto.createHmac('sha256', secret).update(payload, 'utf8').digest('hex'); + return `sha256=${sig}`; +} + +describe('verifySignature', () => { + it('returns true for a valid signature', () => { + const payload = '{"event":"test"}'; + const secret = 'whsec_test_secret'; + const header = computeSignature(payload, secret); + + expect(verifySignature(payload, header, secret)).toBe(true); + }); + + it('returns false for an invalid signature', () => { + const payload = '{"event":"test"}'; + const secret = 'whsec_test_secret'; + const header = computeSignature(payload, secret); + + expect(verifySignature(payload, header, 'wrong_secret')).toBe(false); + }); + + it('returns false when the header does not have the sha256= prefix', () => { + const payload = '{"event":"test"}'; + const result = verifySignature(payload, 'invalidsignature', 'secret'); + expect(result).toBe(false); + }); + + it('returns false on empty payload with a non-matching signature', () => { + const payload = ''; + const secret = 'whsec_secret'; + const header = computeSignature(payload, secret); + + expect(verifySignature(payload, header, secret)).toBe(true); + expect(verifySignature(payload, header, 'different_secret')).toBe(false); + }); + + it('uses constant-time comparison (different lengths handled)', () => { + const payload = '{}'; + const secret = 'test'; + const header = 'sha256=abc'; + + expect(verifySignature(payload, header, secret)).toBe(false); + }); +}); + +describe('extractSignature', () => { + it('extracts from x-webhook-signature header', () => { + const headers = { 'x-webhook-signature': 'sha256=abc123' }; + expect(extractSignature(headers)).toBe('sha256=abc123'); + }); + + it('extracts from X-Webhook-Signature header', () => { + const headers = { 'X-Webhook-Signature': 'sha256=abc123' }; + expect(extractSignature(headers)).toBe('sha256=abc123'); + }); + + it('returns null when no signature header is present', () => { + expect(extractSignature({})).toBeNull(); + }); + + it('takes the first value when header is an array', () => { + const headers = { 'x-webhook-signature': ['sha256=first', 'sha256=second'] }; + expect(extractSignature(headers)).toBe('sha256=first'); + }); +}); + +describe('extractKeyId', () => { + it('extracts from x-webhook-key-id header', () => { + const headers = { 'x-webhook-key-id': 'key-1' }; + expect(extractKeyId(headers)).toBe('key-1'); + }); + + it('extracts from X-Webhook-Key-Id header', () => { + const headers = { 'X-Webhook-Key-Id': 'key-1' }; + expect(extractKeyId(headers)).toBe('key-1'); + }); + + it('returns null when no key-id header is present', () => { + expect(extractKeyId({})).toBeNull(); + }); +}); + +describe('getSecretForKey', () => { + const secrets = [ + { id: 'key-1', secret: 'secret_1' }, + { id: 'key-2', secret: 'secret_2' }, + ]; + + it('returns the matching secret', () => { + expect(getSecretForKey(secrets, 'key-1')).toBe('secret_1'); + expect(getSecretForKey(secrets, 'key-2')).toBe('secret_2'); + }); + + it('returns undefined for an unknown key', () => { + expect(getSecretForKey(secrets, 'unknown-key')).toBeUndefined(); + }); + + it('returns undefined for an empty secrets array', () => { + expect(getSecretForKey([], 'key-1')).toBeUndefined(); + }); +}); diff --git a/listener/src/services/webhook-verifier.ts b/listener/src/services/webhook-verifier.ts new file mode 100644 index 00000000..5633ba07 --- /dev/null +++ b/listener/src/services/webhook-verifier.ts @@ -0,0 +1,48 @@ +import crypto from 'crypto'; +import { WebhookSecret } from '../types'; + +const SIGNATURE_PREFIX = 'sha256='; + +export function verifySignature(payload: string, signatureHeader: string, secret: string): boolean { + if (!signatureHeader.startsWith(SIGNATURE_PREFIX)) { + return false; + } + + const expectedSig = crypto + .createHmac('sha256', secret) + .update(payload, 'utf8') + .digest('hex'); + + const providedSig = signatureHeader.slice(SIGNATURE_PREFIX.length); + + if (expectedSig.length !== providedSig.length) { + return false; + } + + return crypto.timingSafeEqual(Buffer.from(expectedSig, 'utf8'), Buffer.from(providedSig, 'utf8')); +} + +export function extractSignature(headers: Record): string | null { + const sigHeader = headers['x-webhook-signature'] ?? headers['X-Webhook-Signature']; + if (!sigHeader) return null; + return Array.isArray(sigHeader) ? sigHeader[0] : sigHeader; +} + +export function extractKeyId(headers: Record): string | null { + const keyId = headers['x-webhook-key-id'] ?? headers['X-Webhook-Key-Id']; + if (!keyId) return null; + return Array.isArray(keyId) ? keyId[0] : keyId; +} + +export function getSecretForKey(secrets: WebhookSecret[], keyId: string): string | undefined { + return secrets.find((s) => s.id === keyId)?.secret; +} + +export function collectRawBody(req: import('http').IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + req.on('data', (chunk: Buffer) => chunks.push(chunk)); + req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))); + req.on('error', reject); + }); +} diff --git a/listener/src/types/index.ts b/listener/src/types/index.ts index bd0ef3e5..c4e7e1bf 100644 --- a/listener/src/types/index.ts +++ b/listener/src/types/index.ts @@ -17,6 +17,11 @@ export interface RetryQueueConfig { maxRetries?: number; } +export interface WebhookSecret { + id: string; + secret: string; +} + export interface RateLimitConfig { enabled: boolean; windowMs: number; @@ -35,6 +40,7 @@ export interface Config { eventsApiCorsOrigin: string; discord?: DiscordConfig; retryQueue?: RetryQueueConfig; + webhookSecrets?: WebhookSecret[]; scheduler?: SchedulerConfig; databasePath?: string; rateLimit?: RateLimitConfig;