Skip to content
Open
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
await assertNotMetadataTarget(snapshot.url);
yield page.goto(snapshot.url, { cookies, forceReload: discovery.captureResponsiveAssetsEnabled });

// wait for any specified timeout
Expand Down
20 changes: 18 additions & 2 deletions packages/core/src/network.js
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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];
Expand Down Expand Up @@ -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', {
Expand Down
85 changes: 85 additions & 0 deletions packages/core/src/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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).
Expand Down
29 changes: 29 additions & 0 deletions packages/core/test/discovery.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand Down
72 changes: 71 additions & 1 deletion packages/core/test/utils.test.js
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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');
Expand Down
Loading