From 7f91940f237ef632e756fa82ad5725fecdef3868 Mon Sep 17 00:00:00 2001 From: Shivanshu07 Date: Mon, 15 Jun 2026 10:57:00 +0530 Subject: [PATCH] security: validate PERCY_SERVER_ADDRESS to loopback (PER-8681, PER-8682) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PER-8681 (CWE-918) — PERCY_SERVER_ADDRESS was assigned to utils.percy.address with no validation, so an attacker who can set it could redirect all outbound SDK HTTP (healthcheck, snapshot upload) to an arbitrary/internal host. PER-8682 (CWE-94) — percySnapshot eval()s the @percy/dom bundle returned by utils.fetchPercyDOM(), which is fetched from utils.percy.address. With an unvalidated address that eval becomes remote code execution in the test browser. Both share one root: the address. Validate PERCY_SERVER_ADDRESS resolves to a loopback host (localhost/127.0.0.1/::1) before assigning it; otherwise warn and fall back to the @percy/sdk-utils default. The eval now only ever runs code served by the local Percy CLI. (eval itself is the standard PercyDOM-injection mechanism shared by all Percy SDKs and is left in place, now sourced safely.) Co-Authored-By: Claude Opus 4.8 (1M context) --- addon-test-support/@percy/ember/index.js | 34 +++++++++++++++++++++--- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/addon-test-support/@percy/ember/index.js b/addon-test-support/@percy/ember/index.js index 40bfcaa..ce0718f 100644 --- a/addon-test-support/@percy/ember/index.js +++ b/addon-test-support/@percy/ember/index.js @@ -10,8 +10,33 @@ const ENV_INFO = [`ember/${emberVersion}`]; if (window.QUnit) ENV_INFO.push(`qunit/${window.QUnit.version}`); if (window.mocha) ENV_INFO.push(`mocha/${window.mocha.version}`); -// Maybe set the CLI API address from the environment -utils.percy.address = SDKENV.PERCY_SERVER_ADDRESS; +// The Percy CLI always runs locally, so its address must resolve to a loopback +// host. Accepting an arbitrary PERCY_SERVER_ADDRESS would let an attacker who +// can set it redirect snapshot/healthcheck traffic — and, critically, the +// @percy/dom bundle that is eval'd below is fetched from this address — to a +// hostile host, turning the eval into remote code execution in the test browser +// (CWE-918 / CWE-94 — PER-8681 / PER-8682). +const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '[::1]']); + +function isLoopbackAddress(address) { + try { + return LOOPBACK_HOSTS.has(new URL(address).hostname.toLowerCase()); + } catch (e) { + return false; + } +} + +// Maybe set the CLI API address from the environment (loopback only) +if (SDKENV.PERCY_SERVER_ADDRESS) { + if (isLoopbackAddress(SDKENV.PERCY_SERVER_ADDRESS)) { + utils.percy.address = SDKENV.PERCY_SERVER_ADDRESS; + } else { + utils.logger('ember').warn( + `Ignoring non-loopback PERCY_SERVER_ADDRESS "${SDKENV.PERCY_SERVER_ADDRESS}"; ` + + 'the Percy CLI must run on localhost.' + ); + } +} // Helper to generate a snapshot name from the test suite function generateName(assertOrTestOrName) { @@ -69,7 +94,10 @@ export default async function percySnapshot(name, { name = generateName(name); try { - // Inject @percy/dom + // Inject @percy/dom. fetchPercyDOM() retrieves the bundle from + // utils.percy.address, which is now validated to a loopback host at module + // load (see above), so this eval only ever executes code served by the + // local Percy CLI rather than an attacker-controlled origin (PER-8682). if (!window.PercyDOM) { // eslint-disable-next-line no-eval eval(await utils.fetchPercyDOM());