diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 45ceb17..8c9e43d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -75,6 +75,9 @@ importers: '@bettapay/validation': specifier: workspace:* version: link:../../shared/validation + '@bettapay/webhook-delivery': + specifier: workspace:* + version: link:../../shared/webhook-delivery '@fastify/helmet': specifier: ^12.0.0 version: 12.0.1 @@ -90,6 +93,9 @@ importers: '@stellar/stellar-sdk': specifier: ^16.0.1 version: 16.0.1 + bullmq: + specifier: ^5.79.1 + version: 5.80.10 google-auth-library: specifier: ^10.9.0 version: 10.9.0 @@ -315,6 +321,9 @@ importers: '@bettapay/stellar-utils': specifier: workspace:^ version: link:../stellar-utils + ioredis: + specifier: ^5.11.1 + version: 5.11.1 zod: specifier: ^3.22.4 version: 3.25.76 diff --git a/services/api-gateway/src/health-all.test.ts b/services/api-gateway/src/health-all.test.ts index 2cd5090..a113c2f 100644 --- a/services/api-gateway/src/health-all.test.ts +++ b/services/api-gateway/src/health-all.test.ts @@ -59,6 +59,41 @@ test('GET /api/health/all aggregates downstream health with graceful degradation await app.close(); }); +test('HEAD /api/health includes security hardening headers', async () => { + const prisma = createMockPrisma() as any; + + const app = buildApp({ + prisma, + logger: false, + fetchImpl: (async () => jsonResponse(mockHealth('service', 'healthy'))) as any + }); + + const res = await app.inject({ method: 'HEAD', url: '/api/health' }); + + assert.equal(res.statusCode, 200); + + assert.ok(res.headers['strict-transport-security']); + assert.ok(res.headers['x-content-type-options']); + assert.ok(res.headers['x-download-options']); + assert.ok(res.headers['x-frame-options']); + assert.ok(res.headers['x-permitted-cross-domain-policies']); + assert.ok(res.headers['x-xss-protection']); + assert.ok(res.headers['cross-origin-embedder-policy']); + assert.ok(res.headers['cross-origin-opener-policy']); + assert.ok(res.headers['cross-origin-resource-policy']); + assert.ok(res.headers['referrer-policy']); + assert.ok(res.headers['permissions-policy']); + + assert.equal(res.headers['cross-origin-embedder-policy'], 'require-corp'); + assert.equal(res.headers['cross-origin-opener-policy'], 'same-origin'); + assert.equal(res.headers['cross-origin-resource-policy'], 'same-origin'); + assert.equal(res.headers['referrer-policy'], 'strict-origin-when-cross-origin'); + assert.equal(res.headers['permissions-policy'], 'geolocation=(), microphone=(), camera=()'); + assert.ok((res.headers['strict-transport-security'] as string).includes('max-age=31536000')); + + await app.close(); +}); + test('GET /api/health returns gateway dependency and upstream probes', async () => { const prisma = createMockPrisma() as any; diff --git a/services/api-gateway/src/index.ts b/services/api-gateway/src/index.ts index 0c19405..431e5b3 100644 --- a/services/api-gateway/src/index.ts +++ b/services/api-gateway/src/index.ts @@ -315,7 +315,20 @@ export function buildApp(opts: AppOptions = {}) { const logAuditEvent = createAuditLogger(prisma as unknown as Parameters[0], fastify.log); // Setup plugins -fastify.register(helmet, { contentSecurityPolicy: false, hsts: { maxAge: 31536000 }, referrerPolicy: { policy: 'no-referrer' } }); +fastify.register(helmet, { + contentSecurityPolicy: false, + crossOriginEmbedderPolicy: { policy: 'require-corp' }, + crossOriginOpenerPolicy: { policy: 'same-origin' }, + crossOriginResourcePolicy: { policy: 'same-origin' }, + referrerPolicy: { policy: 'strict-origin-when-cross-origin' }, + hsts: { maxAge: 31536000 }, +}); + +fastify.addHook('onSend', async (_request, reply, _payload) => { + if (!reply.getHeader('permissions-policy')) { + reply.header('Permissions-Policy', 'geolocation=(), microphone=(), camera=()'); + } +}); fastify.register(cors, { origin: env.ALLOWED_ORIGINS, diff --git a/shared/validation/package.json b/shared/validation/package.json index 8f0cf67..0690cfb 100644 --- a/shared/validation/package.json +++ b/shared/validation/package.json @@ -17,6 +17,7 @@ }, "dependencies": { "@bettapay/stellar-utils": "workspace:^", + "ioredis": "^5.11.1", "zod": "^3.22.4" } } diff --git a/shared/validation/plugins.test.ts b/shared/validation/plugins.test.ts index 7d09d14..7dfa9d5 100644 --- a/shared/validation/plugins.test.ts +++ b/shared/validation/plugins.test.ts @@ -2,7 +2,7 @@ import test from 'node:test'; import assert from 'node:assert'; import Fastify from 'fastify'; import { z } from 'zod'; -import { registerErrorHandler, registerServiceAuth } from './plugins.js'; +import { registerErrorHandler, registerServiceAuth, redactPiiFromDetails } from './plugins.js'; test('Zod validation error returns 400', async (t) => { const fastify = Fastify({ logger: false }); @@ -106,3 +106,72 @@ test('serviceAuth accepts a valid x-service-token', async () => { assert.strictEqual(res.statusCode, 200); assert.strictEqual(JSON.parse(res.body).ok, true); }); + +test('redactPiiFromDetails redacts email field messages', () => { + const details = [{ path: ['email'], message: 'user@example.com is invalid' }]; + const result = redactPiiFromDetails(details) as any; + assert.strictEqual(result[0].message, '[REDACTED]'); +}); + +test('redactPiiFromDetails redacts secret key field messages', () => { + const details = [{ path: ['secretKey'], message: 'sk_live_abc123 is invalid' }]; + const result = redactPiiFromDetails(details) as any; + assert.strictEqual(result[0].message, '[REDACTED]'); +}); + +test('redactPiiFromDetails preserves non-PII field messages', () => { + const details = [{ path: ['amount'], message: 'Must be a positive number' }]; + const result = redactPiiFromDetails(details) as any; + assert.strictEqual(result[0].message, 'Must be a positive number'); +}); + +test('redactPiiFromDetails preserves error structure', () => { + const details = [ + { path: ['email'], message: 'user@example.com is invalid' }, + { path: ['amount'], message: 'Must be a positive number' }, + { path: ['secretKey'], message: 'sk_live_abc123 is invalid' }, + ]; + const result = redactPiiFromDetails(details) as any; + assert.strictEqual(result[0].message, '[REDACTED]'); + assert.strictEqual(result[1].message, 'Must be a positive number'); + assert.strictEqual(result[2].message, '[REDACTED]'); + assert.strictEqual(result.length, 3); +}); + +test('Zod error handler redacts PII from response', async (t) => { + const fastify = Fastify({ logger: false }); + registerErrorHandler(fastify); + const schema = z.object({ email: z.string().email() }); + fastify.post('/', (req, reply) => { + schema.parse(req.body); + reply.send({ ok: true }); + }); + const response = await fastify.inject({ + method: 'POST', + url: '/', + payload: { email: 'not-an-email' } + }); + assert.strictEqual(response.statusCode, 400); + const body = JSON.parse(response.body); + assert.strictEqual(body.error.details[0].message, '[REDACTED]'); +}); + +test('error handler does not leak PII in message', async (t) => { + const fastify = Fastify({ logger: false }); + registerErrorHandler(fastify); + fastify.post('/', (req, reply) => { + const schema = z.object({ + email: z.string().refine(() => false, { message: 'user@example.com is invalid' }) + }); + schema.parse(req.body); + reply.send({ ok: true }); + }); + const response = await fastify.inject({ + method: 'POST', + url: '/', + payload: { email: 'anything' } + }); + const body = JSON.parse(response.body); + assert.strictEqual(body.error.details[0].message, '[REDACTED]'); + assert.strictEqual(response.body.includes('user@example.com'), false); +}); diff --git a/shared/validation/plugins.ts b/shared/validation/plugins.ts index 74a3130..e8fd106 100644 --- a/shared/validation/plugins.ts +++ b/shared/validation/plugins.ts @@ -61,20 +61,43 @@ export function classifyError(error: unknown, statusCode?: number): ErrorClass { return 'fatal'; } +export const PII_FIELD_PATTERNS = [/email/i, /address/i, /secret/i, /key/i, /token/i]; + +export function isPiiField(path: (string | number)[]): boolean { + return path.some((segment) => + typeof segment === 'string' && PII_FIELD_PATTERNS.some((re) => re.test(segment)) + ); +} + +export function redactPiiFromDetails(details: unknown): unknown { + if (!Array.isArray(details)) return details; + return details.map((item: Record) => { + const path: (string | number)[] = Array.isArray(item.path) + ? (item.path as (string | number)[]) + : typeof item.instancePath === 'string' + ? item.instancePath.split('/').filter(Boolean) + : []; + if (isPiiField(path)) { + return { ...item, message: '[REDACTED]', received: undefined, data: undefined, value: undefined, params: undefined }; + } + return item; + }); +} + export function registerErrorHandler(fastify: FastifyInstance, customLogger?: FastifyBaseLogger) { fastify.setErrorHandler((error, request, reply) => { const logger = customLogger || request.log || fastify.log; if (error instanceof z.ZodError) { - const response = createErrorResponse(ErrorCodes.VALIDATION_ERROR, 'Invalid request data', error.errors); + const response = createErrorResponse(ErrorCodes.VALIDATION_ERROR, 'Invalid request data', redactPiiFromDetails(error.errors)); return reply.code(400).send(response); } if ((error as FastifyError).statusCode) { - const fastifyErr = error as FastifyError; - // Use the attached status code. Preserve the safe message. + const fastifyErr = error as FastifyError & { validation?: unknown }; const code = fastifyErr.code || ErrorCodes.INVALID_REQUEST; - const response = createErrorResponse(code, fastifyErr.message); + const details = fastifyErr.validation ? redactPiiFromDetails(fastifyErr.validation) : undefined; + const response = createErrorResponse(code, fastifyErr.message, details); return reply.code(fastifyErr.statusCode!).send(response); } diff --git a/shared/validation/prisma.test.ts b/shared/validation/prisma.test.ts index a86b908..c6d85fa 100644 --- a/shared/validation/prisma.test.ts +++ b/shared/validation/prisma.test.ts @@ -146,3 +146,83 @@ test('buildPrismaConnectionUrl uses defaults when no poolSize or timeout given', const result = buildPrismaConnectionUrl(url); assert.strictEqual(result, 'postgresql://user:pass@localhost:5432/bettapay?connection_limit=10&pool_timeout=10'); }); + +import { + resetRotation, + hasRotated, + getActiveConnectionUrl, + connectWithRetryWithRotation, +} from './prisma.js'; + +test('connectWithRetryWithRotation switches to rotate URL on 28P01', async () => { + resetRotation(); + let attempts = 0; + const prisma = { + async $connect() { + attempts += 1; + if (attempts === 1) { + const err = new Error('authentication failed'); + (err as any).code = '28P01'; + throw err; + } + }, + }; + + await connectWithRetryWithRotation(prisma, { + debug: () => undefined, + warn: () => undefined, + }, { rotationUrl: 'postgres://rotated/db', baseDelayMs: 1, maxRetries: 5 }); + + assert.strictEqual(hasRotated(), true); + assert.strictEqual(getActiveConnectionUrl('postgres://primary/db'), 'postgres://rotated/db'); + resetRotation(); +}); + +test('connectWithRetryWithRotation does not switch on primary URL success', async () => { + resetRotation(); + const prisma = { + async $connect() {}, + }; + + await connectWithRetryWithRotation(prisma, { + debug: () => undefined, + warn: () => undefined, + }, { rotationUrl: 'postgres://rotated/db', baseDelayMs: 1, maxRetries: 3 }); + + assert.strictEqual(hasRotated(), false); + assert.strictEqual(getActiveConnectionUrl('postgres://primary/db'), 'postgres://primary/db'); + resetRotation(); +}); + +test('rotation logged at warn level', async () => { + resetRotation(); + let attempts = 0; + const prisma = { + async $connect() { + attempts += 1; + if (attempts === 1) { + const err = new Error('authentication failed'); + (err as any).code = '28P01'; + throw err; + } + }, + }; + + const warnMessages: string[] = []; + const logger = { + debug: () => undefined, + warn: (_obj: object, msg?: string) => { + if (msg) warnMessages.push(msg); + }, + }; + + await connectWithRetryWithRotation(prisma, logger, { rotationUrl: 'postgres://rotated/db', baseDelayMs: 1, maxRetries: 5 }); + + assert.ok(warnMessages.some(m => m.includes('credential rotation') || m.includes('authentication error'))); + resetRotation(); +}); + +test('getActiveConnectionUrl returns primary URL when no rotation', () => { + resetRotation(); + assert.strictEqual(getActiveConnectionUrl('postgres://primary/db'), 'postgres://primary/db'); +}); diff --git a/shared/validation/prisma.ts b/shared/validation/prisma.ts index a461d25..c2584c2 100644 --- a/shared/validation/prisma.ts +++ b/shared/validation/prisma.ts @@ -2,6 +2,27 @@ export type PrismaLogLevel = 'query' | 'info' | 'warn' | 'error'; const ALL_PRISMA_LOG_LEVELS: readonly PrismaLogLevel[] = ['query', 'info', 'warn', 'error']; +let _rotateUrl: string | undefined; +let _hasRotated = false; + +export function setRotationUrl(url: string | undefined): void { + _rotateUrl = url; + _hasRotated = false; +} + +export function resetRotation(): void { + _rotateUrl = undefined; + _hasRotated = false; +} + +export function hasRotated(): boolean { + return _hasRotated; +} + +export function getActiveConnectionUrl(primaryUrl: string): string { + return _hasRotated && _rotateUrl !== undefined ? _rotateUrl : primaryUrl; +} + function isPrismaLogLevel(value: string): value is PrismaLogLevel { return (ALL_PRISMA_LOG_LEVELS as readonly string[]).includes(value); } @@ -129,3 +150,53 @@ export function buildPrismaConnectionUrl( const sep = rawUrl.includes('?') ? '&' : '?'; return `${rawUrl}${sep}connection_limit=${poolSize}&pool_timeout=${timeout}`; } + +export interface ConnectWithRotationOptions extends ConnectWithRetryOptions { + rotationUrl?: string; + logger?: PrismaLogger; +} + +export async function connectWithRetryWithRotation( + prisma: PrismaConnectable, + logger: PrismaLogger, + options: ConnectWithRotationOptions = {} +): Promise { + const maxRetries = options.maxRetries ?? 10; + const baseDelayMs = options.baseDelayMs ?? 1000; + const maxDelayMs = options.maxDelayMs ?? 30000; + const rotationUrl = options.rotationUrl; + let lastError: unknown; + + for (let attempt = 0; attempt < maxRetries; attempt++) { + try { + await prisma.$connect(); + return; + } catch (err) { + lastError = err; + const isAuthError = (err as { code?: string })?.code === '28P01'; + + if (isAuthError && rotationUrl && !_hasRotated) { + _hasRotated = true; + _rotateUrl = rotationUrl; + const rotationLogger = options.logger ?? logger; + rotationLogger.warn( + { attempt: attempt + 1 }, + 'Database credential rotation: switching to rotation URL due to authentication error (28P01)' + ); + } + + if (attempt < maxRetries - 1) { + const delay = Math.min(baseDelayMs * Math.pow(2, attempt), maxDelayMs); + logger.warn( + { attempt: attempt + 1, maxRetries, delayMs: delay, err }, + 'Database connection failed, retrying' + ); + await sleep(delay); + } + } + } + + const message = + lastError instanceof Error ? lastError.message : String(lastError ?? 'unknown error'); + throw new Error(`Failed to connect to database after ${maxRetries} attempts: ${message}`); +} diff --git a/shared/validation/webhookSchema.test.ts b/shared/validation/webhookSchema.test.ts index a0f19be..ca4dbd2 100644 --- a/shared/validation/webhookSchema.test.ts +++ b/shared/validation/webhookSchema.test.ts @@ -1,6 +1,16 @@ -import test from 'node:test'; +import test, { mock } from 'node:test'; import assert from 'node:assert'; -import { createWebhookUrlSchema, WebhookUrlSchema } from './webhookSchema.js'; +import dns from 'node:dns'; +import { Redis } from 'ioredis'; +import { + createWebhookUrlSchema, + WebhookUrlSchema, + checkWebhookRateLimit, + resolveWithCache, + clearDnsCache, + validateWebhookUrl, + WEBHOOK_VALIDATE_RATE_LIMIT, +} from './webhookSchema.js'; test('createWebhookUrlSchema - format & length rules apply in every environment', async (t) => { await t.test('rejects a non-URL string', () => { @@ -82,4 +92,134 @@ test('WebhookUrlSchema (default export)', async (t) => { const result = WebhookUrlSchema.safeParse('not-a-url'); assert.strictEqual(result.success, false); }); +}); + +class MockRedis { + private store = new Map(); + async incr(key: string): Promise { + const val = (this.store.get(key) ?? 0) + 1; + this.store.set(key, val); + return val; + } + async expire(_key: string, _seconds: number): Promise { + return 1; + } +} + +test('checkWebhookRateLimit', async (t) => { + await t.test('allows up to WEBHOOK_VALIDATE_RATE_LIMIT requests', async () => { + const mockRedis = new MockRedis() as unknown as Redis; + const ip = '10.0.0.1'; + for (let i = 0; i < WEBHOOK_VALIDATE_RATE_LIMIT; i++) { + assert.strictEqual(await checkWebhookRateLimit(mockRedis, ip), true); + } + }); + + await t.test('blocks request exceeding the limit', async () => { + const mockRedis = new MockRedis() as unknown as Redis; + const ip = '10.0.0.2'; + for (let i = 0; i < WEBHOOK_VALIDATE_RATE_LIMIT; i++) { + await checkWebhookRateLimit(mockRedis, ip); + } + assert.strictEqual(await checkWebhookRateLimit(mockRedis, ip), false); + }); + + await t.test('uses separate counters for different IPs', async () => { + const mockRedis = new MockRedis() as unknown as Redis; + const ip1 = '10.0.0.3'; + const ip2 = '10.0.0.4'; + for (let i = 0; i < WEBHOOK_VALIDATE_RATE_LIMIT; i++) { + await checkWebhookRateLimit(mockRedis, ip1); + } + assert.strictEqual(await checkWebhookRateLimit(mockRedis, ip1), false); + assert.strictEqual(await checkWebhookRateLimit(mockRedis, ip2), true); + }); +}); + +test('resolveWithCache', async (t) => { + await t.test('caches DNS results and returns cached within TTL', async () => { + clearDnsCache(); + const url = 'https://example.com/hook'; + const mockMethod = mock.method(dns.promises, 'resolve4', async () => ['93.184.216.34']); + + const first = await resolveWithCache(url); + assert.deepStrictEqual(first, ['93.184.216.34']); + assert.strictEqual(mockMethod.mock.callCount(), 1); + + const second = await resolveWithCache(url); + assert.deepStrictEqual(second, ['93.184.216.34']); + assert.strictEqual(mockMethod.mock.callCount(), 1); + + mockMethod.mock.restore(); + }); + + await t.test('returns empty array on DNS failure', async () => { + clearDnsCache(); + const url = 'https://invalid.example.com/hook'; + const mockMethod = mock.method(dns.promises, 'resolve4', async () => { throw new Error('ENOTFOUND'); }); + + const result = await resolveWithCache(url); + assert.deepStrictEqual(result, []); + + mockMethod.mock.restore(); + }); +}); + +test('validateWebhookUrl', async (t) => { + await t.test('returns 429 when rate limited', async () => { + clearDnsCache(); + const ip = '10.0.0.5'; + const mockRedis = new MockRedis() as unknown as Redis; + for (let i = 0; i < WEBHOOK_VALIDATE_RATE_LIMIT; i++) { + await checkWebhookRateLimit(mockRedis, ip); + } + + const result = await validateWebhookUrl('https://example.com/hook', mockRedis, ip); + assert.strictEqual(result.valid, false); + assert.strictEqual(result.statusCode, 429); + assert.strictEqual(result.error, 'Rate limit exceeded. Please try again later.'); + }); + + await t.test('returns valid=true for reachable 2xx URL', async () => { + clearDnsCache(); + const ip = '10.0.0.6'; + const mockRedis = new MockRedis() as unknown as Redis; + const mockDns = mock.method(dns.promises, 'resolve4', async () => ['93.184.216.34']); + const mockFetch: typeof globalThis.fetch = async (_url, _init) => new Response(null, { status: 200 }); + + const result = await validateWebhookUrl('https://example.com/hook', mockRedis, ip, { fetch: mockFetch }); + assert.strictEqual(result.valid, true); + assert.strictEqual(result.statusCode, 200); + + mockDns.mock.restore(); + }); + + await t.test('returns error for unreachable URL (fetch throws)', async () => { + clearDnsCache(); + const ip = '10.0.0.7'; + const mockRedis = new MockRedis() as unknown as Redis; + const mockDns = mock.method(dns.promises, 'resolve4', async () => ['93.184.216.34']); + const mockFetch: typeof globalThis.fetch = async (_url, _init) => { throw new Error('Connection refused'); }; + + const result = await validateWebhookUrl('https://example.com/hook', mockRedis, ip, { fetch: mockFetch }); + assert.strictEqual(result.valid, false); + assert.strictEqual(result.statusCode, 400); + assert.strictEqual(result.error, 'Webhook URL is not reachable'); + + mockDns.mock.restore(); + }); + + await t.test('returns error for non-2xx response', async () => { + clearDnsCache(); + const ip = '10.0.0.8'; + const mockRedis = new MockRedis() as unknown as Redis; + const mockDns = mock.method(dns.promises, 'resolve4', async () => ['93.184.216.34']); + const mockFetch: typeof globalThis.fetch = async (_url, _init) => new Response(null, { status: 500 }); + + const result = await validateWebhookUrl('https://example.com/hook', mockRedis, ip, { fetch: mockFetch }); + assert.strictEqual(result.valid, false); + assert.strictEqual(result.statusCode, 500); + + mockDns.mock.restore(); + }); }); \ No newline at end of file diff --git a/shared/validation/webhookSchema.ts b/shared/validation/webhookSchema.ts index b936251..ccd353c 100644 --- a/shared/validation/webhookSchema.ts +++ b/shared/validation/webhookSchema.ts @@ -1,11 +1,8 @@ import { z } from 'zod'; +import { Redis } from 'ioredis'; +import dns from 'node:dns'; import { createValidationContext } from './envAwareSchema.js'; -/** - * Matches hostnames that resolve to loopback / RFC1918 private ranges. - * Used to stop merchants from registering webhooks that point back into - * our own infrastructure (a classic SSRF vector) once we're in production. - */ const PRIVATE_HOST_PATTERN = /^(localhost|127\.\d{1,3}\.\d{1,3}\.\d{1,3}|10\.\d{1,3}\.\d{1,3}\.\d{1,3}|172\.(1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}|192\.168\.\d{1,3}\.\d{1,3}|::1|\[::1\]|0\.0\.0\.0)$/i; @@ -18,11 +15,78 @@ function isPrivateOrLocalhost(urlString: string): boolean { } } -/** - * Webhook URL schema. - * @envSpecific HTTPS enforcement is only applied in production (NODE_ENV=production). - * In development, HTTP URLs are accepted to simplify local testing. - */ +export const WEBHOOK_VALIDATE_RATE_LIMIT = 10; +export const WEBHOOK_VALIDATE_RATE_WINDOW_SEC = 60; +export const DNS_CACHE_TTL_MS = 60_000; +const dnsCache = new Map(); + +export async function checkWebhookRateLimit(redis: Redis, ip: string): Promise { + const key = `webhook_validate_rate:${ip}`; + const count = await redis.incr(key); + if (count === 1) { + await redis.expire(key, WEBHOOK_VALIDATE_RATE_WINDOW_SEC); + } + return count <= WEBHOOK_VALIDATE_RATE_LIMIT; +} + +export async function resolveWithCache(url: string): Promise { + const { hostname } = new URL(url); + const cached = dnsCache.get(hostname); + if (cached && Date.now() - cached.timestamp < DNS_CACHE_TTL_MS) { + return cached.addresses; + } + try { + const addresses = await dns.promises.resolve4(hostname); + dnsCache.set(hostname, { addresses, timestamp: Date.now() }); + return addresses; + } catch { + return []; + } +} + +export function clearDnsCache(): void { + dnsCache.clear(); +} + +export interface WebhookValidationResult { + valid: boolean; + error?: string; + statusCode?: number; +} + +export async function validateWebhookUrl( + url: string, + redis: Redis, + ip: string, + options?: { fetch?: typeof globalThis.fetch }, +): Promise { + const allowed = await checkWebhookRateLimit(redis, ip); + if (!allowed) { + return { valid: false, error: 'Rate limit exceeded. Please try again later.', statusCode: 429 }; + } + + const addresses = await resolveWithCache(url); + if (addresses.length === 0) { + return { valid: false, error: 'Could not resolve webhook URL', statusCode: 400 }; + } + + const doFetch = options?.fetch ?? globalThis.fetch; + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 5000); + + try { + const response = await doFetch(url, { method: 'HEAD', signal: controller.signal }); + if (response.ok) { + return { valid: true, statusCode: response.status }; + } + return { valid: false, error: 'Webhook URL is not reachable', statusCode: response.status }; + } catch { + return { valid: false, error: 'Webhook URL is not reachable', statusCode: 400 }; + } finally { + clearTimeout(timeoutId); + } +} + export function createWebhookUrlSchema(nodeEnv?: string) { const { isProduction } = createValidationContext(nodeEnv); @@ -51,4 +115,4 @@ export function createWebhookUrlSchema(nodeEnv?: string) { export const WebhookUrlSchema = createWebhookUrlSchema(); -export type WebhookUrl = z.infer; \ No newline at end of file +export type WebhookUrl = z.infer;