mirror of
https://github.com/documenso/documenso.git
synced 2026-07-10 13:06:00 +10:00
6b1b1d0417
Webhook URLs were being fetched without validating whether they resolved to private/loopback addresses, exposing the server to SSRF. Current SSRF is best effort and fail open, you should never host services that you cant risk exposure of. This extracts webhook execution into a shared module that validates URLs against private IP ranges (including DNS resolution), enforces timeouts, and disables redirect following. The resend route now queues through the job system instead of calling fetch inline.
83 lines
1.8 KiB
TypeScript
83 lines
1.8 KiB
TypeScript
import { z } from 'zod';
|
|
|
|
const ZIpSchema = z.string().ip();
|
|
|
|
/**
|
|
* Check whether a URL points to a known private/loopback address.
|
|
*
|
|
* Performs a synchronous check against known private hostnames and IP ranges.
|
|
* Works regardless of the URL protocol.
|
|
*/
|
|
export const isPrivateUrl = (url: string): boolean => {
|
|
try {
|
|
const parsed = new URL(url);
|
|
const hostname = parsed.hostname.toLowerCase();
|
|
|
|
// Strip IPv6 brackets.
|
|
const bare = hostname.startsWith('[') ? hostname.slice(1, -1) : hostname;
|
|
const normalizedHost = bare.replace(/\.+$/, '');
|
|
|
|
if (normalizedHost === 'localhost') {
|
|
return true;
|
|
}
|
|
|
|
const parsedIp = ZIpSchema.safeParse(normalizedHost);
|
|
|
|
if (!parsedIp.success) {
|
|
return false;
|
|
}
|
|
|
|
if (normalizedHost === '::1' || normalizedHost === '::') {
|
|
return true;
|
|
}
|
|
|
|
if (normalizedHost === '0.0.0.0') {
|
|
return true;
|
|
}
|
|
|
|
if (normalizedHost.startsWith('127.')) {
|
|
return true;
|
|
}
|
|
|
|
if (normalizedHost.startsWith('10.')) {
|
|
return true;
|
|
}
|
|
|
|
if (normalizedHost.startsWith('192.168.')) {
|
|
return true;
|
|
}
|
|
|
|
if (normalizedHost.startsWith('169.254.')) {
|
|
return true;
|
|
}
|
|
|
|
if (normalizedHost.startsWith('fe80:')) {
|
|
return true;
|
|
}
|
|
|
|
if (normalizedHost.startsWith('fc') || normalizedHost.startsWith('fd')) {
|
|
return true;
|
|
}
|
|
|
|
// 172.16.0.0/12
|
|
if (normalizedHost.startsWith('172.')) {
|
|
const second = parseInt(normalizedHost.split('.')[1], 10);
|
|
|
|
if (second >= 16 && second <= 31) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
// IPv4-mapped IPv6 (e.g. ::ffff:127.0.0.1)
|
|
const v4Mapped = normalizedHost.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i);
|
|
|
|
if (v4Mapped) {
|
|
return isPrivateUrl(`http://${v4Mapped[1]}`);
|
|
}
|
|
|
|
return false;
|
|
} catch {
|
|
return false;
|
|
}
|
|
};
|