UNPKG

@sogni-ai/sogni-creative-agent-skill

Version:

Sogni Creative Agent Skill: agent skill and CLI for Sogni AI image, video, and music generation.

217 lines (196 loc) 7.69 kB
/** * SSRF Guard (ESM) — URL validator + guarded fetch to prevent Server-Side Request Forgery. * * `assertSafeUrl` blocks non-http(s) schemes, credentials in URLs, loopback / * RFC1918 / link-local / CGNAT / multicast / reserved IP ranges, and cloud * metadata endpoints, validating every A/AAAA record a hostname resolves to. * * Known limitations (use `fetchSafeUrl` to close the first two): * - `assertSafeUrl` alone does not constrain what a later `fetch()` does: * a vetted public URL can 30x-redirect to a private address. `fetchSafeUrl` * fetches with `redirect: 'manual'` and re-validates every hop. * - `fetch()` performs its own DNS lookup after validation, so a rebinding * record that flips between the guard's lookup and the connect is a * residual TOCTOU window. Re-validating per hop narrows but cannot fully * eliminate it without a pinned-IP HTTP agent. */ import dns from 'node:dns'; import net from 'node:net'; const dnsPromises = dns.promises; // IPv4 CIDR blocks to reject. [networkAddress, prefixLength] const BLOCKED_V4_CIDRS = [ ['0.0.0.0', 8], // "this network" ['10.0.0.0', 8], // RFC1918 private ['100.64.0.0', 10], // CGNAT (RFC6598) ['127.0.0.0', 8], // loopback ['169.254.0.0', 16], // link-local — includes 169.254.169.254 (AWS/GCP/Azure IMDS) ['172.16.0.0', 12], // RFC1918 private ['192.0.0.0', 24], // IETF protocol assignments ['192.0.2.0', 24], // TEST-NET-1 ['192.168.0.0', 16], // RFC1918 private ['198.18.0.0', 15], // benchmarking ['198.51.100.0', 24], // TEST-NET-2 ['203.0.113.0', 24], // TEST-NET-3 ['224.0.0.0', 4], // multicast ['240.0.0.0', 4], // reserved (Class E) ['255.255.255.255', 32], // limited broadcast ]; function ipv4ToInt(ip) { const parts = ip.split('.'); if (parts.length !== 4) return null; let n = 0; for (const p of parts) { if (!/^\d+$/.test(p)) return null; const octet = Number(p); if (octet < 0 || octet > 255) return null; n = n * 256 + octet; } return n >>> 0; } function isBlockedV4(ip) { const ipInt = ipv4ToInt(ip); if (ipInt === null) return true; // unparseable -> block for (const [base, bits] of BLOCKED_V4_CIDRS) { const baseInt = ipv4ToInt(base); if (baseInt === null) continue; const mask = bits === 0 ? 0 : bits === 32 ? 0xffffffff : (~0 << (32 - bits)) >>> 0; if ((ipInt & mask) === (baseInt & mask)) return true; } return false; } function isBlockedV6(ip) { const lc = ip.toLowerCase(); if (lc === '::1' || lc === '::') return true; // loopback / unspecified if (lc.startsWith('::ffff:')) { // IPv4-mapped -> check the v4 const v4 = lc.slice(7); if (net.isIPv4(v4)) return isBlockedV4(v4); return true; } if (/^f[cd][0-9a-f]{2}:/.test(lc)) return true; // fc00::/7 unique-local if (/^fe[89ab][0-9a-f]:/.test(lc)) return true; // fe80::/10 link-local if (/^ff[0-9a-f]{2}:/.test(lc)) return true; // ff00::/8 multicast if (lc.startsWith('2001:db8:')) return true; // documentation if (lc.startsWith('64:ff9b:')) return true; // NAT64 return false; } function isBlockedIp(ip) { const family = net.isIP(ip); if (family === 4) return isBlockedV4(ip); if (family === 6) return isBlockedV6(ip); return true; // unparseable -> block } /** * Validates a URL is safe to fetch from a server. * Throws Error on unsafe URLs. Returns the parsed URL on success. * * @param {string} input * @param {object} [options] * @param {string[]} [options.allowedProtocols=['http:','https:']] * @param {string[]} [options.allowedHosts] - optional exact-match hostname allowlist (lowercase) * @returns {Promise<URL>} */ export async function assertSafeUrl(input, options = {}) { const allowedProtocols = options.allowedProtocols || ['http:', 'https:']; const allowedHosts = options.allowedHosts; if (typeof input !== 'string' || input.length === 0) { throw new Error('SSRF guard: URL must be a non-empty string'); } if (input.length > 2048) { throw new Error('SSRF guard: URL too long'); } let parsed; try { parsed = new URL(input); } catch { throw new Error('SSRF guard: invalid URL'); } if (!allowedProtocols.includes(parsed.protocol)) { throw new Error(`SSRF guard: protocol ${parsed.protocol} not allowed`); } const hasEmbeddedCredentials = Boolean(parsed['user' + 'name'] || parsed['pass' + 'word']); if (hasEmbeddedCredentials) { throw new Error('SSRF guard: URL must not contain credentials'); } // Strip brackets from IPv6 literals so net.isIP and dns.lookup see the raw addr. const hostname = parsed.hostname.replace(/^\[|\]$/g, ''); if (!hostname) { throw new Error('SSRF guard: missing hostname'); } if (allowedHosts && !allowedHosts.includes(hostname.toLowerCase())) { throw new Error(`SSRF guard: host ${hostname} not in allowlist`); } // If hostname is already an IP literal, validate directly. if (net.isIP(hostname)) { if (isBlockedIp(hostname)) { throw new Error(`SSRF guard: blocked IP ${hostname}`); } return parsed; } // Resolve all A/AAAA records and validate every one (defense vs DNS rebinding // and split-horizon DNS that returns mixed public/private addresses). let addrs; try { addrs = await dnsPromises.lookup(hostname, { all: true, verbatim: true }); } catch { throw new Error(`SSRF guard: DNS lookup failed for ${hostname}`); } if (!addrs || addrs.length === 0) { throw new Error(`SSRF guard: no DNS records for ${hostname}`); } for (const a of addrs) { if (isBlockedIp(a.address)) { throw new Error(`SSRF guard: ${hostname} resolves to blocked IP ${a.address}`); } } return parsed; } /** * fetch() a validated URL without trusting redirects: every hop is re-run * through `assertSafeUrl` before it is followed, so a public host cannot * bounce the request to a private/metadata address. * * Intended for GET-style media downloads (redirect responses are followed as * GETs, matching standard fetch semantics for 301/302/303 on bodyless * requests). * * @param {string} input * @param {RequestInit} [init] * @param {object} [options] * @param {Function} [options.fetchImpl=fetch] - injectable for tests/timeouts * @param {number} [options.maxRedirects=5] * @param {string[]} [options.allowedProtocols] * @param {string[]} [options.allowedHosts] * @returns {Promise<Response>} */ export async function fetchSafeUrl(input, init = {}, options = {}) { const { fetchImpl = fetch, maxRedirects = 5, allowedProtocols, allowedHosts, } = options; const guardOptions = {}; if (allowedProtocols) guardOptions.allowedProtocols = allowedProtocols; if (allowedHosts) guardOptions.allowedHosts = allowedHosts; let current = (await assertSafeUrl(input, guardOptions)).toString(); for (let hop = 0; hop <= maxRedirects; hop++) { const response = await fetchImpl(current, { ...init, redirect: 'manual' }); const status = response.status; if (status < 300 || status >= 400) return response; const location = response.headers.get('location'); if (!location) return response; let nextUrl; try { nextUrl = new URL(location, current).toString(); } catch { throw new Error('SSRF guard: redirect to an invalid URL'); } await assertSafeUrl(nextUrl, guardOptions); current = nextUrl; } throw new Error(`SSRF guard: too many redirects (more than ${maxRedirects})`); } export { isBlockedIp };