-
Notifications
You must be signed in to change notification settings - Fork 1
fix(core): fail builds loudly on env leakage #47
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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. |
| 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
|
||
| expect(code).toContain("hydrateRoot"); | ||
| expect(code.length).toBeGreaterThan(50); | ||
| expect(errorSpy.mock.calls.length).toBe(0); | ||
| } finally { | ||
| errorSpy.mockRestore(); | ||
| } | ||
| }); | ||
| }); | ||
| 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}"; | ||
|
|
||
| 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
|
||
| 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); | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| }); | ||
| }); | ||
There was a problem hiding this comment.
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:
Repository: abdelkabirouadoukou/x
Length of output: 353
π Script executed:
Repository: abdelkabirouadoukou/x
Length of output: 1517
π Script executed:
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).hrefat all generated import sites:env-leak.test.ts:29,env-leak.test.ts:54,env-leak-dev.test.ts:28, andenv-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-L54packages/core/src/env-leak-dev.test.ts#L28-L28packages/core/src/env-leak-dev.test.ts#L53-L53π€ Prompt for AI Agents