Skip to content
52 changes: 42 additions & 10 deletions packages/core/src/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import path, { dirname, resolve } from 'path';
import logger from '@percy/logger';
import { normalize } from '@percy/config/utils';
import { getPackageJSON, Server, percyAutomateRequestHandler, percyBuildEventHandler, computeResponsiveWidths } from './utils.js';
import { ServerError } from './server.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';
Expand Down Expand Up @@ -91,6 +91,19 @@ function stripBlockedConfigFields(body, log) {
return _applyHttpReadOnlyStripping(body, findHttpReadOnlyPaths(body, ROOT_CONFIG_SCHEMA), log);
}

// 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');
}
}

// 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
Expand Down Expand Up @@ -271,8 +284,13 @@ export function createPercyServer(percy, port) {
try { req.body = JSON.parse(req.body); } catch {}
}

// 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');
}

// skip or change api version header in testing mode
if (percy.testing?.version !== false) {
Expand All @@ -295,12 +313,23 @@ export function createPercyServer(percy, port) {
next = () => req.connection.destroy();
}

// 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 => res.json(e.status ?? 500, {
build: percy.testing?.build || percy.build,
error: e.message,
success: false
}));
return Promise.resolve()
.then(() => {
if (!['GET', 'HEAD', 'OPTIONS'].includes(req.method)) assertNotCrossOrigin(req);
return next();
})
.catch(e => res.json(e.status ?? 500, {
build: percy.testing?.build || percy.build,
error: e.message,
success: false
}));
})
// healthcheck returns basic information
.route('get', '/percy/healthcheck', (req, res) => res.json(200, {
Expand Down Expand Up @@ -344,6 +373,7 @@ export function createPercyServer(percy, port) {
})
// get or set config options
.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'));
return res.json(200, {
config: body ? percy.set(body) : percy.config,
Expand Down Expand Up @@ -981,8 +1011,10 @@ export function createPercyServer(percy, port) {

res.json(200, { 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());
return res.json(200, { success: true });
});
Expand Down
54 changes: 45 additions & 9 deletions packages/core/src/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,25 @@
compile as makeToPath
} from 'path-to-regexp';

// 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
// buffer respectively; both available after the 'end' event is emitted
export class IncomingMessage extends http.IncomingMessage {
Expand Down Expand Up @@ -218,19 +237,36 @@
#routes = [{
priority: -1,
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);

Check warning

Code scanning / Semgrep OSS

Semgrep Finding: javascript.express.security.cors-misconfiguration.cors-misconfiguration Warning

By letting user input control CORS parameters, there is a risk that software does not properly verify that the source of data or communication is valid. Use literal values for CORS settings.
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

}

if (req.method === 'OPTIONS') {
let allowHeaders = req.headers['access-control-request-headers'] || '*';
let allowMethods = [...new Set(this.#routes.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(this.#routes.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();
}
}
Expand Down
70 changes: 70 additions & 0 deletions packages/core/test/api.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,76 @@ describe('API Server', () => {
]));
});

it('rejects /config POST carrying a cross-origin Origin header (PER-8601)', async () => {
await percy.start();
let before = percy.config;

await expectAsync(request('/percy/config', {
method: 'POST',
body: { snapshot: { widths: [1234] } },
headers: { Origin: 'https://evil.example' }
})).toBeRejected();

// live config was not mutated by the cross-origin request
expect(percy.config).toEqual(before);
});

it('allows /config POST from a loopback origin', async () => {
await percy.start();

await expectAsync(request('/percy/config', {
method: 'POST',
body: { snapshot: { widths: [1000] } },
headers: { Origin: 'http://localhost:6006' }
})).toBeResolved();

expect(percy.config.snapshot.widths).toEqual([1000]);
});

it('rejects /stop carrying a cross-origin Origin header (PER-8600)', async () => {
await percy.start();
let stopSpy = spyOn(percy, 'stop').and.resolveTo();

await expectAsync(request('/percy/stop', {
method: 'POST',
headers: { Origin: 'https://evil.example' }
})).toBeRejected();

expect(stopSpy).not.toHaveBeenCalled();
});

it('does not stop Percy on a GET to /stop (no-Origin CSRF vector, PER-8600)', async () => {
await percy.start();
let stopSpy = spyOn(percy, 'stop').and.resolveTo();

// A browser can issue a cross-origin GET (e.g. via <img>) with no Origin
// header; the endpoint is POST-only so this must not reach the handler.
await expectAsync(request('/percy/stop', 'GET')).toBeRejected();

expect(stopSpy).not.toHaveBeenCalled();
});

it('blocks cross-origin POSTs to every state-changing endpoint at the middleware choke point (PER-8600/8601)', async () => {
await percy.start();

// CORS-safelisted content types reach these handlers with no preflight, but
// a cross-origin request always carries an Origin, so the general-middleware
// choke point must reject all of them before any side effect runs.
let endpoints = [
'/percy/snapshot', '/percy/comparison', '/percy/comparison/upload',
'/percy/maestro-screenshot', '/percy/flush', '/percy/automateScreenshot',
'/percy/events', '/percy/log'
];

for (let path of endpoints) {
await expectAsync(request(path, {
method: 'POST',
body: { name: 'x' },
headers: { Origin: 'https://evil.example' }
})).toBeRejectedWithError(/Cross-origin requests are not allowed/);
}
});

it('has an /idle endpoint that calls #idle()', async () => {
spyOn(percy, 'idle').and.resolveTo();
await percy.start();
Expand Down
62 changes: 56 additions & 6 deletions packages/core/test/unit/server.test.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { fs, mockfs } from '../helpers/index.js';
import Server from '../../src/server.js';
import Server, { isLoopbackOrigin } from '../../src/server.js';

describe('Unit / Server', () => {
let server;
Expand Down Expand Up @@ -241,28 +241,54 @@ describe('Unit / Server', () => {
});
});

it('handles CORS preflight requests', async () => {
it('handles CORS preflight requests for loopback origins', async () => {
server.route(['get', 'post'], '/1', (req, res) => res.send(200));
server.route(['put', 'delete'], '/2', (req, res) => res.text(200));

let res1 = await request('/1', 'OPTIONS', false);
// CORS is scoped to loopback origins (CWE-942): Access-Control-Allow-Origin
// is the echoed loopback origin, not a wildcard `*`.
let origin = server.address(); // http://localhost:8000

let res1 = await request('/1', {
method: 'OPTIONS',
headers: { Origin: origin }
}, false);

expect(res1.statusCode).toBe(204);
expect(res1.headers).toHaveProperty('access-control-allow-origin', '*');
expect(res1.headers).toHaveProperty('access-control-allow-origin', origin);
expect(res1.headers).toHaveProperty('access-control-allow-headers', '*');
expect(res1.headers).toHaveProperty('access-control-allow-methods', 'GET, POST');

let res2 = await request('/2', {
method: 'OPTIONS',
headers: { 'Access-Control-Request-Headers': 'Content-Type' }
headers: { Origin: origin, 'Access-Control-Request-Headers': 'Content-Type' }
}, false);

expect(res2.statusCode).toBe(204);
expect(res2.headers).toHaveProperty('access-control-allow-origin', '*');
expect(res2.headers).toHaveProperty('access-control-allow-origin', origin);
expect(res2.headers).toHaveProperty('access-control-allow-headers', 'Content-Type');
expect(res2.headers).toHaveProperty('access-control-allow-methods', 'PUT, DELETE');
});

it('omits CORS headers for missing or non-loopback origins', async () => {
server.route(['get', 'post'], '/1', (req, res) => res.send(200));

// No Origin header (e.g. a Node SDK client) — still a valid 204 preflight,
// but no CORS headers are emitted.
let res1 = await request('/1', 'OPTIONS', false);
expect(res1.statusCode).toBe(204);
expect(res1.headers).not.toHaveProperty('access-control-allow-origin');
expect(res1.headers).not.toHaveProperty('access-control-allow-methods');

// Cross-origin website must not receive permissive CORS headers.
let res2 = await request('/1', {
method: 'OPTIONS',
headers: { Origin: 'https://evil.example.com' }
}, false);
expect(res2.statusCode).toBe(204);
expect(res2.headers).not.toHaveProperty('access-control-allow-origin');
});

it('handles server errors', async () => {
server.route('/e/foo', () => { throw new Error('foo'); });
server.route('/e/bar', () => { throw new Server.Error(418); });
Expand Down Expand Up @@ -290,6 +316,30 @@ describe('Unit / Server', () => {
});
});

describe('isLoopbackOrigin', () => {
it('returns true for loopback origins', () => {
expect(isLoopbackOrigin('http://localhost:8000')).toBe(true);
expect(isLoopbackOrigin('http://127.0.0.1')).toBe(true);
expect(isLoopbackOrigin('http://app.localhost')).toBe(true);
});

it('returns true for the bracketed IPv6 loopback literal', () => {
expect(isLoopbackOrigin('http://[::1]:5338')).toBe(true);
expect(isLoopbackOrigin('http://[::1]')).toBe(true);
});

it('returns false for non-loopback and missing origins', () => {
expect(isLoopbackOrigin('https://evil.example.com')).toBe(false);
expect(isLoopbackOrigin('')).toBe(false);
expect(isLoopbackOrigin(undefined)).toBe(false);
expect(isLoopbackOrigin(42)).toBe(false);
});

it('returns false for a malformed origin that cannot be parsed as a URL', () => {
expect(isLoopbackOrigin('not a valid origin')).toBe(false);
});
});

describe('#serve([pathname], directory[, options])', () => {
beforeEach(async () => {
await server.listen();
Expand Down
Loading