diff --git a/packages/blocks-cli/scripts/codegen-injection.test.ts b/packages/blocks-cli/scripts/codegen-injection.test.ts new file mode 100644 index 00000000..cb6118d6 --- /dev/null +++ b/packages/blocks-cli/scripts/codegen-injection.test.ts @@ -0,0 +1,94 @@ +/** + * Regression guard for the codegen code-injection fix. + * + * generate-loaders.ts / generate-sections.ts build TypeScript source by + * interpolating filename-derived values (`entry.key`, `importPath`, `rel`) into + * string literals in the emitted `.gen.ts`. Those values were pasted raw, so a + * file whose NAME contained `"` `)` `;` broke out of the string literal into + * executable generated code — run on the next `dev`/`build`. The fix emits every + * such value via JSON.stringify. + * + * A POSIX filename cannot contain `/` or NUL, but `"`, `)`, `;`, `(` are all + * legal — enough for a breakout. The payload base name below closes the string + * and opens a call; we assert the emitted quote is ESCAPED (safe) and the raw + * unescaped breakout never appears. + */ +import * as cp from "node:child_process"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +// `zz` immediately precedes the quote so the escaped form is `zz\")` and the +// unescaped (vulnerable) form is `zz")` — the two are textually distinguishable. +const PAYLOAD = 'zz");PWN;('; +const SAFE = 'zz\\");PWN'; // escaped quote: what JSON.stringify must produce +const RAW = 'zz");PWN'; // unescaped breakout: must NOT appear + +describe("generate-loaders — hostile filename cannot inject code", () => { + let dir: string; + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), "codegen-inj-loaders-")); + }); + afterEach(() => { + fs.rmSync(dir, { recursive: true, force: true }); + }); + + it("JSON-escapes a filename containing quote/paren breakout chars", () => { + const loadersDir = path.join(dir, "src", "loaders"); + fs.mkdirSync(loadersDir, { recursive: true }); + // Filename itself carries the payload. + fs.writeFileSync(path.join(loadersDir, `${PAYLOAD}.ts`), "export default async () => [];\n"); + + const r = cp.spawnSync("npx", ["tsx", path.resolve(__dirname, "generate-loaders.ts")], { + encoding: "utf8", + cwd: dir, + }); + expect(r.status).toBe(0); + const out = fs.readFileSync(path.join(dir, ".deco", "loaders.gen.ts"), "utf8"); + + expect(out).toContain(SAFE); // payload present, quote escaped + expect(out).not.toContain(RAW); // no unescaped string breakout + }); +}); + +describe("generate-sections — hostile filename cannot inject code", () => { + let tmpDir: string; + let sectionsDir: string; + let outFile: string; + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "codegen-inj-sections-")); + sectionsDir = path.join(tmpDir, "sections"); + outFile = path.join(tmpDir, "out", "sections.gen.ts"); + fs.mkdirSync(sectionsDir, { recursive: true }); + }); + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("JSON-escapes a section filename containing quote/paren breakout chars", () => { + fs.writeFileSync( + path.join(sectionsDir, `${PAYLOAD}.tsx`), + "export default function S() { return null; }\n", + ); + + const r = cp.spawnSync( + "npx", + [ + "tsx", + path.resolve(__dirname, "generate-sections.ts"), + "--sections-dir", + sectionsDir, + "--out-file", + outFile, + "--registry", // emit the sectionImports map (the filename-derived sink) + ], + { encoding: "utf8", cwd: tmpDir }, + ); + expect(r.status).toBe(0); + const out = fs.readFileSync(outFile, "utf8"); + + expect(out).toContain(SAFE); + expect(out).not.toContain(RAW); + }); +}); diff --git a/packages/blocks-cli/scripts/generate-loaders.ts b/packages/blocks-cli/scripts/generate-loaders.ts index 8383cf38..74fc6c3c 100644 --- a/packages/blocks-cli/scripts/generate-loaders.ts +++ b/packages/blocks-cli/scripts/generate-loaders.ts @@ -230,22 +230,25 @@ lines.push( // `(props, req, ctx)` Fresh/Deno loaders still type-check. Any ctx-dependent // path in the loader body throws at runtime and must be refactored. for (const entry of entries) { + // Emit every filename-derived value via JSON.stringify so a hostile file name + // (containing `"`, `)`, `;`, a newline, ...) cannot break out of the string + // literal into executable generated code. For ordinary paths the output is + // byte-identical to a plain double-quoted string. + const keyLit = JSON.stringify(entry.key); + const aliasLit = JSON.stringify(`${entry.key}.ts`); + const importLit = JSON.stringify(entry.importPath); if (entry.kind === "loader") { // Both alias keys share the same dedup namespace (the non-.ts name) so a // render that references either collapses onto one in-flight call. - lines.push( - ` "${entry.key}": createLoaderEntry("${entry.key}", () => import("${entry.importPath}")),`, - ); - lines.push( - ` "${entry.key}.ts": createLoaderEntry("${entry.key}", () => import("${entry.importPath}")),`, - ); + lines.push(` ${keyLit}: createLoaderEntry(${keyLit}, () => import(${importLit})),`); + lines.push(` ${aliasLit}: createLoaderEntry(${keyLit}, () => import(${importLit})),`); } else { - lines.push(` "${entry.key}": async (props: any, request?: Request) => {`); - lines.push(` const mod = await import("${entry.importPath}");`); + lines.push(` ${keyLit}: async (props: any, request?: Request) => {`); + lines.push(` const mod = await import(${importLit});`); lines.push(" return (mod.default as any)(props, request);"); lines.push(" },"); - lines.push(` "${entry.key}.ts": async (props: any, request?: Request) => {`); - lines.push(` const mod = await import("${entry.importPath}");`); + lines.push(` ${aliasLit}: async (props: any, request?: Request) => {`); + lines.push(` const mod = await import(${importLit});`); lines.push(" return (mod.default as any)(props, request);"); lines.push(" },"); } diff --git a/packages/blocks-cli/scripts/generate-sections.ts b/packages/blocks-cli/scripts/generate-sections.ts index a71f32c6..8310194e 100644 --- a/packages/blocks-cli/scripts/generate-sections.ts +++ b/packages/blocks-cli/scripts/generate-sections.ts @@ -188,7 +188,7 @@ for (let i = 0; i < syncEntries.length; i++) { const e = syncEntries[i]; const importPath = relativeImportPath(outFile, e.filePath); const varName = `_sync${i}`; - lines.push(`import * as ${varName} from "${importPath}";`); + lines.push(`import * as ${varName} from ${JSON.stringify(importPath)};`); } // LoadingFallback imports — sections with LoadingFallback that aren't sync-imported @@ -196,7 +196,7 @@ const nonSyncFallbacks = fallbackEntries.filter((e) => !e.meta.sync); for (let i = 0; i < nonSyncFallbacks.length; i++) { const e = nonSyncFallbacks[i]; const importPath = relativeImportPath(outFile, e.filePath); - lines.push(`import { LoadingFallback as _fb${i} } from "${importPath}";`); + lines.push(`import { LoadingFallback as _fb${i} } from ${JSON.stringify(importPath)};`); } lines.push(""); @@ -221,10 +221,13 @@ lines.push("}"); lines.push(""); lines.push("export const sectionMeta: Record = {"); for (const e of entries) { + // JSON.stringify both the meta values and the section key so a hostile + // section filename or `@cache` annotation cannot break out of the generated + // string literal. Field names (`k`) are fixed SectionMetaEntry keys. const props = Object.entries(e.meta) - .map(([k, v]) => `${k}: ${typeof v === "string" ? `"${v}"` : v}`) + .map(([k, v]) => `${k}: ${JSON.stringify(v)}`) .join(", "); - lines.push(` "${e.key}": { ${props} },`); + lines.push(` ${JSON.stringify(e.key)}: { ${props} },`); } lines.push("};"); lines.push(""); @@ -233,7 +236,7 @@ lines.push(""); if (syncEntries.length > 0) { lines.push("export const syncComponents: Record = {"); for (let i = 0; i < syncEntries.length; i++) { - lines.push(` "${syncEntries[i].key}": _sync${i},`); + lines.push(` ${JSON.stringify(syncEntries[i].key)}: _sync${i},`); } lines.push("};"); } else { @@ -248,10 +251,10 @@ if (allFallbacks.length > 0) { for (const e of allFallbacks) { if (e.meta.sync) { const syncIdx = syncEntries.indexOf(e); - lines.push(` "${e.key}": _sync${syncIdx}.LoadingFallback,`); + lines.push(` ${JSON.stringify(e.key)}: _sync${syncIdx}.LoadingFallback,`); } else { const fbIdx = nonSyncFallbacks.indexOf(e); - lines.push(` "${e.key}": _fb${fbIdx},`); + lines.push(` ${JSON.stringify(e.key)}: _fb${fbIdx},`); } } lines.push("};"); @@ -277,7 +280,7 @@ if (EMIT_REGISTRY) { for (const filePath of sectionFiles) { const rel = path.relative(sectionsDir, filePath).replace(/\\/g, "/"); const importPath = relativeImportPath(outFile, filePath); - lines.push(` "./sections/${rel}": () => import("${importPath}"),`); + lines.push(` ${JSON.stringify(`./sections/${rel}`)}: () => import(${JSON.stringify(importPath)}),`); } lines.push("};"); }