From 6433041e8b823a9d04c378978362c525e1cf8ea0 Mon Sep 17 00:00:00 2001 From: rissrice2105-agent Date: Thu, 9 Jul 2026 14:00:02 -0600 Subject: [PATCH] fix(webhooks): reject unsafe outbound URLs --- src/app/api/webhooks/route.js | 10 +-- src/lib/webhooks/url-validation.js | 95 ++++++++++++++++++++++++++++ tests/webhook-url-validation.test.js | 37 +++++++++++ 3 files changed, 137 insertions(+), 5 deletions(-) create mode 100644 src/lib/webhooks/url-validation.js create mode 100644 tests/webhook-url-validation.test.js diff --git a/src/app/api/webhooks/route.js b/src/app/api/webhooks/route.js index f43ae61..d9a04cc 100644 --- a/src/app/api/webhooks/route.js +++ b/src/app/api/webhooks/route.js @@ -1,5 +1,6 @@ import { NextResponse } from 'next/server'; import { withAuth } from '@/lib/api/middleware/auth.js'; +import { validateWebhookUrl } from '@/lib/webhooks/url-validation.js'; /** List webhooks for the authenticated user */ export const GET = withAuth(async (event) => { @@ -36,10 +37,9 @@ export const POST = withAuth(async (event) => { return NextResponse.json({ error: 'url is required' }, { status: 400 }); } - try { - new URL(url); - } catch { - return NextResponse.json({ error: 'Invalid URL' }, { status: 400 }); + const webhookUrl = validateWebhookUrl(url); + if (!webhookUrl.ok) { + return NextResponse.json({ error: webhookUrl.error }, { status: 400 }); } if (!Array.isArray(events) || events.length === 0) { @@ -48,7 +48,7 @@ export const POST = withAuth(async (event) => { const { data, error } = await supabase .from('webhooks') - .insert({ user_id: user.id, url, events }) + .insert({ user_id: user.id, url: webhookUrl.url, events }) .select('id, url, events, created_at') .single(); diff --git a/src/lib/webhooks/url-validation.js b/src/lib/webhooks/url-validation.js new file mode 100644 index 0000000..f57c7b7 --- /dev/null +++ b/src/lib/webhooks/url-validation.js @@ -0,0 +1,95 @@ +const MAX_WEBHOOK_URL_LENGTH = 2048; + +/** + * Validate outbound webhook destinations before they are later fetched by the + * server-side dispatcher. + * + * @param {string} value + * @returns {{ ok: true, url: string } | { ok: false, error: string }} + */ +export function validateWebhookUrl(value) { + if (typeof value !== 'string' || value.trim().length === 0) { + return { ok: false, error: 'url is required' }; + } + + const input = value.trim(); + if (input.length > MAX_WEBHOOK_URL_LENGTH) { + return { ok: false, error: 'Webhook URL is too long' }; + } + + let parsed; + try { + parsed = new URL(input); + } catch { + return { ok: false, error: 'Invalid URL' }; + } + + if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') { + return { ok: false, error: 'Webhook URL must use http or https' }; + } + + if (isBlockedHostname(parsed.hostname)) { + return { ok: false, error: 'Webhook URL must point to a public host' }; + } + + return { ok: true, url: parsed.toString() }; +} + +function isBlockedHostname(hostname) { + const host = hostname.toLowerCase().replace(/^\[|\]$/g, '').replace(/\.$/, ''); + + if ( + host === 'localhost' || + host === '0.0.0.0' || + host.endsWith('.localhost') || + host.endsWith('.local') + ) { + return true; + } + + const ipv4 = parseIpv4(host); + if (ipv4) return isPrivateIpv4(ipv4); + + return isPrivateIpv6(host); +} + +function parseIpv4(host) { + const parts = host.split('.'); + if (parts.length !== 4) return null; + + const octets = parts.map((part) => { + if (!/^(0|[1-9]\d{0,2})$/.test(part)) return Number.NaN; + const value = Number(part); + return value >= 0 && value <= 255 ? value : Number.NaN; + }); + + return octets.every(Number.isInteger) ? octets : null; +} + +function isPrivateIpv4([a, b]) { + return ( + a === 0 || + a === 10 || + a === 127 || + (a === 169 && b === 254) || + (a === 172 && b >= 16 && b <= 31) || + (a === 192 && b === 168) || + (a === 100 && b >= 64 && b <= 127) || + (a === 198 && (b === 18 || b === 19)) || + a >= 224 + ); +} + +function isPrivateIpv6(host) { + const normalized = host.toLowerCase(); + return ( + normalized === '::' || + normalized === '::1' || + normalized.startsWith('fe80:') || + normalized.startsWith('fc') || + normalized.startsWith('fd') || + normalized.startsWith('::ffff:127.') || + normalized.startsWith('::ffff:10.') || + normalized.startsWith('::ffff:192.168.') + ); +} diff --git a/tests/webhook-url-validation.test.js b/tests/webhook-url-validation.test.js new file mode 100644 index 0000000..19ee400 --- /dev/null +++ b/tests/webhook-url-validation.test.js @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; +import { validateWebhookUrl } from '../src/lib/webhooks/url-validation.js'; + +describe('validateWebhookUrl', () => { + it('accepts public http and https webhook URLs', () => { + expect(validateWebhookUrl('https://example.com/webhook')).toEqual({ + ok: true, + url: 'https://example.com/webhook' + }); + expect(validateWebhookUrl(' http://hooks.example.com/path ')).toEqual({ + ok: true, + url: 'http://hooks.example.com/path' + }); + }); + + it('rejects non-http webhook schemes before server-side fetch', () => { + expect(validateWebhookUrl('javascript:alert(1)')).toMatchObject({ ok: false }); + expect(validateWebhookUrl('file:///etc/passwd')).toMatchObject({ ok: false }); + expect(validateWebhookUrl('ftp://example.com/hook')).toMatchObject({ ok: false }); + }); + + it('rejects localhost and private network webhook destinations', () => { + for (const url of [ + 'http://localhost/hook', + 'http://api.localhost/hook', + 'http://127.0.0.1/hook', + 'http://10.0.0.4/hook', + 'http://172.16.0.1/hook', + 'http://192.168.1.1/hook', + 'http://[::1]/hook', + 'http://[fe80::1]/hook', + 'http://[fd00::1]/hook' + ]) { + expect(validateWebhookUrl(url)).toMatchObject({ ok: false }); + } + }); +});