Skip to content

Commit 5e56b11

Browse files
authored
Merge pull request #856 from heygen-com/05-15-fix_security_close_codeql_critical_bad-code-sanitization
fix(security): close CodeQL critical command-line-injection and bad-code-sanitization
2 parents e8e2e81 + fc3aa4d commit 5e56b11

2 files changed

Lines changed: 49 additions & 16 deletions

File tree

packages/cli/src/commands/upgrade.ts

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { defineCommand } from "citty";
22
import type { Example } from "./_examples.js";
33
import * as clack from "@clack/prompts";
4-
import { execSync } from "node:child_process";
4+
import { execFileSync } from "node:child_process";
55
import { c } from "../ui/colors.js";
66

77
export const examples: Example[] = [
@@ -67,13 +67,29 @@ export default defineCommand({
6767
}
6868
}
6969

70-
const installCmd = `npm install -g hyperframes@${result.latest}`;
70+
// Reject anything that isn't a strict semver-shaped string before it reaches
71+
// the install command. A poisoned npm registry response could otherwise put
72+
// shell metacharacters into `result.latest`; rejecting up front means the
73+
// version flows through execFile (and the displayed command) as an opaque
74+
// token, not something the shell might re-parse.
75+
const SAFE_VERSION = /^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
76+
if (!SAFE_VERSION.test(result.latest)) {
77+
clack.outro(c.dim("Refusing to install: unexpected version string from npm registry."));
78+
process.exitCode = 1;
79+
return;
80+
}
81+
82+
const installArgs = ["install", "-g", `hyperframes@${result.latest}`];
83+
const installCmd = `npm ${installArgs.join(" ")}`;
7184
if (autoYes) {
7285
console.log();
7386
console.log(` ${c.dim("Running:")} ${c.accent(installCmd)}`);
7487
console.log();
7588
try {
76-
execSync(installCmd, { stdio: "inherit" });
89+
// execFileSync with shell:false — the version is now provably safe per
90+
// SAFE_VERSION above, but keep the no-shell call so future edits can't
91+
// regress the shell-injection surface area.
92+
execFileSync("npm", installArgs, { stdio: "inherit", shell: false });
7793
clack.outro(c.success(`Upgraded to v${result.latest}`));
7894
} catch {
7995
clack.outro(c.dim("Install failed. Try running manually:"));

packages/engine/src/services/frameCapture.ts

Lines changed: 30 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -349,6 +349,29 @@ async function pollPageExpression(
349349
return Boolean(await page.evaluate(expression));
350350
}
351351

352+
async function pollVideosReady(
353+
page: Page,
354+
skipIds: readonly string[],
355+
timeoutMs: number,
356+
intervalMs: number = 100,
357+
): Promise<boolean> {
358+
const check = async (): Promise<boolean> => {
359+
return Boolean(
360+
await page.evaluate((skipIdList: readonly string[]) => {
361+
const skip = new Set(skipIdList);
362+
const vids = Array.from(document.querySelectorAll("video")).filter((v) => !skip.has(v.id));
363+
return vids.length === 0 || vids.every((v) => (v as HTMLVideoElement).readyState >= 2);
364+
}, skipIds),
365+
);
366+
};
367+
const deadline = Date.now() + timeoutMs;
368+
while (Date.now() < deadline) {
369+
if (await check()) return true;
370+
await new Promise((resolve) => setTimeout(resolve, intervalMs));
371+
}
372+
return check();
373+
}
374+
352375
async function applyVideoMetadataHints(
353376
page: Page,
354377
hints: readonly CaptureVideoMetadataHint[] | undefined,
@@ -490,10 +513,9 @@ export async function initializeSession(session: CaptureSession): Promise<void>
490513
// sources) whose frames come from ffmpeg out-of-band. videoMetadataHints
491514
// supply intrinsic dimensions for skipped videos whose layout depends on
492515
// aspect ratio, while Chromium may still fail to decode/load metadata.
493-
const skipIdsLiteral = JSON.stringify(session.options.skipReadinessVideoIds ?? []);
494-
const videosReady = await pollPageExpression(
516+
const videosReady = await pollVideosReady(
495517
page,
496-
`(() => { const skip = new Set(${skipIdsLiteral}); const vids = Array.from(document.querySelectorAll("video")).filter(v => !skip.has(v.id)); return vids.length === 0 || vids.every(v => v.readyState >= 2); })()`,
518+
session.options.skipReadinessVideoIds ?? [],
497519
pageReadyTimeout,
498520
);
499521
if (!videosReady) {
@@ -596,16 +618,11 @@ export async function initializeSession(session: CaptureSession): Promise<void>
596618
await applyVideoMetadataHints(page, session.options.videoMetadataHints);
597619

598620
// Same readyState contract as the screenshot path above (>= 2 / HAVE_CURRENT_DATA).
599-
const beginframeSkipIdsLiteral = JSON.stringify(session.options.skipReadinessVideoIds ?? []);
600-
const videoDeadline =
601-
Date.now() + (session.config?.playerReadyTimeout ?? DEFAULT_CONFIG.playerReadyTimeout);
602-
while (Date.now() < videoDeadline) {
603-
const videosReady = await page.evaluate(
604-
`(() => { const skip = new Set(${beginframeSkipIdsLiteral}); const vids = Array.from(document.querySelectorAll("video")).filter(v => !skip.has(v.id)); return vids.length === 0 || vids.every(v => v.readyState >= 2); })()`,
605-
);
606-
if (videosReady) break;
607-
await new Promise((r) => setTimeout(r, 100));
608-
}
621+
await pollVideosReady(
622+
page,
623+
session.options.skipReadinessVideoIds ?? [],
624+
session.config?.playerReadyTimeout ?? DEFAULT_CONFIG.playerReadyTimeout,
625+
);
609626

610627
// Font check (no rAF dependency — uses fonts.ready API directly)
611628
await page.evaluate(`document.fonts?.ready`);

0 commit comments

Comments
 (0)