-
Notifications
You must be signed in to change notification settings - Fork 61
fix(core): block security-sensitive launchOptions over HTTP API (PER-… #2242
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
241d78f
7e5f2c9
d0c1da4
36d05c6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 { | ||
|
|
@@ -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 []; | ||
| 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('.'); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
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:
Given the helper is |
||
| 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 | ||
|
|
@@ -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')); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Question: should the response include 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 — Also minor: 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) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 }, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Question: what schema validator does Percy use to validate
Worth either:
Also: customer-facing docs for |
||
| 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 } | ||
| } | ||
|
|
||
There was a problem hiding this comment.
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.Today this works because both blocked fields (
executable,args) sit directly underpropertiesoflaunchOptions. But the PR sets up an open extension point: "addhttpReadOnly: trueto any future sensitive field." That contract breaks silently when:argsitems become objects with a nested sensitive sub-field — walker doesn't descend throughitems:.additionalProperties(e.g., dynamic keys) — walker doesn't descend.oneOf/anyOfbranch — walker doesn't descend.The failure mode is the worst kind: a future contributor adds
httpReadOnly: trueto 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:
properties— annotating fields underitems/additionalPropertieswill be silently ignored") and add an assertion in test setup that walksconfigSchemafor anyhttpReadOnlykeys not reachable from the root, ORitems/additionalProperties(less code than you'd think).Given this is a security primitive, option 2 is the safer long-term play.