Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 57 additions & 4 deletions packages/core/src/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { Readable } from 'stream';
// This change ensures better compatibility and avoids relying on Node.js-specific APIs that might cause issues in ESM environments.
import { fileURLToPath } from 'url';
import { createRequire } from 'module';
import { configSchema } from './config.js';

export const getPercyDomPath = (url) => {
try {
Expand All @@ -41,6 +42,55 @@ function encodeURLSearchParams(subj, prefix) {
)).join('&') : `${prefix}=${encodeURIComponent(subj)}`;
}

// Walks the config schema and collects dot-paths of any fields marked `httpReadOnly: true`
// that are present in `body`. Driving this from the schema means new HTTP-blocked fields
// only need a one-line annotation next to their definition — no list to keep in sync here.
function findHttpReadOnlyPaths(body, schema, path = '') {
if (!body || typeof body !== 'object' || !schema?.properties) return [];

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.

Schema walker only descends through properties — silent miss for arrays / additionalProperties / oneOf.

if (!body || typeof body !== 'object' || !schema?.properties) return [];

Today this works because both blocked fields (executable, args) sit directly under properties of launchOptions. But the PR sets up an open extension point: "add httpReadOnly: true to any future sensitive field." That contract breaks silently when:

  • args items become objects with a nested sensitive sub-field — walker doesn't descend through items:.
  • A field lives under additionalProperties (e.g., dynamic keys) — walker doesn't descend.
  • A field lives in a oneOf / anyOf branch — walker doesn't descend.

The failure mode is the worst kind: a future contributor adds httpReadOnly: true to a sensitive field, no one notices the walker doesn't reach it, and the field is silently settable over HTTP. The annotation looks correct, the test for that field is green (it's never reached), and the security hole is invisible until discovered.

Two options:

  • Document the limit explicitly on the function ("only descends through properties — annotating fields under items / additionalProperties will be silently ignored") and add an assertion in test setup that walks configSchema for any httpReadOnly keys not reachable from the root, OR
  • Extend the walker to descend into items/additionalProperties (less code than you'd think).

Given this is a security primitive, option 2 is the safer long-term play.

let paths = [];
for (let [key, propSchema] of Object.entries(schema.properties)) {
if (!Object.prototype.hasOwnProperty.call(body, key)) continue;
let childPath = path ? `${path}.${key}` : key;
if (propSchema?.httpReadOnly) {
paths.push(childPath);
} else {
paths.push(...findHttpReadOnlyPaths(body[key], propSchema, childPath));
}
}
return paths;
}

// Top-level configSchema is a map of subschemas keyed by top-level config namespace
// (`discovery`, `snapshot`, …). Wrap it as a single object schema so the walker can recurse
// uniformly from the root.
const ROOT_CONFIG_SCHEMA = { type: 'object', properties: configSchema };

// Removes each dot-path's leaf from a deep clone of `body` and logs a warning per path.
// Returns the original `body` unchanged when `paths` is empty so we don't pay for a clone
// on every config request. Exported for unit testing: the `?.` chain in the reduce is a
// defensive guard for paths whose ancestor is absent from `body`. Through the production
// caller (stripBlockedConfigFields → findHttpReadOnlyPaths) every intermediate is verified
// present, so the guard is unreachable in normal use — but the explicit paths parameter
// lets a unit test exercise it without contorting the schema.
export function _applyHttpReadOnlyStripping(body, paths, log) {
if (!paths.length) return body;

let stripped = JSON.parse(JSON.stringify(body));
for (let p of paths) {
let parts = p.split('.');

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.

structuredClone is faster + handles more types if you're on Node 17+.

let stripped = JSON.parse(JSON.stringify(body));

For JSON-typed HTTP bodies this works fine — nothing in the body should be a Date/Map/Set/Function. So pure nitpick. But:

  • structuredClone(body) is ~3× faster on Node 17+ (V8 native).
  • It preserves types JSON loses (Dates, Maps, undefined slots in arrays), which makes the helper safer if it ever gets reused beyond HTTP request bodies.
  • It throws on un-cloneable values (functions, DOM nodes) rather than silently dropping them.

Given the helper is exported and prefixed _ (i.e., "private but usable"), the wider type-correctness matters more than the perf delta. Worth a swap unless there's a stated Node-version floor below 17 that I'm missing.

let leaf = parts.pop();
let parent = parts.reduce((o, k) => o?.[k], stripped);
if (parent && typeof parent === 'object') delete parent[leaf];
log.warn(`Ignoring \`${p}\` from /percy/config request: this field can only be set via the config file or CLI at startup.`);
}
return stripped;
}

// Returns a body with `httpReadOnly` fields removed. Caller guarantees `body` is truthy.
function stripBlockedConfigFields(body, log) {
return _applyHttpReadOnlyStripping(body, findHttpReadOnlyPaths(body, ROOT_CONFIG_SCHEMA), log);
}

// 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 @@ -293,10 +343,13 @@ export function createPercyServer(percy, port) {
});
})
// get or set config options
.route(['get', 'post'], '/percy/config', async (req, res) => res.json(200, {
config: req.body ? percy.set(req.body) : percy.config,
success: true
}))
.route(['get', 'post'], '/percy/config', async (req, res) => {
let body = req.body && stripBlockedConfigFields(req.body, logger('core:server'));

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.

Silent stripping — response shape doesn't surface what was rejected.

let body = req.body && stripBlockedConfigFields(req.body, logger('core:server'));
return res.json(200, {
  config: body ? percy.set(body) : percy.config,
  success: true
});

A caller (Percy SDK, custom HTTP client, internal tooling) sends { discovery: { launchOptions: { executable: '/foo' } } }, gets 200 OK with success: true and a config object that silently lacks their executable setting. The WARN is logged server-side but the caller never sees it. "Successfully ignored your security-sensitive setting" is not great DX.

Question: should the response include ignored_fields: ['discovery.launchOptions.executable'] so the caller can show an actionable message in their UI / log? Or even return 400 Bad Request for HTTP-only sensitive fields, matching how other validators behave?

Argument for silent: less info-leak to a hostile caller; existing pattern matches "strip unknown fields." Argument against: this isn't a malicious-caller scenario by design — /percy/config is localhost-only, the caller is presumably trusted. Surface info wins.

Also minor: logger('core:server') is invoked on every config request. If logger() does any non-trivial lookup or instance allocation, hoist it to module scope:

const configLog = logger('core:server');
// ...
let body = req.body && stripBlockedConfigFields(req.body, configLog);

return res.json(200, {
config: body ? percy.set(body) : percy.config,
success: true
});
})
// responds once idle (may take a long time)
.route('get', '/percy/idle', async (req, res) => res.json(200, {
success: await percy.idle().then(() => true)
Expand Down
7 changes: 5 additions & 2 deletions packages/core/src/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -489,9 +489,12 @@ export const configSchema = {
type: 'object',
additionalProperties: false,
properties: {
executable: { type: 'string' },
// httpReadOnly: never settable over /percy/config — accepting these would let any
// local process execute arbitrary code as the Percy user via flags like
// --renderer-cmd-prefix / --gpu-launcher. Must be set via static config or CLI.
executable: { type: 'string', httpReadOnly: true },

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.

httpReadOnly is a non-standard JSON Schema keyword — compatibility check.

Question: what schema validator does Percy use to validate /percy/config bodies (and the static config file)?

  • AJV with strict: true would reject unknown keywords with an error.
  • AJV with strict: false (or default in older versions) silently ignores them — fine for stripping, but the keyword becomes invisible to any tooling that reads the schema.
  • Other tools that consume configSchema (docs generation, TypeScript type generation, IDE auto-completion, schema-aware validators) won't know what httpReadOnly means.

Worth either:

  • Confirming the validator is lenient and adding a comment here noting the keyword is read by findHttpReadOnlyPaths in api.js (so a reader doesn't grep for it in vain), OR
  • Defining httpReadOnly as a documented custom keyword via the validator's extension API (AJV's addKeyword) so it's first-class.

Also: customer-facing docs for POST /percy/config should list which fields are HTTP-read-only. Today a customer programmatically configuring executable or args gets silent loss with only a server-side WARN log they probably won't see.

timeout: { type: 'integer' },
args: { type: 'array', items: { type: 'string' } },
args: { type: 'array', items: { type: 'string' }, httpReadOnly: true },
headless: { type: 'boolean' },
closeBrowser: { type: 'boolean', default: true }
}
Expand Down
85 changes: 84 additions & 1 deletion packages/core/test/api.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import PercyConfig from '@percy/config';
import { logger, setupTest, fs } from './helpers/index.js';
import Percy from '@percy/core';
import WebdriverUtils from '@percy/webdriver-utils';
import { getPercyDomPath } from '../src/api.js';
import { getPercyDomPath, _applyHttpReadOnlyStripping } from '../src/api.js';

describe('API Server', () => {
let percy;
Expand Down Expand Up @@ -113,6 +113,55 @@ describe('API Server', () => {
expect(percy.config).toEqual(expected);
});

it('does not warn when /config POST contains launchOptions without blocked keys', async () => {
await percy.start();

await request('/percy/config', {
method: 'POST',
body: { discovery: { launchOptions: { headless: false, closeBrowser: false } } }
});

expect(percy.config.discovery.launchOptions.headless).toBe(false);
expect(percy.config.discovery.launchOptions.closeBrowser).toBe(false);
expect(logger.stderr).not.toEqual(jasmine.arrayContaining([
jasmine.stringMatching(/Ignoring `discovery\.launchOptions/)
]));
});

it('strips security-sensitive launchOptions fields from /config POST', async () => {
await percy.start();

let before = percy.config.discovery.launchOptions;
expect(before.executable).toBeUndefined();
expect(before.args).toBeUndefined();

await request('/percy/config', {
method: 'POST',
body: {
discovery: {
launchOptions: {
executable: '/tmp/evil-binary',
args: ['--renderer-cmd-prefix=/tmp/payload'],
headless: false,
closeBrowser: false
}
}
}
});

// dangerous fields ignored
expect(percy.config.discovery.launchOptions.executable).toBeUndefined();
expect(percy.config.discovery.launchOptions.args).toBeUndefined();
// benign fields still settable
expect(percy.config.discovery.launchOptions.headless).toBe(false);
expect(percy.config.discovery.launchOptions.closeBrowser).toBe(false);

expect(logger.stderr).toEqual(jasmine.arrayContaining([
jasmine.stringMatching(/Ignoring `discovery\.launchOptions\.executable`/),
jasmine.stringMatching(/Ignoring `discovery\.launchOptions\.args`/)
]));
});

it('has an /idle endpoint that calls #idle()', async () => {
spyOn(percy, 'idle').and.resolveTo();
await percy.start();
Expand Down Expand Up @@ -1749,3 +1798,37 @@ describe('API Server', () => {
});
});
});

// Pure unit tests for the stripping helper — kept in their own describe so they don't
// drag the API Server's beforeEach (Percy instance + Chromium setup). Through the
// production caller every intermediate of a returned path is verified present, so the
// `o?.[k]` defensive guard inside _applyHttpReadOnlyStripping is unreachable in normal
// use. These tests pin that guard directly so a refactor can't silently lose it.
describe('_applyHttpReadOnlyStripping', () => {
it('tolerates paths whose ancestor is absent from body', () => {
let log = { warn: jasmine.createSpy('warn') };
let body = { unrelated: { keep: true } };
let result = _applyHttpReadOnlyStripping(
body,
['discovery.launchOptions.executable'],
log
);

// Body is deep-cloned (not mutated); the missing path is a no-op delete.
expect(result).toEqual({ unrelated: { keep: true } });
expect(result).not.toBe(body);
// The warning still fires — caller is told the field was rejected.
expect(log.warn).toHaveBeenCalledWith(
jasmine.stringMatching(/Ignoring `discovery\.launchOptions\.executable`/)
);
});

it('returns body unchanged (no clone) when paths is empty', () => {
let log = { warn: jasmine.createSpy('warn') };
let body = { discovery: { launchOptions: { headless: true } } };
let result = _applyHttpReadOnlyStripping(body, [], log);

expect(result).toBe(body);
expect(log.warn).not.toHaveBeenCalled();
});
});
Loading