Skip to content
Open
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
10 changes: 5 additions & 5 deletions src/app/api/webhooks/route.js
Original file line number Diff line number Diff line change
@@ -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) => {
Expand Down Expand Up @@ -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) {
Expand All @@ -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();

Expand Down
95 changes: 95 additions & 0 deletions src/lib/webhooks/url-validation.js
Original file line number Diff line number Diff line change
@@ -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.')
);
}
37 changes: 37 additions & 0 deletions tests/webhook-url-validation.test.js
Original file line number Diff line number Diff line change
@@ -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 });
}
});
});
Loading