@percy/core
Advanced tools
| 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, |
+1
-1
@@ -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 @@ })); |
+181
-46
| 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, |
+16
-1
@@ -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 @@ |
+41
-6
@@ -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 @@ } |
+16
-6
@@ -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 |
+143
-11
@@ -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 @@ } |
+11
-11
| { | ||
| "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
Network access
Supply chain riskThis module accesses the network.
Potential vulnerability
Supply chain riskInitial human review suggests the presence of a vulnerability in this package. It is pending further analysis and confirmation.
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
No v1
QualityPackage is not semver >=1. This means it is not stable and does not support ^ ranges.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Potential vulnerability
Supply chain riskInitial human review suggests the presence of a vulnerability in this package. It is pending further analysis and confirmation.
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
704865
5.14%37
15.63%12069
6.29%2
100%53
6%24
4.35%+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
Updated
Updated
Updated
Updated