augmenttoolkit / server /src /utils /ssrfGuard.ts
Leon4gr45's picture
Upload folder using huggingface_hub (part 6)
1f5ea39 verified
Raw
History Blame Contribute Delete
8.52 kB
import dns from 'node:dns/promises';
import { Agent } from 'undici';
const ALLOW_INTERNAL_NETWORK = process.env.ALLOW_INTERNAL_NETWORK?.toLowerCase() === 'true';
export interface SsrfResult {
allowed: boolean;
resolvedIp?: string;
isPrivate: boolean;
error?: string;
}
// Always blocked — no override possible
function isAlwaysBlocked(ip: string): boolean {
// Strip IPv6 brackets
const addr = ip.startsWith('[') ? ip.slice(1, -1) : ip;
// Loopback
if (addr.startsWith('127.') || addr === '::1') return true;
// Unspecified
if (addr.startsWith('0.')) return true;
// Link-local / cloud metadata
if (addr.startsWith('169.254.') || /^fe80:/i.test(addr)) return true;
// IPv4-mapped loopback / link-local: ::ffff:127.x.x.x, ::ffff:169.254.x.x
if (/^::ffff:127\./i.test(addr) || /^::ffff:169\.254\./i.test(addr)) return true;
return false;
}
// Blocked unless ALLOW_INTERNAL_NETWORK=true
function isPrivateNetwork(ip: string): boolean {
const addr = ip.startsWith('[') ? ip.slice(1, -1) : ip;
// RFC-1918 private ranges
if (addr.startsWith('10.')) return true;
if (/^172\.(1[6-9]|2\d|3[01])\./.test(addr)) return true;
if (addr.startsWith('192.168.')) return true;
// CGNAT / Tailscale shared address space (100.64.0.0/10)
if (/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(addr)) return true;
// IPv6 ULA (fc00::/7)
if (/^f[cd]/i.test(addr)) return true;
// IPv4-mapped RFC-1918
if (/^::ffff:10\./i.test(addr)) return true;
if (/^::ffff:172\.(1[6-9]|2\d|3[01])\./i.test(addr)) return true;
if (/^::ffff:192\.168\./i.test(addr)) return true;
return false;
}
function isInternalHostname(hostname: string): boolean {
const h = hostname.toLowerCase();
return h.endsWith('.local') || h.endsWith('.internal') || h === 'localhost';
}
export async function checkSsrf(rawUrl: string, bypassInternalIpAllowed: boolean = false): Promise<SsrfResult> {
let url: URL;
try {
url = new URL(rawUrl);
} catch {
return { allowed: false, isPrivate: false, error: 'Invalid URL' };
}
if (!['http:', 'https:'].includes(url.protocol)) {
return { allowed: false, isPrivate: false, error: 'Only HTTP and HTTPS URLs are allowed' };
}
const hostname = url.hostname.toLowerCase();
// Resolve hostname to IP
let resolvedIp: string;
try {
const result = await dns.lookup(hostname);
resolvedIp = result.address;
} catch (error_) {
const code = error_ instanceof Error && 'code' in error_ ? String(error_.code) : 'unknown';
return { allowed: false, isPrivate: false, error: `Could not resolve hostname (${code})` };
}
if (isAlwaysBlocked(resolvedIp)) {
return {
allowed: false,
isPrivate: true,
resolvedIp,
error: 'Requests to loopback and link-local addresses are not allowed',
};
}
if (isPrivateNetwork(resolvedIp) || isInternalHostname(hostname)) {
if (!ALLOW_INTERNAL_NETWORK || bypassInternalIpAllowed) {
return {
allowed: false,
isPrivate: true,
resolvedIp,
error:
'Requests to private/internal network addresses are not allowed. Set ALLOW_INTERNAL_NETWORK=true to permit this for self-hosted setups.',
};
}
return { allowed: true, isPrivate: true, resolvedIp };
}
return { allowed: true, isPrivate: false, resolvedIp };
}
/**
* Thrown by safeFetch() when the URL is blocked by the SSRF guard.
*/
export class SsrfBlockedError extends Error {
constructor(message: string) {
super(message);
this.name = 'SsrfBlockedError';
}
}
export interface SafeFetchOptions {
rejectUnauthorized?: boolean;
}
/**
* SSRF-safe fetch wrapper. Validates the URL with checkSsrf(), then makes
* the request using a DNS-pinned dispatcher so the resolved IP cannot change
* between the check and the actual connection (DNS rebinding prevention).
*
* Pass `{ rejectUnauthorized: false }` for targets that use self-signed TLS
* certificates (e.g. a Synology NAS on a local network). The SSRF guard still
* applies — only the TLS certificate check is relaxed.
*/
export async function safeFetch(url: string, init?: RequestInit, options?: SafeFetchOptions): Promise<Response> {
const ssrf = await checkSsrf(url);
if (!ssrf.allowed) {
throw new SsrfBlockedError(ssrf.error ?? 'Request blocked by SSRF guard');
}
const dispatcher = createPinnedDispatcher(ssrf.resolvedIp!, options?.rejectUnauthorized ?? true);
return fetch(url, { ...init, dispatcher } as any);
}
export interface SafeFetchFollowOptions extends SafeFetchOptions {
/** Maximum number of redirects to follow before giving up. Defaults to 5. */
maxRedirects?: number;
/**
* When true, private/internal IPs that ALLOW_INTERNAL_NETWORK would normally
* permit are still blocked (matches `checkSsrf(url, true)`). Loopback and
* link-local are always blocked regardless. Defaults to false.
*/
bypassInternalIpAllowed?: boolean;
}
/**
* SSRF-safe fetch that follows redirects MANUALLY, re-validating every hop.
*
* `safeFetch()` (and a one-shot `checkSsrf()` + `fetch(redirect:'follow')`) only
* guards the INITIAL URL: a validated public URL can 302-redirect to an internal
* IP that the platform fetch would then follow unchecked (redirect TOCTOU). This
* helper instead requests with `redirect: 'manual'`, and on every 3xx it resolves
* the `Location` header against the current URL, runs `checkSsrf()` on the new
* target, and only then fetches the next hop through a dispatcher pinned to THAT
* hop's resolved IP. Each hop is therefore SSRF-checked + DNS-pinned, while
* legitimate cross-host redirects (e.g. goo.gl → maps.google.com) still resolve
* because the dispatcher is re-pinned per hop rather than locked to the first IP.
*
* The returned Response is the first non-redirect response (or the last redirect
* if the hop limit is reached). `response.url` reflects the final hop so callers
* relying on the resolved URL keep working.
*/
export async function safeFetchFollow(
url: string,
init?: RequestInit,
options?: SafeFetchFollowOptions,
): Promise<Response> {
const maxRedirects = options?.maxRedirects ?? 5;
const rejectUnauthorized = options?.rejectUnauthorized ?? true;
const bypassInternalIpAllowed = options?.bypassInternalIpAllowed ?? false;
let currentUrl = url;
for (let hop = 0; ; hop++) {
const ssrf = await checkSsrf(currentUrl, bypassInternalIpAllowed);
if (!ssrf.allowed) {
throw new SsrfBlockedError(ssrf.error ?? 'Request blocked by SSRF guard');
}
const dispatcher = createPinnedDispatcher(ssrf.resolvedIp!, rejectUnauthorized);
const response = await fetch(currentUrl, {
...init,
redirect: 'manual',
dispatcher,
} as any);
// Only a 3xx WITH a Location header is a redirect we follow; anything else
// (2xx/4xx/5xx, or a 3xx with no Location) is the final response.
const status = typeof response.status === 'number' ? response.status : 0;
const isRedirectStatus = status >= 300 && status < 400;
const location = isRedirectStatus ? (response.headers?.get('location') ?? null) : null;
if (!location) {
return response;
}
if (hop >= maxRedirects) {
throw new SsrfBlockedError('Too many redirects');
}
// Resolve relative redirects against the current URL, then loop to
// re-check + re-pin on the next iteration. Drain the body so the
// connection can be reused/closed.
let nextUrl: string;
try {
nextUrl = new URL(location, currentUrl).toString();
} catch {
throw new SsrfBlockedError('Invalid redirect location');
}
void response.body?.cancel().catch(() => {});
currentUrl = nextUrl;
}
}
/**
* Returns an undici Agent whose connect.lookup is pinned to the already-validated
* IP. This prevents DNS rebinding (TOCTOU) by ensuring the outbound connection
* goes to the IP we checked, not a re-resolved one.
*/
export function createPinnedDispatcher(resolvedIp: string, rejectUnauthorized = true): Agent {
return new Agent({
connect: {
rejectUnauthorized,
lookup: (_hostname: string, opts: Record<string, unknown>, callback: Function) => {
const family = resolvedIp.includes(':') ? 6 : 4;
// Node.js 18+ may call lookup with `all: true`, expecting an array of address objects
if (opts?.all) {
callback(null, [{ address: resolvedIp, family }]);
} else {
callback(null, resolvedIp, family);
}
},
},
});
}