fix(live): switch live-poll to execFileSync, validate ids strictly (#124)

* fix(live): switch live-poll to execFileSync, validate ids strictly

live-poll.mjs built the live-accept invocation with execSync and string
interpolation of event.id and event.variantId. Both fields originate in
the browser; validateEvent only checked truthiness, so shell metacharacters
in either field would land in the shell-parsed command.

Real exploitability is gated by the per-session token (loopback only,
unguessable UUID), so risk is low. The construction itself is structurally
unsafe though, and the fix is small.

- live-poll.mjs: execSync(string) → execFileSync('node', argv). Drops the
  hand-rolled single-quote wrap for --param-values; execFileSync passes
  each arg as a discrete argv slot, no shell parsing.
- live-server.mjs validateEvent: tighten id and variantId to match the
  actual generator shapes (8 hex chars and 1-3 digit numeric strings).
  Defense in depth so any value reaching downstream code is inert by
  construction.
- live-server.test.mjs: add three regression tests covering accept/discard
  rejection of shell-metachar ids and non-numeric variantIds. Update the
  three existing fixture ids to match the new pattern.

Reported in #122.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: refresh pnpm-lock.yaml to match package.json

Cloudflare Pages runs pnpm install --frozen-lockfile and was failing on
ERR_PNPM_OUTDATED_LOCKFILE: the lockfile was missing entries for
@ai-sdk/anthropic, @ai-sdk/openai, @anthropic-ai/claude-agent-sdk,
@anthropic-ai/sdk, @google/genai, ai, modern-screenshot, zod, and had
stale specifiers for jsdom, marked, playwright, wrangler, puppeteer.

Drift was introduced when package.json was last edited without a lockfile
regen. Running pnpm install --lockfile-only resolves it; verified with
pnpm install --frozen-lockfile (clean install succeeds).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Paul Bakaus <paulbakaus@pauls-mbp-3.lan>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Paul Bakaus
2026-04-28 19:00:04 -07:00
committed by GitHub
co-authored by Paul Bakaus Claude Opus 4.7
parent 7e0ce5e6b1
commit 9a5d0e71a9
36 changed files with 1967 additions and 286 deletions
@@ -8,7 +8,7 @@
* npx impeccable poll --reply <id> error "msg" # Reply with error
*/
import { execSync } from 'node:child_process';
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
@@ -146,13 +146,12 @@ Options:
? ['--id', event.id, '--discard']
: ['--id', event.id, '--variant', event.variantId];
if (event.type === 'accept' && event.paramValues && Object.keys(event.paramValues).length > 0) {
// Pass through a JSON blob; the shell-safe wrap uses single quotes because
// values are finite {id, number|string|boolean} pairs from a validated payload.
scriptArgs.push('--param-values', `'${JSON.stringify(event.paramValues).replace(/'/g, "'\\''")}'`);
scriptArgs.push('--param-values', JSON.stringify(event.paramValues));
}
try {
const out = execSync(
`node "${acceptScript}" ${scriptArgs.join(' ')}`,
const out = execFileSync(
'node',
[acceptScript, ...scriptArgs],
{ encoding: 'utf-8', cwd: process.cwd(), timeout: 30_000 }
);
event._acceptResult = JSON.parse(out.trim());
@@ -131,11 +131,21 @@ const VISUAL_ACTIONS = [
'colorize', 'layout', 'adapt', 'animate', 'delight', 'overdrive',
];
// Browser generates ids via crypto.randomUUID().slice(0, 8) (8 hex chars)
// and variantIds via String(small integer). Restrict to those shapes so
// any value that reaches a downstream child_process or DOM selector is
// inert by construction.
const ID_PATTERN = /^[0-9a-f]{8}$/;
const VARIANT_ID_PATTERN = /^[0-9]{1,3}$/;
function isValidId(v) { return typeof v === 'string' && ID_PATTERN.test(v); }
function isValidVariantId(v) { return typeof v === 'string' && VARIANT_ID_PATTERN.test(v); }
function validateEvent(msg) {
if (!msg || typeof msg !== 'object' || !msg.type) return 'Missing or invalid message';
switch (msg.type) {
case 'generate':
if (!msg.id || typeof msg.id !== 'string') return 'generate: missing id';
if (!isValidId(msg.id)) return 'generate: missing or malformed id';
if (!msg.action || !VISUAL_ACTIONS.includes(msg.action)) return 'generate: invalid action';
if (!Number.isInteger(msg.count) || msg.count < 1 || msg.count > 8) return 'generate: count must be 1-8';
if (!msg.element || !msg.element.outerHTML) return 'generate: missing element context';
@@ -145,8 +155,8 @@ function validateEvent(msg) {
if (msg.strokes !== undefined && !Array.isArray(msg.strokes)) return 'generate: strokes must be array';
return null;
case 'accept':
if (!msg.id) return 'accept: missing id';
if (!msg.variantId) return 'accept: missing variantId';
if (!isValidId(msg.id)) return 'accept: missing or malformed id';
if (!isValidVariantId(msg.variantId)) return 'accept: missing or malformed variantId';
if (msg.paramValues !== undefined) {
if (typeof msg.paramValues !== 'object' || msg.paramValues === null || Array.isArray(msg.paramValues)) {
return 'accept: paramValues must be an object';
@@ -154,7 +164,7 @@ function validateEvent(msg) {
}
return null;
case 'discard':
return msg.id ? null : 'discard: missing id';
return isValidId(msg.id) ? null : 'discard: missing or malformed id';
case 'exit':
return null;
case 'prefetch':