scripts/ssrf-guard.mjs
A supporting file of the design-critique skill, shown as source.
/**
* ssrf-guard.mjs — address-validate EVERY browser request, not just the entry URL.
*
* The backend validates the URL/repo the operator types, but Chromium then follows
* redirects and loads subresources on its own: a public page can 3xx to
* http://10.0.0.1/ or embed a private-network subresource (169.254.169.254 cloud
* metadata, a LAN box) and the browser would fetch it server-side. This installs a
* request interceptor on a Playwright context/page that resolves each request's
* host and aborts anything on an internal/private address. Loopback stays allowed:
* the localhost preview and the repo's own loopback static server are the feature.
*
* Mirrors the backend `_url_target_allowed` rule (allow loopback + public; reject
* private / link-local incl. 169.254.169.254 / reserved / multicast / unspecified).
*/
import dns from 'node:dns/promises'
import net from 'node:net'
function ipv4Disallowed(ip) {
const p = ip.split('.').map(Number)
if (p.length !== 4 || p.some(n => Number.isNaN(n) || n < 0 || n > 255)) return true
const [a, b, c] = p
if (a === 127) return false // loopback — allowed (localhost preview)
if (a === 10) return true // private 10/8
if (a === 172 && b >= 16 && b <= 31) return true // private 172.16/12
if (a === 192 && b === 168) return true // private 192.168/16
if (a === 169 && b === 254) return true // link-local incl. metadata
if (a === 100 && b >= 64 && b <= 127) return true // CGNAT 100.64/10
if (a === 0) return true // 0.0.0.0/8 this-network
if (a === 192 && b === 0 && c === 0) return true // IETF protocol 192.0.0/24
if (a === 192 && b === 0 && c === 2) return true // TEST-NET-1 192.0.2/24
if (a === 192 && b === 88 && c === 99) return true // 6to4 relay anycast 192.88.99/24
if (a === 198 && (b === 18 || b === 19)) return true // benchmark 198.18/15
if (a === 198 && b === 51 && c === 100) return true // TEST-NET-2 198.51.100/24
if (a === 203 && b === 0 && c === 113) return true // TEST-NET-3 203.0.113/24
if (a >= 224) return true // multicast 224/4 + reserved 240/4 + 255.255.255.255
return false // public
}
function ipv6Disallowed(ip) {
const s = ip.toLowerCase().replace(/^\[|\]$/g, '')
if (s === '::1') return false // loopback — allowed
if (s === '::') return true // unspecified
if (/^fe[89ab]/.test(s)) return true // link-local fe80::/10
if (/^fe[c-f]/.test(s)) return true // site-local fec0::/10 (deprecated)
if (/^f[cd]/.test(s)) return true // unique-local fc00::/7
if (/^ff/.test(s)) return true // multicast
const dotted = s.match(/::ffff:(\d+\.\d+\.\d+\.\d+)$/) // IPv4-mapped, dotted
if (dotted) return ipv4Disallowed(dotted[1])
const hex = s.match(/::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/) // IPv4-mapped, hex
if (hex) {
const hi = parseInt(hex[1], 16), lo = parseInt(hex[2], 16)
return ipv4Disallowed([(hi >> 8) & 255, hi & 255, (lo >> 8) & 255, lo & 255].join('.'))
}
if (/^2002:/.test(s)) { // 6to4 — decode the embedded IPv4 and vet it
const m = s.match(/^2002:([0-9a-f]{1,4}):([0-9a-f]{1,4})/)
if (!m) return true // undecodable 6to4 → fail closed
const hi = parseInt(m[1], 16), lo = parseInt(m[2], 16)
return ipv4Disallowed([(hi >> 8) & 255, hi & 255, (lo >> 8) & 255, lo & 255].join('.'))
}
return false // global v6 — allowed
}
function ipDisallowed(ip) {
const kind = net.isIP(ip)
if (kind === 4) return ipv4Disallowed(ip)
if (kind === 6) return ipv6Disallowed(ip)
return true // not an IP literal — refuse
}
function isLoopbackIp(ip) {
const kind = net.isIP(ip)
if (kind === 4) return ip.split('.')[0] === '127'
if (kind === 6) {
const s = ip.toLowerCase().replace(/^\[|\]$/g, '')
if (s === '::1') return true
const m = s.match(/::ffff:(\d+\.\d+\.\d+\.\d+)$/)
return !!m && m[1].split('.')[0] === '127'
}
return false
}
// Three-way verdict: 'public' | 'loopback' | 'internal'. Resolves ALL addresses;
// a name that resolves to any disallowed non-loopback address is 'internal', and
// a name mixing loopback with a public address is also 'internal' (rebinding-safe).
async function hostVerdict(hostname) {
let addrs
try {
addrs = await dns.lookup(hostname, { all: true })
} catch {
return 'internal'
}
if (!addrs.length) return 'internal'
let anyLoop = false
let anyPublic = false
for (const a of addrs) {
if (isLoopbackIp(a.address)) { anyLoop = true; continue }
if (ipDisallowed(a.address)) return 'internal'
anyPublic = true
}
if (anyLoop) return anyPublic ? 'internal' : 'loopback'
return 'public'
}
// `target` is a Playwright BrowserContext or Page (both expose `.route`).
// `allowOrigin` (e.g. "http://127.0.0.1:5173") is the ONLY origin for which a
// loopback request is permitted — the local build server, or a localhost-preview
// base — so a public page cannot reach some OTHER service on 127.0.0.1. Every
// request/redirect/subresource is re-resolved here; a public host is allowed,
// an internal one is aborted.
export async function installSsrfGuard(target, allowOrigin = null) {
// HTTP route() does not intercept WebSockets, so a rendered page could open a
// ws:// to an internal host and read it back. Filtering that egress needs
// routeWebSocket (Playwright >= 1.48). When this build lacks it, REFUSE the
// capture rather than silently skipping the guard: fail closed, never open.
// Checked first so no half-installed guard is left on the context.
if (typeof target.routeWebSocket !== 'function') {
throw new Error(
'ssrf-guard: this Playwright build has no routeWebSocket (added in 1.48); ' +
'refusing to capture because WebSocket egress cannot be filtered — upgrade playwright'
)
}
await target.route('**/*', async (route) => {
try {
const u = new URL(route.request().url())
if (u.protocol !== 'http:' && u.protocol !== 'https:') return route.abort('blockedbyclient')
const verdict = await hostVerdict(u.hostname)
if (verdict === 'public') return route.continue()
if (verdict === 'loopback') {
return (allowOrigin && u.origin === allowOrigin) ? route.continue() : route.abort('blockedbyclient')
}
return route.abort('blockedbyclient')
} catch {
return route.abort('blockedbyclient')
}
})
// Apply the same host verdict to every WebSocket: connect to the server only
// when allowed, else close. The fail-closed capability check at the top of
// this function guarantees routeWebSocket exists here.
await target.routeWebSocket(/.*/, (ws) => {
void (async () => {
try {
const u = new URL(ws.url())
const verdict = await hostVerdict(u.hostname)
// Loopback is scoped to the allowed base's host:port (schemes differ
// for ws vs http, so origin can't match) — the preview server's own
// HMR socket is permitted; any other loopback port/host is not.
let sameBase = false
if (allowOrigin) {
try {
const b = new URL(allowOrigin)
sameBase = b.hostname === u.hostname && (b.port || '') === (u.port || '')
} catch { sameBase = false }
}
const ok = verdict === 'public' || (verdict === 'loopback' && sameBase)
if (ok) ws.connectToServer()
else ws.close({ code: 1008, reason: 'blockedbyclient' })
} catch {
ws.close({ code: 1008, reason: 'blockedbyclient' })
}
})()
})
}