Playwright 的 page.route() 仍會放行重定向,僅在 page.goto() 前驗證 URL 並不足夠
Validating the URL before page.goto() is not enough: redirects slip past Playwright's page.route()
作者以 Playwright 1.63.0 測試三種 URL 防護方式,發現只在 page.goto() 前驗證 URL,或再加 page.route(),仍會讓重定向請求抵達內部伺服器。
If your service takes a URL from a user and renders it with Playwright, the obvious protection is to check the URL before page.goto(). I tested that on 2026-11-01 and it leaks. A public page that answers 302 Location: http://127.0.0.1/... made Chromium fetch the internal address, and the page content ended up in the rendered output.
Adding page.route() closes some holes and leaves the redirect one open. This post shows the three setups side by side, with the output, so you can see which request reaches the internal server in each.
The setup
Two local HTTP servers stand in for the real world:
-
internalon127.0.0.1:4001plays the cloud metadata endpoint or an admin panel. It records every path it receives. -
publicon port 4000 plays the attacker's page. Chromium reaches it aspublic.testthrough--host-resolver-rules, and the guard is told thatpublic.testresolves to a public address. That is a simulation, since I did not want to hit anyone else's site.
First the guard. It resolves the host, blocks private, loopback and link-local ranges, and allows only http and https. The address check is what I would write in a hurry, and it had a bug I found while testing (see below).
// guard.js
const net = require('net');
const BLOCKED_V4 = [
['0.0.0.0', 8], ['10.0.0.0', 8], ['100.64.0.0', 10], ['127.0.0.0', 8],
['169.254.0.0', 16], ['172.16.0.0', 12], ['192.168.0.0', 16],
];
const toInt = (ip) => ip.split('.').reduce((n, o) => (n << 8) + Number(o), 0) >>> 0;
function isBlockedIp(ip) {
if (net.isIPv4(ip)) {
const n = toInt(ip);
return BLOCKED_V4.some(([base, bits]) => {
const mask = bits === 0 ? 0 : (~0 << (32 - bits)) >>> 0;
return (n & mask) === (toInt(base) & mask);
});
}
if (net.isIPv6(ip)) {
const l = ip.toLowerCase();
if (l === '::1' || l === '::') return true;
if (l.startsWith('fe80:') || l.startsWith('fc') || l.startsWith('fd')) return true;
// IPv4-mapped. WHATWG URL rewrites ::ffff:127.0.0.1 to ::ffff:7f00:1, so handle the hex form too.
const dotted = l.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
if (dotted) return isBlockedIp(dotted[1]);
const hex = l.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
if (hex) {
const hi = parseInt(hex[1], 16), lo = parseInt(hex[2], 16);
return isBlockedIp(`${hi >> 8}.${hi & 255}.${lo >> 8}.${lo & 255}`);
}
return false;
}
return true; // not an IP at all: refuse
}
// `resolve` is injected so the demo can pretend public.test has a public address.
async function checkUrl(raw, resolve) {
let u;
try { u = new URL(raw); } catch { return 'unparseable URL'; }
if (u.protocol !== 'http:' && u.protocol !== 'https:') return `scheme ${u.protocol}`;
const host = u.hostname.replace(/^\[|\]$/g, '');
const ips = net.isIP(host) ? [host] : await resolve(host);
if (!ips.length) return 'does not resolve';
const bad = ips.find(isBlockedIp);
return bad ? `blocked address ${bad}` : null; // null = allowed
}
module.exports = { checkUrl, isBlockedIp };
Node's URL parser does some of the work. http://2130706433/ and http://0x7f.1/ both come out as 127.0.0.1 before my code sees them. I ran the guard over a few spellings of loopback:
http://127.0.0.1/ -> 127.0.0.1 blocked address 127.0.0.1
http://2130706433/ -> 127.0.0.1 blocked address 127.0.0.1
http://0x7f.1/ -> 127.0.0.1 blocked address 127.0.0.1
http://[::1]/ -> [::1] blocked address ::1
http://[::ffff:127.0.0.1]/ -> [::ffff:7f00:1] blocked address ::ffff:7f00:1
http://169.254.169.254/latest/meta-data/ -> 169.254.169.254 blocked address 169.254.169.254
http://localhost/ -> localhost blocked address ::1
file:///etc/passwd -> scheme file:
http://93.184.216.34/ -> 93.184.216.34 ALLOWED
The first version of my IPv6 check only matched ::ffff:127.0.0.1 in dotted form. The URL parser hands back ::ffff:7f00:1, and that line printed ALLOWED. The hex branch above is the fix. If you write your own range check, test it with the output of new URL(...).hostname, not with the string the user typed.
The fixtures
// servers.js
const http = require('http');
const hits = [];
const internal = http.createServer((req, res) => {
hits.push(req.url);
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Content-Type', 'text/html');
res.end('<h1 id="secret">INTERNAL-SECRET-TOKEN</h1>');
});
const pub = http.createServer((req, res) => {
const internalUrl = 'http://127.0.0.1:4001/secret';
if (req.url === '/ok') {
res.setHeader('Content-Type', 'text/html');
return res.end('<h1>public page</h1>');
}
if (req.url === '/redirect') {
res.writeHead(302, { Location: internalUrl });
return res.end();
}
if (req.url === '/embed') {
res.setHeader('Content-Type', 'text/html');
return res.end(`<h1>embed</h1>
<img src="http://127.0.0.1:4001/img.png">
<iframe src="${internalUrl}"></iframe>
<script>fetch("http://127.0.0.1:4001/fetch").catch(()=>{})</script>`);
}
if (req.url === '/redir-ok') {
res.writeHead(302, { Location: '/ok' });
return res.end();
}
if (req.url === '/chain') {
res.writeHead(302, { Location: '/redirect' });
return res.end();
}
if (req.url === '/redirect-img') {
res.setHeader('Content-Type', 'text/html');
return res.end('<h1>redir img</h1><img src="/hop">');
}
if (req.url === '/hop') {
res.writeHead(302, { Location: 'http://127.0.0.1:4001/img-via-redirect.png' });
return res.end();
}
res.statusCode = 404;
res.end();
});
exports.start = () => new Promise((resolve) => {
internal.listen(4001, '127.0.0.1', () => pub.listen(4000, '127.0.0.1', resolve));
});
exports.stop = () => { internal.close(); pub.close(); };
exports.hits = hits;
Six paths: a plain page, a harmless redirect, a redirect to the internal server, a two-hop chain ending there, a page that embeds internal resources directly, and a page whose <img> redirects to the internal server.
The renderer, in three modes
// render.js: node render.js naive|route|manual
const { chromium } = require('playwright');
const { checkUrl } = require('./guard');
const servers = require('./servers');
// Demo only: pretend public.test has a public address; Chromium maps it to localhost.
const resolve = async (h) => (h === 'public.test' ? ['93.184.216.34'] : ['127.0.0.1']);
const toWire = (u) => u.replace('//public.test:', '//127.0.0.1:'); // Node ignores host-resolver-rules
const MODE = process.argv[2];
const MAX_HOPS = 5;
async function routeCheckOnly(route, blocked) {
const url = route.request().url();
const reason = await checkUrl(url, resolve);
if (reason) { blocked.push(`${url} (${reason})`); return route.abort('blockedbyclient'); }
return route.continue();
}
async function routeFollowRedirects(route, blocked) {
let url = route.request().url();
for (let hop = 0; hop <= MAX_HOPS; hop++) {
const reason = await checkUrl(url, resolve);
if (reason) { blocked.push(`${url} (${reason})`); return route.abort('blockedbyclient'); }
const res = await route.fetch({ url: toWire(url), maxRedirects: 0 });
const loc = res.headers()['location'];
if (res.status() >= 300 && res.status() < 400 && loc) {
url = new URL(loc, url).href; // validate the next hop before following it
continue;
}
return route.fulfill({ response: res });
}
blocked.push(`${url} (too many redirects)`);
return route.abort('blockedbyclient');
}
async function render(path) {
const url = `http://public.test:4000${path}`;
servers.hits.length = 0;
const refused = await checkUrl(url, resolve);
if (refused) return { refused };
const browser = await chromium.launch({ args: ['--host-resolver-rules=MAP public.test 127.0.0.1'] });
const page = await browser.newPage();
const blocked = [];
if (MODE === 'route') await page.route('**/*', (r) => routeCheckOnly(r, blocked));
if (MODE === 'manual') await page.route('**/*', (r) => routeFollowRedirects(r, blocked));
await page.goto(url, { waitUntil: 'load' }).catch((e) => blocked.push('goto: ' + e.message.split('\n')[0]));
const h1 = await page.evaluate(() => document.querySelector('h1')?.textContent ?? null).catch(() => null);
await page.waitForTimeout(300);
await browser.close();
return { h1, internalHits: [...servers.hits].sort(), blocked: blocked.sort() };
}
(async () => {
await servers.start();
for (const path of ['/ok', '/redir-ok', '/redirect', '/chain', '/embed', '/redirect-img']) {
console.log(path.padEnd(14), JSON.stringify(await render(path)));
}
servers.stop();
})();
Set up with npm i playwright@1.63.0. internalHits is what the internal server saw, so it is the number that matters.
Mode 1: validate the URL, then goto
$ node render.js naive
/ok {"h1":"public page","internalHits":[],"blocked":[]}
/redir-ok {"h1":"public page","internalHits":[],"blocked":[]}
/redirect {"h1":"INTERNAL-SECRET-TOKEN","internalHits":["/secret"],"blocked":[]}
/chain {"h1":"INTERNAL-SECRET-TOKEN","internalHits":["/secret"],"blocked":[]}
/embed {"h1":"embed","internalHits":["/fetch","/img.png","/secret"],"blocked":[]}
/redirect-img {"h1":"redir img","internalHits":["/img-via-redirect.png"],"blocked":[]}
The guard approves public.test, and Chromium goes wherever the page sends it. Redirects, iframes, images and script fetch() all reach the internal server. On /redirect the secret is the page's <h1>, so it would be in your screenshot or PDF.
Mode 2: also check every request with page.route()
$ node render.js route
/ok {"h1":"public page","internalHits":[],"blocked":[]}
/redir-ok {"h1":"public page","internalHits":[],"blocked":[]}
/redirect {"h1":"INTERNAL-SECRET-TOKEN","internalHits":["/secret"],"blocked":[]}
/chain {"h1":"INTERNAL-SECRET-TOKEN","internalHits":["/secret"],"blocked":[]}
/embed {"h1":"embed","internalHits":[],"blocked":["http://127.0.0.1:4001/fetch (blocked address 127.0.0.1)","http://127.0.0.1:4001/img.png (blocked address 127.0.0.1)","http://127.0.0.1:4001/secret (blocked address 127.0.0.1)"]}
/redirect-img {"h1":"redir img","internalHits":["/img-via-redirect.png"],"blocked":[]}
Subresources are now blocked. Redirects still go through. The Playwright network docs explain why: a request and its redirects are one unit, the handler is called once for the original request, and the browser follows the redirect on its own. Your handler never sees 127.0.0.1:4001/secret as a request of its own.
Mode 3: follow redirects yourself
route.fetch() accepts maxRedirects: 0. The handler in routeFollowRedirects makes the request from Node, reads the Location header, runs the guard on the next URL, and only then continues. Chromium never gets a redirect to follow. It receives the final response through route.fulfill().
$ node render.js manual
/ok {"h1":"public page","internalHits":[],"blocked":[]}
/redir-ok {"h1":"public page","internalHits":[],"blocked":[]}
/redirect {"h1":null,"internalHits":[],"blocked":["goto: page.goto: net::ERR_BLOCKED_BY_CLIENT at http://public.test:4000/redirect","http://127.0.0.1:4001/secret (blocked address 127.0.0.1)"]}
/chain {"h1":null,"internalHits":[],"blocked":["goto: page.goto: net::ERR_BLOCKED_BY_CLIENT at http://public.test:4000/chain","http://127.0.0.1:4001/secret (blocked address 127.0.0.1)"]}
/embed {"h1":"embed","internalHits":[],"blocked":["http://127.0.0.1:4001/fetch (blocked address 127.0.0.1)","http://127.0.0.1:4001/img.png (blocked address 127.0.0.1)","http://127.0.0.1:4001/secret (blocked address 127.0.0.1)"]}
/redirect-img {"h1":"redir img","internalHits":[],"blocked":["http://127.0.0.1:4001/img-via-redirect.png (blocked address 127.0.0.1)"]}
internalHits is empty on all six rows, and the harmless redirect /redir-ok still renders.
What this does not cover
- DNS rebinding. The guard resolves a name, then Chromium (or Node, in mode 3) resolves it again. A hostname that answers differently the second time passes the check. I did not reproduce a rebinding attack here. The OWASP SSRF prevention cheat sheet says the HTTP client should connect only to the validated address, and that a second unchecked lookup between validation and connection can bypass the checks. Playwright's route API does not give you that.
- A firewall is the real boundary. Run the browser where it has no route to internal ranges and the metadata address. The code above is a second layer. OWASP recommends both.
- Fidelity in mode 3. The browser believes it loaded the original URL, because the redirect was resolved in Node. I did not test what that does to relative links on a redirected page. If you render redirected pages, test that case before you ship.
-
What I did not try: WebSockets, service workers, popups, non-GET requests, HTTPS certificates, and large response bodies, which
route.fetch()buffers in Node's memory.
Written with AI assistance. Every code sample was run on 2026-11-01 with Playwright 1.63.0 on Node v24.22.2 (Linux); the output above is from that run.
來源:DEV Community · dev.to