feat: Add chat embeds v1
Youtube and Website metadata embeds
This commit is contained in:
119
server/src/routes/ssrf-guard.ts
Normal file
119
server/src/routes/ssrf-guard.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { lookup } from 'dns/promises';
|
||||
|
||||
const MAX_REDIRECTS = 5;
|
||||
|
||||
function isPrivateIp(ip: string): boolean {
|
||||
if (
|
||||
ip === '127.0.0.1' ||
|
||||
ip === '::1' ||
|
||||
ip === '0.0.0.0' ||
|
||||
ip === '::'
|
||||
)
|
||||
return true;
|
||||
|
||||
// 10.x.x.x
|
||||
if (ip.startsWith('10.'))
|
||||
return true;
|
||||
|
||||
// 172.16.0.0 - 172.31.255.255
|
||||
if (ip.startsWith('172.')) {
|
||||
const second = parseInt(ip.split('.')[1], 10);
|
||||
|
||||
if (second >= 16 && second <= 31)
|
||||
return true;
|
||||
}
|
||||
|
||||
// 192.168.x.x
|
||||
if (ip.startsWith('192.168.'))
|
||||
return true;
|
||||
|
||||
// 169.254.x.x (link-local, AWS metadata)
|
||||
if (ip.startsWith('169.254.'))
|
||||
return true;
|
||||
|
||||
// IPv6 private ranges (fc00::/7, fe80::/10)
|
||||
const lower = ip.toLowerCase();
|
||||
|
||||
if (lower.startsWith('fc') || lower.startsWith('fd') || lower.startsWith('fe80'))
|
||||
return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function resolveAndValidateHost(url: string): Promise<boolean> {
|
||||
let hostname: string;
|
||||
|
||||
try {
|
||||
hostname = new URL(url).hostname;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Block obvious private hostnames
|
||||
if (hostname === 'localhost' || hostname === 'metadata.google.internal')
|
||||
return false;
|
||||
|
||||
// If hostname is already an IP literal, check it directly
|
||||
if (/^[\d.]+$/.test(hostname) || hostname.startsWith('['))
|
||||
return !isPrivateIp(hostname.replace(/[[\]]/g, ''));
|
||||
|
||||
try {
|
||||
const { address } = await lookup(hostname);
|
||||
|
||||
return !isPrivateIp(address);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export interface SafeFetchOptions {
|
||||
signal?: AbortSignal;
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches a URL while following redirects safely, validating each
|
||||
* hop against SSRF (private/reserved IPs, blocked hostnames).
|
||||
*
|
||||
* The caller must validate the initial URL with `resolveAndValidateHost`
|
||||
* before calling this function.
|
||||
*/
|
||||
export async function safeFetch(url: string, options: SafeFetchOptions = {}): Promise<Response | undefined> {
|
||||
let currentUrl = url;
|
||||
let response: Response | undefined;
|
||||
|
||||
for (let redirects = 0; redirects <= MAX_REDIRECTS; redirects++) {
|
||||
response = await fetch(currentUrl, {
|
||||
redirect: 'manual',
|
||||
signal: options.signal,
|
||||
headers: options.headers
|
||||
});
|
||||
|
||||
const location = response.headers.get('location');
|
||||
|
||||
if (response.status >= 300 && response.status < 400 && location) {
|
||||
let nextUrl: string;
|
||||
|
||||
try {
|
||||
nextUrl = new URL(location, currentUrl).href;
|
||||
} catch {
|
||||
break;
|
||||
}
|
||||
|
||||
if (!/^https?:\/\//i.test(nextUrl))
|
||||
break;
|
||||
|
||||
const redirectAllowed = await resolveAndValidateHost(nextUrl);
|
||||
|
||||
if (!redirectAllowed)
|
||||
break;
|
||||
|
||||
currentUrl = nextUrl;
|
||||
continue;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
Reference in New Issue
Block a user