security: scope CORS to loopback + block cross-origin config/stop (PER-8600/8601/8602/8603)#2282
Conversation
Claude Code PR ReviewPR: #2282 • Head: 8d04218 • Reviewers: stack:code-reviewer SummaryScopes the local Percy CLI API server's CORS to loopback origins and blocks cross-origin state changes (PER-8600/8601/8602/8603, CWE-942/CWE-352). Review Table
Findings (all resolved or non-blocking)
Verdict: PASS |
…R-8600/8601/8602/8603) The local Percy server is unauthenticated by design (SDKs post to it), but it served `Access-Control-Allow-Origin: *` and accepted state-changing requests from any origin, so a malicious website the user was visiting could read responses from, mutate the live config of, or stop the local server via the browser. PER-8602 (CWE-942) — replace the wildcard ACAO with loopback-origin-only reflection in the server CORS middleware (isLoopbackOrigin). Non-loopback origins receive no ACAO, so arbitrary sites can no longer read local-server responses. Node SDK clients don't use CORS and are unaffected; loopback browser tooling (any localhost port) still works. PER-8600 / PER-8601 (CWE-306/CWE-352) — add assertNotCrossOrigin() and apply it to the state-changing endpoints POST /percy/config and /percy/stop: a request carrying a non-loopback Origin header is rejected (403). Node SDK clients send no Origin and are unaffected. PER-8603 (CWE-915, mass assignment) — already mitigated by the existing stripBlockedConfigFields (httpReadOnly) control on /percy/config; the cross-origin guard further prevents unauthenticated browser-driven mutation. Host-based validation was deliberately NOT added: the server binds to all interfaces by default (PERCY_SERVER_HOST, default `::`), so Dockerized SDKs legitimately reach it via a non-loopback Host. Origin-based checks defend the browser attack vector without breaking those setups. Adds unit tests for the cross-origin config/stop rejection and loopback allow. NOTE: fully authenticating the local API (a shared token adopted by every SDK) remains a larger coordinated change; this PR closes the browser-origin vector that the findings (and chain PER-8626) actually exploit. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ad [::1] branch The CORS hardening changed Access-Control-Allow-Origin from a wildcard '*' to an echoed loopback origin (and omits CORS headers entirely for missing/ non-loopback origins). The existing 'handles CORS preflight requests' spec still asserted '*' and was not updated, so it would fail in CI. Rewrite it to send a loopback Origin and assert the echoed value, and add a negative case covering missing and cross-origin requests. Also remove the dead 'host === "[::1]"' branch in isLoopbackOrigin: URL.hostname strips the brackets from an IPv6 literal, so it normalises to '::1' and the bracketed comparison can never match. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ch branch
The CORS origin-hardening change added isLoopbackOrigin, whose
`catch { return false }` path (malformed Origin that cannot be parsed as
a URL) was never exercised, leaving server.js below the 100% line
coverage gate. Add direct unit tests for the loopback, non-loopback, and
unparseable-origin cases to restore full coverage.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
99944a9 to
c012ddd
Compare
The reflected Origin is validated against a loopback-only allowlist before being echoed into Access-Control-Allow-Origin, so it is not attacker-controlled. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Resolved the Semgrep |
Claude Code PR ReviewPR: #2282 • Head: b2668d7 • Reviewers: fallback inline checklist SummaryScopes the local Percy server's CORS to loopback origins (echoes a validated loopback Review Table
Findings
Verdict: FAIL |
The cross-origin gate on /percy/stop only rejects requests that carry a non-loopback Origin header. A method-less route matches every verb, so a browser could stop the running build with a no-Origin GET (e.g. an <img> tag), slipping past the gate. Restrict the route to POST (all legitimate callers already POST) so cross-origin browser requests always carry an Origin header and are blocked. Adds a regression test for the GET vector. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude Code PR ReviewPR: #2282 • Head: 2ffd545 • Reviewers: fallback inline checklist Re-review after fix commit SummaryScopes the local Percy server's CORS to loopback origins (echoes a validated loopback Review Table
Findings
Verdict: PASS |
Claude Code PR ReviewPR: #2282 • Head: 2ffd545 • Reviewers: orchestrator (self, security-focused per PER-8600..8603) SummaryHardens the local (unauthenticated) Percy core server against a malicious website in the user's browser: scopes CORS to loopback origins instead of wildcard Review Table
FindingsAdversarial verification performed (all closed): method-less routes (only the two middlewares and test-only
Verdict: PASS |
pranavz28
left a comment
There was a problem hiding this comment.
Automated multi-agent security review. The CORS change and isLoopbackOrigin are solid — I verified no over-allow bypass (0x7f000001, 127.1, ①27.0.0.1 all canonicalize to real loopback; localhost.evil.com / 127.0.0.1.evil.com correctly rejected; .endsWith('.localhost') is safe per RFC 6761). However, the CSRF/unauthenticated-access fix is incomplete and I'd treat it as a blocker — it guards 2 of ~10 state-changing endpoints. See the inline P1.
| // get or set config options | ||
| .route(['get', 'post'], '/percy/config', async (req, res) => { | ||
| // mutating the live config is only allowed from same-host callers | ||
| if (req.body) assertNotCrossOrigin(req); |
There was a problem hiding this comment.
P1 (blocker) — this guard reaches only /percy/config and /percy/stop; the other state-changing POSTs are unprotected. Unguarded: /percy/snapshot (:374), /percy/comparison (:385), /percy/comparison/upload (:424), /percy/maestro-screenshot (:426), /percy/flush (:940), /percy/automateScreenshot (:943), /percy/events (:963), /percy/log (:968).
CORS does not protect these: text/plain and multipart/form-data are CORS-safelisted, so a cross-origin fetch(...,{mode:'no-cors'}) sends no preflight — the request reaches the handler and its side effects run (only the response is unreadable). And api.js:270 re-parses any non-multipart body as JSON regardless of Content-Type, so a text/plain JSON string arrives fully-formed. Cross-origin POST always carries an Origin header, so assertNotCrossOrigin would block them — it's just not applied.
Impact: while percy exec runs, any page open in the dev's browser can POST /percy/snapshot (pollute the build / burn quota / corrupt baselines) or /percy/flush (drop the queue — DoS).
Fix: enforce at one choke point in the general middleware (api.js:267) and drop the two per-route calls:
if (!['GET','HEAD','OPTIONS'].includes(req.method)) assertNotCrossOrigin(req);This covers all present and future state-changing routes.
There was a problem hiding this comment.
Fixed in 82f4f0e. Moved the guard to a single choke point in the general middleware (api.js) — if (!['GET','HEAD','OPTIONS'].includes(req.method)) assertNotCrossOrigin(req) inside the request promise chain so the 403 is returned as JSON — and dropped the two per-route calls on /config and /stop. This now covers all state-changing routes including the eight you listed (/snapshot, /comparison, /comparison/upload, /maestro-screenshot, /flush, /automateScreenshot, /events, /log), plus any future one, since a cross-origin request always carries an Origin. No-Origin SDK callers and loopback tooling are unaffected. Added a test asserting every one of those endpoints rejects a cross-origin Origin with "Cross-origin requests are not allowed" before any side effect runs (verified passing locally).
| // `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); |
There was a problem hiding this comment.
P3 (cache-correctness, low risk for a localhost server). Vary: Origin is emitted only on the allowed branch. For strict correctness it should be set on every response that branches on Origin (including the no-ACAO path) so an intermediary can't serve a cached no-ACAO body to a loopback origin. Also note api.js:275 still sets Access-Control-Expose-Headers unconditionally — inert without ACAO, but now inconsistent with the conditional Expose-Headers here; worth aligning.
There was a problem hiding this comment.
Fixed in 82f4f0e. Vary: Origin is now set unconditionally in the server.js CORS handler (both the allowed and no-ACAO branches), so an intermediary can't serve a cached no-ACAO body to a loopback origin. Also aligned the Expose-Headers layers: api.js now gates Access-Control-Expose-Headers on a loopback origin (via isLoopbackOrigin) to match the conditional Expose-Headers in server.js, instead of emitting it unconditionally.
…e point The Origin guard only covered /percy/config and /percy/stop; the other mutating POSTs (/snapshot, /comparison, /comparison/upload, /maestro-screenshot, /flush, /automateScreenshot, /events, /log) were reachable cross-origin via a preflight-free text/plain simple-request POST (bodies are parsed as JSON regardless of Content-Type). Move the guard to a single choke point in the general middleware — reject any non-GET/HEAD/OPTIONS request carrying a non-loopback Origin — covering all present and future state-changing routes, and drop the two per-route calls. No-Origin SDK callers and loopback tooling are unaffected. Also (P3): emit Vary: Origin unconditionally so an intermediary can't serve a cached no-ACAO body to a loopback origin, and gate Access-Control-Expose-Headers on a loopback origin to match the CORS handler. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
| // `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); |
Claude Code PR ReviewPR: #2282 • Head: 82f4f0e • Reviewers: fallback inline checklist SummaryHardens the local (unauthenticated) Percy server against browser drive-by CSRF/CORS: gates every state-changing request behind a single cross-origin choke point, scopes CORS to loopback origins, makes Review Table
FindingsNo Critical or High findings. Note (non-blocking): the local server remains unauthenticated by design — this PR closes the browser-reachable cross-origin vector (a visited website can no longer drive it), which is the exploitable class. A co-located process on the same host is out of scope here (that's the machine-access class tracked separately). Verdict: PASS |
…:103) The CORS/SSRF hardening in #2282 added canonicalHost's `catch { return bare }` fallback for colon-bearing hosts that are not valid IPv6 literals, but no test exercised it — leaving utils.js line 103 uncovered and dropping @percy/core below the 100% NYC gate (a failure pre-existing on master's HEAD). Add an isMetadataIP('gg::1') case: the invalid IPv6 makes `new URL` throw, so canonicalHost returns the raw value and the non-metadata address is allowed through (null). Restores 100% coverage. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Summary
Fifth (final contained) percy-cli security PR — the local-server origin findings (deadline 2026-06-16) + chain PER-8626.
POST /percy/configThe constraint
The local API is unauthenticated by design — every SDK posts to it without credentials. The real exploit path in these findings is a malicious website the user is visiting reaching
http://localhost:5338from the browser. The fix defends that vector without breaking the SDK contract.Changes
server.js(PER-8602): the CORS middleware no longer returnsAccess-Control-Allow-Origin: *. It reflects the requestOriginonly when it's loopback (isLoopbackOrigin—localhost/127.0.0.1/::1/*.localhost, any port). Arbitrary sites get no ACAO and can't read local-server responses (build data, config). Node SDK clients don't use CORS; loopback browser tooling (e.g. the Storybook manager onlocalhost:6006) still works.api.js(PER-8600 / PER-8601):assertNotCrossOrigin()rejects (403) anyPOST /percy/configor/percy/stoprequest carrying a non-loopbackOriginheader. Node SDK clients send noOrigin→ unaffected; a malicious site's cross-originfetch→ blocked.PER-8603: already mitigated by the existing
stripBlockedConfigFields(httpReadOnly) control; the cross-origin guard adds defense against browser-driven mutation.Why not Host-validation
The server binds to all interfaces by default (
PERCY_SERVER_HOST, default::), so Dockerized SDKs legitimately reach it via a non-loopbackHost. Origin-based checks defend the browser vector without breaking those setups.Verification
isLoopbackOriginverified (localhost/127.0.0.1/*.localhostallowed;evil.com/empty/undefined rejected); both filesnode --checkclean./configPOST rejected + config unmutated; loopback/configPOST allowed; cross-origin/stoprejected +percy.stopnot called.Origin) → unaffected.Closes PER-8602; closes the browser-origin vector for PER-8600/8601/8603 and chain PER-8626.
🤖 Generated with Claude Code