diff --git a/packages/core/src/discovery.js b/packages/core/src/discovery.js index cf4f5c6c0..29767357b 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 + await 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..730346df9 100644 --- a/packages/core/src/network.js +++ b/packages/core/src/network.js @@ -1,7 +1,7 @@ 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 { AbortError, DefaultMap, createResource, hostnameMatches, normalizeURL, waitFor, decodeAndEncodeURLWithLogging, handleIncorrectFontMimeType, executeDomainValidation, isMetadataTarget } 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 @@ -578,7 +578,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]; @@ -695,6 +696,21 @@ async function sendResponseResource(network, request, session) { responseHeaders: Object.entries(resource.headers || {}) .map(([k, v]) => ({ name: k.toLowerCase(), value: String(v) })) }); + } else if (await isMetadataTarget(url)) { + // Block SSRF pivots to cloud instance-metadata endpoints before issuing a + // real outbound request. Cache hits and root resources are served above + // and never reach here, so loopback/RFC1918 snapshotting is unaffected. + logAssetInstrumentation(log, 'asset_not_uploaded', 'metadata_endpoint_blocked', { + url, + hostname: new URL(url).hostname, + snapshot: meta.snapshot + }); + log.warn(`Refusing to fetch resource from cloud metadata endpoint: ${new URL(url).hostname}`, 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', { diff --git a/packages/core/src/utils.js b/packages/core/src/utils.js index bdc206587..ba9531882 100644 --- a/packages/core/src/utils.js +++ b/packages/core/src/utils.js @@ -4,6 +4,8 @@ import { camelcase, merge } from '@percy/config/utils'; import YAML from 'yaml'; import path from 'path'; import url from 'url'; +import net from 'net'; +import dns from 'dns'; import { readFileSync } from 'fs'; import logger from '@percy/logger'; import DetectProxy from '@percy/client/detect-proxy'; @@ -57,6 +59,89 @@ 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, and normalizes IPv6 addresses to their compressed form (so +// e.g. fd00:ec2:0:0:0:0:0:254 and fd00:ec2::254 compare equal). +function canonicalHost(host) { + if (!host) return host; + let h = String(host).toLowerCase().replace(/\.$/, ''); + let bare = h.replace(/^\[/, '').replace(/\]$/, ''); + + if (bare.includes(':')) { + try { + return new URL(`http://[${bare}]/`).hostname.replace(/^\[/, '').replace(/\]$/, ''); + } catch { + return bare; + } + } + + return bare; +} + +// Returns the offending host string when the URL points at a known cloud +// instance-metadata endpoint, otherwise null. Matches literal metadata IPs +// (IPv4 and IPv6) and metadata hostnames, and — to defeat DNS rebinding — also +// resolves hostnames and matches when any resolved address is a metadata IP. +// DNS resolution failures are intentionally non-fatal (only a positive +// metadata match blocks) so offline/localhost snapshots are unaffected. +export async 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; + } + + let canon = canonicalHost(host); + if (METADATA_IPS.has(canon) || METADATA_HOSTNAMES.has(canon)) return host; + + // Resolving an IP literal is pointless, and resolving arbitrary hostnames + // must stay best-effort, so only look up non-IP hosts. + if (net.isIP(canon)) return null; + + try { + let addresses = await dns.promises.lookup(canon, { all: true }); + for (let { address } of addresses) { + if (METADATA_IPS.has(canonicalHost(address))) return host; + } + } catch { + // Non-fatal: unresolvable hosts (offline, localhost-only, private DNS) are + // left alone. Only a positive metadata match blocks a request. + } + + return null; +} + +// 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 async function assertNotMetadataTarget(rawUrl) { + let host = await 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..550531123 100644 --- a/packages/core/test/discovery.test.js +++ b/packages/core/test/discovery.test.js @@ -1109,6 +1109,35 @@ 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('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/utils.test.js b/packages/core/test/utils.test.js index 22349823c..303a00a37 100644 --- a/packages/core/test/utils.test.js +++ b/packages/core/test/utils.test.js @@ -1,5 +1,6 @@ -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, assertNotMetadataTarget } from '../src/utils.js'; import { logger, setupTest, mockRequests } from './helpers/index.js'; +import dns from 'dns'; import percyLogger from '@percy/logger'; import Percy from '@percy/core'; import Pako from 'pako'; @@ -828,6 +829,75 @@ describe('utils', () => { }); }); + describe('isMetadataTarget', () => { + it('blocks literal cloud metadata IPs', async () => { + expect(await isMetadataTarget('http://169.254.169.254/latest/meta-data/')).toBe('169.254.169.254'); + expect(await isMetadataTarget('http://169.254.170.2/v2/credentials')).toBe('169.254.170.2'); + expect(await isMetadataTarget('http://100.100.100.200/latest/meta-data/')).toBe('100.100.100.200'); + }); + + it('blocks the AWS IMDS IPv6 endpoint regardless of formatting', async () => { + // the URL parser normalizes IPv6 hostnames to their compressed, bracketed form + expect(await isMetadataTarget('http://[fd00:ec2::254]/latest/meta-data/')).toBe('[fd00:ec2::254]'); + expect(await isMetadataTarget('http://[fd00:ec2:0:0:0:0:0:254]/')).toBe('[fd00:ec2::254]'); + }); + + it('blocks known metadata hostnames case-insensitively and with a trailing dot', async () => { + expect(await 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(await isMetadataTarget('http://Metadata.Google.Internal./')).toBe('metadata.google.internal.'); + expect(await isMetadataTarget('http://metadata.goog/')).toBe('metadata.goog'); + }); + + it('blocks hostnames that resolve to a metadata IP (DNS rebinding)', async () => { + spyOn(dns.promises, 'lookup').and.resolveTo([{ address: '169.254.169.254', family: 4 }]); + expect(await isMetadataTarget('http://rebind.attacker.example/')).toBe('rebind.attacker.example'); + }); + + it('allows loopback hosts', async () => { + spyOn(dns.promises, 'lookup').and.resolveTo([{ address: '127.0.0.1', family: 4 }, { address: '::1', family: 6 }]); + expect(await isMetadataTarget('http://localhost:3000/')).toBeNull(); + expect(await isMetadataTarget('http://127.0.0.1:8080/')).toBeNull(); + expect(await isMetadataTarget('http://[::1]/')).toBeNull(); + }); + + it('allows RFC1918 private hosts and normal public hosts', async () => { + spyOn(dns.promises, 'lookup').and.resolveTo([{ address: '93.184.216.34', family: 4 }]); + expect(await isMetadataTarget('http://192.168.1.10:3000/')).toBeNull(); + expect(await isMetadataTarget('http://10.0.0.5/')).toBeNull(); + expect(await isMetadataTarget('https://example.com/index.html')).toBeNull(); + }); + + it('does not block when DNS resolution fails', async () => { + spyOn(dns.promises, 'lookup').and.rejectWith(new Error('ENOTFOUND')); + expect(await isMetadataTarget('http://offline.internal.example/')).toBeNull(); + }); + + it('does not block when a resolved address cannot be canonicalized', async () => { + // a resolved address that looks IPv6-ish (contains colons) but is not a + // valid IPv6 literal exercises the canonicalization fallback and must not + // match a metadata IP + spyOn(dns.promises, 'lookup').and.resolveTo([{ address: 'not:a:valid::ipv6::x', family: 6 }]); + expect(await isMetadataTarget('http://weird.example/')).toBeNull(); + }); + + it('returns null for unparseable URLs', async () => { + expect(await isMetadataTarget('not a url')).toBeNull(); + }); + }); + + describe('assertNotMetadataTarget', () => { + it('throws a clear error for a metadata endpoint', async () => { + await expectAsync(assertNotMetadataTarget('http://169.254.169.254/')) + .toBeRejectedWithError('Refusing to navigate to cloud metadata endpoint: 169.254.169.254'); + }); + + it('resolves for a normal host', async () => { + spyOn(dns.promises, 'lookup').and.resolveTo([{ address: '93.184.216.34', family: 4 }]); + await expectAsync(assertNotMetadataTarget('https://example.com/')).toBeResolved(); + }); + }); + 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');