Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion packages/core/src/discovery.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ import {
withRetries,
waitForSelectorInsideBrowser,
isGzipped,
maybeScrollToBottom
maybeScrollToBottom,
assertNotMetadataTarget
} from './utils.js';
import { ByteLRU, entrySize, DiskSpillStore, createSpillDir } from './cache/byte-lru.js';
import {
Expand Down Expand Up @@ -315,6 +316,8 @@ async function* captureSnapshotResources(page, snapshot, options) {

// navigate to the url
yield resizePage(snapshot.widths[0]);
// refuse to navigate the top-level snapshot URL to a cloud metadata endpoint
assertNotMetadataTarget(snapshot.url);
yield page.goto(snapshot.url, { cookies, forceReload: discovery.captureResponsiveAssetsEnabled });

// wait for any specified timeout
Expand Down
187 changes: 151 additions & 36 deletions packages/core/src/network.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { request as makeRequest } from '@percy/client/utils';
import logger from '@percy/logger';
import mime from 'mime-types';
import { AbortError, DefaultMap, createResource, hostnameMatches, normalizeURL, waitFor, decodeAndEncodeURLWithLogging, handleIncorrectFontMimeType, executeDomainValidation } from './utils.js';
import dns from 'dns';
import { AbortError, DefaultMap, createResource, hostnameMatches, normalizeURL, waitFor, decodeAndEncodeURLWithLogging, handleIncorrectFontMimeType, executeDomainValidation, isMetadataTarget, isMetadataIP } from './utils.js';

export const MAX_RESOURCE_SIZE = 25 * (1024 ** 2) * 0.63; // 25MB, 0.63 factor for accounting for base64 encoding
// CDP returns binary bodies via Network.getResponseBody as base64 in the JSON-RPC
Expand Down Expand Up @@ -38,6 +39,17 @@ export const AbortCodes = Object.freeze({
TIMEOUT_NETWORK_IDLE: 'TIMEOUT_NETWORK_IDLE'
});

// Thrown from the direct-fetch choke point (makeDirectRequest) when a Node-side
// fetch connects to a cloud metadata IP. Callers treat it as "already blocked
// and logged" and simply drop the resource without re-logging a network error.
export class MetadataBlockedError extends Error {
constructor(host) {
super(`Refusing to save direct-fetched resource from cloud metadata endpoint: ${host}`);
this.name = 'MetadataBlockedError';
this.host = host;
}
}

// RequestLifeCycleHandler handles life cycle of a requestId
// Ideal flow: requestWillBeSent -> requestPaused -> responseReceived -> loadingFinished / loadingFailed
// ServiceWorker flow: requestWillBeSent -> responseReceived -> loadingFinished / loadingFailed
Expand Down Expand Up @@ -420,6 +432,28 @@ export class Network {
if (!request) return;

request.response = response;

// DNS-rebinding-safe SSRF gate: block on the IP Chromium actually connected
// to (response.remoteIPAddress), before the body is ever buffered/uploaded.
// The request-time pre-check only inspects the literal host, so a hostname
// that resolves benignly at request time but rebinds to a metadata IP for
// the real connection slips past it and is caught here instead. Dropping the
// request now means _handleLoadingFinished finds nothing to save, so the
// metadata response body is never fetched via Network.getResponseBody.
let metadataHost = isMetadataIP(response.remoteIPAddress);
if (metadataHost) {
let url = originURL(request);
logAssetInstrumentation(this.log, 'asset_not_uploaded', 'metadata_endpoint_blocked', {
url,
hostname: metadataHost,
snapshot: this.meta?.snapshot
});
this.log.warn(`Refusing to capture resource from cloud metadata endpoint: ${metadataHost}`, { ...this.meta, url });
this._forgetRequest(request);
this.#requestsLifeCycleHandler.get(requestId).resolveResponseReceived();
return;
}

request.response.buffer = async () => {
let result = await this.send(session, 'Network.getResponseBody', { requestId });
return Buffer.from(result.body, result.base64Encoded ? 'base64' : 'utf-8');
Expand Down Expand Up @@ -560,6 +594,58 @@ export class Network {
'its recommended to increase CI resources where this cli is running.');
}
}

// Perform the actual Node-side HTTP fetch for a request, attaching the page's
// cookies and (same-origin only) Basic auth. A custom dns `lookup` hook records
// the IP(s) the socket is actually resolving/connecting to — this is the same
// resolution Node uses for the connection (not an extra DNS round-trip), so the
// caller can enforce the SSRF metadata block on the real connected IP and defeat
// DNS rebinding on the direct-fetch path. Kept as an instance method so tests
// can stub the transport (and the connected IP) without hitting the network.
async directFetch(request, session) {
let cookies = [];
let cookieSession = pickCookieSession(this, session);
try {
({ cookies } = await cookieSession.send('Network.getCookies', { urls: [request.url] }));
} catch (error) {
this.log.debug(`Network.getCookies unavailable for ${request.url}: ${error.message}`);
}

let headers = {
// add default browser
accept: '*/*',
'sec-fetch-site': 'same-origin',
'sec-fetch-mode': 'cors',
'sec-fetch-dest': 'font',
'sec-ch-ua': '"Chromium";v="143", "Google Chrome";v="143", "Not?A_Brand";v="99"',
'sec-ch-ua-mobile': '?0',
'sec-ch-ua-platform': '"macOS"',
'sec-fetch-user': '?1',
// add request fetched headers
...request.headers,
// add applicable cookies
cookie: cookies.map(cookie => `${cookie.name}=${cookie.value}`).join('; ')
};

if (shouldAttachAuth(this.authorization, request.url, this.meta?.snapshotURL)) {
let { username, password } = this.authorization;
let token = Buffer.from([username, password || ''].join(':')).toString('base64');
headers.Authorization = `Basic ${token}`;
}

let remoteAddresses = [];
let lookup = (hostname, opts, cb) => dns.lookup(hostname, opts, (err, address, family) => {
remoteAddresses.push(...flattenLookupAddresses(address));
cb(err, address, family);
});

let { body, status, headers: responseHeaders } = await makeRequest(
request.url, { buffer: true, headers, lookup }, (body, res) => ({
body, status: res.statusCode, headers: res.headers
}));

return { body, status, headers: responseHeaders, remoteAddresses };
}
}

// Logs asset instrumentation for failed/skipped asset loading
Expand All @@ -578,7 +664,8 @@ export function logAssetInstrumentation(log, category, reason, details) {
resource_too_large: 'Resource too large',
no_response: 'No response received',
empty_response: 'Empty response',
disallowed_resource_type: 'Disallowed resource type'
disallowed_resource_type: 'Disallowed resource type',
metadata_endpoint_blocked: 'Cloud metadata endpoint blocked'
};

const prefix = categoryMap[category];
Expand Down Expand Up @@ -670,6 +757,7 @@ async function sendResponseResource(network, request, session) {

try {
let resource = network.intercept.getResource(url, network.intercept.currentWidth);
let metadataHost;
network.log.debug(`Handling request: ${url}`, meta);

if (!resource?.root && hostnameMatches(disallowedHostnames, url)) {
Expand All @@ -695,6 +783,32 @@ async function sendResponseResource(network, request, session) {
responseHeaders: Object.entries(resource.headers || {})
.map(([k, v]) => ({ name: k.toLowerCase(), value: String(v) }))
});
} else if ((metadataHost = isMetadataTarget(url)) ||
(request.redirectChain.length && (metadataHost = isMetadataTarget(request.url)))) {
// Cheap, synchronous first-line block for SSRF pivots to cloud
// instance-metadata endpoints whose target is a literal metadata IP or a
// known metadata hostname — refused before issuing a real outbound
// request. This is a literal-only pre-check: it does NOT resolve DNS and
// therefore does NOT defend against DNS rebinding (a hostname that
// resolves benignly here can still connect to a metadata IP). The
// rebinding leg is closed at the response stage in _handleResponseReceived,
// which gates on response.remoteIPAddress — the IP actually connected to.
// Cache hits and root resources are served above and never reach here, so
// loopback/RFC1918 snapshotting is unaffected. We check both the origin
// URL and, on a redirect hop, the actual target (request.url) so an open
// redirect to a metadata endpoint cannot bypass the block — originURL
// reports the pre-redirect URL for resource identity.
logAssetInstrumentation(log, 'asset_not_uploaded', 'metadata_endpoint_blocked', {
url,
hostname: metadataHost,
snapshot: meta.snapshot
});
log.warn(`Refusing to fetch resource from cloud metadata endpoint: ${metadataHost}`, meta);

await send('Fetch.failRequest', {
requestId: request.interceptId,
errorReason: 'Aborted'
});
} else {
// interceptResponse:true triggers a second pause at the response stage. See _handleResponsePaused.
await send('Fetch.continueRequest', {
Expand Down Expand Up @@ -774,43 +888,38 @@ export function resolveDirectFetchMime(responseHeaders, urlForLookup) {
return serverMime || mime.lookup(urlForLookup) || 'application/octet-stream';
}

// Make a new request with Node based on a network request. Cookies are read
// from the page session because worker/auxiliary sessions have a partial
// Network domain where Network.getCookies throws "Internal error".
async function makeDirectRequest(network, request, session) {
let cookies = [];
let cookieSession = pickCookieSession(network, session);
try {
({ cookies } = await cookieSession.send('Network.getCookies', { urls: [request.url] }));
} catch (error) {
network.log.debug(`Network.getCookies unavailable for ${request.url}: ${error.message}`);
}

let headers = {
// add default browser
accept: '*/*',
'sec-fetch-site': 'same-origin',
'sec-fetch-mode': 'cors',
'sec-fetch-dest': 'font',
'sec-ch-ua': '"Chromium";v="143", "Google Chrome";v="143", "Not?A_Brand";v="99"',
'sec-ch-ua-mobile': '?0',
'sec-ch-ua-platform': '"macOS"',
'sec-fetch-user': '?1',
// add request fetched headers
...request.headers,
// add applicable cookies
cookie: cookies.map(cookie => `${cookie.name}=${cookie.value}`).join('; ')
};
// Normalize the address argument passed to a dns.lookup callback into a flat
// array of IP strings. Node's http stack calls lookup with `all: true` (Happy
// Eyeballs), so `address` is an array of { address, family }; older/other
// callers may pass a single string. Missing values yield an empty list.
export function flattenLookupAddresses(address) {
if (Array.isArray(address)) return address.map(a => a.address);
return address ? [address] : [];
}

if (shouldAttachAuth(network.authorization, request.url, network.meta?.snapshotURL)) {
let { username, password } = network.authorization;
let token = Buffer.from([username, password || ''].join(':')).toString('base64');
headers.Authorization = `Basic ${token}`;
// The single choke point for all Node-side direct fetches (the direct-fetch
// fallback for worker scripts and the font-mime re-fetch in saveResponseResource).
// It runs the transport (network.directFetch) then enforces the SSRF metadata
// block on the IP(s) the socket actually connected to — the DNS-rebinding-safe
// equivalent of the response-stage remoteIPAddress gate, for requests that never
// surface a CDP response. On a hit it logs + warns and throws MetadataBlockedError
// so the caller drops the resource instead of buffering/uploading (or attaching
// cookies/auth to) a metadata response.
async function makeDirectRequest(network, request, session) {
let { body, status, headers, remoteAddresses } = await network.directFetch(request, session);

let metadataHost = remoteAddresses.map(isMetadataIP).find(Boolean);
if (metadataHost) {
let url = originURL(request);
let meta = { ...network.meta, url };
logAssetInstrumentation(network.log, 'asset_not_uploaded', 'metadata_endpoint_blocked', {
url, hostname: metadataHost, snapshot: meta.snapshot
});
network.log.warn(`Refusing to save direct-fetched resource from cloud metadata endpoint: ${metadataHost}`, meta);
throw new MetadataBlockedError(metadataHost);
}

return makeRequest(request.url, { buffer: true, headers }, (body, res) => ({
body, status: res.statusCode, headers: res.headers
}));
return { body, status, headers };
}

// Capture a resource via direct HTTP fetch when the browser-side response
Expand Down Expand Up @@ -851,6 +960,12 @@ async function captureResourceDirectly(network, request, session) {
log.debug(`- Saving direct-fetched resource sha=${resource.sha} mimetype=${mimeType}`, meta);
network.intercept.saveResource(resource);
} catch (error) {
if (error instanceof MetadataBlockedError) {
// The connected IP was a cloud metadata endpoint — makeDirectRequest has
// already logged + warned. Drop the resource without re-logging it as a
// generic network error so the metadata body is never saved/uploaded.
return;
}
logAssetInstrumentation(log, 'asset_load_missing', 'network_error', {
url, snapshot: meta.snapshot, requestType: request.type, error: error.message
});
Expand Down
100 changes: 100 additions & 0 deletions packages/core/src/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,106 @@ export function isHttpOrHttpsUrl(urlString) {
}
}

// Cloud instance-metadata endpoints. These are never legitimate snapshot
// targets, but they hand out short-lived cloud credentials to anything that
// can reach them — so a page (or a subresource it requests) that navigates
// Chromium to one of these can exfiltrate credentials via SSRF. We block
// these specific targets only; loopback and general RFC1918 hosts stay
// allowed so that snapshotting http://localhost:3000 and internal staging
// hosts keeps working.
const METADATA_IPS = new Set([
'169.254.169.254', // AWS/Azure/GCP IMDS
'169.254.170.2', // AWS ECS task metadata
'100.100.100.200', // Alibaba Cloud
'fd00:ec2::254' // AWS IMDS over IPv6
].map(canonicalHost));

const METADATA_HOSTNAMES = new Set([
'metadata.google.internal',
'metadata.goog'
]);

// Canonicalizes a host for comparison: lowercases, strips a trailing dot and
// IPv6 brackets, normalizes IPv6 addresses to their compressed form (so e.g.
// fd00:ec2:0:0:0:0:0:254 and fd00:ec2::254 compare equal), and unwraps
// IPv4-mapped IPv6 addresses to their dotted-quad form (so e.g.
// ::ffff:169.254.169.254 compares equal to the literal 169.254.169.254 — the
// OS routes it to the IPv4 service, so it must not slip past the IP set).
function canonicalHost(host) {
if (!host) return host;
let h = String(host).toLowerCase().replace(/\.$/, '');
let bare = h.replace(/^\[/, '').replace(/\]$/, '');

if (bare.includes(':')) {
try {
let canon = new URL(`http://[${bare}]/`).hostname.replace(/^\[/, '').replace(/\]$/, '');
// The URL parser renders IPv4-mapped IPv6 as ::ffff:wwww:xxxx (hex);
// fold those 32 bits back into dotted-quad so mapped metadata IPs match.
let mapped = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(canon);
if (mapped) {
let hi = parseInt(mapped[1], 16);
let lo = parseInt(mapped[2], 16);
return `${hi >> 8}.${hi & 0xff}.${lo >> 8}.${lo & 0xff}`;
}
return canon;
} catch {
return bare;
}
}

return bare;
}

// The single decision point for the metadata block: returns the given host
// (hostname or IP literal) when it canonicalizes to a known cloud
// instance-metadata endpoint, otherwise null. Purely synchronous and DNS-free —
// every outbound path (the request-time literal pre-check, the response-stage
// remoteIPAddress gate, and the direct-fetch socket-address gate) funnels
// through here so the block behaves identically everywhere.
function matchMetadataHost(host) {
if (!host) return null;
let canon = canonicalHost(host);
return (METADATA_IPS.has(canon) || METADATA_HOSTNAMES.has(canon)) ? host : null;
}

// Cheap, synchronous first-line pre-check on a URL's literal host: blocks only
// literal metadata IPs and metadata hostnames. It does NOT resolve DNS and so
// cannot defend against DNS rebinding on its own — an attacker's hostname can
// resolve benignly here and then to a metadata IP for the actual connection.
// The DNS-rebinding leg is closed at the response stage by isMetadataIP, which
// gates on response.remoteIPAddress — the IP the request actually connected to.
export function isMetadataTarget(rawUrl) {
let host;

try {
host = new URL(rawUrl).hostname;
} catch {
// Unparseable URLs are handled elsewhere; they are not our concern here.
return null;
}

return matchMetadataHost(host);
}

// Response/connection-stage gate: given the IP a request actually connected to
// (response.remoteIPAddress from CDP, or a direct fetch's socket.remoteAddress),
// returns the offending IP when it is a metadata target, otherwise null. This
// is the authoritative enforcement point — it inspects the real connected IP,
// so a hostname that rebinds to a metadata IP after the request-time pre-check
// is still blocked here. No DNS needed: the input is already an IP literal.
export function isMetadataIP(remoteIP) {
return matchMetadataHost(remoteIP);
}

// Throws when the URL points at a cloud instance-metadata endpoint. Used to
// refuse navigating the top-level snapshot URL to such a target.
export function assertNotMetadataTarget(rawUrl) {
let host = isMetadataTarget(rawUrl);
if (host) {
throw new Error(`Refusing to navigate to cloud metadata endpoint: ${host}`);
}
}

// Rewrites localhost/127.0.0.1 origins to Percy's internal render host so
// serialized resources resolve consistently during rendering. Mirrors the
// rewrite applied to serialized DOM resources in @percy/dom (serialize-cssom).
Expand Down
Loading
Loading