security: scope CORS to loopback + block cross-origin config/stop (PER-8600/8601/8602/8603)#2282
security: scope CORS to loopback + block cross-origin config/stop (PER-8600/8601/8602/8603)#2282Shivanshu-07 wants to merge 2 commits into
Conversation
…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>
| let origin = req.headers.origin; | ||
| let originAllowed = isLoopbackOrigin(origin); | ||
| if (originAllowed) { | ||
| res.setHeader('Access-Control-Allow-Origin', origin); |
…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>
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 |
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