🎩 You're Invited:Meet the Socket team at Black Hat in Las Vegas, August 3-6.RSVP
Sign In

@percy/core

Package Overview
Dependencies
Maintainers
1
Versions
376
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@percy/core - npm Package Compare versions

Comparing version
1.32.4
to
1.32.5-beta.0
+101
dist/comparison-upload.js
import { normalize } from '@percy/config/utils';
import { ServerError } from './server.js';
import { encodeURLSearchParams } from './utils.js';
import Busboy from 'busboy';
import { Readable } from 'stream';
/* istanbul ignore next — multipart /percy/comparison/upload handler;
exercises Busboy stream parsing + PNG magic-byte validation + base64
encoding + percy.upload. Integration-tested via the regression suite
(real multipart POST) rather than the unit suite, which would require
constructing valid multipart bodies. */
export async function handleComparisonUpload(req, res, percy) {
var _percy$build;
const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB
const PNG_MAGIC_BYTES = Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]);
let contentType = req.headers['content-type'] || '';
if (!contentType.startsWith('multipart/form-data')) {
throw new ServerError(400, 'Content-Type must be multipart/form-data');
}
if (!req.body) {
throw new ServerError(400, 'Empty request body');
}
let fields = Object.create(null);
let fileBuffer = null;
await new Promise((resolve, reject) => {
let bb = Busboy({
headers: req.headers,
limits: {
fileSize: MAX_FILE_SIZE
}
});
bb.on('file', (fieldname, stream, info) => {
let chunks = [];
stream.on('data', chunk => chunks.push(chunk));
stream.on('limit', () => {
reject(new ServerError(413, 'File size exceeds maximum of 50MB'));
});
stream.on('end', () => {
if (fieldname === 'screenshot') {
fileBuffer = Buffer.concat(chunks);
}
});
});
bb.on('field', (fieldname, value) => {
if (['name', 'tag', 'clientInfo', 'environmentInfo', 'testCase', 'labels'].includes(fieldname)) {
fields[fieldname] = value;
}
});
bb.on('close', resolve);
bb.on('error', reject);
let stream = Readable.from(req.body);
stream.on('error', reject);
stream.pipe(bb);
});
if (!fileBuffer) {
throw new ServerError(400, 'Missing required file part: screenshot');
}
if (fileBuffer.length < 8 || !fileBuffer.subarray(0, 8).equals(PNG_MAGIC_BYTES)) {
throw new ServerError(400, 'File is not a valid PNG image');
}
if (!fields.name) throw new ServerError(400, 'Missing required field: name');
if (!fields.tag) throw new ServerError(400, 'Missing required field: tag');
let tag;
try {
tag = JSON.parse(fields.tag);
} catch {
throw new ServerError(400, 'Invalid JSON in tag field');
}
let base64Content = fileBuffer.toString('base64');
let payload = {
name: fields.name,
tag,
tiles: [{
content: base64Content,
statusBarHeight: 0,
navBarHeight: 0,
headerHeight: 0,
footerHeight: 0,
fullscreen: false
}],
clientInfo: fields.clientInfo || '',
environmentInfo: fields.environmentInfo || ''
};
if (fields.testCase) payload.testCase = fields.testCase;
if (fields.labels) payload.labels = fields.labels;
let upload = percy.upload(payload, null, 'app');
if (req.url.searchParams.has('await')) await upload;
let link = [percy.client.apiUrl, '/comparisons/redirect?', encodeURLSearchParams(normalize({
buildId: (_percy$build = percy.build) === null || _percy$build === void 0 ? void 0 : _percy$build.id,
snapshot: {
name: payload.name
},
tag
}, {
snake: true
}))].join('');
return res.json(200, {
success: true,
link
});
}
import { ServerError } from './server.js';
import { dump as maestroDump, firstMatch as maestroFirstMatch, SELECTOR_KEYS_WHITELIST } from './maestro-hierarchy.js';
// Three parallel region input arrays share the same per-item shape; algorithm
// semantics differ per array (regions only — ignoreRegions/considerRegions are
// implicit).
const REGION_INPUT_FIELDS = ['regions', 'ignoreRegions', 'considerRegions'];
// Validate regions input shape early (before file I/O and ADB work) so
// malformed requests don't consume resolver/relay work. Throws ServerError(400)
// on the first shape violation.
export function validateRegionInputs(body) {
for (let fieldName of REGION_INPUT_FIELDS) {
let input = body[fieldName];
if (input === undefined) continue;
if (!Array.isArray(input)) {
throw new ServerError(400, `${fieldName} must be an array`);
}
if (input.length > 50) {
throw new ServerError(400, `${fieldName} exceeds maximum of 50`);
}
for (let [idx, region] of input.entries()) {
if (region && region.element !== undefined) {
if (typeof region.element !== 'object' || region.element === null || Array.isArray(region.element)) {
throw new ServerError(400, `${fieldName}[${idx}].element must be an object`);
}
let keys = Object.keys(region.element);
if (keys.length !== 1) {
throw new ServerError(400, `${fieldName}[${idx}].element must have exactly one selector key`);
}
let [key] = keys;
if (!SELECTOR_KEYS_WHITELIST.includes(key)) {
throw new ServerError(400, `${fieldName}[${idx}].element: unsupported selector key "${key}" (allowed: ${SELECTOR_KEYS_WHITELIST.join(', ')})`);
}
let value = region.element[key];
if (typeof value !== 'string' || value.length === 0) {
throw new ServerError(400, `${fieldName}[${idx}].element.${key} must be a non-empty string`);
}
if (value.length > 512) {
throw new ServerError(400, `${fieldName}[${idx}].element.${key} exceeds maximum length of 512`);
}
}
}
}
}
// ───────────────────────────────────────────────────────────────────
// REGIONS — end-to-end architecture
// ───────────────────────────────────────────────────────────────────
//
// Regions tell Percy's diff engine which parts of a mobile screenshot
// to ignore / consider / layout-compare. Two ways to specify one:
//
// 1. Coordinate region — caller already knows the pixel rectangle.
// Shape: { top, left, right, bottom }. Forwarded as-is after
// transform to `{x, y, width, height}` boundingBox.
//
// 2. Element region — caller knows a selector (`resource-id`, `text`,
// `content-desc`, `class`, `id`) but not the on-screen bounds.
// Resolved at relay-time against the live device's view hierarchy.
//
// ── Data flow (element region case) ────────────────────────────────
//
// SDK (percy-screenshot.js)
// │ POST /percy/maestro-screenshot
// │ { name, sessionId, platform, regions:[{element:{...}}], ... }
// ▼
// Relay (maestro-screenshot.js → resolveRegions)
// │ validate selector shape (SELECTOR_KEYS_WHITELIST)
// │ maestroDump({ platform, sessionId, grpcClientCache }) ← lazy + memoized per request
// │ │
// │ ├─ Android cascade (maestro-hierarchy.js)
// │ │ gRPC primary → maestro-CLI → adb uiautomator
// │ │ Three-class taxonomy: schema-class (drift bit, no
// │ │ fallback) / channel-broken (evict cache, fall back) /
// │ │ contention-class (keep cache, skip CLI → adb).
// │ │
// │ └─ iOS cascade
// │ HTTP primary (Maestro XCTestRunner /viewHierarchy)
// │ → maestro-CLI shell-out. AUT-root detection skips
// │ SpringBoard frames.
// │
// │ firstMatch(nodes, selector) → bbox or null (warn-skip).
// │ payload.regions[i].elementSelector.boundingBox = bbox
// ▼
// Percy backend — compares masked regions across builds.
//
// ── Observability ──────────────────────────────────────────────────
//
// /percy/healthcheck exposes maestroHierarchyDrift per platform:
// { lastFailureClass, fallbackCount, succeededVia, code?, reason?, firstSeenAt? }
// Every primary→fallback transition also emits one info-level line:
// [percy] hierarchy: <primary> failed (<class>: <reason>) → falling back to <next>
//
// ── Failure shape ──────────────────────────────────────────────────
//
// Element regions degrade gracefully: resolver failure → warn-skip
// those regions only; the snapshot itself still uploads. Coordinate
// regions don't depend on the resolver and always pass through.
//
// ───────────────────────────────────────────────────────────────────
// Resolve all region inputs to comparison-payload fragments. Returns an object
// with only the populated keys among { regions, ignoredElementsData,
// consideredElementsData }; merge it into the comparison payload. The hierarchy
// dump and the warn-once flag are request-scoped (call-local) — one dump per
// request, one warn-once skip notice.
export async function resolveRegions({
body,
platform,
sessionId,
percy
}) {
let out = {};
let cachedDump = null;
let elementSkipWarned = false;
const totalElementRegionCount = REGION_INPUT_FIELDS.reduce((sum, f) => {
let arr = body[f];
return sum + (Array.isArray(arr) ? arr.filter(r => r && r.element).length : 0);
}, 0);
// Resolve one region input to {x, y, width, height}, or null when the
// region is invalid or the resolver couldn't match it. Mutates the
// shared cachedDump / warn-flag state above.
async function resolveBbox(region) {
if (region.top != null && region.bottom != null && region.left != null && region.right != null) {
return {
x: region.left,
y: region.top,
width: region.right - region.left,
height: region.bottom - region.top
};
}
/* istanbul ignore else — region.element false branch falls through
to the istanbul-ignored "Invalid region format" warn below. */
if (region.element) {
/* istanbul ignore else — cachedDump === null only on first
element-region per request; subsequent regions hit the cache. */
if (cachedDump === null) {
// Thread the per-Percy gRPC client cache so the Android gRPC
// primary path can reuse channels across snapshots in the same
// session (D9 of 2026-05-07-002 plan). iOS path ignores it.
cachedDump = await maestroDump({
platform,
sessionId,
grpcClientCache: percy.grpcClientCache
});
}
/* istanbul ignore else — branch where dump resolves to hierarchy is
happy-path element-region territory, integration-tested only. */
if (cachedDump.kind !== 'hierarchy') {
/* istanbul ignore else — elementSkipWarned latches after first
warn; second+ iterations take the no-op branch. */
if (!elementSkipWarned) {
percy.log.warn(`Element-region resolver ${cachedDump.kind} (${cachedDump.reason}) — skipping ${totalElementRegionCount} element regions`);
elementSkipWarned = true;
}
return null;
}
/* istanbul ignore next */
let bbox = maestroFirstMatch(cachedDump.nodes, region.element);
/* istanbul ignore next */
if (!bbox) {
percy.log.warn(`Element region not found: ${JSON.stringify(region.element)} — skipping`);
return null;
}
/* istanbul ignore next — element-region happy path requires a
non-stub maestroDump returning hierarchy nodes; unit tests run
with stubbed resolver (env-missing), happy path covered by the
cross-platform-parity integration harness against fixture data. */
return bbox;
}
/* istanbul ignore next */
percy.log.warn('Invalid region format, skipping');
/* istanbul ignore next — region shape is validated upstream by the
SDK before posting; this is a defensive catch-all for regions that
lack both coordinate fields AND an element selector. */
return null;
}
// regions[]: comparison-shape items with algorithm. Default algorithm is
// 'ignore' (back-compat with SDK ≤ 0.3).
if (Array.isArray(body.regions)) {
let resolvedRegions = [];
for (let region of body.regions) {
let bbox = await resolveBbox(region);
if (!bbox) continue;
let resolved = {
elementSelector: {
boundingBox: bbox
},
algorithm: region.algorithm || 'ignore'
};
/* istanbul ignore if — region.configuration optional field; only
passed when SDK opts in to per-region config overrides. */
if (region.configuration) resolved.configuration = region.configuration;
/* istanbul ignore if — region.padding optional field. */
if (region.padding) resolved.padding = region.padding;
/* istanbul ignore if — region.assertion optional field. */
if (region.assertion) resolved.assertion = region.assertion;
resolvedRegions.push(resolved);
}
/* istanbul ignore else — empty resolvedRegions branch only fires when
ALL regions failed to resolve; happy path resolves at least one. */
if (resolvedRegions.length > 0) out.regions = resolvedRegions;
}
// ignoreRegions[] and considerRegions[]: parallel top-level payload
// fields. Each item is shaped per regionsSchema (config.js:792) —
// { coOrdinates: {top, left, bottom, right} } with an optional selector
// hint preserved when the caller supplied an element selector.
const REGION_OUTPUT_MAP = {
ignoreRegions: {
payloadKey: 'ignoredElementsData',
innerKey: 'ignoreElementsData'
},
considerRegions: {
payloadKey: 'consideredElementsData',
innerKey: 'considerElementsData'
}
};
for (let [inputField, {
payloadKey,
innerKey
}] of Object.entries(REGION_OUTPUT_MAP)) {
let input = body[inputField];
if (!Array.isArray(input)) continue;
let resolved = [];
for (let region of input) {
let bbox = await resolveBbox(region);
/* istanbul ignore if — null bbox skip in ignoreRegions/considerRegions
loop; tests cover the happy path where every region resolves. */
if (!bbox) continue;
let item = {
coOrdinates: {
top: bbox.y,
left: bbox.x,
bottom: bbox.y + bbox.height,
right: bbox.x + bbox.width
}
};
/* istanbul ignore if — element selector echo on resolved region;
only fires when resolveBbox returned a bbox for an element region,
which itself is integration-test territory (see resolveBbox
above for the resolver-mock rationale). */
if (region.element) {
let [key] = Object.keys(region.element);
item.selector = `${key}=${region.element[key]}`;
}
resolved.push(item);
}
/* istanbul ignore else — empty resolved branch only fires when ALL
regions in this category failed to resolve; happy path resolves
at least one. */
if (resolved.length > 0) out[payloadKey] = {
[innerKey]: resolved
};
}
return out;
}
import fs from 'fs';
import path from 'path';
import { ServerError } from './server.js';
/* istanbul ignore next — defensive manual directory walker invoked only when
fast-glob import fails (broken install / FS corruption). Unit tests
exercise the primary glob path; integration tests on BS hosts exercise
the walker against real session layouts. Path-traversal sinks inside this
function are suppressed at file level in .semgrepignore with the same
rationale (upstream SAFE_ID validation, depth cap, exact filename match). */
async function manualScreenshotWalk(platform, sessionId, name) {
const files = [];
try {
if (platform === 'ios') {
const sessionDir = `/tmp/${sessionId}`;
const walk = async (dir, depth) => {
if (depth > 15) return; // sanity cap
let entries;
try {
entries = await fs.promises.readdir(dir, {
withFileTypes: true
});
} catch {
return;
}
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
await walk(full, depth + 1);
} else if (entry.isFile() && entry.name === `${name}.png` && full.includes('_maestro_debug_')) {
files.push(full);
}
}
};
await walk(sessionDir, 0);
} else {
const baseDir = `/tmp/${sessionId}_test_suite/logs`;
const logDirs = await fs.promises.readdir(baseDir);
for (const dir of logDirs) {
const screenshotPath = path.join(baseDir, dir, 'screenshots', `${name}.png`);
try {
await fs.promises.access(screenshotPath);
files.push(screenshotPath);
} catch {/* not found, continue */}
}
}
} catch {/* base dir not found */}
return files;
}
// Locate the screenshot on disk and confirm it lives under `scopeRoot`. Three
// paths converge on `chosenFile`:
// 1. `filePath` supplied (BrowserStack new SDK — absolute path under the BS
// session root; rejected upstream in self-hosted mode).
// 2. BrowserStack glob (the BS-infra SCREENSHOTS_DIR layout).
// 3. Self-hosted recursive glob under scopeRoot (PERCY_MAESTRO_SCREENSHOT_DIR).
// Either way, the shared realpath + scopeRoot prefix check below enforces the
// security invariant. Returns the canonicalized absolute path, or throws
// ServerError(404) when the file is missing or resolves outside scopeRoot.
// Callers pass `filePath` already shape-validated, plus the resolved `scopeRoot`
// and `selfHosted` flag.
export async function locateScreenshot({
platform,
sessionId,
name,
filePath,
scopeRoot,
selfHosted
}) {
let chosenFile;
if (filePath) {
chosenFile = filePath;
} else {
// Glob pattern depends on deployment shape:
// BrowserStack Android: /tmp/{sid}_test_suite/logs/*/screenshots/{name}.png
// BrowserStack iOS: /tmp/{sid}/<maestro_debug_dir>/**/{name}.png
// (realmobile builds a deeply nested {device}_maestro_debug_ tree; `**`
// handles any depth, exact {name}.png filters Maestro's emoji-prefixed
// debug frames, e.g. `screenshot-❌-<timestamp>-(flow).png`).
// Self-hosted: recursive glob under the customer's --test-output-dir
// (scopeRoot = PERCY_MAESTRO_SCREENSHOT_DIR). `name` is SAFE_ID-validated
// by the caller, so it cannot contain separators or traversal chars.
let searchPattern;
if (selfHosted) {
// fast-glob requires forward-slashes in patterns on every platform; on
// Windows scopeRoot contains backslashes, so normalize before embedding.
// Production-code Windows portability — verified by the CI Windows runner.
const globRoot = scopeRoot.replace(/\\/g, '/');
searchPattern = `${globRoot}/**/${name}.png`;
} else {
searchPattern = platform === 'ios' ? `/tmp/${sessionId}/*_maestro_debug_*/**/${name}.png` : `/tmp/${sessionId}_test_suite/logs/*/screenshots/${name}.png`;
}
let files;
try {
let {
default: glob
} = await import('fast-glob');
// Self-hosted needs `dot: true` because Maestro's default output dir is
// `.maestro/` — a dot-prefixed entry fast-glob hides by default. BS
// layouts have no dot-prefixed segments, so omitting it there keeps the
// byte-identical behavior.
files = await glob(searchPattern, selfHosted ? {
dot: true
} : undefined);
} catch {
// Fast-glob import / glob call failed — fall back to manual walker (BS
// only; self-hosted has no fixed-layout convention, so empty → 404 with
// the actionable PERCY_MAESTRO_SCREENSHOT_DIR guidance from the caller).
// See manualScreenshotWalk() at file top + the file-level .semgrepignore.
/* istanbul ignore next — only fires when fast-glob import throws
(broken install / FS corruption); integration-test territory. */
files = selfHosted ? [] : await manualScreenshotWalk(platform, sessionId, name);
}
if (!files || files.length === 0) {
throw new ServerError(404, `Screenshot not found: ${name}.png (searched ${searchPattern})`);
}
// If multiple files match (iOS — same name reused across flows), pick the most recently modified
// for determinism. The else branch only fires when a snapshot name
// is reused across two flows in the same session; the realmobile
// layout normally writes one file per snapshot per session, so the
// multi-match path is exercised by integration tests on BS hosts
// rather than the unit suite.
/* istanbul ignore else */
if (files.length === 1) {
chosenFile = files[0];
} else {
let mtimes = await Promise.all(files.map(async f => {
try {
return {
f,
mtime: (await fs.promises.stat(f)).mtimeMs
};
} catch {
return {
f,
mtime: 0
};
}
}));
mtimes.sort((a, b) => b.mtime - a.mtime);
chosenFile = mtimes[0].f;
}
}
// Canonicalize and confirm the resolved path still lives under scopeRoot.
// Defeats symlink swaps where the root points elsewhere. Both ends are
// realpath'd because /tmp is a symlink on macOS (where iOS hosts run). The
// trailing `/` on the prefix is load-bearing — it prevents sibling-prefix
// bypass (e.g. /x/.maestro vs /x/.maestro-secrets). Both sides are
// normalized to forward-slashes so the check works on Windows (real-fs
// returns backslashes), POSIX (no-op), and memfs (POSIX-style virtual paths).
let realPath, realPrefix;
try {
realPath = await fs.promises.realpath(chosenFile);
realPrefix = await fs.promises.realpath(scopeRoot);
} catch {
throw new ServerError(404, `Screenshot not found: ${name}.png (path resolution failed)`);
}
const realPathFwd = realPath.replace(/\\/g, '/');
const realPrefixFwd = realPrefix.replace(/\\/g, '/');
if (!realPathFwd.startsWith(`${realPrefixFwd}/`)) {
throw new ServerError(404, `Screenshot not found: ${name}.png (resolved outside ${selfHosted ? 'PERCY_MAESTRO_SCREENSHOT_DIR' : 'session dir'})`);
}
return realPath;
}
import fs from 'fs';
import path from 'path';
import { normalize } from '@percy/config/utils';
import { ServerError } from './server.js';
import { encodeURLSearchParams } from './utils.js';
import { handleSyncJob } from './snapshot.js';
import { locateScreenshot } from './maestro-screenshot-file.js';
import { validateRegionInputs, resolveRegions } from './maestro-regions.js';
import { deriveDeviceInsets } from './maestro-hierarchy.js';
// Parse PNG IHDR chunk for the screenshot's actual rendered dimensions.
// Returns { width, height } when the buffer is a valid PNG with non-zero
// dimensions, or null otherwise (non-PNG signature, truncated file, zero
// IHDR values). PNG layout per W3C spec:
// bytes 0..7 PNG signature (89 50 4E 47 0D 0A 1A 0A)
// bytes 8..15 IHDR chunk header (length + type, fixed)
// bytes 16..19 width (big-endian uint32)
// bytes 20..23 height (big-endian uint32)
// No library dependency — pure stdlib Buffer access on the bytes the relay
// has already read.
export function parsePngDimensions(buffer) {
if (!buffer || buffer.length < 24) return null;
if (buffer[0] !== 0x89 || buffer[1] !== 0x50 || buffer[2] !== 0x4E || buffer[3] !== 0x47 || buffer[4] !== 0x0D || buffer[5] !== 0x0A || buffer[6] !== 0x1A || buffer[7] !== 0x0A) {
return null;
}
const width = buffer.readUInt32BE(16);
const height = buffer.readUInt32BE(20);
if (width <= 0 || height <= 0) return null;
return {
width,
height
};
}
// Handler for `post /percy/maestro-screenshot`: post a comparison by reading
// a Maestro screenshot from disk.
export async function handleMaestroScreenshot(req, res, percy) {
var _insets, _insets2, _percy$build;
/* istanbul ignore next — req.body falsy guard; tests always pass a body. */
let {
name,
sessionId
} = req.body || {};
if (!name) throw new ServerError(400, 'Missing required field: name');
// Self-hosted vs BrowserStack is signaled by sessionId presence: BS
// host-injection always supplies it; self-hosted runs never do.
let selfHosted = !sessionId;
// Strict character-class validation — rejects path separators, shell metacharacters,
// NUL, newlines, and anything else that could confuse the glob or the filesystem.
// `name` is load-bearing for the recursive glob — must not be loosened.
const SAFE_ID = /^[a-zA-Z0-9_-]+$/;
if (!SAFE_ID.test(name)) {
throw new ServerError(400, 'Invalid screenshot name');
}
if (sessionId && !SAFE_ID.test(sessionId)) {
throw new ServerError(400, 'Invalid sessionId');
}
// Resolve platform signal: strict whitelist on `platform` when present; default Android when absent.
// Backward compatible with SDK v0.2.0 (no platform field → Android glob).
let platform = 'android';
if (req.body.platform !== undefined) {
if (typeof req.body.platform !== 'string') {
throw new ServerError(400, 'Invalid platform: must be a string');
}
let normalized = req.body.platform.toLowerCase();
if (normalized !== 'ios' && normalized !== 'android') {
throw new ServerError(400, `Invalid platform: must be "ios" or "android", got "${req.body.platform}"`);
}
platform = normalized;
}
// Optional caller-supplied absolute path. When present, the relay reads
// the file directly and skips the legacy glob — the SDK has already
// chosen the path under the BS session root. Shape errors (non-string,
// non-absolute, too long) are 400. Existence and session-root scoping
// are enforced by the shared realpath + prefix check below, which
// returns 404 — same shape as the glob path. Treat empty string as
// absent so older SDKs that emit the field unconditionally still fall
// through to the glob.
let suppliedFilePath = null;
if (req.body.filePath !== undefined && req.body.filePath !== null && req.body.filePath !== '') {
if (typeof req.body.filePath !== 'string') {
throw new ServerError(400, 'Invalid filePath: must be a string');
}
if (req.body.filePath.length > 1024) {
throw new ServerError(400, 'Invalid filePath: exceeds maximum length of 1024');
}
if (!path.isAbsolute(req.body.filePath)) {
throw new ServerError(400, 'Invalid filePath: must be an absolute path');
}
suppliedFilePath = req.body.filePath;
}
// Resolve the file-find scope root. On BrowserStack (sessionId present), the
// root is the BS host's /tmp/{sessionId}{_test_suite} convention. Self-hosted
// (sessionId absent) requires PERCY_MAESTRO_SCREENSHOT_DIR (read from
// process.env, never the request body) to be an absolute, existing directory
// — typically the customer's `maestro test --test-output-dir <DIR>` path. The
// realpath + prefix check inside locateScreenshot enforces the security
// invariant at whichever root applies; the boundary is relocated, not removed.
let scopeRoot;
if (selfHosted) {
// Reject filePath outright in self-hosted mode. The SDK never emits it (it
// sends a relative SCREENSHOT_NAME); honoring an absolute filePath against
// a caller-influenceable root would re-open arbitrary in-root reads.
if (suppliedFilePath) {
throw new ServerError(400, 'filePath is not accepted in self-hosted mode (omit it; PERCY_MAESTRO_SCREENSHOT_DIR + relative SCREENSHOT_NAME is the supported path)');
}
let dir = process.env.PERCY_MAESTRO_SCREENSHOT_DIR;
if (!dir) {
throw new ServerError(400, 'Missing required env: PERCY_MAESTRO_SCREENSHOT_DIR (set it to your `maestro test --test-output-dir` path)');
}
if (!path.isAbsolute(dir)) {
throw new ServerError(400, 'PERCY_MAESTRO_SCREENSHOT_DIR must be an absolute path');
}
// UX guard ONLY: surface an actionable 400 ("dir not found") instead of the
// opaque 404 the realpath+prefix containment check would emit for a missing
// dir. A small TOCTOU window exists between this stat and locateScreenshot's
// realpath — acceptable because realpath (not stat) is the security
// invariant: even if the dir is swapped for a symlink in between, realpath
// resolves the target and the sep-prefix check rejects anything outside it.
let stat;
try {
stat = await fs.promises.stat(dir);
} catch {
stat = null;
}
if (!stat || !stat.isDirectory()) {
throw new ServerError(400, `PERCY_MAESTRO_SCREENSHOT_DIR is not an existing directory: ${dir}`);
}
scopeRoot = dir;
} else {
scopeRoot = platform === 'ios' ? `/tmp/${sessionId}` : `/tmp/${sessionId}_test_suite`;
}
// Validate regions input shape early (before file I/O and ADB work) so
// malformed requests don't consume resolver/relay work.
validateRegionInputs(req.body);
// Locate the screenshot on disk (supplied filePath, BS session glob, or
// self-hosted PERCY_MAESTRO_SCREENSHOT_DIR recursive glob) and confirm it
// resolves under scopeRoot. Throws ServerError(404) when missing/out-of-root.
let realPath = await locateScreenshot({
platform,
sessionId,
name,
filePath: suppliedFilePath,
scopeRoot,
selfHosted
});
// Read and base64-encode the screenshot
let fileContent = await fs.promises.readFile(realPath);
let base64Content = fileContent.toString('base64');
// Parse the PNG header for actual rendered dimensions. The PNG bytes
// ARE the source of truth — what Percy stores and compares against.
// Fills tag.width/height when the customer didn't supply them (or
// supplied invalid values); customer-supplied values continue to win
// for backward compat with any flow that pins a specific tag dim.
let pngDims = parsePngDimensions(fileContent);
// Build tag from optional request body fields
let tag = req.body.tag || {
name: 'Unknown Device',
osName: 'Android'
};
/* istanbul ignore if — fallback when tag.name is missing; tests always
pass a complete tag object. */
if (!tag.name) tag.name = 'Unknown Device';
if (pngDims) {
if (typeof tag.width !== 'number' || tag.width <= 0 || isNaN(tag.width)) {
tag.width = pngDims.width;
}
if (typeof tag.height !== 'number' || tag.height <= 0 || isNaN(tag.height)) {
tag.height = pngDims.height;
}
}
// Derive exact device system-bar insets (pixels), once per session — they're
// device-constant, so the first snapshot pays one /viewHierarchy (iOS) or
// `dumpsys` (Android) call and the rest reuse the cached result (incl. a null
// "use SDK default" outcome). CLI-derived values are authoritative over the
// SDK's static defaults (those are SDK internal constants, not customer-set);
// any derivation failure falls back to the SDK value. iOS navBarHeight is
// always 0 — the home indicator is static and unmeasured, fleet-consistent.
let insets = percy.maestroInsetCache.get(sessionId);
if (insets === undefined) {
insets = await deriveDeviceInsets({
platform,
sessionId,
pngDims
});
percy.maestroInsetCache.set(sessionId, insets);
percy.log.debug(`maestro device insets (${platform}): ${JSON.stringify(insets)}`);
}
let statusBarHeight = ((_insets = insets) === null || _insets === void 0 ? void 0 : _insets.statusBarHeight) ?? (req.body.statusBarHeight || 0);
let navBarHeight = platform === 'ios' ? 0 : ((_insets2 = insets) === null || _insets2 === void 0 ? void 0 : _insets2.navBarHeight) ?? (req.body.navBarHeight || 0);
// Construct comparison payload with tile metadata from request
let payload = {
name,
tag,
tiles: [{
content: base64Content,
statusBarHeight,
navBarHeight,
headerHeight: 0,
footerHeight: 0,
fullscreen: req.body.fullscreen || false
}],
clientInfo: req.body.clientInfo || 'percy-maestro/0.1.0',
environmentInfo: req.body.environmentInfo || 'percy-maestro'
};
if (req.body.testCase) payload.testCase = req.body.testCase;
if (req.body.labels) payload.labels = req.body.labels;
if (req.body.thTestCaseExecutionId) payload.thTestCaseExecutionId = req.body.thTestCaseExecutionId;
// Resolve element/coordinate regions to comparison-payload fragments.
// Element regions degrade gracefully — a resolver failure warn-skips those
// regions only; the snapshot still uploads. The hierarchy dump is memoized
// per request inside resolveRegions. Assign the three known comparison keys
// explicitly (never Object.assign of request-derived data) so only these
// fields can ever be set here.
let regionData = await resolveRegions({
body: req.body,
platform,
sessionId,
percy
});
if (regionData.regions) payload.regions = regionData.regions;
if (regionData.ignoredElementsData) payload.ignoredElementsData = regionData.ignoredElementsData;
if (regionData.consideredElementsData) payload.consideredElementsData = regionData.consideredElementsData;
// Upload via percy — sync or fire-and-forget
if (req.body.sync === true) payload.sync = true;
let data;
if (percy.syncMode(payload)) {
// percy.upload() is the generatePromise-wrapped method: calling it drives the
// underlying async generator to completion (enqueuing #snapshots) and the sync
// queue resolves/rejects the attached callback. Do NOT `for await` the return
// value — it is a Promise, not an async iterable (that throws "upload is not
// async iterable"). The raw generator lives at percy.yield.upload() if direct
// iteration is ever needed. The trailing .catch(reject) surfaces generator
// errors that bypass the sync-queue callback (e.g. a throw before the queue task
// runs) instead of leaking an unhandled rejection and hanging the request.
const snapshotPromise = new Promise((resolve, reject) => {
percy.upload(payload, {
resolve,
reject
}, 'app').catch(reject);
});
data = await handleSyncJob(snapshotPromise, percy, 'comparison');
return res.json(200, {
success: true,
data
});
}
let upload = percy.upload(payload, null, 'app');
/* istanbul ignore if — ?await=true URL flag triggers fire-and-wait;
tests cover both syncMode and fire-and-forget but not the explicit
?await query-param variant. */
if (req.url.searchParams.has('await')) await upload;
// Generate redirect link
let link = [percy.client.apiUrl, '/comparisons/redirect?', encodeURLSearchParams(normalize({
buildId: (_percy$build = percy.build) === null || _percy$build === void 0 ? void 0 : _percy$build.id,
snapshot: {
name
},
tag
}, {
snake: true
}))].join('');
return res.json(200, {
success: true,
link
});
}
import fs from 'fs';
import path from 'path';
import AdmZip from 'adm-zip';
// stat mode constants for entry external file attributes
const IFMT = 0o170000;
const IFDIR = 0o040000;
const IFLNK = 0o120000;
// Returns the file mode of an extracted entry, with defaults matching the
// previous extract-zip behavior when the archive does not provide one
function extractedMode(mode, isDir) {
return (mode || (isDir ? 0o755 : 0o644)) & 0o777;
}
// Extracts a zip archive into a directory, preserving file modes, directory
// entries, and symlinks. Replaces the unmaintained extract-zip package, whose
// yauzl@2 dependency deadlocks on deflate entries under Node 26
// (https://github.com/thejoshwolfe/yauzl/issues/176). adm-zip inflates
// synchronously so it is unaffected; its own extractAllTo is not used because
// it writes symlink entries as regular files, which breaks the Chromium.app
// bundle on macOS.
export default async function unzip(archive, {
dir
}) {
if (!path.isAbsolute(dir)) {
throw new Error('Target directory is expected to be absolute');
}
await fs.promises.mkdir(dir, {
recursive: true
});
dir = await fs.promises.realpath(dir);
for (let entry of new AdmZip(archive).getEntries()) {
// skip macOS resource fork entries
if (entry.entryName.startsWith('__MACOSX/')) continue;
// `dir` is the trusted install target from install.js, not user input,
// and entry names are validated by the traversal guard below
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
let dest = path.join(dir, entry.entryName);
// guard against zip-slip
if (path.relative(dir, dest).startsWith('..')) {
throw new Error(`Out of bound path "${dest}" found while processing file ${entry.entryName}`);
}
let mode = entry.header.attr >> 16 & 0xFFFF;
let isSymlink = (mode & IFMT) === IFLNK;
let isDir = (mode & IFMT) === IFDIR || entry.isDirectory ||
// directories from some windows archivers lack mode bits
entry.header.made >> 8 === 0 && entry.header.attr === 16;
if (isDir) {
await fs.promises.mkdir(dest, {
recursive: true,
mode: extractedMode(mode, true)
});
} else {
await fs.promises.mkdir(path.dirname(dest), {
recursive: true
});
// synchronous inflate with crc validation; throws on corrupted data
let contents = entry.getData();
if (isSymlink) {
await fs.promises.symlink(contents.toString('utf8'), dest);
} else {
await fs.promises.writeFile(dest, contents, {
mode: extractedMode(mode, false)
});
}
}
}
}
+42
-729

@@ -5,9 +5,9 @@ import fs from 'fs';

import { normalize } from '@percy/config/utils';
import { getPackageJSON, Server, percyAutomateRequestHandler, percyBuildEventHandler, computeResponsiveWidths } from './utils.js';
import { ServerError } from './server.js';
import { getPackageJSON, Server, percyAutomateRequestHandler, percyBuildEventHandler, computeResponsiveWidths, encodeURLSearchParams } from './utils.js';
import { ServerError, isLoopbackOrigin } from './server.js';
import WebdriverUtils from '@percy/webdriver-utils';
import { handleSyncJob } from './snapshot.js';
import { dump as maestroDump, firstMatch as maestroFirstMatch, SELECTOR_KEYS_WHITELIST, getMaestroHierarchyDrift } from './maestro-hierarchy.js';
import Busboy from 'busboy';
import { Readable } from 'stream';
import { getMaestroHierarchyDrift } from './maestro-hierarchy.js';
import { handleComparisonUpload } from './comparison-upload.js';
import { handleMaestroScreenshot } from './maestro-screenshot.js';
// Previously, we used `createRequire(import.meta.url).resolve` to resolve the path to the module.

@@ -35,7 +35,2 @@ // This approach relied on `createRequire`, which is Node.js-specific and less compatible with modern ESM (ECMAScript Module) standards.

// Returns a URL encoded string of nested query params
function encodeURLSearchParams(subj, prefix) {
return typeof subj === 'object' ? Object.entries(subj).map(([key, value]) => encodeURLSearchParams(value, prefix ? `${prefix}[${key}]` : key)).join('&') : `${prefix}=${encodeURIComponent(subj)}`;
}
// Walks the config schema and collects dot-paths of any fields marked `httpReadOnly: true`

@@ -92,168 +87,16 @@ // that are present in `body`. Driving this from the schema means new HTTP-blocked fields

// Parse PNG IHDR chunk for the screenshot's actual rendered dimensions.
// Returns { width, height } when the buffer is a valid PNG with non-zero
// dimensions, or null otherwise (non-PNG signature, truncated file, zero
// IHDR values). PNG layout per W3C spec:
// bytes 0..7 PNG signature (89 50 4E 47 0D 0A 1A 0A)
// bytes 8..15 IHDR chunk header (length + type, fixed)
// bytes 16..19 width (big-endian uint32)
// bytes 20..23 height (big-endian uint32)
// No library dependency — pure stdlib Buffer access on the bytes the relay
// has already read.
export function parsePngDimensions(buffer) {
if (!buffer || buffer.length < 24) return null;
if (buffer[0] !== 0x89 || buffer[1] !== 0x50 || buffer[2] !== 0x4E || buffer[3] !== 0x47 || buffer[4] !== 0x0D || buffer[5] !== 0x0A || buffer[6] !== 0x1A || buffer[7] !== 0x0A) {
return null;
// Reject state-changing requests that carry a cross-origin (non-loopback) Origin
// header. The local server is unauthenticated by design (SDKs post to it), so
// this blocks a malicious website the user is visiting from driving sensitive
// endpoints — live config mutation or stopping the build — via the browser
// (CWE-352 / CWE-306, PER-8600/8601). Node SDK clients send no Origin header and
// are unaffected; loopback browser tooling (any localhost port) is allowed.
function assertNotCrossOrigin(req) {
let origin = req.headers.origin;
if (origin && !isLoopbackOrigin(origin)) {
throw new ServerError(403, 'Cross-origin requests are not allowed on this endpoint');
}
const width = buffer.readUInt32BE(16);
const height = buffer.readUInt32BE(20);
if (width <= 0 || height <= 0) return null;
return {
width,
height
};
}
// Create a Percy CLI API server instance
/* istanbul ignore next — defensive manual directory walker invoked only when
fast-glob import fails (broken install / FS corruption). Unit tests
exercise the primary glob path; integration tests on BS hosts exercise
the walker against real session layouts. Path-traversal sinks inside this
function are suppressed at file level in .semgrepignore with the same
rationale (upstream SAFE_ID validation, depth cap, exact filename match). */
async function manualScreenshotWalk(platform, sessionId, name) {
const files = [];
try {
if (platform === 'ios') {
const sessionDir = `/tmp/${sessionId}`;
const walk = async (dir, depth) => {
if (depth > 15) return; // sanity cap
let entries;
try {
entries = await fs.promises.readdir(dir, {
withFileTypes: true
});
} catch {
return;
}
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
await walk(full, depth + 1);
} else if (entry.isFile() && entry.name === `${name}.png` && full.includes('_maestro_debug_')) {
files.push(full);
}
}
};
await walk(sessionDir, 0);
} else {
const baseDir = `/tmp/${sessionId}_test_suite/logs`;
const logDirs = await fs.promises.readdir(baseDir);
for (const dir of logDirs) {
const screenshotPath = path.join(baseDir, dir, 'screenshots', `${name}.png`);
try {
await fs.promises.access(screenshotPath);
files.push(screenshotPath);
} catch {/* not found, continue */}
}
}
} catch {/* base dir not found */}
return files;
}
/* istanbul ignore next — multipart /percy/comparison/upload handler;
exercises Busboy stream parsing + PNG magic-byte validation + base64
encoding + percy.upload. Integration-tested via the regression suite
(real multipart POST) rather than the unit suite, which would require
constructing valid multipart bodies. */
async function handleComparisonUpload(req, res, percy) {
var _percy$build;
const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB
const PNG_MAGIC_BYTES = Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]);
let contentType = req.headers['content-type'] || '';
if (!contentType.startsWith('multipart/form-data')) {
throw new ServerError(400, 'Content-Type must be multipart/form-data');
}
if (!req.body) {
throw new ServerError(400, 'Empty request body');
}
let fields = Object.create(null);
let fileBuffer = null;
await new Promise((resolve, reject) => {
let bb = Busboy({
headers: req.headers,
limits: {
fileSize: MAX_FILE_SIZE
}
});
bb.on('file', (fieldname, stream, info) => {
let chunks = [];
stream.on('data', chunk => chunks.push(chunk));
stream.on('limit', () => {
reject(new ServerError(413, 'File size exceeds maximum of 50MB'));
});
stream.on('end', () => {
if (fieldname === 'screenshot') {
fileBuffer = Buffer.concat(chunks);
}
});
});
bb.on('field', (fieldname, value) => {
if (['name', 'tag', 'clientInfo', 'environmentInfo', 'testCase', 'labels'].includes(fieldname)) {
fields[fieldname] = value;
}
});
bb.on('close', resolve);
bb.on('error', reject);
let stream = Readable.from(req.body);
stream.on('error', reject);
stream.pipe(bb);
});
if (!fileBuffer) {
throw new ServerError(400, 'Missing required file part: screenshot');
}
if (fileBuffer.length < 8 || !fileBuffer.subarray(0, 8).equals(PNG_MAGIC_BYTES)) {
throw new ServerError(400, 'File is not a valid PNG image');
}
if (!fields.name) throw new ServerError(400, 'Missing required field: name');
if (!fields.tag) throw new ServerError(400, 'Missing required field: tag');
let tag;
try {
tag = JSON.parse(fields.tag);
} catch {
throw new ServerError(400, 'Invalid JSON in tag field');
}
let base64Content = fileBuffer.toString('base64');
let payload = {
name: fields.name,
tag,
tiles: [{
content: base64Content,
statusBarHeight: 0,
navBarHeight: 0,
headerHeight: 0,
footerHeight: 0,
fullscreen: false
}],
clientInfo: fields.clientInfo || '',
environmentInfo: fields.environmentInfo || ''
};
if (fields.testCase) payload.testCase = fields.testCase;
if (fields.labels) payload.labels = fields.labels;
let upload = percy.upload(payload, null, 'app');
if (req.url.searchParams.has('await')) await upload;
let link = [percy.client.apiUrl, '/comparisons/redirect?', encodeURLSearchParams(normalize({
buildId: (_percy$build = percy.build) === null || _percy$build === void 0 ? void 0 : _percy$build.id,
snapshot: {
name: payload.name
},
tag
}, {
snake: true
}))].join('');
return res.json(200, {
success: true,
link
});
}
export function createPercyServer(percy, port) {

@@ -275,4 +118,9 @@ let pkg = getPackageJSON(import.meta.url);

// add version header
res.setHeader('Access-Control-Expose-Headers', '*, X-Percy-Core-Version');
// Expose headers only to loopback origins, consistent with the CORS
// handler in server.js. Access-Control-Expose-Headers is inert without a
// matching Access-Control-Allow-Origin, so gating it here keeps the two
// layers aligned rather than emitting it unconditionally.
if (isLoopbackOrigin(req.headers.origin)) {
res.setHeader('Access-Control-Expose-Headers', '*, X-Percy-Core-Version');
}

@@ -305,4 +153,13 @@ // skip or change api version header in testing mode

// Block cross-origin browser requests to every state-changing route at a
// single choke point. CORS-safelisted content types (text/plain, form
// data) reach handlers with no preflight, but a cross-origin request
// always carries an Origin header, so gating every non-safe method here
// covers all present and future mutating routes. Read-only methods and
// same-host / no-Origin SDK callers are unaffected.
// return json errors
return next().catch(e => {
return Promise.resolve().then(() => {
if (!['GET', 'HEAD', 'OPTIONS'].includes(req.method)) assertNotCrossOrigin(req);
return next();
}).catch(e => {
var _percy$testing6;

@@ -358,2 +215,3 @@ return res.json(e.status ?? 500, {

.route(['get', 'post'], '/percy/config', async (req, res) => {
// POST mutation is blocked cross-origin by the general middleware above.
let body = req.body && stripBlockedConfigFields(req.body, logger('core:server'));

@@ -421,5 +279,5 @@ return res.json(200, {

}) => {
var _percy$build2;
var _percy$build;
return [percy.client.apiUrl, '/comparisons/redirect?', encodeURLSearchParams(normalize({
buildId: (_percy$build2 = percy.build) === null || _percy$build2 === void 0 ? void 0 : _percy$build2.id,
buildId: (_percy$build = percy.build) === null || _percy$build === void 0 ? void 0 : _percy$build.id,
snapshot: {

@@ -449,550 +307,3 @@ name

// post a comparison by reading a Maestro screenshot from disk
.route('post', '/percy/maestro-screenshot', async (req, res) => {
var _percy$build3;
/* istanbul ignore next — req.body falsy guard; tests always pass a body. */
let {
name,
sessionId
} = req.body || {};
if (!name) throw new ServerError(400, 'Missing required field: name');
// Self-hosted vs BrowserStack is signaled by sessionId presence: BS
// host-injection always supplies it; self-hosted runs never do.
let selfHosted = !sessionId;
// Strict character-class validation — rejects path separators, shell metacharacters,
// NUL, newlines, and anything else that could confuse the glob or the filesystem.
// `name` is load-bearing for the recursive glob — must not be loosened.
const SAFE_ID = /^[a-zA-Z0-9_-]+$/;
if (!SAFE_ID.test(name)) {
throw new ServerError(400, 'Invalid screenshot name');
}
if (sessionId && !SAFE_ID.test(sessionId)) {
throw new ServerError(400, 'Invalid sessionId');
}
// Resolve platform signal: strict whitelist on `platform` when present; default Android when absent.
// Backward compatible with SDK v0.2.0 (no platform field → Android glob).
let platform = 'android';
if (req.body.platform !== undefined) {
if (typeof req.body.platform !== 'string') {
throw new ServerError(400, 'Invalid platform: must be a string');
}
let normalized = req.body.platform.toLowerCase();
if (normalized !== 'ios' && normalized !== 'android') {
throw new ServerError(400, `Invalid platform: must be "ios" or "android", got "${req.body.platform}"`);
}
platform = normalized;
}
// Optional caller-supplied absolute path. When present, the relay reads
// the file directly and skips the legacy glob — the SDK has already
// chosen the path under the BS session root. Shape errors (non-string,
// non-absolute, too long) are 400. Existence and session-root scoping
// are enforced by the shared realpath + prefix check below, which
// returns 404 — same shape as the glob path. Treat empty string as
// absent so older SDKs that emit the field unconditionally still fall
// through to the glob.
let suppliedFilePath = null;
if (req.body.filePath !== undefined && req.body.filePath !== null && req.body.filePath !== '') {
if (typeof req.body.filePath !== 'string') {
throw new ServerError(400, 'Invalid filePath: must be a string');
}
if (req.body.filePath.length > 1024) {
throw new ServerError(400, 'Invalid filePath: exceeds maximum length of 1024');
}
if (!path.isAbsolute(req.body.filePath)) {
throw new ServerError(400, 'Invalid filePath: must be an absolute path');
}
suppliedFilePath = req.body.filePath;
}
// Resolve the file-find scope root. On BrowserStack (sessionId present),
// the root is the BS host's /tmp/{sessionId}{_test_suite} convention.
// Self-hosted (sessionId absent) requires PERCY_MAESTRO_SCREENSHOT_DIR
// (read from process.env, never the request body) to be an absolute,
// existing directory — typically the customer's
// `maestro test --test-output-dir <DIR>` path. The realpath + prefix
// check below enforces the security invariant at whichever root applies;
// the boundary is relocated, not removed.
let scopeRoot;
if (selfHosted) {
// Reject filePath outright in self-hosted mode. The SDK never emits
// it (it sends a relative SCREENSHOT_NAME); honoring an absolute
// filePath against a caller-influenceable root would re-open
// arbitrary in-root reads.
if (suppliedFilePath) {
throw new ServerError(400, 'filePath is not accepted in self-hosted mode (omit it; PERCY_MAESTRO_SCREENSHOT_DIR + relative SCREENSHOT_NAME is the supported path)');
}
let dir = process.env.PERCY_MAESTRO_SCREENSHOT_DIR;
if (!dir) {
throw new ServerError(400, 'Missing required env: PERCY_MAESTRO_SCREENSHOT_DIR (set it to your `maestro test --test-output-dir` path)');
}
if (!path.isAbsolute(dir)) {
throw new ServerError(400, 'PERCY_MAESTRO_SCREENSHOT_DIR must be an absolute path');
}
// UX guard ONLY: surface an actionable 400 ("dir not found") instead
// of the opaque 404 the realpath+prefix containment check below would
// emit for a missing dir. There is a small TOCTOU window between this
// stat and the realpath at line 647 — that window is acceptable here
// because realpath (not stat) is the security invariant: even if the
// dir is replaced with a symlink in between, realpath resolves the
// target and the sep-prefix check rejects anything outside scopeRoot.
let stat;
try {
stat = await fs.promises.stat(dir);
} catch {
stat = null;
}
if (!stat || !stat.isDirectory()) {
throw new ServerError(400, `PERCY_MAESTRO_SCREENSHOT_DIR is not an existing directory: ${dir}`);
}
scopeRoot = dir;
} else {
scopeRoot = platform === 'ios' ? `/tmp/${sessionId}` : `/tmp/${sessionId}_test_suite`;
}
// Validate regions input shape early (before file I/O and ADB work) so
// malformed requests don't consume resolver/relay work. Three parallel
// input arrays share the same per-item shape; algorithm semantics differ
// per array (regions only — ignoreRegions/considerRegions are implicit).
const REGION_INPUT_FIELDS = ['regions', 'ignoreRegions', 'considerRegions'];
for (let fieldName of REGION_INPUT_FIELDS) {
let input = req.body[fieldName];
if (input === undefined) continue;
if (!Array.isArray(input)) {
throw new ServerError(400, `${fieldName} must be an array`);
}
if (input.length > 50) {
throw new ServerError(400, `${fieldName} exceeds maximum of 50`);
}
for (let [idx, region] of input.entries()) {
if (region && region.element !== undefined) {
if (typeof region.element !== 'object' || region.element === null || Array.isArray(region.element)) {
throw new ServerError(400, `${fieldName}[${idx}].element must be an object`);
}
let keys = Object.keys(region.element);
if (keys.length !== 1) {
throw new ServerError(400, `${fieldName}[${idx}].element must have exactly one selector key`);
}
let [key] = keys;
if (!SELECTOR_KEYS_WHITELIST.includes(key)) {
throw new ServerError(400, `${fieldName}[${idx}].element: unsupported selector key "${key}" (allowed: ${SELECTOR_KEYS_WHITELIST.join(', ')})`);
}
let value = region.element[key];
if (typeof value !== 'string' || value.length === 0) {
throw new ServerError(400, `${fieldName}[${idx}].element.${key} must be a non-empty string`);
}
if (value.length > 512) {
throw new ServerError(400, `${fieldName}[${idx}].element.${key} exceeds maximum length of 512`);
}
}
}
}
// Locate the screenshot on disk. Two paths converge on `chosenFile`:
// 1. `filePath` supplied (new SDK ≥ v0.4 — the SDK chose an absolute
// path under the BS session root and saved Maestro's PNG there).
// 2. Legacy glob (older SDKs — file lives at the BS-infra-chosen
// SCREENSHOTS_DIR layout). Either way, the shared realpath +
// session-root prefix check below enforces the security invariant.
let chosenFile;
if (suppliedFilePath) {
chosenFile = suppliedFilePath;
} else {
// Legacy glob. Pattern depends on platform:
// Android (BrowserStack mobile): /tmp/{sid}_test_suite/logs/*/screenshots/{name}.png
// iOS (BrowserStack realmobile): /tmp/{sid}/<maestro_debug_dir>/**/{name}.png
// realmobile builds SCREENSHOTS_DIR with literal slashes from the flow-path
// concatenation, causing Maestro to mkdir a deeply nested structure under the
// {device}_maestro_debug_ root. The `**` recursive match handles any depth.
// Exact {name}.png match at the leaf filters out Maestro's emoji-prefixed
// debug frames (e.g., `screenshot-❌-<timestamp>-(flow).png`).
let searchPattern;
if (selfHosted) {
// Self-hosted: recursive glob under the customer's --test-output-dir
// (PERCY_MAESTRO_SCREENSHOT_DIR). Recursive depth handles arbitrary
// Maestro layouts; `name` is SAFE_ID-validated above so it cannot
// contain separators or traversal characters.
//
// fast-glob requires forward-slashes in patterns on every platform
// (per its docs: "Always use forward-slashes in glob expressions").
// On Windows the scopeRoot from path.resolve contains backslashes,
// so we normalize before embedding into the pattern. Production-
// code Windows portability — verified by the CI Windows runner.
const globRoot = scopeRoot.replace(/\\/g, '/');
searchPattern = `${globRoot}/**/${name}.png`;
} else {
searchPattern = platform === 'ios' ? `/tmp/${sessionId}/*_maestro_debug_*/**/${name}.png` : `/tmp/${sessionId}_test_suite/logs/*/screenshots/${name}.png`;
}
let files;
try {
let {
default: glob
} = await import('fast-glob');
// Self-hosted needs `dot: true` because Maestro's default output
// directory is `.maestro/` — a dot-prefixed entry that fast-glob
// hides by default. BS layouts have no dot-prefixed segments, so
// omitting the option there keeps the byte-identical behavior.
files = await glob(searchPattern, selfHosted ? {
dot: true
} : undefined);
} catch {
// Fast-glob import / glob call failed — fall back to manual walker.
// See manualScreenshotWalk() at file top for the rationale + the
// file-level .semgrepignore covering path-traversal sinks inside.
// Self-hosted has no walker fallback (no fixed-layout convention) —
// empty files → 404 with the actionable PERCY_MAESTRO_SCREENSHOT_DIR
// guidance above.
/* istanbul ignore next — only fires when fast-glob import throws
(broken install / FS corruption); integration-test territory. */
files = selfHosted ? [] : await manualScreenshotWalk(platform, sessionId, name);
}
if (!files || files.length === 0) {
throw new ServerError(404, `Screenshot not found: ${name}.png (searched ${searchPattern})`);
}
// If multiple files match (iOS — same name reused across flows), pick the most recently modified
// for determinism. The else branch only fires when a snapshot name
// is reused across two flows in the same session; the realmobile
// layout normally writes one file per snapshot per session, so the
// multi-match path is exercised by integration tests on BS hosts
// rather than the unit suite.
/* istanbul ignore else */
if (files.length === 1) {
chosenFile = files[0];
} else {
let mtimes = await Promise.all(files.map(async f => {
try {
return {
f,
mtime: (await fs.promises.stat(f)).mtimeMs
};
} catch {
return {
f,
mtime: 0
};
}
}));
mtimes.sort((a, b) => b.mtime - a.mtime);
chosenFile = mtimes[0].f;
}
}
// Canonicalize and confirm the resolved path still lives under scopeRoot.
// Defeats symlink swaps where the root points elsewhere. Both ends are
// realpath'd because /tmp is a symlink on macOS (where iOS hosts run).
// The trailing `/` on the prefix is load-bearing — it prevents
// sibling-prefix bypass (e.g. /x/.maestro vs /x/.maestro-secrets).
//
// Normalize both sides to forward-slashes before the prefix check so
// the same code works on Windows (real-fs returns backslashes) AND on
// POSIX (no-op) AND on memfs in tests (POSIX-style virtual paths
// regardless of host OS).
let realPath, realPrefix;
try {
realPath = await fs.promises.realpath(chosenFile);
realPrefix = await fs.promises.realpath(scopeRoot);
} catch {
throw new ServerError(404, `Screenshot not found: ${name}.png (path resolution failed)`);
}
const realPathFwd = realPath.replace(/\\/g, '/');
const realPrefixFwd = realPrefix.replace(/\\/g, '/');
if (!realPathFwd.startsWith(`${realPrefixFwd}/`)) {
throw new ServerError(404, `Screenshot not found: ${name}.png (resolved outside ${selfHosted ? 'PERCY_MAESTRO_SCREENSHOT_DIR' : 'session dir'})`);
}
// Read and base64-encode the screenshot
let fileContent = await fs.promises.readFile(realPath);
let base64Content = fileContent.toString('base64');
// Parse the PNG header for actual rendered dimensions. The PNG bytes
// ARE the source of truth — what Percy stores and compares against.
// Fills tag.width/height when the customer didn't supply them (or
// supplied invalid values); customer-supplied values continue to win
// for backward compat with any flow that pins a specific tag dim.
let pngDims = parsePngDimensions(fileContent);
// Build tag from optional request body fields
let tag = req.body.tag || {
name: 'Unknown Device',
osName: 'Android'
};
/* istanbul ignore if — fallback when tag.name is missing; tests always
pass a complete tag object. */
if (!tag.name) tag.name = 'Unknown Device';
if (pngDims) {
if (typeof tag.width !== 'number' || tag.width <= 0 || isNaN(tag.width)) {
tag.width = pngDims.width;
}
if (typeof tag.height !== 'number' || tag.height <= 0 || isNaN(tag.height)) {
tag.height = pngDims.height;
}
}
// Construct comparison payload with tile metadata from request
let payload = {
name,
tag,
tiles: [{
content: base64Content,
statusBarHeight: req.body.statusBarHeight || 0,
navBarHeight: req.body.navBarHeight || 0,
headerHeight: 0,
footerHeight: 0,
fullscreen: req.body.fullscreen || false
}],
clientInfo: req.body.clientInfo || 'percy-maestro/0.1.0',
environmentInfo: req.body.environmentInfo || 'percy-maestro'
};
if (req.body.testCase) payload.testCase = req.body.testCase;
if (req.body.labels) payload.labels = req.body.labels;
if (req.body.thTestCaseExecutionId) payload.thTestCaseExecutionId = req.body.thTestCaseExecutionId;
// ───────────────────────────────────────────────────────────────────
// REGIONS — end-to-end architecture
// ───────────────────────────────────────────────────────────────────
//
// Regions tell Percy's diff engine which parts of a mobile screenshot
// to ignore / consider / layout-compare. Two ways to specify one:
//
// 1. Coordinate region — caller already knows the pixel rectangle.
// Shape: { top, left, right, bottom }. Forwarded as-is after
// transform to `{x, y, width, height}` boundingBox.
//
// 2. Element region — caller knows a selector (`resource-id`, `text`,
// `content-desc`, `class`, `id`) but not the on-screen bounds.
// Resolved at relay-time against the live device's view hierarchy.
//
// ── Data flow (element region case) ────────────────────────────────
//
// SDK (percy-screenshot.js)
// │ POST /percy/maestro-screenshot
// │ { name, sessionId, platform, regions:[{element:{...}}], ... }
// ▼
// Relay (this handler)
// │ validate selector shape (SELECTOR_KEYS_WHITELIST)
// │ maestroDump({ platform, sessionId, grpcClientCache }) ← lazy + memoized per request
// │ │
// │ ├─ Android cascade (maestro-hierarchy.js)
// │ │ gRPC primary → maestro-CLI → adb uiautomator
// │ │ Three-class taxonomy: schema-class (drift bit, no
// │ │ fallback) / channel-broken (evict cache, fall back) /
// │ │ contention-class (keep cache, skip CLI → adb).
// │ │
// │ └─ iOS cascade
// │ HTTP primary (Maestro XCTestRunner /viewHierarchy)
// │ → maestro-CLI shell-out. AUT-root detection skips
// │ SpringBoard frames.
// │
// │ firstMatch(nodes, selector) → bbox or null (warn-skip).
// │ payload.regions[i].elementSelector.boundingBox = bbox
// ▼
// Percy backend — compares masked regions across builds.
//
// ── Observability ──────────────────────────────────────────────────
//
// /percy/healthcheck exposes maestroHierarchyDrift per platform:
// { lastFailureClass, fallbackCount, succeededVia, code?, reason?, firstSeenAt? }
// Every primary→fallback transition also emits one info-level line:
// [percy] hierarchy: <primary> failed (<class>: <reason>) → falling back to <next>
//
// ── Failure shape ──────────────────────────────────────────────────
//
// Element regions degrade gracefully: resolver failure → warn-skip
// those regions only; the snapshot itself still uploads. Coordinate
// regions don't depend on the resolver and always pass through.
//
// ───────────────────────────────────────────────────────────────────
// Shared resolver state across regions/ignoreRegions/considerRegions —
// one hierarchy dump per request, one warn-once skip notice.
let cachedDump = null;
let elementSkipWarned = false;
const totalElementRegionCount = REGION_INPUT_FIELDS.reduce((sum, f) => {
let arr = req.body[f];
return sum + (Array.isArray(arr) ? arr.filter(r => r && r.element).length : 0);
}, 0);
// Resolve one region input to {x, y, width, height}, or null when the
// region is invalid or the resolver couldn't match it. Mutates the
// shared cachedDump / warn-flag state above.
async function resolveBbox(region) {
if (region.top != null && region.bottom != null && region.left != null && region.right != null) {
return {
x: region.left,
y: region.top,
width: region.right - region.left,
height: region.bottom - region.top
};
}
/* istanbul ignore else — region.element false branch falls through
to the istanbul-ignored "Invalid region format" warn below. */
if (region.element) {
/* istanbul ignore else — cachedDump === null only on first
element-region per request; subsequent regions hit the cache. */
if (cachedDump === null) {
// Thread the per-Percy gRPC client cache so the Android gRPC
// primary path can reuse channels across snapshots in the same
// session (D9 of 2026-05-07-002 plan). iOS path ignores it
// (the iOS resolver reads PERCY_IOS_DRIVER_HOST_PORT directly;
// no per-session port cache needed since the port is prescribed
// upstream by `@percy/cli-app`'s `maybeInjectDriverHostPort`).
cachedDump = await maestroDump({
platform,
sessionId,
grpcClientCache: percy.grpcClientCache
});
}
/* istanbul ignore else — branch where dump resolves to hierarchy is
happy-path element-region territory, integration-tested only. */
if (cachedDump.kind !== 'hierarchy') {
/* istanbul ignore else — elementSkipWarned latches after first
warn; second+ iterations take the no-op branch. */
if (!elementSkipWarned) {
percy.log.warn(`Element-region resolver ${cachedDump.kind} (${cachedDump.reason}) — skipping ${totalElementRegionCount} element regions`);
elementSkipWarned = true;
}
return null;
}
/* istanbul ignore next */
let bbox = maestroFirstMatch(cachedDump.nodes, region.element);
/* istanbul ignore next */
if (!bbox) {
percy.log.warn(`Element region not found: ${JSON.stringify(region.element)} — skipping`);
return null;
}
/* istanbul ignore next — element-region happy path requires a
non-stub maestroDump returning hierarchy nodes; unit tests run
with stubbed resolver (env-missing), happy path covered by the
cross-platform-parity integration harness against fixture data. */
return bbox;
}
/* istanbul ignore next */
percy.log.warn('Invalid region format, skipping');
/* istanbul ignore next — region shape is validated upstream by the
SDK before posting; this is a defensive catch-all for regions that
lack both coordinate fields AND an element selector. */
return null;
}
// regions[]: comparison-shape items with algorithm. Default algorithm is
// 'ignore' (back-compat with SDK ≤ 0.3).
if (Array.isArray(req.body.regions)) {
let resolvedRegions = [];
for (let region of req.body.regions) {
let bbox = await resolveBbox(region);
if (!bbox) continue;
let resolved = {
elementSelector: {
boundingBox: bbox
},
algorithm: region.algorithm || 'ignore'
};
/* istanbul ignore if — region.configuration optional field; only
passed when SDK opts in to per-region config overrides. */
if (region.configuration) resolved.configuration = region.configuration;
/* istanbul ignore if — region.padding optional field. */
if (region.padding) resolved.padding = region.padding;
/* istanbul ignore if — region.assertion optional field. */
if (region.assertion) resolved.assertion = region.assertion;
resolvedRegions.push(resolved);
}
/* istanbul ignore else — empty resolvedRegions branch only fires when
ALL regions failed to resolve; happy path resolves at least one. */
if (resolvedRegions.length > 0) payload.regions = resolvedRegions;
}
// ignoreRegions[] and considerRegions[]: parallel top-level payload
// fields. Each item is shaped per regionsSchema (config.js:792) —
// { coOrdinates: {top, left, bottom, right} } with an optional selector
// hint preserved when the caller supplied an element selector.
const REGION_OUTPUT_MAP = {
ignoreRegions: {
payloadKey: 'ignoredElementsData',
innerKey: 'ignoreElementsData'
},
considerRegions: {
payloadKey: 'consideredElementsData',
innerKey: 'considerElementsData'
}
};
for (let [inputField, {
payloadKey,
innerKey
}] of Object.entries(REGION_OUTPUT_MAP)) {
let input = req.body[inputField];
if (!Array.isArray(input)) continue;
let resolved = [];
for (let region of input) {
let bbox = await resolveBbox(region);
/* istanbul ignore if — null bbox skip in ignoreRegions/considerRegions
loop; tests cover the happy path where every region resolves. */
if (!bbox) continue;
let item = {
coOrdinates: {
top: bbox.y,
left: bbox.x,
bottom: bbox.y + bbox.height,
right: bbox.x + bbox.width
}
};
/* istanbul ignore if — element selector echo on resolved region;
only fires when resolveBbox returned a bbox for an element region,
which itself is integration-test territory (see resolveBbox
above for the resolver-mock rationale). */
if (region.element) {
let [key] = Object.keys(region.element);
item.selector = `${key}=${region.element[key]}`;
}
resolved.push(item);
}
/* istanbul ignore else — empty resolved branch only fires when ALL
regions in this category failed to resolve; happy path resolves
at least one. */
if (resolved.length > 0) payload[payloadKey] = {
[innerKey]: resolved
};
}
// Upload via percy — sync or fire-and-forget
if (req.body.sync === true) payload.sync = true;
let data;
if (percy.syncMode(payload)) {
// See the /percy/comparison route: percy.upload() is the Promise-wrapped method;
// calling it drives the generator and the sync queue resolves/rejects the callback.
// The .catch(reject) surfaces generator errors that bypass that callback.
const snapshotPromise = new Promise((resolve, reject) => {
percy.upload(payload, {
resolve,
reject
}, 'app').catch(reject);
});
data = await handleSyncJob(snapshotPromise, percy, 'comparison');
return res.json(200, {
success: true,
data
});
}
let upload = percy.upload(payload, null, 'app');
/* istanbul ignore if — ?await=true URL flag triggers fire-and-wait;
tests cover both syncMode and fire-and-forget but not the explicit
?await query-param variant. */
if (req.url.searchParams.has('await')) await upload;
// Generate redirect link
let link = [percy.client.apiUrl, '/comparisons/redirect?', encodeURLSearchParams(normalize({
buildId: (_percy$build3 = percy.build) === null || _percy$build3 === void 0 ? void 0 : _percy$build3.id,
snapshot: {
name
},
tag
}, {
snake: true
}))].join('');
return res.json(200, {
success: true,
link
});
})
.route('post', '/percy/maestro-screenshot', (req, res) => handleMaestroScreenshot(req, res, percy))
// flushes one or more snapshots from the internal queue

@@ -1026,5 +337,5 @@ .route('post', '/percy/flush', async (req, res) => res.json(200, {

.route('post', '/percy/events', async (req, res) => {
var _percy$build4;
var _percy$build2;
const body = percyBuildEventHandler(req, pkg.version);
await percy.client.sendBuildEvents((_percy$build4 = percy.build) === null || _percy$build4 === void 0 ? void 0 : _percy$build4.id, body);
await percy.client.sendBuildEvents((_percy$build2 = percy.build) === null || _percy$build2 === void 0 ? void 0 : _percy$build2.id, body);
res.json(200, {

@@ -1049,4 +360,6 @@ success: true

})
// stops percy at the end of the current event loop
.route('/percy/stop', (req, res) => {
// stops percy at the end of the current event loop; POST-only so a browser
// cannot trigger it via a no-Origin GET (e.g. an <img> tag). Cross-origin
// POSTs are blocked by the general middleware's single choke point above.
.route('post', '/percy/stop', (req, res) => {
setImmediate(() => percy.stop());

@@ -1053,0 +366,0 @@ return res.json(200, {

import logger from '@percy/logger';
import Queue from './queue.js';
import Page from './page.js';
import { normalizeURL, hostnameMatches, createResource, createRootResource, createPercyCSSResource, createLogResource, yieldAll, snapshotLogName, waitForTimeout, withRetries, waitForSelectorInsideBrowser, isGzipped, maybeScrollToBottom } from './utils.js';
import { normalizeURL, hostnameMatches, createResource, createRootResource, createPercyCSSResource, createLogResource, redactSecrets, yieldAll, snapshotLogName, waitForTimeout, withRetries, waitForSelectorInsideBrowser, isGzipped, maybeScrollToBottom, assertNotMetadataTarget } from './utils.js';
import { ByteLRU, entrySize, DiskSpillStore, createSpillDir } from './cache/byte-lru.js';

@@ -227,4 +227,6 @@ import { sha256hash } from '@percy/client/utils';

// include associated snapshot logs matched by meta information
resources.push(createLogResource(logger.snapshotLogs(snapshot.meta.snapshot)));
// include associated snapshot logs matched by meta information. Redact
// secrets before egress — this per-snapshot log resource is a parallel
// egress path to sendBuildLogs and must scrub tokens/credentials too (CWE-532).
resources.push(createLogResource(redactSecrets(logger.snapshotLogs(snapshot.meta.snapshot))));
logger.evictSnapshot(snapshot.meta.snapshot);

@@ -352,2 +354,4 @@ if (process.env.PERCY_GZIP) {

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, {

@@ -354,0 +358,0 @@ cookies,

@@ -138,3 +138,3 @@ import fs from 'fs';

} = {}) {
let extract = (i, o) => import('extract-zip').then(ex => ex.default(i, {
let extract = (i, o) => import('./unzip.js').then(ex => ex.default(i, {
dir: o

@@ -141,0 +141,0 @@ }));

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

@@ -39,2 +40,13 @@ // CDP returns binary bodies via Network.getResponseBody as base64 in the JSON-RPC

// 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

@@ -457,2 +469,27 @@ // Ideal flow: requestWillBeSent -> requestPaused -> responseReceived -> loadingFinished / loadingFailed

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) {
var _this$meta2;
let url = originURL(request);
logAssetInstrumentation(this.log, 'asset_not_uploaded', 'metadata_endpoint_blocked', {
url,
hostname: metadataHost,
snapshot: (_this$meta2 = this.meta) === null || _this$meta2 === void 0 ? void 0 : _this$meta2.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 () => {

@@ -600,2 +637,71 @@ let result = await this.send(session, 'Network.getResponseBody', {

}
// 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) {
var _this$meta3;
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$meta3 = this.meta) === null || _this$meta3 === void 0 ? void 0 : _this$meta3.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
};
}
}

@@ -618,3 +724,4 @@

empty_response: 'Empty response',
disallowed_resource_type: 'Disallowed resource type'
disallowed_resource_type: 'Disallowed resource type',
metadata_endpoint_blocked: 'Cloud metadata endpoint blocked'
};

@@ -717,2 +824,3 @@ const prefix = categoryMap[category];

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

@@ -742,2 +850,26 @@ if (!(resource !== null && resource !== void 0 && resource.root) && hostnameMatches(disallowedHostnames, url)) {

});
} 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 {

@@ -820,49 +952,46 @@ // interceptResponse:true triggers a second pause at the response stage. See _handleResponsePaused.

// 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".
// 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] : [];
}
// 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) {
var _network$meta;
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 {
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);
}
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('; ')
return {
body,
status,
headers
};
if (shouldAttachAuth(network.authorization, request.url, (_network$meta = network.meta) === null || _network$meta === void 0 ? void 0 : _network$meta.snapshotURL)) {
let {
username,
password
} = network.authorization;
let token = Buffer.from([username, password || ''].join(':')).toString('base64');
headers.Authorization = `Basic ${token}`;
}
return makeRequest(request.url, {
buffer: true,
headers
}, (body, res) => ({
body,
status: res.statusCode,
headers: res.headers
}));
}

@@ -910,2 +1039,8 @@

} 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', {

@@ -912,0 +1047,0 @@ url,

@@ -135,2 +135,11 @@ function _classPrivateMethodInitSpec(e, a) { _checkPrivateRedeclaration(e, a), a.add(e); }

// Per-Percy cache of derived device system-bar insets, keyed by sessionId.
// Insets are device-constant within a session, so the Maestro relay derives
// them once (one /viewHierarchy or `dumpsys` call) and reuses the result —
// including a null "derivation failed, use SDK default" outcome — for every
// subsequent snapshot in that session. Per-instance (not module-scoped) so
// concurrent Percy instances don't share session state; holds plain data
// (no sockets), so stop() just clears it.
this.maestroInsetCache = new Map();
// Domain validation state for auto domain allow-listing

@@ -451,2 +460,5 @@ this.domainValidation = {

// Drop the per-session device-inset cache (plain data, no sockets).
this.maestroInsetCache.clear();
// mark instance as stopped

@@ -854,3 +866,6 @@ this.readyState = 3;

const logsObject = {
clilogs: logger.query(log => !['ci'].includes(log.debug))
// Redact secrets from CLI logs before egress to the Percy API — these
// can contain tokens or URLs with embedded credentials (CWE-532). The
// cilogs below were already redacted; clilogs were not.
clilogs: redactSecrets(logger.query(log => !['ci'].includes(log.debug)))
};

@@ -857,0 +872,0 @@

@@ -14,2 +14,20 @@ function _classPrivateMethodInitSpec(e, a) { _checkPrivateRedeclaration(e, a), a.add(e); }

// Returns true when an Origin header value resolves to a loopback host. The
// local Percy server is only ever legitimately reached by Node SDK clients
// (which send no Origin) or by loopback browser tooling (e.g. the Storybook
// manager on localhost:<port>). Treating non-loopback origins as untrusted lets
// us scope CORS and reject cross-origin state changes (CWE-942 / CWE-352).
export function isLoopbackOrigin(origin) {
if (!origin || typeof origin !== 'string') return false;
try {
// URL.hostname KEEPS the brackets around an IPv6 literal, so `[::1]`
// stays `[::1]` here and must be unwrapped before matching `::1`.
let host = new URL(origin).hostname.toLowerCase();
if (host.startsWith('[') && host.endsWith(']')) host = host.slice(1, -1);
return host === 'localhost' || host === '127.0.0.1' || host === '::1' || host.endsWith('.localhost');
} catch {
return false;
}
}
// custom incoming message adds a `url` and `body` properties containing the parsed URL and message

@@ -120,11 +138,28 @@ // buffer respectively; both available after the 'end' event is emitted

handle: (req, res, next) => {
res.setHeader('Access-Control-Allow-Origin', '*');
// Only echo a loopback Origin back as Access-Control-Allow-Origin. The
// previous wildcard `*` let any website the user visited read responses
// from the local Percy server (build data, config) cross-origin (CWE-942).
let origin = req.headers.origin;
let originAllowed = isLoopbackOrigin(origin);
// The response branches on Origin on every path (ACAO is emitted only for
// loopback origins), so Vary: Origin must be set unconditionally —
// otherwise a cache/intermediary could serve a no-ACAO body to a loopback
// origin, or a loopback body to another origin.
res.setHeader('Vary', 'Origin');
if (originAllowed) {
// `origin` is validated against a loopback-only allowlist above, so it
// is not attacker-controlled here.
// nosemgrep: javascript.express.security.cors-misconfiguration.cors-misconfiguration
res.setHeader('Access-Control-Allow-Origin', origin);
}
if (req.method === 'OPTIONS') {
let allowHeaders = req.headers['access-control-request-headers'] || '*';
let allowMethods = [...new Set(_classPrivateFieldGet(_routes, this).flatMap(route => (!route.match || route.match(req.url.pathname)) && route.methods || []))].join(', ');
res.setHeader('Access-Control-Allow-Headers', allowHeaders);
res.setHeader('Access-Control-Allow-Methods', allowMethods);
if (originAllowed) {
let allowHeaders = req.headers['access-control-request-headers'] || '*';
let allowMethods = [...new Set(_classPrivateFieldGet(_routes, this).flatMap(route => (!route.match || route.match(req.url.pathname)) && route.methods || []))].join(', ');
res.setHeader('Access-Control-Allow-Headers', allowHeaders);
res.setHeader('Access-Control-Allow-Methods', allowMethods);
}
res.writeHead(204).end();
} else {
res.setHeader('Access-Control-Expose-Headers', '*');
if (originAllowed) res.setHeader('Access-Control-Expose-Headers', '*');
return next();

@@ -131,0 +166,0 @@ }

@@ -41,2 +41,9 @@ import logger from '@percy/logger';

// Upper bound on the snapshot name length we will run user-controllable
// regex/glob matching against. A crafted, very long snapshot name reaching this
// matcher (e.g. via the local API) combined with a backtracking-prone pattern
// could otherwise trigger catastrophic backtracking / ReDoS (CWE-1333). Real
// snapshot names are short; an over-long name simply does not match patterns.
const MAX_MATCH_INPUT_LENGTH = 2048;
// Returns true or false if a snapshot matches the provided include and exclude predicates. A

@@ -52,10 +59,13 @@ // predicate can be an array of predicates, a regular expression, a glob pattern, or a function.

// guard pattern matching against pathologically long inputs (ReDoS)
let patternSafe = typeof snapshot.name === 'string' && snapshot.name.length <= MAX_MATCH_INPUT_LENGTH;
// recursive predicate test function
let test = (predicate, fallback) => {
if (predicate && typeof predicate === 'string') {
// snapshot name matches exactly or matches a glob
let result = snapshot.name === predicate || micromatch.isMatch(snapshot.name, predicate);
// exact match is always safe; glob matching is only run on bounded input
let result = snapshot.name === predicate || patternSafe && micromatch.isMatch(snapshot.name, predicate);
// snapshot might match a string-based regexp pattern
if (!result) {
// snapshot might match a string-based regexp pattern (bounded input only)
if (!result && patternSafe) {
try {

@@ -68,4 +78,4 @@ let [, parsed, flags] = RE_REGEXP.exec(predicate) || [];

} else if (predicate instanceof RegExp) {
// snapshot matches a regular expression
return predicate.test(snapshot.name);
// snapshot matches a regular expression (bounded input only)
return patternSafe && predicate.test(snapshot.name);
} else if (typeof predicate === 'function') {

@@ -72,0 +82,0 @@ // advanced matching

@@ -57,2 +57,100 @@ import EventEmitter from 'events';

// 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) {
/* istanbul ignore next -- @preserve: defensive guard; the sole caller
matchMetadataHost already returns on a falsy host before calling this,
so this branch is unreachable via the public API and cannot be exercised
without widening the export surface. */
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

@@ -84,2 +182,7 @@ // serialized resources resolve consistently during rendering. Mirrors the

// Returns a URL encoded string of nested query params
export function encodeURLSearchParams(subj, prefix) {
return typeof subj === 'object' ? Object.entries(subj).map(([key, value]) => encodeURLSearchParams(value, prefix ? `${prefix}[${key}]` : key)).join('&') : `${prefix}=${encodeURIComponent(subj)}`;
}
// Process CORS iframes in a single domSnapshot object. `rootUrl` is the page

@@ -617,17 +720,46 @@ // URL of the snapshot, used as the base for synthetic resource URLs.

}
// Lazily load and compile the secret patterns once. The pattern file holds
// ~1.7k regexes; parsing the YAML and compiling every RegExp on each call made
// redactSecrets O(patterns) per string and re-read the file for every recursive
// call. Since redactSecrets now runs over the full CLI log array on egress
// (sendBuildLogs), that per-call cost is paid hundreds of times and could blow
// past test/runtime timeouts. Compile once and reuse.
let _compiledSecretPatterns;
function getSecretPatterns() {
if (!_compiledSecretPatterns) {
const filepath = path.resolve(url.fileURLToPath(import.meta.url), '../secretPatterns.yml');
const secretPatterns = YAML.parse(readFileSync(filepath, 'utf-8'));
// Regex sources come from the first-party, bundled secretPatterns.yml that
// ships in this package - never from remote or attacker-controlled input.
// nosemgrep: javascript.lang.security.audit.detect-non-literal-regexp.detect-non-literal-regexp
_compiledSecretPatterns = secretPatterns.patterns.map(p => new RegExp(p.pattern.regex, 'g'));
}
return _compiledSecretPatterns;
}
export function redactSecrets(data) {
const filepath = path.resolve(url.fileURLToPath(import.meta.url), '../secretPatterns.yml');
const secretPatterns = YAML.parse(readFileSync(filepath, 'utf-8'));
// Strings are redacted against every compiled secret pattern.
if (typeof data === 'string') {
for (const pattern of getSecretPatterns()) {
data = data.replace(pattern, '[REDACTED]');
}
return data;
}
// Arrays (the CLI-log entry list) are redacted element-wise, in place.
if (Array.isArray(data)) {
// Process each item in the array
return data.map(item => redactSecrets(item));
} else if (typeof data === 'object' && data !== null) {
// Process each key-value pair in the object
for (let i = 0; i < data.length; i++) data[i] = redactSecrets(data[i]);
return data;
}
// Log entries: redact the human-readable `message` in place and return the
// same reference (sendBuildLogs reads the return value; the CI-log path reads
// the same memory-mode entry back — both see the redacted message). We do NOT
// recurse over `meta`: it holds structured instrumentation (e.g. a numeric
// `size`, ids) and a broad secret pattern matches plain digit strings, so a
// blanket meta sweep clobbers legitimate diagnostic data. Log-line secrets
// surface in `message`.
if (typeof data === 'object' && data !== null) {
data.message = redactSecrets(data.message);
return data;
}
if (typeof data === 'string') {
for (const pattern of secretPatterns.patterns) {
data = data.replace(new RegExp(pattern.pattern.regex, 'g'), '[REDACTED]');
}
}
// Any other primitive (number, boolean, null, undefined) passes through.
return data;

@@ -634,0 +766,0 @@ }

{
"name": "@percy/core",
"version": "1.32.4",
"version": "1.32.5-beta.0",
"license": "MIT",

@@ -12,3 +12,3 @@ "repository": {

"access": "public",
"tag": "latest"
"tag": "beta"
},

@@ -50,12 +50,12 @@ "engines": {

"@grpc/proto-loader": "^0.8.0",
"@percy/client": "1.32.4",
"@percy/config": "1.32.4",
"@percy/dom": "1.32.4",
"@percy/logger": "1.32.4",
"@percy/monitoring": "1.32.4",
"@percy/webdriver-utils": "1.32.4",
"@percy/client": "1.32.5-beta.0",
"@percy/config": "1.32.5-beta.0",
"@percy/dom": "1.32.5-beta.0",
"@percy/logger": "1.32.5-beta.0",
"@percy/monitoring": "1.32.5-beta.0",
"@percy/webdriver-utils": "1.32.5-beta.0",
"adm-zip": "^0.6.0",
"busboy": "^1.6.0",
"content-disposition": "^0.5.4",
"cross-spawn": "^7.0.3",
"extract-zip": "^2.0.1",
"fast-glob": "^3.2.11",

@@ -72,5 +72,5 @@ "fast-xml-parser": "^4.4.1",

"optionalDependencies": {
"@percy/cli-doctor": "1.32.4"
"@percy/cli-doctor": "1.32.5-beta.0"
},
"gitHead": "3cc47184f972f991e35b5cc58d3035ecca6cf221"
"gitHead": "768c93a69060d3abe8dd4db57768c0970d2a9b79"
}

Sorry, the diff of this file is too big to display

Sorry, the diff of this file is too big to display