Skip to content

security: scope CORS to loopback + block cross-origin config/stop (PER-8600/8601/8602/8603)#2282

Open
Shivanshu-07 wants to merge 1 commit into
masterfrom
security/PER-8600-8603-server-origin-hardening
Open

security: scope CORS to loopback + block cross-origin config/stop (PER-8600/8601/8602/8603)#2282
Shivanshu-07 wants to merge 1 commit into
masterfrom
security/PER-8600-8603-server-origin-hardening

Conversation

@Shivanshu-07

@Shivanshu-07 Shivanshu-07 commented Jun 14, 2026

Copy link
Copy Markdown
Contributor

Summary

Fifth (final contained) percy-cli security PR — the local-server origin findings (deadline 2026-06-16) + chain PER-8626.

Ticket CWE Finding
PER-8602 CWE-942 CORS wildcard enables cross-origin state mutation
PER-8600 CWE-306 Missing auth on the process-terminating endpoint
PER-8601 CWE-306 Unauthenticated live config mutation via POST /percy/config
PER-8603 CWE-915 Live config mass assignment

The 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:5338 from the browser. The fix defends that vector without breaking the SDK contract.

Changes

server.js (PER-8602): the CORS middleware no longer returns Access-Control-Allow-Origin: *. It reflects the request Origin only when it's loopback (isLoopbackOriginlocalhost/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 on localhost:6006) still works.

api.js (PER-8600 / PER-8601): assertNotCrossOrigin() rejects (403) any POST /percy/config or /percy/stop request carrying a non-loopback Origin header. Node SDK clients send no Origin → unaffected; a malicious site's cross-origin fetch → 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-loopback Host. Origin-based checks defend the browser vector without breaking those setups.

Verification

  • isLoopbackOrigin verified (localhost/127.0.0.1/*.localhost allowed; evil.com/empty/undefined rejected); both files node --check clean.
  • Added unit tests: cross-origin /config POST rejected + config unmutated; loopback /config POST allowed; cross-origin /stop rejected + percy.stop not called.
  • Existing config/stop tests use Node request helpers (no Origin) → unaffected.

Closes PER-8602; closes the browser-origin vector for PER-8600/8601/8603 and chain PER-8626.

Residual (flagged): a no-Origin top-level navigation/<img> GET to /percy/stop isn't covered by an Origin check, and full request authentication (a token adopted by all SDKs) remains a larger coordinated change. This PR closes the cross-origin (CORS/fetch) vector the findings exploit; full auth tracked as follow-up.

🤖 Generated with Claude Code

…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>
@Shivanshu-07 Shivanshu-07 requested a review from a team as a code owner June 14, 2026 16:55
let origin = req.headers.origin;
let originAllowed = isLoopbackOrigin(origin);
if (originAllowed) {
res.setHeader('Access-Control-Allow-Origin', origin);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants