Compare commits

...

1 Commits

Author SHA1 Message Date
Claude Bot
2358bdfb20 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
2025-10-06 11:13:29 +00:00
7 changed files with 305 additions and 3 deletions

View File

@@ -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;

View File

@@ -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();

View File

@@ -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);

View File

@@ -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)})));
};

View File

@@ -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);

View File

@@ -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" },
});
});

View File

@@ -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"
`);
});