Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
12 changes: 12 additions & 0 deletions .changeset/quiet-seas-serve.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"@thexjs/core": patch
---

Make env-leak detection fail production builds loudly instead of silently
degrading. A leaked server-only variable was previously caught by
`assertNoEnvLeakage`, but the build continued and emitted a non-interactive
fallback island while logging only a routine warning β€” a dead island in
production that looked like a recovered build error. Now `x build` aborts
with an `EnvLeakageError` (non-zero exit, visible to CI/CD), while the dev
server keeps serving but logs a visually distinct `SECURITY` warning instead
of a generic build error so it can't be mistaken for a hot-reload hiccup.
1 change: 1 addition & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ writing.
| API routes (method dispatch, 405) | stable | yes | `createApp-request.test.ts` |
| Server functions (dispatch, CSRF) | stable | yes | `server-functions.test.ts` |
| Security headers / rate limiting | stable | yes | `security/security.test.ts`, `security/rate-limit-redis.test.ts` |
| Env-leak guard (server-only env in client code) | stable | yes | `env-leak.test.ts` (prod fails loudly), `env-leak-dev.test.ts` (dev stub + πŸ”’ warning) |
| Image proxy (SSRF allow-list) | stable | yes | `images/proxy.test.ts` |
| Env validation (`@thexjs/env`) | stable | yes | `packages/env/src/index.test.ts` |
| Build toolchain (`x build`, `.x/` output) | stable | yes | `build.test.ts`, `cli.test.ts` |
Expand Down
9 changes: 8 additions & 1 deletion packages/core/src/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import {
scanPages,
scanRoutes,
} from "./router";
import { assertNoEnvLeakage } from "./security/env-isolation";
import { EnvLeakageError, assertNoEnvLeakage } from "./security/env-isolation";
import { registerServerFunctions } from "./server-functions";

export type RouteMode = "static" | "server";
Expand Down Expand Up @@ -331,6 +331,13 @@ async function bundleRouteIslands(
console.warn(` [islands] build error: ${log.message}`);
}
} catch (err) {
// A leaked server-only env var is a security failure, not a routine build
// hiccup. Rethrow so `x build` fails loudly and CI/CD sees a non-zero
// exit β€” a build that silently ships a dead, non-interactive island is
// worse than no build at all. The guarantee (the secret never reaches the
// client) already holds; this makes the failure distinguishable from an
// ordinary Bun.build() error.
if (err instanceof EnvLeakageError) throw err;
console.warn(` [islands] build failed for ${routeFilePath}:`, err);
}

Expand Down
130 changes: 130 additions & 0 deletions packages/core/src/env-leak-dev.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { afterAll, beforeAll, describe, expect, spyOn, test } from "bun:test";
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { buildIslandBundleInMemory } from "./island-bundle";

/**
* Dev-mode env isolation lives in its own file so this process can make the
* dev bundle the FIRST browser-target Bun.build it runs. Bun 1.3.x has an
* intermittent in-process race where later browser bundles mis-resolve react
* from the node_modules module store ("Unexpected reading file … react/…"),
* so keeping these bundles to a fresh process makes the test deterministic.
*/

const FIXTURE_DIR = join(import.meta.dir, "__fixtures__/env-leak-dev");
const ROUTES_DIR = join(FIXTURE_DIR, "src/routes");
const ISLAND_PATH = join(import.meta.dir, "island.tsx");
const LEAK_ROUTE = join(ROUTES_DIR, "leaky.tsx");
const CLEAN_ROUTE = join(ROUTES_DIR, "clean.tsx");

function writeRoute(path: string, body: string): void {
mkdirSync(join(path, ".."), { recursive: true });
writeFileSync(path, body);
}

beforeAll(() => {
writeRoute(
LEAK_ROUTE,
`import { Island } from "${ISLAND_PATH}";

export function Leaky() {
const secret = process.env.API_SECRET_TOKEN;
return <button>{secret}</button>;
}

export const islands = { Leaky };

export const mode = "static";

export default function LeakPage() {
return (
<main>
<h1>Leak</h1>
<Island name="Leaky" client="idle">
<Leaky />
</Island>
</main>
);
}
`,
);
writeRoute(
CLEAN_ROUTE,
`import { Island } from "${ISLAND_PATH}";

export function Counter() {
const count = 0;
return <button>{count}</button>;
}

export const islands = { Counter };

export const mode = "static";

export default function CleanPage() {
return (
<main>
<h1>Clean</h1>
<Island name="Counter" client="idle">
<Counter />
</Island>
</main>
);
}
`,
);
});

afterAll(() => {
rmSync(join(import.meta.dir, "__fixtures__/env-leak-dev"), { recursive: true, force: true });
});

describe("dev-mode island env isolation", () => {
test("a leaking island serves the fallback stub and logs a SECURITY warning without crashing", async () => {
const errorSpy = spyOn(console, "error");
try {
const code = await buildIslandBundleInMemory(
LEAK_ROUTE,
[],
["Leaky"],
new Map(),
FIXTURE_DIR,
);

// Dev keeps serving so iteration continues, but only a stub. The
// stub's distinctive marker is the "not hydrated" attribute β€” the
// real React bundle also contains the ASCII word "fallback" in
// internals, so match the marker, not the word.
expect(code).toContain("data-island-hydrated");
expect(code).not.toContain("API_SECRET_TOKEN");

// The warning is visually distinct (πŸ”’ + SECURITY) so it can't be
// mistaken for a routine hot-reload build error.
const warnings = errorSpy.mock.calls.map((c) => String(c[0])).join("\n");
expect(warnings).toContain("πŸ”’");
expect(warnings).toContain("SECURITY:");
expect(warnings).toContain("API_SECRET_TOKEN");
} finally {
errorSpy.mockRestore();
}
});

test("a clean island bundles normally without a SECURITY warning", async () => {
const errorSpy = spyOn(console, "error");
try {
const code = await buildIslandBundleInMemory(
CLEAN_ROUTE,
[],
["Counter"],
new Map(),
FIXTURE_DIR,
);
expect(code).not.toContain("data-island-hydrated");

Check failure on line 122 in packages/core/src/env-leak-dev.test.ts

View workflow job for this annotation

GitHub Actions / Test (macos-latest)

error: expect(received).not.toContain(expected)

Expected to not contain: "data-island-hydrated" Counter\ndocument.querySelectorAll(\"[data-island]\").forEach(function(el) {\n el.setAttribute(\"data-island-hydrated\", \"false\");\n});\n\n})();\n" at <anonymous> (/Users/runner/work/x/x/packages/core/src/env-leak-dev.test.ts:122:24)

Check failure on line 122 in packages/core/src/env-leak-dev.test.ts

View workflow job for this annotation

GitHub Actions / Test (ubuntu-latest)

error: expect(received).not.toContain(expected)

Expected to not contain: "data-island-hydrated" Counter\ndocument.querySelectorAll(\"[data-island]\").forEach(function(el) {\n el.setAttribute(\"data-island-hydrated\", \"false\");\n});\n\n})();\n" at <anonymous> (/home/runner/work/x/x/packages/core/src/env-leak-dev.test.ts:122:24)
expect(code).toContain("hydrateRoot");
expect(code.length).toBeGreaterThan(50);
expect(errorSpy.mock.calls.length).toBe(0);
} finally {
errorSpy.mockRestore();
}
});
});
107 changes: 107 additions & 0 deletions packages/core/src/env-leak.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { build } from "./build";
import { EnvLeakageError } from "./security/env-isolation";

/**
* Env isolation is deliberately tested from a dedicated file: it bundles
* React islands via Bun.build, and the browser pre-Ill bundle path must be
* the first thing this process builds so an in-process React import can't
* perturb the isolated module store resolution.
*/

const FIXTURE_DIR = join(import.meta.dir, "__fixtures__/env-leak");
const ROUTES_DIR = join(FIXTURE_DIR, "src/routes");
const ISLAND_PATH = join(import.meta.dir, "island.tsx");
const LEAK_ROUTE = join(ROUTES_DIR, "leaky.tsx");
const CLEAN_ROUTE = join(ROUTES_DIR, "clean.tsx");
const OUT_DIR = join(FIXTURE_DIR, "dist");

function writeRoute(path: string, body: string): void {
mkdirSync(join(path, ".."), { recursive: true });
writeFileSync(path, body);
}

beforeAll(() => {
writeRoute(
LEAK_ROUTE,
`import { Island } from "${ISLAND_PATH}";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟑 Minor | ⚑ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== files =="
git ls-files | grep -E 'packages/core/src/env-leak(-dev)?\.test\.ts$' || true

echo "== relevant snippets =="
for f in packages/core/src/env-leak.test.ts packages/core/src/env-leak-dev.test.ts; do
  if [ -f "$f" ]; then
    echo "--- $f ---"
    nl -ba "$f" | sed -n '1,90p'
  fi
done

echo "== imports and constants =="
rg -n "ISLAND_PATH|pathToFileURL|join\\(|writeFile|createTemp" packages/core/src/env-leak*.test.ts packages/core/src -S || true

echo "== package config relevant =="
rg -n '"type":|verbatimModuleSyntax|test' package.json packages/core/package.json README.md tsconfig*.json 2>/dev/null || true

Repository: abdelkabirouadoukou/x

Length of output: 353


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== deterministic JS/Esm specifier behavior probe =="
node - <<'JS'
const path = require('node:path');
const isWindowsLike = process.platform === 'win32';
const native = isWindowsLike ? 'C:\\temp\\fixture.island.tsx' : '/tmp/fixture.island.tsx';
const nodeUrl = require('node:url');
const fileUrl = new URL(`file://${native.replace(/\\/g, '/')}`).href;

console.log(JSON.stringify({
  platform: process.platform,
  native,
  fileUrlFromReplacer: fileUrl,
  specifierHasBackslash: native.includes('\\'),
  specifierString: `import { Island } from "${native}";`,
  fileUrlSpecifierString: `import { Island } from "${fileUrl}";`
}, null, 2));

try {
  eval(`(async () => { const m = await import("${native.replace(/\\/g, '/')}"); console.log("eval dynamic import resolved", m.Ifo); })()`);
} catch (e) {
  console.log("eval dynamic import error:", e.constructor.name, String(e.message).split('\n')[0]);
}
JS

echo "== Windows-style import parser behavior probe =="
node - <<'JS'
const cases = [
  `import x from "C:\\temp\\module.tsx";`,
  `import x from "C:/temp/module.tsx";`,
];
for (const src of cases) {
  console.log("source:", src);
  try {
    console.log(JSON.stringify(src));
    console.log(JSON.stringify(src.replace(/["']/g, m => m)));
  } catch (e) {
    console.log("error:", e.constructor.name, String(e.message).split('\n')[0]);
  }
}
JS

Repository: abdelkabirouadoukou/x

Length of output: 1517


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== file counts/locations =="
git ls-files | grep -E 'packages/core/src/env-leak(-dev)?\.test\.ts$' || true

echo "== relevant snippets =="
for f in packages/core/src/env-leak.test.ts packages/core/src/env-leak-dev.test.ts; do
  if [ -f "$f" ]; then
    echo "--- $f ---"
    awk '{printf "%6d\t%s\n", NR, $0}' "$f" | sed -n '1,90p'
  fi
done

echo "== imports/constants/search =="
rg -n "ISLAND_PATH|pathToFileURL|join\\(|writeFile|createTemp|import { Island }" packages/core/src/env-leak*.test.ts packages/core/src -S || true

echo "== package config relevant =="
rg -n '"type":|verbatimModuleSyntax|test' package.json packages/core/package.json README.md tsconfig*.json 2>/dev/null || true

echo "== JavaScript import specifier semantics probe =="
node - <<'JS'
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'envleak-'));
const filePath = path.join(tmpDir, 'module.js');
const content = 'export const value = 123;';
fs.writeFileSync(filePath, content);

const nativeWindowsLike = filePath.replace(/\//g, '\\');
const fileUrl = new URL('file:///' + filePath).href;

for (const src of [
  `import { value } from "${nativeWindowsLike}";`,
  `import { value } from "${fileUrl}";`,
]) {
  console.log('source:', src);
  try {
    const fn = new Function(src);
    fn();
    console.log('no syntax error');
  } catch (e) {
    console.log('syntax error:', e.constructor.name, String(e.message).split('\n')[0]);
  }
}
fs.rmSync(tmpDir, { recursive: true, force: true });
JS

Repository: abdelkabirouadoukou/x

Length of output: 7899


🌐 Web query:

BMD module specifier backslash Windows JavaScript string escape syntax error

πŸ’‘ Result:

The issue you are encountering is a classic JavaScript string handling problem, often triggered when Windows file paths containing backslashes () are injected into strings or code templates [1][2][3]. In JavaScript (and many related shell/template environments), the backslash is the escape character [3]. When a Windows path like C:\Users is placed inside a double-quoted string, the engine attempts to interpret the backslash as the start of an escape sequence (e.g., \U is invalid, \v becomes a vertical tab) [1][2][3]. This causes the path to be "mangled" or corrupted, leading to file-not-found (ENOENT) errors [1][2]. Common scenarios and solutions include: 1. String Interpolation/Injection: If you are dynamically building strings that contain file paths, you must escape the backslashes [4][3]. A single backslash in a Windows path must become a double backslash (\) in your JavaScript source or template string [4][3]. Example: Instead of "C:\Users", use "C:\Users" [4][3]. 2. CLI/Command Templates: When passing paths to Node.js scripts via CLI arguments (e.g., node -e "..."), the shell often strips or misinterprets backslashes before Node.js even receives the string [1][2]. - Recommended Fix: Pass the path as a separate command-line argument using process.argv instead of embedding it directly in the script string [1]. - Example: node -e "const fs = require('fs'); console.log(fs.readFileSync(process.argv[4], 'utf8'))" "C:\path\to\file.txt" [1]. 3. Path Normalization: Node.js and many modern JavaScript tools natively support forward slashes (/) as path separators on Windows [1][2]. Converting your Windows paths to use forward slashes before using them in code or as module specifiers avoids the escape character issue entirely [1][2]. Regarding "BMD": Based on search results, BMD commonly refers to "Bot Maker for Discord" and its associated community mods, or "Both Module Definition" in webpack contexts [5][6][7][8][9]. There is no specific standard "BMD module specifier" syntax error; the error you are seeing is almost certainly a generic JavaScript path-escaping issue consistent with the environment you are working in [1][2]. Top results: [1], [2], [3]

Citations:


🌐 Web query:

Node.js modules import specifier backslash Windows "Cannot use import statement"

πŸ’‘ Result:

In Node.js, ECMAScript module (ESM) import specifiers must follow URL syntax, which uses forward slashes (/) as path separators, even on Windows [1][2]. Using backslashes () in an import specifier is invalid and will cause an error because Node.js resolves ESM import specifiers as URLs, which do not support backslashes as directory separators [2][3]. To correctly reference files on Windows in an ESM environment, you must use forward slashes in your import strings or programmatically generate the correct file URL [4][2]. Recommended Solutions: 1. Use Forward Slashes: For relative imports, always use forward slashes, regardless of the operating system [1][2]. import { myFunction } from './folder/my-module.js'; 2. Use fileURLToPath: If you need to construct paths dynamically (for example, when using dirname equivalents in ESM), use the node:url module to convert file URLs to platform-specific paths safely [4]. This ensures that backslashes are handled correctly by the underlying file system APIs [4]. Example of safe path construction: import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; // Get the directory path of the current module const dirname = dirname(fileURLToPath(import.meta.url)); // Join paths using the platform-specific separator (backslash on Windows) const myModulePath = join(dirname, 'subfolder', 'module.js'); By using node:url and node:path utilities, you avoid hardcoding path separators and ensure compatibility across different operating systems [4][5].

Citations:


🌐 Web query:

Bun ESM import specifier backslash Windows "Cannot find module"

πŸ’‘ Result:

In Bun, ES module (ESM) import specifiers must follow URL-like syntax, which requires the use of forward slashes (/) as path separators, even on Windows [1]. Using backslashes () in import specifiers is non-compliant with the ESM specification and will generally result in a "Cannot find module" error because Bun expects forward-slash-delimited paths [1][2]. If you are experiencing "Cannot find module" errors related to backslashes on Windows, please note the following: 1. Mandatory Forward Slashes: Unlike some older Windows-specific tools that might accept backslashes in paths, ESM import specifiers are URL-based and must strictly use forward slashes [1]. Ensure your import statements (e.g., import "./folder/file.js") use only forward slashes [1]. 2. Bun's Internal Resolution: Bun has addressed various bugs where backslashes were incorrectly inserted into import specifiers (e.g., during bundling) [3][4]. If your code uses correct forward-slash syntax but you still receive these errors, ensure you are running the latest version of Bun, as recent updates have included fixes for Windows-specific path normalization issues [3][2][4]. 3. Debugging: If you are dynamically generating paths, ensure you are normalizing them to forward slashes before passing them to an import statement. If you encounter this error while using features like package.json "imports" or "exports" fields, verify that those paths also use forward slashes, as Bun's resolver requires them to correctly map subpaths [1][2].

Citations:


Use file URLs for generated fixture imports.

The generated modules interpolate native absolute paths into ESM import specifiers. On Windows, backslashes break import parsing or module resolution. Use pathToFileURL(ISLAND_PATH).href at all generated import sites: env-leak.test.ts:29, env-leak.test.ts:54, env-leak-dev.test.ts:28, and env-leak-dev.test.ts:53.

πŸ“ Affects 2 files
  • packages/core/src/env-leak.test.ts#L29-L29 (this comment)
  • packages/core/src/env-leak.test.ts#L54-L54
  • packages/core/src/env-leak-dev.test.ts#L28-L28
  • packages/core/src/env-leak-dev.test.ts#L53-L53
πŸ€– Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/env-leak.test.ts` at line 29, Replace native absolute-path
interpolation in the generated fixture imports with pathToFileURL(...).href so
ESM specifiers are valid on all platforms. Apply this at
packages/core/src/env-leak.test.ts lines 29-29 and 54-54, and
packages/core/src/env-leak-dev.test.ts lines 28-28 and 53-53, using the existing
ISLAND_PATH value at each generated import site.


export function Leaky() {
const secret = process.env.API_SECRET_TOKEN;
return <button>{secret}</button>;
}

export const islands = { Leaky };

export const mode = "static";

export default function LeakPage() {
return (
<main>
<h1>Leak</h1>
<Island name="Leaky" client="idle">
<Leaky />
</Island>
</main>
);
}
`,
);
writeRoute(
CLEAN_ROUTE,
`import { Island } from "${ISLAND_PATH}";

export function Counter() {
const count = 0;
return <button>{count}</button>;
}

export const islands = { Counter };

export const mode = "static";

export default function CleanPage() {
return (
<main>
<h1>Clean</h1>
<Island name="Counter" client="idle">
<Counter />
</Island>
</main>
);
}
`,
);
});

afterAll(() => {
rmSync(join(import.meta.dir, "__fixtures__/env-leak"), { recursive: true, force: true });
});

describe("production build env isolation", () => {
test("a build with a leaking island fails loudly with EnvLeakageError", async () => {
await expect(build({ routesDir: ROUTES_DIR, outDir: OUT_DIR })).rejects.toThrow(

Check failure on line 85 in packages/core/src/env-leak.test.ts

View workflow job for this annotation

GitHub Actions / Test (macos-latest)

error:

Expected promise that rejects Received promise that resolved: Promise { <resolved> } at <anonymous> (/Users/runner/work/x/x/packages/core/src/env-leak.test.ts:85:77)

Check failure on line 85 in packages/core/src/env-leak.test.ts

View workflow job for this annotation

GitHub Actions / Test (ubuntu-latest)

error:

Expected promise that rejects Received promise that resolved: Promise { <resolved> } at <anonymous> (/home/runner/work/x/x/packages/core/src/env-leak.test.ts:85:77)
EnvLeakageError,
);

// The aborted build must not leave a silently-degraded stub claiming
// success: either the islands dir holds real (non-leaking) bundles, or β€”
// since the build aborted on the leak β€” no client output at all. Also,
// whatever bundle it did emit, the secret never reaches the client.
const clientDir = join(OUT_DIR, "client");
const islandsDir = join(clientDir, "_islands");
if (existsSync(islandsDir)) {
for (const entry of readdirSync(islandsDir)) {
const entryDir = join(islandsDir, entry);
for (const file of readdirSync(entryDir)) {
if (!file.endsWith(".js")) continue;
const js = readFileSync(join(entryDir, file), "utf-8");
expect(js).not.toContain("API_SECRET_TOKEN");
}
}
}
expect(existsSync(islandsDir) || !existsSync(join(clientDir, "index.html"))).toBe(true);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});
});
11 changes: 10 additions & 1 deletion packages/core/src/island-bundle.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { basename, join } from "node:path";
import { assertNoEnvLeakage } from "./security/env-isolation";
import { EnvLeakageError, assertNoEnvLeakage } from "./security/env-isolation";
import { generateServerFunctionClient } from "./server-functions";

export interface ActionModuleInfo {
Expand Down Expand Up @@ -156,6 +156,15 @@ export async function buildIslandBundleInMemory(
assertNoEnvLeakage(code, entryPath);
return wrapIslandBundle(code);
} catch (err) {
// A leaked server-only env var isn't a routine build hiccup. Dev keeps
// serving the fallback stub (fast iteration wins), but the warning must
// be visually distinct so it can't be mistaken for a hot-reload error.
if (err instanceof EnvLeakageError) {
console.error(
`\x1b[1;31mπŸ”’ SECURITY: [islands] ${err.message}\x1b[0m\n Falling back to a non-interactive hydration stub β€” fix the env isolation (prefix with THEXJS_PUBLIC_ or move the access server-side) before relying on this page's client behavior.`,
);
return wrapIslandBundle(generateFallbackHydration(islandNames));
}
console.warn(` [islands] build failed for ${routeFilePath}:`, err);
return wrapIslandBundle(generateFallbackHydration(islandNames));
} finally {
Expand Down
Loading