diff --git a/packages/core/src/discovery.js b/packages/core/src/discovery.js index cf4f5c6c0..200c8af2a 100644 --- a/packages/core/src/discovery.js +++ b/packages/core/src/discovery.js @@ -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 { @@ -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 diff --git a/packages/core/src/network.js b/packages/core/src/network.js index 07ec9bfa8..9be444b45 100644 --- a/packages/core/src/network.js +++ b/packages/core/src/network.js @@ -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 @@ -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 @@ -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'); @@ -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 @@ -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]; @@ -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)) { @@ -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', { @@ -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 @@ -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 }); diff --git a/packages/core/src/utils.js b/packages/core/src/utils.js index bdc206587..062d309dd 100644 --- a/packages/core/src/utils.js +++ b/packages/core/src/utils.js @@ -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). diff --git a/packages/core/test/discovery.test.js b/packages/core/test/discovery.test.js index 14be2af75..d9008c842 100644 --- a/packages/core/test/discovery.test.js +++ b/packages/core/test/discovery.test.js @@ -6,6 +6,7 @@ import { RESOURCE_CACHE_KEY, CACHE_STATS_KEY, DISK_SPILL_KEY } from '../src/disc import { ByteLRU, DiskSpillStore } from '../src/cache/byte-lru.js'; import fs from 'fs'; import Session from '../src/session.js'; +import Network from '../src/network.js'; import Pako from 'pako'; import * as CoreConfig from '@percy/core/config'; import PercyConfig from '@percy/config'; @@ -1109,6 +1110,164 @@ describe('Discovery', () => { expect(emptyLogs[0].message).toContain('[ASSET_NOT_UPLOADED]'); }); + it('blocks and logs instrumentation for cloud metadata endpoints', async () => { + await percy.snapshot({ + name: 'test snapshot', + url: 'http://localhost:8000', + domSnapshot: testDOM.replace('img.gif', 'http://169.254.169.254/latest/meta-data/'), + discovery: { disableCache: true } + }); + + await percy.idle(); + + const logs = logger.instance.query(log => log.debug === 'core:discovery'); + expect(logs.length).toBeGreaterThan(0, 'No core:discovery logs found'); + + const notUploadedLogs = logs.filter(l => l.meta && l.meta.instrumentationCategory === 'asset_not_uploaded'); + const metadataLogs = notUploadedLogs.filter(l => l.meta && l.meta.reason === 'metadata_endpoint_blocked'); + expect(metadataLogs.length).toBeGreaterThan(0, 'No metadata_endpoint_blocked logs found'); + expect(metadataLogs[0].meta.hostname).toBe('169.254.169.254'); + expect(metadataLogs[0].message).toContain('[ASSET_NOT_UPLOADED]'); + + // the metadata subresource must never be captured/uploaded + expect(captured[0]).not.toContain( + jasmine.objectContaining({ + attributes: jasmine.objectContaining({ + 'resource-url': 'http://169.254.169.254/latest/meta-data/' + }) + }) + ); + }); + + it('blocks a resource that redirects to a cloud metadata endpoint', async () => { + // an open redirect on an allowed host must not be able to smuggle a + // request through to the metadata endpoint — the redirect hop target + // (not just the original URL) has to be checked + server.reply('/redirect-meta', () => [301, { Location: 'http://169.254.169.254/latest/meta-data/' }]); + + await percy.snapshot({ + name: 'test snapshot', + url: 'http://localhost:8000', + domSnapshot: testDOM.replace('img.gif', 'redirect-meta'), + discovery: { disableCache: true } + }); + + await percy.idle(); + + const logs = logger.instance.query(log => log.debug === 'core:discovery'); + const notUploadedLogs = logs.filter(l => l.meta && l.meta.instrumentationCategory === 'asset_not_uploaded'); + const metadataLogs = notUploadedLogs.filter(l => l.meta && l.meta.reason === 'metadata_endpoint_blocked'); + expect(metadataLogs.length).toBeGreaterThan(0, 'No metadata_endpoint_blocked logs found for redirect'); + expect(metadataLogs[0].meta.hostname).toBe('169.254.169.254'); + + // the redirect target must never be captured/uploaded + expect(captured[0]).not.toContain( + jasmine.objectContaining({ + attributes: jasmine.objectContaining({ + 'resource-url': 'http://169.254.169.254/latest/meta-data/' + }) + }) + ); + }); + + it('blocks a subresource whose connection rebinds to a metadata IP (response-stage gate)', async () => { + // DNS rebinding: the request-time literal pre-check sees only the benign + // host (localhost) and lets the request through, but Chromium's actual + // connection is to a cloud metadata IP. We simulate that by rewriting the + // connected remoteIPAddress reported on Network.responseReceived for the + // subresources — one plain IPv4 metadata IP, one IPv4-mapped IPv6 form. + // The response-stage gate must block each resource before its body is ever + // buffered/uploaded. This is the leg the literal pre-check cannot cover. + spyOn(percy.browser, '_handleMessage').and.callFake(function(data) { + let parsed; try { parsed = JSON.parse(data); } catch { /* binary frame */ } + let url = parsed?.method === 'Network.responseReceived' && parsed.params?.response?.url; + if (url && url.endsWith('/img.gif')) { + parsed.params.response.remoteIPAddress = '169.254.169.254'; + return this._handleMessage.and.originalFn.call(this, JSON.stringify(parsed)); + } + if (url && url.endsWith('/style.css')) { + parsed.params.response.remoteIPAddress = '::ffff:169.254.169.254'; + return this._handleMessage.and.originalFn.call(this, JSON.stringify(parsed)); + } + this._handleMessage.and.originalFn.call(this, data); + }); + + await percy.snapshot({ + name: 'rebind snapshot', + url: 'http://localhost:8000', + domSnapshot: testDOM, + discovery: { disableCache: true } + }); + + await percy.idle(); + + const logs = logger.instance.query(log => log.debug === 'core:discovery'); + const notUploadedLogs = logs.filter(l => l.meta && l.meta.instrumentationCategory === 'asset_not_uploaded'); + const metadataLogs = notUploadedLogs.filter(l => l.meta && l.meta.reason === 'metadata_endpoint_blocked'); + const blockedHosts = metadataLogs.map(l => l.meta.hostname); + + expect(blockedHosts).toContain('169.254.169.254'); + expect(blockedHosts).toContain('::ffff:169.254.169.254'); + + // neither rebound resource body may be captured/uploaded + expect(captured[0]).not.toContain(jasmine.objectContaining({ + attributes: jasmine.objectContaining({ 'resource-url': 'http://localhost:8000/img.gif' }) + })); + expect(captured[0]).not.toContain(jasmine.objectContaining({ + attributes: jasmine.objectContaining({ 'resource-url': 'http://localhost:8000/style.css' }) + })); + }); + + it('blocks a direct-fetched resource whose connection is a metadata IP', async () => { + percy.loglevel('debug'); + + // The direct-fetch fallback is a Node-side path that also attaches + // cookies/auth and never goes through the request-time continue path. Force + // it by dropping the resource's CDP response (so loadingFinished falls back + // to captureResourceDirectly, mirroring the PlzDedicatedWorker case), then + // stub the transport's reported connected IP to a cloud metadata address. + // The direct-fetch choke point (makeDirectRequest → isMetadataIP) must block + // it and never save the body. + spyOn(percy.browser, '_handleMessage').and.callFake(function(data) { + let parsed; try { parsed = JSON.parse(data); } catch { /* binary frame */ } + if (parsed?.method === 'Network.responseReceived' && + parsed.params?.response?.url?.endsWith('/direct-meta.css')) { + return; // drop → responseReceived times out → captureResourceDirectly + } + this._handleMessage.and.originalFn.call(this, data); + }); + + let origDirectFetch = Network.prototype.directFetch; + spyOn(Network.prototype, 'directFetch').and.callFake(async function(request, session) { + let result = await origDirectFetch.call(this, request, session); + if (request.url.endsWith('/direct-meta.css')) { + return { ...result, remoteAddresses: ['169.254.169.254'] }; + } + return result; + }); + + server.reply('/direct-meta.css', () => [200, 'text/css', 'p{}']); + + let dom = 'x'; + await percy.snapshot({ + name: 'direct-fetch metadata snapshot', + url: 'http://localhost:8000', + domSnapshot: dom + }); + + await percy.idle(); + + const logs = logger.instance.query(log => log.debug === 'core:discovery'); + const notUploadedLogs = logs.filter(l => l.meta && l.meta.instrumentationCategory === 'asset_not_uploaded'); + const metadataLogs = notUploadedLogs.filter(l => l.meta && l.meta.reason === 'metadata_endpoint_blocked'); + expect(metadataLogs.map(l => l.meta.hostname)).toContain('169.254.169.254'); + + // the direct-fetched resource must never be captured/uploaded + expect(captured[0]).not.toContain(jasmine.objectContaining({ + attributes: jasmine.objectContaining({ 'resource-url': 'http://localhost:8000/direct-meta.css' }) + })); + }); + it('logs instrumentation for network errors', async () => { // Simulate a network error by closing connection without response server.reply('/aborted.css', req => { diff --git a/packages/core/test/unit/network.test.js b/packages/core/test/unit/network.test.js index f45e8e969..45402d56e 100644 --- a/packages/core/test/unit/network.test.js +++ b/packages/core/test/unit/network.test.js @@ -1,5 +1,5 @@ -import { setupTest } from '../helpers/index.js'; -import { Network, AbortCodes, pickCookieSession, shouldAttachAuth, raceWithTimeout, resolveDirectFetchMime } from '../../src/network.js'; +import { setupTest, logger } from '../helpers/index.js'; +import { Network, AbortCodes, pickCookieSession, shouldAttachAuth, raceWithTimeout, resolveDirectFetchMime, flattenLookupAddresses, MetadataBlockedError } from '../../src/network.js'; import { AbortError } from '../../src/utils.js'; describe('Unit / Network', () => { @@ -134,4 +134,95 @@ describe('Unit / Network', () => { expect(resolveDirectFetchMime({ 'content-type': '' }, '/no-ext')).toBe('application/octet-stream'); }); }); + + // flattenLookupAddresses — normalizes the dns.lookup callback address argument + // (Node's http stack passes an array under Happy Eyeballs; others a string) so + // the direct-fetch choke point can gate on every candidate connection IP. + describe('flattenLookupAddresses', () => { + it('flattens the { address, family }[] form used by Node http (all:true)', () => { + expect(flattenLookupAddresses([ + { address: '127.0.0.1', family: 4 }, + { address: '::1', family: 6 } + ])).toEqual(['127.0.0.1', '::1']); + }); + + it('wraps a single address string', () => { + expect(flattenLookupAddresses('169.254.169.254')).toEqual(['169.254.169.254']); + }); + + it('returns an empty list for a missing address', () => { + expect(flattenLookupAddresses(undefined)).toEqual([]); + expect(flattenLookupAddresses(null)).toEqual([]); + }); + }); + + // MetadataBlockedError — the typed error the direct-fetch choke point throws so + // callers can drop the resource without re-logging a generic network error. + describe('MetadataBlockedError', () => { + it('carries the offending host and a stable name', () => { + let err = new MetadataBlockedError('169.254.169.254'); + expect(err).toEqual(jasmine.any(Error)); + expect(err.name).toBe('MetadataBlockedError'); + expect(err.host).toBe('169.254.169.254'); + expect(err.message).toContain('169.254.169.254'); + }); + }); + + // Response-stage SSRF gate — the DNS-rebinding leg. Drives _handleResponseReceived + // directly (no browser) to prove the gate blocks on the IP Chromium actually + // connected to (response.remoteIPAddress) and never wires up the body buffer, so + // a rebound metadata response can't be fetched/uploaded even though the + // request-time literal pre-check let the request through. + describe('_handleResponseReceived metadata gate', () => { + async function driveResponse(remoteIPAddress) { + let net = new Network({ session: {} }, { + userAgent: 'test', + meta: { snapshot: { name: 'snap' }, snapshotURL: 'http://localhost:8000/' }, + intercept: { getResource: () => null, disallowedHostnames: [], disableCache: true, currentWidth: 0 } + }); + net.send = jasmine.createSpy('send').and.resolveTo({}); + + let session = {}; + let requestId = 'req-1'; + let url = 'http://localhost:8000/rebind.css'; + + // seed the request through the normal request lifecycle + net._handleRequestWillBeSent({ requestId, request: { url }, type: 'Stylesheet' }); + await net._handleRequest(session, { request: { url }, requestId, interceptId: 'int-1', resourceType: 'Stylesheet' }); + + // deliver the CDP response with the (simulated) connected IP + let response = { remoteIPAddress, status: 200, headers: {} }; + await net._handleResponseReceived(session, { requestId, response }); + return { response }; + } + + it('blocks a response whose connected IP is a metadata endpoint (rebinding) and never buffers the body', async () => { + let { response } = await driveResponse('169.254.169.254'); + + // the body buffer is never attached, so the metadata body cannot be fetched/uploaded + expect(response.buffer).toBeUndefined(); + expect(logger.stderr).toEqual(jasmine.arrayContaining([ + jasmine.stringMatching(/Refusing to capture resource from cloud metadata endpoint: 169\.254\.169\.254/) + ])); + }); + + it('blocks an IPv4-mapped IPv6 connected metadata IP', async () => { + let { response } = await driveResponse('::ffff:169.254.169.254'); + + expect(response.buffer).toBeUndefined(); + expect(logger.stderr).toEqual(jasmine.arrayContaining([ + jasmine.stringMatching(/Refusing to capture resource from cloud metadata endpoint: ::ffff:169\.254\.169\.254/) + ])); + }); + + it('allows a benign connected IP and wires up the body buffer', async () => { + let { response } = await driveResponse('127.0.0.1'); + + // the allowed path attaches the buffer so the body can be captured + expect(typeof response.buffer).toBe('function'); + expect(logger.stderr).not.toEqual(jasmine.arrayContaining([ + jasmine.stringMatching(/Refusing to capture resource from cloud metadata endpoint/) + ])); + }); + }); }); diff --git a/packages/core/test/utils.test.js b/packages/core/test/utils.test.js index 22349823c..b75cc091e 100644 --- a/packages/core/test/utils.test.js +++ b/packages/core/test/utils.test.js @@ -1,4 +1,4 @@ -import { decodeAndEncodeURLWithLogging, waitForSelectorInsideBrowser, compareObjectTypes, isGzipped, checkSDKVersion, percyAutomateRequestHandler, detectFontMimeType, handleIncorrectFontMimeType, computeResponsiveWidths, appendUrlSearchParam, processCorsIframesInDomSnapshot, processCorsIframes, isHttpOrHttpsUrl, rewriteLocalhostURL, buildSyntheticFrameResourceUrl } from '../src/utils.js'; +import { decodeAndEncodeURLWithLogging, waitForSelectorInsideBrowser, compareObjectTypes, isGzipped, checkSDKVersion, percyAutomateRequestHandler, detectFontMimeType, handleIncorrectFontMimeType, computeResponsiveWidths, appendUrlSearchParam, processCorsIframesInDomSnapshot, processCorsIframes, isHttpOrHttpsUrl, rewriteLocalhostURL, buildSyntheticFrameResourceUrl, isMetadataTarget, isMetadataIP, assertNotMetadataTarget } from '../src/utils.js'; import { logger, setupTest, mockRequests } from './helpers/index.js'; import percyLogger from '@percy/logger'; import Percy from '@percy/core'; @@ -828,6 +828,114 @@ describe('utils', () => { }); }); + describe('isMetadataTarget', () => { + it('blocks literal cloud metadata IPs', () => { + expect(isMetadataTarget('http://169.254.169.254/latest/meta-data/')).toBe('169.254.169.254'); + expect(isMetadataTarget('http://169.254.170.2/v2/credentials')).toBe('169.254.170.2'); + expect(isMetadataTarget('http://100.100.100.200/latest/meta-data/')).toBe('100.100.100.200'); + }); + + it('blocks the AWS IMDS IPv6 endpoint regardless of formatting', () => { + // compressed and fully-expanded IPv6 forms must both be blocked; assert the + // decision rather than the exact canonical rendering (see the dedicated + // canonicalization test below for the one place we pin the format) + expect(isMetadataTarget('http://[fd00:ec2::254]/latest/meta-data/')).toBeTruthy(); + expect(isMetadataTarget('http://[fd00:ec2:0:0:0:0:0:254]/')).toBeTruthy(); + }); + + it('blocks IPv4-mapped IPv6 forms of metadata IPs across equivalent inputs', () => { + // ::ffff:a.b.c.d maps to the IPv4 service at the socket layer, so every + // equivalent spelling must be blocked. Assert the decision (truthy host + // returned), not the exact canonical string, so a WHATWG-URL normalization + // change can't fail this without a real behavior regression. + expect(isMetadataTarget('http://[::ffff:169.254.169.254]/latest/meta-data/')).toBeTruthy(); + expect(isMetadataTarget('http://[::ffff:169.254.170.2]/')).toBeTruthy(); + expect(isMetadataTarget('http://[0:0:0:0:0:ffff:100.100.100.200]/')).toBeTruthy(); + }); + + it('pins the canonical form of an IPv4-mapped IPv6 metadata host', () => { + // The single test that locks the exact canonicalization output, so an + // accidental change to canonicalHost's rendering is caught deliberately. + expect(isMetadataTarget('http://[::ffff:169.254.169.254]/latest/meta-data/')).toBe('[::ffff:a9fe:a9fe]'); + }); + + it('allows a non-metadata IPv4-mapped IPv6 host', () => { + expect(isMetadataTarget('http://[::ffff:93.184.216.34]/')).toBeNull(); + }); + + it('blocks known metadata hostnames case-insensitively and with a trailing dot', () => { + expect(isMetadataTarget('http://metadata.google.internal/computeMetadata/v1/')).toBe('metadata.google.internal'); + // the URL parser lowercases and preserves the trailing dot in the returned hostname + expect(isMetadataTarget('http://Metadata.Google.Internal./')).toBe('metadata.google.internal.'); + expect(isMetadataTarget('http://metadata.goog/')).toBe('metadata.goog'); + }); + + it('does NOT resolve DNS — a benign-looking hostname passes the literal pre-check', () => { + // The request-time pre-check is literal-only by design; a hostname that + // could rebind to a metadata IP is intentionally NOT blocked here (it is + // caught at the response stage via isMetadataIP on the connected IP). + expect(isMetadataTarget('http://rebind.attacker.example/')).toBeNull(); + }); + + it('allows loopback hosts', () => { + expect(isMetadataTarget('http://localhost:3000/')).toBeNull(); + expect(isMetadataTarget('http://127.0.0.1:8080/')).toBeNull(); + expect(isMetadataTarget('http://[::1]/')).toBeNull(); + }); + + it('allows RFC1918 private hosts and normal public hosts', () => { + expect(isMetadataTarget('http://192.168.1.10:3000/')).toBeNull(); + expect(isMetadataTarget('http://10.0.0.5/')).toBeNull(); + expect(isMetadataTarget('https://example.com/index.html')).toBeNull(); + }); + + it('returns null for unparseable URLs', () => { + expect(isMetadataTarget('not a url')).toBeNull(); + }); + }); + + describe('isMetadataIP', () => { + it('blocks the literal connected metadata IPs (no DNS needed)', () => { + // This is the authoritative response/connection-stage gate: it inspects the + // IP actually connected to, so it closes the DNS-rebinding leg that the + // literal pre-check cannot. + expect(isMetadataIP('169.254.169.254')).toBe('169.254.169.254'); + expect(isMetadataIP('169.254.170.2')).toBe('169.254.170.2'); + expect(isMetadataIP('100.100.100.200')).toBe('100.100.100.200'); + expect(isMetadataIP('fd00:ec2::254')).toBeTruthy(); + }); + + it('blocks an IPv4-mapped IPv6 connected address for a metadata IP', () => { + // Chromium/Node can report the connected IP in IPv4-mapped IPv6 form; it + // routes to the IPv4 metadata service and must still be blocked. + expect(isMetadataIP('::ffff:169.254.169.254')).toBeTruthy(); + }); + + it('allows loopback, RFC1918 and public connected IPs', () => { + expect(isMetadataIP('127.0.0.1')).toBeNull(); + expect(isMetadataIP('::1')).toBeNull(); + expect(isMetadataIP('10.0.0.5')).toBeNull(); + expect(isMetadataIP('192.168.1.10')).toBeNull(); + expect(isMetadataIP('93.184.216.34')).toBeNull(); + }); + + it('returns null for an empty/absent connected address', () => { + expect(isMetadataIP('')).toBeNull(); + expect(isMetadataIP(undefined)).toBeNull(); + }); + }); + + describe('assertNotMetadataTarget', () => { + it('throws a clear error for a metadata endpoint', () => { + expect(() => assertNotMetadataTarget('http://169.254.169.254/')) + .toThrowError('Refusing to navigate to cloud metadata endpoint: 169.254.169.254'); + }); + + it('does not throw for a normal host', () => { + expect(() => assertNotMetadataTarget('https://example.com/')).not.toThrow(); + }); + }); + describe('rewriteLocalhostURL', () => { it('rewrites localhost and 127.0.0.1 origins to render.percy.local', () => { expect(rewriteLocalhostURL('http://localhost:8000/a.html')).toBe('http://render.percy.local/a.html');