Skip to content
Open
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
94 changes: 94 additions & 0 deletions packages/blocks-cli/scripts/codegen-injection.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
23 changes: 13 additions & 10 deletions packages/blocks-cli/scripts/generate-loaders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(" },");
}
Expand Down
19 changes: 11 additions & 8 deletions packages/blocks-cli/scripts/generate-sections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,15 +188,15 @@ 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
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("");
Expand All @@ -221,10 +221,13 @@ lines.push("}");
lines.push("");
lines.push("export const sectionMeta: Record<string, SectionMetaEntry> = {");
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("");
Expand All @@ -233,7 +236,7 @@ lines.push("");
if (syncEntries.length > 0) {
lines.push("export const syncComponents: Record<string, any> = {");
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 {
Expand All @@ -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("};");
Expand All @@ -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("};");
}
Expand Down