fix: split config-eval from config-rendering for browser safety

Move Node.js-only functions (evalConfigFileContent, tryEvalConfigFileContent,
detectImportPackageFromDir) to new config-eval.ts. This prevents the dashboard
browser build from failing on fs/path/jiti imports.

Dashboard now uses parseStaticConfigLiteral (regex+JSON.parse) instead of
jiti eval for untrusted GitHub-fetched config content, avoiding RCE risk.

Remove type casts in favor of isRecord type guard.

Co-Authored-By: mantra <mantra@stack-auth.com>
This commit is contained in:
Devin AI 2026-06-23 23:58:42 +00:00
parent 576bdcfb8a
commit f71cde84b8
7 changed files with 200 additions and 115 deletions

View File

@ -1,6 +1,7 @@
import { globalPrismaClient } from "@/prisma-client";
import { showOnboardingHexclaveConfigValue } from "@hexclave/shared/dist/config-authoring";
import { detectImportPackageFromDir, evalConfigFileContent, renderConfigFileContent } from "@hexclave/shared/dist/config-rendering";
import { renderConfigFileContent } from "@hexclave/shared/dist/config-rendering";
import { detectImportPackageFromDir, evalConfigFileContent } from "@hexclave/shared/dist/config-eval";
import { isValidConfig } from "@hexclave/shared/dist/config/format";
import { LOCAL_EMULATOR_ADMIN_EMAIL, LOCAL_EMULATOR_ADMIN_PASSWORD } from "@hexclave/shared/dist/local-emulator";
import { getEnvVariable } from "@hexclave/shared/dist/utils/env";

View File

@ -12,8 +12,7 @@
import type { PushedConfigSource } from "@hexclave/next";
import type { EnvironmentConfigOverrideOverride } from "@hexclave/shared/dist/config/schema";
import { isValidConfig, override } from "@hexclave/shared/dist/config/format";
import { evalConfigFileContent, renderConfigFileContent } from "@hexclave/shared/dist/config-rendering";
import { showOnboardingHexclaveConfigValue } from "@hexclave/shared/dist/hexclave-config-file";
import { parseStaticConfigLiteral, renderConfigFileContent } from "@hexclave/shared/dist/config-rendering";
import {
commitFile,
@ -52,10 +51,10 @@ export function buildUpdatedConfigFileContent(
currentFileContent: string,
configUpdate: EnvironmentConfigOverrideOverride,
): string {
const parsed = evalConfigFileContent(currentFileContent, "stack.config.ts");
if (parsed === showOnboardingHexclaveConfigValue) {
const parsed = parseStaticConfigLiteral(currentFileContent);
if (parsed == null) {
throw new Error(
"The config file currently exports the onboarding placeholder. Finish setting up Hexclave in your repo before pushing dashboard changes."
"Could not parse the existing config file. The file must be a static JSON config object (as produced by the dashboard). Config files with dynamic expressions or imports are not supported for dashboard-driven updates."
);
}
if (!isValidConfig(parsed)) {

View File

@ -1,5 +1,5 @@
import { replaceConfigObject, updateConfigObject } from "@hexclave/shared-backend";
import { detectImportPackageFromDir } from "@hexclave/shared/dist/config-rendering";
import { detectImportPackageFromDir } from "@hexclave/shared/dist/config-eval";
import { isValidConfig } from "@hexclave/shared/dist/config/format";
import type { EnvironmentConfigOverrideOverride } from "@hexclave/shared/dist/config/schema";
import { throwErr } from "@hexclave/shared/dist/utils/errors";

View File

@ -13,7 +13,8 @@ import { createInitPrompt } from "../lib/init-prompt.js";
import { createProjectInteractively } from "../lib/create-project.js";
import { runClaudeAgent } from "../lib/claude-agent.js";
import { resolveConfigFilePathOption } from "../lib/config-file-path.js";
import { detectImportPackageFromDir, renderConfigFileContent } from "@hexclave/shared/dist/config-rendering";
import { renderConfigFileContent } from "@hexclave/shared/dist/config-rendering";
import { detectImportPackageFromDir } from "@hexclave/shared/dist/config-eval";
import { throwErr } from "@hexclave/shared/dist/utils/errors";
const VALID_INIT_MODES = ["create", "create-cloud", "link-config", "link-cloud"] as const;

View File

@ -1,5 +1,6 @@
import { showOnboardingHexclaveConfigValue } from "@hexclave/shared/dist/config-authoring";
import { detectImportPackageFromDir, evalConfigFileContent, renderConfigFileContent } from "@hexclave/shared/dist/config-rendering";
import { renderConfigFileContent } from "@hexclave/shared/dist/config-rendering";
import { detectImportPackageFromDir, evalConfigFileContent } from "@hexclave/shared/dist/config-eval";
import type { Config, ConfigValue, NormalizedConfig } from "@hexclave/shared/dist/config/format";
import { isValidConfig, normalize, override } from "@hexclave/shared/dist/config/format";
import { captureError } from "@hexclave/shared/dist/utils/errors";
@ -130,7 +131,7 @@ export async function updateConfigObject(configFilePath: string, configUpdate: C
const content = readFileSync(configFilePath, "utf-8");
// Fast path: if the config is a plain static literal (no imports, no helpers),
// Fast path: if the config can be evaluated by jiti (no unresolvable imports),
// apply the update deterministically without invoking the AI agent.
const staticConfig = tryParseConfigFileContent(content, configFilePath);
if (staticConfig != null && isValidConfig(staticConfig)) {
@ -318,6 +319,10 @@ function configFileExportsConfig(content: string, configFilePath: string): boole
evalConfigFileContent(content, configFilePath);
return true;
} catch {
// jiti may fail to resolve imports that are valid in the user's project but
// absent from the current process (e.g. relative asset imports, workspace
// packages). For the structural sanity check we only need to know a runtime
// `config` binding still exists after the agent edited the file.
return /\bexport\s+const\s+config\b/.test(content);
}
}

View File

@ -0,0 +1,147 @@
import { existsSync, readFileSync } from "fs";
import { createJiti } from "jiti";
import path from "path";
export { hexclaveConfigFileExportsConfig } from "./hexclave-config-file";
const jiti = createJiti(import.meta.url, { moduleCache: false });
/**
* Packages that export the `HexclaveConfig` type, in priority order.
* The first match found in a project's dependencies wins. Hexclave-branded
* packages come first (canonical); the legacy `@stackframe/*` names remain
* so projects pinned to the last legacy release still render a config file
* that compiles against their installed SDK.
*/
const CONFIG_IMPORT_PACKAGES = [
"@hexclave/next",
"@hexclave/react",
"@hexclave/tanstack-start",
"@hexclave/js",
"@hexclave/template",
"@stackframe/stack",
"@stackframe/react",
"@stackframe/js",
"@stackframe/template",
] as const;
/**
* Walks up from `dir` to find the nearest `package.json` and returns the
* best SDK package to use for the `HexclaveConfig` type import.
*/
export function detectImportPackageFromDir(dir: string): string | undefined {
let current = dir;
while (true) {
const pkgPath = path.join(current, "package.json");
if (existsSync(pkgPath)) {
try {
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
const deps = [
...Object.keys(pkg.dependencies ?? {}),
...Object.keys(pkg.devDependencies ?? {}),
];
for (const known of CONFIG_IMPORT_PACKAGES) {
if (deps.includes(known)) {
return known;
}
}
return undefined;
} catch {
return undefined;
}
}
const parent = path.dirname(current);
if (parent === current) break;
current = parent;
}
return undefined;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return value != null && typeof value === "object" && !Array.isArray(value);
}
type ParsedConfigValue = Record<string, unknown> | string;
/**
* Evaluates config file content using jiti and returns the exported `config`
* value. Replaces the old Babel AST-based `parseHexclaveConfigFileContent`.
*
* WARNING: This executes arbitrary code via `jiti.evalModule` only use on
* content that is fully operator-controlled (local filesystem). Never call
* this on untrusted input (e.g. content fetched from a remote repository).
*/
export function evalConfigFileContent(content: string, filePath: string): ParsedConfigValue {
if (content.trim() === "") return {};
const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(filePath);
const mod: unknown = jiti.evalModule(content, { filename: resolvedPath });
if (!isRecord(mod)) {
throw new Error(`Invalid config in ${filePath}. The file must export a plain \`config\` object or "show-onboarding".`);
}
const config = mod.config;
if (config === undefined) {
throw new Error(`Invalid config in ${filePath}. The file must export a plain \`config\` object or "show-onboarding".`);
}
if (typeof config === "string") return config;
if (isRecord(config)) return config;
throw new Error(`Invalid config in ${filePath}. The file must export a plain \`config\` object or "show-onboarding".`);
}
/**
* Like {@link evalConfigFileContent}, but returns `null` instead of throwing
* when the content cannot be evaluated.
*/
export function tryEvalConfigFileContent(content: string, filePath: string): ParsedConfigValue | null {
try {
return evalConfigFileContent(content, filePath);
} catch {
return null;
}
}
// --- inline vitest tests ---
import.meta.vitest?.test("evalConfigFileContent parses static config exports", ({ expect }) => {
expect(evalConfigFileContent(`
import type { StackConfig } from "@hexclave/js";
export const config: StackConfig = {
auth: { allowSignUp: true },
payments: { testMode: false },
};
`, "stack.config.ts")).toMatchInlineSnapshot(`
{
"auth": {
"allowSignUp": true,
},
"payments": {
"testMode": false,
},
}
`);
});
import.meta.vitest?.test("evalConfigFileContent parses show-onboarding", ({ expect }) => {
expect(evalConfigFileContent('export const config = "show-onboarding";', "stack.config.ts")).toBe("show-onboarding");
});
import.meta.vitest?.test("evalConfigFileContent rejects content without config export", ({ expect }) => {
expect(() => evalConfigFileContent("export const other = {};", "stack.config.ts")).toThrow(/must export/);
});
import.meta.vitest?.test("tryEvalConfigFileContent returns the config for valid exports", ({ expect }) => {
expect(tryEvalConfigFileContent("export const config = { auth: { allowSignUp: true } };", "stack.config.ts")).toEqual({
auth: { allowSignUp: true },
});
});
import.meta.vitest?.test("tryEvalConfigFileContent returns null on unresolvable function call", ({ expect }) => {
expect(tryEvalConfigFileContent("export const config = someUndefinedFunction();", "stack.config.ts")).toBeNull();
});
import.meta.vitest?.test("tryEvalConfigFileContent returns null on unresolvable import", ({ expect }) => {
expect(tryEvalConfigFileContent('import x from "./nonexistent-file";\nexport const config = { a: x };', "stack.config.ts")).toBeNull();
});
import.meta.vitest?.test("tryEvalConfigFileContent returns null on syntax error", ({ expect }) => {
expect(tryEvalConfigFileContent("export const config = {", "stack.config.ts")).toBeNull();
});

View File

@ -1,14 +1,7 @@
import { existsSync, readFileSync } from "fs";
import { createJiti } from "jiti";
import path from "path";
import { isValidConfig, normalize } from "./config/format";
import { hexclaveConfigFileExportsConfig } from "./hexclave-config-file";
export { hexclaveConfigFileExportsConfig };
const DEFAULT_CONFIG_IMPORT_PACKAGE = "@hexclave/js";
const jiti = createJiti(import.meta.url, { moduleCache: false });
/**
* Packages that export the `HexclaveConfig` type, in priority order.
* The first match found in a project's dependencies wins. Hexclave-branded
@ -42,33 +35,6 @@ export function detectConfigImportPackage(dependencies: string[]): string | unde
return undefined;
}
/**
* Walks up from `dir` to find the nearest `package.json` and returns the
* best SDK package to use for the `HexclaveConfig` type import.
*/
export function detectImportPackageFromDir(dir: string): string | undefined {
let current = dir;
while (true) {
const pkgPath = path.join(current, "package.json");
if (existsSync(pkgPath)) {
try {
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
const deps = [
...Object.keys(pkg.dependencies ?? {}),
...Object.keys(pkg.devDependencies ?? {}),
];
return detectConfigImportPackage(deps);
} catch {
return undefined;
}
}
const parent = path.dirname(current);
if (parent === current) break;
current = parent;
}
return undefined;
}
/**
* Renders a config object into the source text of a `stack.config.ts` file.
*/
@ -92,39 +58,38 @@ export function renderConfigFileContent(config: unknown, importPackage?: string)
return `${importLine}\n\nexport const config: HexclaveConfig = ${JSON.stringify(normalizedConfig, null, 2)};\n`;
}
type ParsedConfigValue = Record<string, unknown> | string;
/**
* Evaluates config file content using jiti and returns the exported `config`
* value. Replaces the old Babel AST-based `parseHexclaveConfigFileContent`.
*/
export function evalConfigFileContent(content: string, filePath: string): ParsedConfigValue {
if (content.trim() === "") return {};
const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(filePath);
const mod = jiti.evalModule(content, { filename: resolvedPath }) as Record<string, unknown>;
const config = mod.config;
if (config === undefined) {
throw new Error(`Invalid config in ${filePath}. The file must export a plain \`config\` object or "show-onboarding".`);
}
if (typeof config === "string") return config;
if (config !== null && typeof config === "object" && !Array.isArray(config)) {
return config as Record<string, unknown>;
}
throw new Error(`Invalid config in ${filePath}. The file must export a plain \`config\` object or "show-onboarding".`);
function isRecord(value: unknown): value is Record<string, unknown> {
return value != null && typeof value === "object" && !Array.isArray(value);
}
/**
* Like {@link evalConfigFileContent}, but returns `null` instead of throwing
* when the content cannot be evaluated.
* Parses a config file that was produced by {@link renderConfigFileContent}.
* Extracts the JSON object literal after the `export const config` assignment
* and parses it with `JSON.parse`. This is intentionally limited to the exact
* format we emit it never executes code, so it is safe for untrusted input
* such as content fetched from a remote repository.
*
* Returns `null` when the content does not match the expected static format.
*/
export function tryEvalConfigFileContent(content: string, filePath: string): ParsedConfigValue | null {
export function parseStaticConfigLiteral(content: string): Record<string, unknown> | null {
// Match `export const config ... = <json>;` — the type annotation is optional.
const match = content.match(/export\s+const\s+config(?:\s*:\s*\w+)?\s*=\s*([\s\S]+);?\s*$/m);
if (match == null) return null;
// Trim any trailing semicolons and whitespace from the captured JSON body.
const jsonBody = match[1].replace(/;\s*$/, "").trim();
try {
return evalConfigFileContent(content, filePath);
const parsed: unknown = JSON.parse(jsonBody);
if (isRecord(parsed)) return parsed;
return null;
} catch {
return null;
}
}
// --- inline vitest tests ---
import.meta.vitest?.test("renderConfigFileContent normalizes config exports", ({ expect }) => {
expect(renderConfigFileContent({
"payments.items.todos.displayName": "Todo Slots",
@ -141,50 +106,6 @@ import.meta.vitest?.test("renderConfigFileContent normalizes config exports", ({
};`);
});
import.meta.vitest?.test("evalConfigFileContent parses static config exports", ({ expect }) => {
expect(evalConfigFileContent(`
import type { StackConfig } from "@hexclave/js";
export const config: StackConfig = {
auth: { allowSignUp: true },
payments: { testMode: false },
};
`, "stack.config.ts")).toMatchInlineSnapshot(`
{
"auth": {
"allowSignUp": true,
},
"payments": {
"testMode": false,
},
}
`);
});
import.meta.vitest?.test("evalConfigFileContent parses show-onboarding", ({ expect }) => {
expect(evalConfigFileContent('export const config = "show-onboarding";', "stack.config.ts")).toBe("show-onboarding");
});
import.meta.vitest?.test("evalConfigFileContent rejects content without config export", ({ expect }) => {
expect(() => evalConfigFileContent("export const other = {};", "stack.config.ts")).toThrow(/must export/);
});
import.meta.vitest?.test("tryEvalConfigFileContent returns the config for valid exports", ({ expect }) => {
expect(tryEvalConfigFileContent("export const config = { auth: { allowSignUp: true } };", "stack.config.ts")).toEqual({
auth: { allowSignUp: true },
});
});
import.meta.vitest?.test("tryEvalConfigFileContent returns null on failure", ({ expect }) => {
expect(tryEvalConfigFileContent("export const config = {", "stack.config.ts")).toBeNull();
});
import.meta.vitest?.test("hexclaveConfigFileExportsConfig detects a config export", ({ expect }) => {
expect(hexclaveConfigFileExportsConfig("export const config = { a: 1 };", "stack.config.ts")).toBe(true);
expect(hexclaveConfigFileExportsConfig('import x from "./x.txt" with { type: "text" };\nexport const config = { a: x };', "stack.config.ts")).toBe(true);
expect(hexclaveConfigFileExportsConfig("export const notConfig = { a: 1 };", "stack.config.ts")).toBe(false);
expect(hexclaveConfigFileExportsConfig("export const config = {", "stack.config.ts")).toBe(false);
});
import.meta.vitest?.test("renderConfigFileContent rejects conflicting dotted keys", ({ expect }) => {
expect(() => renderConfigFileContent({
"a.b": 1,
@ -209,8 +130,6 @@ import.meta.vitest?.test("renderConfigFileContent defaults to @hexclave/js", ({
});
import.meta.vitest?.test("renderConfigFileContent keeps legacy @stackframe packages on their root entrypoint", ({ expect }) => {
// The lightweight `/config` subpath only exists on Hexclave-branded packages;
// already-published @stackframe/* releases predate it.
const content = renderConfigFileContent({}, "@stackframe/next");
expect(content).toContain('import type { HexclaveConfig } from "@stackframe/next";');
});
@ -220,11 +139,24 @@ import.meta.vitest?.test("detectConfigImportPackage picks first matching package
expect(detectConfigImportPackage(["@hexclave/react", "@hexclave/js"])).toBe("@hexclave/react");
expect(detectConfigImportPackage(["@hexclave/js"])).toBe("@hexclave/js");
expect(detectConfigImportPackage(["@hexclave/tanstack-start"])).toBe("@hexclave/tanstack-start");
// Hexclave names take priority over legacy stackframe names when both appear.
expect(detectConfigImportPackage(["@stackframe/stack", "@hexclave/next"])).toBe("@hexclave/next");
// Legacy fallback still works for projects pinned to the last @stackframe/* release.
expect(detectConfigImportPackage(["@stackframe/stack"])).toBe("@stackframe/stack");
expect(detectConfigImportPackage(["@stackframe/template"])).toBe("@stackframe/template");
expect(detectConfigImportPackage(["lodash", "express"])).toBeUndefined();
expect(detectConfigImportPackage([])).toBeUndefined();
});
import.meta.vitest?.test("parseStaticConfigLiteral extracts JSON from rendered config", ({ expect }) => {
const rendered = renderConfigFileContent({ auth: { allowSignUp: true } });
expect(parseStaticConfigLiteral(rendered)).toEqual({ auth: { allowSignUp: true } });
});
import.meta.vitest?.test("parseStaticConfigLiteral returns null for non-static content", ({ expect }) => {
expect(parseStaticConfigLiteral("export const config = someFunction();")).toBeNull();
expect(parseStaticConfigLiteral("export const other = {};")).toBeNull();
expect(parseStaticConfigLiteral("")).toBeNull();
});
import.meta.vitest?.test("parseStaticConfigLiteral returns null for show-onboarding string", ({ expect }) => {
expect(parseStaticConfigLiteral('export const config = "show-onboarding";')).toBeNull();
});