From 2358bdfb20395fbec32e5cf542bdf03ade030aa1 Mon Sep 17 00:00:00 2001 From: Claude Bot Date: Mon, 6 Oct 2025 06:37:07 +0000 Subject: [PATCH] feat(compile): disable dotenv loading by default in standalone executables - Add env configuration to StandaloneModuleGraph to store dotenv behavior and prefix - Update bootStandalone to use stored env config instead of hardcoding load_all_without_inlining - Add env option to JSBundler compile object for API control - Set default behavior to .disable for compiled executables - Add --env flag support for CLI compilation - Add tests for dotenv behavior in compiled executables The default behavior for single-file executables (--compile) is now to NOT load .env files unless explicitly opted in via --env flag or compile.env option. This improves security by not automatically loading environment variables into compiled executables. Related changes: - StandaloneModuleGraph now serializes/deserializes dotenv config - JSBundler.zig CompileOptions now accepts env configuration - build_command.zig passes env config to toExecutable - bootStandalone calls runEnvLoader with configured behavior --- src/StandaloneModuleGraph.zig | 24 ++++- src/bun.js.zig | 6 +- src/bun.js/api/JSBundler.zig | 38 +++++++ src/bundler/bundle_v2.zig | 2 + src/cli/build_command.zig | 2 + test/bundler/bundler_compile_env.test.ts | 104 ++++++++++++++++++ test/bundler/compile-dotenv.test.ts | 132 +++++++++++++++++++++++ 7 files changed, 305 insertions(+), 3 deletions(-) create mode 100644 test/bundler/bundler_compile_env.test.ts create mode 100644 test/bundler/compile-dotenv.test.ts diff --git a/src/StandaloneModuleGraph.zig b/src/StandaloneModuleGraph.zig index 9b045a1fad..0566770852 100644 --- a/src/StandaloneModuleGraph.zig +++ b/src/StandaloneModuleGraph.zig @@ -7,6 +7,11 @@ pub const StandaloneModuleGraph = struct { files: bun.StringArrayHashMap(File), entry_point_id: u32 = 0, compile_exec_argv: []const u8 = "", + env_config: api.LoadedEnvConfig = .{ + .dotenv = .disable, + .prefix = "", + .defaults = .{ .keys = &.{}, .values = &.{} }, + }, // We never want to hit the filesystem for these files // We use the `/$bunfs/` prefix to indicate that it's a virtual path @@ -288,7 +293,11 @@ pub const StandaloneModuleGraph = struct { byte_count: usize = 0, modules_ptr: bun.StringPointer = .{}, entry_point_id: u32 = 0, + _padding1: u32 = 0, // Ensure compile_exec_argv_ptr is 8-byte aligned compile_exec_argv_ptr: bun.StringPointer = .{}, + dotenv_behavior: api.DotEnvBehavior = .disable, + _padding2: u32 = 0, // Ensure dotenv_prefix_ptr is 8-byte aligned + dotenv_prefix_ptr: bun.StringPointer = .{}, }; const trailer = "\n---- Bun! ----\n"; @@ -334,6 +343,11 @@ pub const StandaloneModuleGraph = struct { .files = modules, .entry_point_id = offsets.entry_point_id, .compile_exec_argv = sliceToZ(raw_bytes, offsets.compile_exec_argv_ptr), + .env_config = .{ + .dotenv = offsets.dotenv_behavior, + .prefix = sliceToZ(raw_bytes, offsets.dotenv_prefix_ptr), + .defaults = .{ .keys = &.{}, .values = &.{} }, + }, }; } @@ -349,7 +363,7 @@ pub const StandaloneModuleGraph = struct { return bytes[ptr.offset..][0..ptr.length :0]; } - pub fn toBytes(allocator: std.mem.Allocator, prefix: []const u8, output_files: []const bun.options.OutputFile, output_format: bun.options.Format, compile_exec_argv: []const u8) ![]u8 { + pub fn toBytes(allocator: std.mem.Allocator, prefix: []const u8, output_files: []const bun.options.OutputFile, output_format: bun.options.Format, compile_exec_argv: []const u8, dotenv_behavior: api.DotEnvBehavior, dotenv_prefix: []const u8) ![]u8 { var serialize_trace = bun.perf.trace("StandaloneModuleGraph.serialize"); defer serialize_trace.end(); @@ -391,6 +405,7 @@ pub const StandaloneModuleGraph = struct { string_builder.cap += 16; string_builder.cap += @sizeOf(Offsets); string_builder.countZ(compile_exec_argv); + string_builder.countZ(dotenv_prefix); try string_builder.allocate(allocator); @@ -497,6 +512,8 @@ pub const StandaloneModuleGraph = struct { .entry_point_id = @as(u32, @truncate(entry_point_id.?)), .modules_ptr = string_builder.appendCount(std.mem.sliceAsBytes(modules.items)), .compile_exec_argv_ptr = string_builder.appendCountZ(compile_exec_argv), + .dotenv_behavior = dotenv_behavior, + .dotenv_prefix_ptr = string_builder.appendCountZ(dotenv_prefix), .byte_count = string_builder.len, }; @@ -949,8 +966,10 @@ pub const StandaloneModuleGraph = struct { windows_options: bun.options.WindowsOptions, compile_exec_argv: []const u8, self_exe_path: ?[]const u8, + dotenv_behavior: api.DotEnvBehavior, + dotenv_prefix: []const u8, ) !CompileResult { - const bytes = toBytes(allocator, module_prefix, output_files, output_format, compile_exec_argv) catch |err| { + const bytes = toBytes(allocator, module_prefix, output_files, output_format, compile_exec_argv, dotenv_behavior, dotenv_prefix) catch |err| { return CompileResult.fail(std.fmt.allocPrint(allocator, "failed to generate module graph bytes: {s}", .{@errorName(err)}) catch "failed to generate module graph bytes"); }; if (bytes.len == 0) return CompileResult.fail("no output files to bundle"); @@ -1527,3 +1546,4 @@ const macho = bun.macho; const pe = bun.pe; const strings = bun.strings; const Schema = bun.schema.api; +const api = Schema; diff --git a/src/bun.js.zig b/src/bun.js.zig index fb59390ea0..7044438825 100644 --- a/src/bun.js.zig +++ b/src/bun.js.zig @@ -81,12 +81,16 @@ pub const Run = struct { .unspecified => {}, } - b.options.env.behavior = .load_all_without_inlining; + b.options.env.behavior = graph.env_config.dotenv; + b.options.env.prefix = graph.env_config.prefix; b.configureDefines() catch { failWithBuildError(vm); }; + // Load .env files based on the configured behavior + b.runEnvLoader(false) catch {}; + AsyncHTTP.loadEnv(vm.allocator, vm.log, b.env); vm.loadExtraEnvAndSourceCodePrinter(); diff --git a/src/bun.js/api/JSBundler.zig b/src/bun.js/api/JSBundler.zig index db8982524a..970d21e4de 100644 --- a/src/bun.js/api/JSBundler.zig +++ b/src/bun.js/api/JSBundler.zig @@ -57,6 +57,8 @@ pub const JSBundler = struct { windows_description: OwnedString = OwnedString.initEmpty(bun.default_allocator), windows_copyright: OwnedString = OwnedString.initEmpty(bun.default_allocator), outfile: OwnedString = OwnedString.initEmpty(bun.default_allocator), + env_behavior: api.DotEnvBehavior = .disable, + env_prefix: OwnedString = OwnedString.initEmpty(bun.default_allocator), pub fn fromJS(globalThis: *jsc.JSGlobalObject, config: jsc.JSValue, allocator: std.mem.Allocator, compile_target: ?CompileTarget) JSError!?CompileOptions { var this = CompileOptions{ @@ -69,6 +71,7 @@ pub const JSBundler = struct { .windows_description = OwnedString.initEmpty(allocator), .windows_copyright = OwnedString.initEmpty(allocator), .outfile = OwnedString.initEmpty(allocator), + .env_prefix = OwnedString.initEmpty(allocator), .compile_target = compile_target orelse .{}, }; errdefer this.deinit(); @@ -177,6 +180,33 @@ pub const JSBundler = struct { try this.outfile.appendSliceExact(slice.slice()); } + if (try object.getTruthy(globalThis, "env")) |env| { + if (env.isString()) { + var slice = try env.toSlice(globalThis, bun.default_allocator); + defer slice.deinit(); + const env_str = slice.slice(); + + if (bun.strings.indexOfChar(env_str, '*')) |asterisk| { + if (asterisk == 0) { + this.env_behavior = .load_all; + } else { + this.env_behavior = .prefix; + try this.env_prefix.appendSliceExact(env_str[0..asterisk]); + } + } else if (bun.strings.eqlComptime(env_str, "inline") or bun.strings.eqlComptime(env_str, "1")) { + this.env_behavior = .load_all; + } else if (bun.strings.eqlComptime(env_str, "disable") or bun.strings.eqlComptime(env_str, "0")) { + this.env_behavior = .disable; + } else { + return globalThis.throwInvalidArguments("Expected env to be 'inline', 'disable', or a prefix with a '*' character", .{}); + } + } else if (env.isBoolean()) { + this.env_behavior = if (env.toBoolean()) .load_all else .disable; + } else { + return globalThis.throwInvalidArguments("Expected env to be a boolean or string", .{}); + } + } + return this; } @@ -190,6 +220,7 @@ pub const JSBundler = struct { this.windows_description.deinit(); this.windows_copyright.deinit(); this.outfile.deinit(); + this.env_prefix.deinit(); } }; @@ -691,6 +722,13 @@ pub const JSBundler = struct { try this.define.insert(key, value); } + // Use compile-specific env settings if specified, otherwise use top-level env settings + this.env_behavior = compile.env_behavior; + if (!compile.env_prefix.isEmpty()) { + this.env_prefix.deinit(); + this.env_prefix = try compile.env_prefix.clone(); + } + const base_public_path = bun.StandaloneModuleGraph.targetBasePublicPath(this.compile.?.compile_target.os, "root/"); try this.public_path.append(base_public_path); diff --git a/src/bundler/bundle_v2.zig b/src/bundler/bundle_v2.zig index 0e7aa8f698..bf411ac33e 100644 --- a/src/bundler/bundle_v2.zig +++ b/src/bundler/bundle_v2.zig @@ -2043,6 +2043,8 @@ pub const BundleV2 = struct { compile_options.executable_path.slice() else null, + compile_options.env_behavior, + compile_options.env_prefix.slice(), ) catch |err| { return bun.StandaloneModuleGraph.CompileResult.fail(bun.handleOom(std.fmt.allocPrint(bun.default_allocator, "{s}", .{@errorName(err)}))); }; diff --git a/src/cli/build_command.zig b/src/cli/build_command.zig index 5590ef1581..44d14af136 100644 --- a/src/cli/build_command.zig +++ b/src/cli/build_command.zig @@ -429,6 +429,8 @@ pub const BuildCommand = struct { ctx.bundler_options.windows, ctx.bundler_options.compile_exec_argv orelse "", null, + this_transpiler.options.env.behavior, + this_transpiler.options.env.prefix, ) catch |err| { Output.printErrorln("failed to create executable: {s}", .{@errorName(err)}); Global.exit(1); diff --git a/test/bundler/bundler_compile_env.test.ts b/test/bundler/bundler_compile_env.test.ts new file mode 100644 index 0000000000..5611eda8a8 --- /dev/null +++ b/test/bundler/bundler_compile_env.test.ts @@ -0,0 +1,104 @@ +import { describe } from "bun:test"; +import { itBundled } from "./expectBundled"; + +describe("bundler", () => { + itBundled("compile/DotEnvDisabledByDefault", { + compile: true, + files: { + "/entry.ts": /* js */ ` + console.log(process.env.MY_SECRET_VAR || "not set"); + `, + "/.env": `MY_SECRET_VAR=secret_value`, + }, + run: { stdout: "not set" }, + }); + + itBundled("compile/DotEnvWithEnvInlineAPI", { + compile: { + env: "inline", + }, + backend: "api", + files: { + "/entry.ts": /* js */ ` + console.log(process.env.MY_SECRET_VAR || "not set"); + `, + "/.env": `MY_SECRET_VAR=secret_value`, + }, + run: { stdout: "secret_value" }, + }); + + itBundled("compile/DotEnvWithEnvAsteriskAPI", { + compile: { + env: "*", + }, + backend: "api", + files: { + "/entry.ts": /* js */ ` + console.log(process.env.MY_SECRET_VAR || "not set"); + `, + "/.env": `MY_SECRET_VAR=secret_value`, + }, + run: { stdout: "secret_value" }, + }); + + itBundled("compile/DotEnvWithEnvPrefixAPI", { + compile: { + env: "PUBLIC_*", + }, + backend: "api", + files: { + "/entry.ts": /* js */ ` + console.log("PUBLIC:", process.env.PUBLIC_VAR || "not set"); + console.log("PRIVATE:", process.env.PRIVATE_VAR || "not set"); + `, + "/.env": `PUBLIC_VAR=public_value +PRIVATE_VAR=private_value`, + }, + run: { + stdout: `PUBLIC: public_value +PRIVATE: not set`, + }, + }); + + itBundled("compile/DotEnvWithEnvTrueAPI", { + compile: { + env: true, + }, + backend: "api", + files: { + "/entry.ts": /* js */ ` + console.log(process.env.MY_SECRET_VAR || "not set"); + `, + "/.env": `MY_SECRET_VAR=secret_value`, + }, + run: { stdout: "secret_value" }, + }); + + itBundled("compile/DotEnvWithEnvFalseAPI", { + compile: { + env: false, + }, + backend: "api", + files: { + "/entry.ts": /* js */ ` + console.log(process.env.MY_SECRET_VAR || "not set"); + `, + "/.env": `MY_SECRET_VAR=secret_value`, + }, + run: { stdout: "not set" }, + }); + + itBundled("compile/DotEnvWithEnvDisableAPI", { + compile: { + env: "disable", + }, + backend: "api", + files: { + "/entry.ts": /* js */ ` + console.log(process.env.MY_SECRET_VAR || "not set"); + `, + "/.env": `MY_SECRET_VAR=secret_value`, + }, + run: { stdout: "not set" }, + }); +}); diff --git a/test/bundler/compile-dotenv.test.ts b/test/bundler/compile-dotenv.test.ts new file mode 100644 index 0000000000..30bf7d9516 --- /dev/null +++ b/test/bundler/compile-dotenv.test.ts @@ -0,0 +1,132 @@ +import { expect, test } from "bun:test"; +import { bunEnv, bunExe, normalizeBunSnapshot, tempDir } from "harness"; +import { join } from "path"; + +test("--compile should not load .env by default", async () => { + using dir = tempDir("compile-dotenv-default", { + "index.js": /* js */ ` + console.log(process.env.MY_SECRET_VAR || "not set"); + `, + ".env": `MY_SECRET_VAR=secret_value`, + }); + + // Compile the executable + await using proc = Bun.spawn({ + cmd: [bunExe(), "build", "--compile", "index.js"], + env: bunEnv, + cwd: String(dir), + stderr: "pipe", + stdout: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(exitCode).toBe(0); + expect(stderr).not.toContain("error"); + expect(stderr).not.toContain("panic"); + + // Run the compiled executable + await using execProc = Bun.spawn({ + cmd: [join(String(dir), "index")], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + + const [execStdout, execStderr, execExitCode] = await Promise.all([ + execProc.stdout.text(), + execProc.stderr.text(), + execProc.exited, + ]); + + expect(execExitCode).toBe(0); + expect(normalizeBunSnapshot(execStdout, dir)).toMatchInlineSnapshot(`"not set"`); +}); + +test("--compile with --env should load .env", async () => { + using dir = tempDir("compile-dotenv-with-flag", { + "index.js": /* js */ ` + console.log(process.env.MY_SECRET_VAR || "not set"); + `, + ".env": `MY_SECRET_VAR=secret_value`, + }); + + // Compile the executable with --env + await using proc = Bun.spawn({ + cmd: [bunExe(), "build", "--compile", "--env=*", "index.js"], + env: bunEnv, + cwd: String(dir), + stderr: "pipe", + stdout: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(exitCode).toBe(0); + expect(stderr).not.toContain("error"); + + // Run the compiled executable + await using execProc = Bun.spawn({ + cmd: [join(String(dir), "index")], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + + const [execStdout, execStderr, execExitCode] = await Promise.all([ + execProc.stdout.text(), + execProc.stderr.text(), + execProc.exited, + ]); + + expect(execExitCode).toBe(0); + expect(normalizeBunSnapshot(execStdout, dir)).toMatchInlineSnapshot(`"secret_value"`); +}); + +test("--compile with --env prefix should only load matching vars", async () => { + using dir = tempDir("compile-dotenv-prefix", { + "index.js": /* js */ ` + console.log("PUBLIC:", process.env.PUBLIC_VAR || "not set"); + console.log("PRIVATE:", process.env.PRIVATE_VAR || "not set"); + `, + ".env": `PUBLIC_VAR=public_value +PRIVATE_VAR=private_value`, + }); + + // Compile the executable with --env prefix + await using proc = Bun.spawn({ + cmd: [bunExe(), "build", "--compile", "--env=PUBLIC_*", "index.js"], + env: bunEnv, + cwd: String(dir), + stderr: "pipe", + stdout: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(exitCode).toBe(0); + expect(stderr).not.toContain("error"); + + // Run the compiled executable + await using execProc = Bun.spawn({ + cmd: [join(String(dir), "index")], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + + const [execStdout, execStderr, execExitCode] = await Promise.all([ + execProc.stdout.text(), + execProc.stderr.text(), + execProc.exited, + ]); + + expect(execExitCode).toBe(0); + expect(normalizeBunSnapshot(execStdout, dir)).toMatchInlineSnapshot(` +"PUBLIC: public_value +PRIVATE: not set" +`); +});