diff --git a/.gitignore b/.gitignore index 528cf0fa5f4a9e..fe012c48d17ba1 100644 --- a/.gitignore +++ b/.gitignore @@ -193,3 +193,4 @@ scripts/lldb-inline # We regenerate these in all the build scripts cmake/sources/*.txt +bun_secure diff --git a/docs/runtime/permissions.mdx b/docs/runtime/permissions.mdx new file mode 100644 index 00000000000000..dac1a4efa1fadd --- /dev/null +++ b/docs/runtime/permissions.mdx @@ -0,0 +1,335 @@ +--- +title: Permissions +description: Control what resources your code can access with Bun's permissions model +--- + +Bun includes a permissions model that lets you control what resources your code can access. This is useful for running untrusted code or hardening production applications. + +--- + +## Security modes + +By default, Bun allows all operations for backwards compatibility. Use `--secure` to enable the security sandbox: + +```bash terminal icon="terminal" +# Default mode: everything is allowed +bun script.js + +# Secure mode: may prompt for permissions interactively +bun --secure script.js + +# Secure mode without prompts: always deny unless explicitly allowed +bun --secure --no-prompt script.js +``` + +In secure mode, operations like file I/O, network access, and subprocess spawning require permission. By default, the user may be prompted interactively. With `--no-prompt`, operations throw an error unless explicitly granted. + +When permission is denied, the error message includes details about what's needed: + +``` +PermissionDenied: Requires read access to "/path/to/file", run with --allow-read +``` + + +The `--allow-*` and `--deny-*` flags only take effect when `--secure` is specified. Without `--secure`, all operations are allowed regardless of permission flags. + + +--- + +## Granting permissions + +Use `--allow-*` flags to grant specific permissions: + +```bash terminal icon="terminal" +# Allow reading files +bun --secure --allow-read script.js + +# Allow network access +bun --secure --allow-net script.js + +# Allow multiple permission types +bun --secure --allow-read --allow-net --allow-env script.js +``` + +To grant all permissions at once, use `-A` or `--allow-all`: + +```bash terminal icon="terminal" +bun --secure -A script.js +``` + +--- + +## Permission types + +| Permission | Flag | Description | +|------------|------|-------------| +| File Read | `--allow-read` | Read files and directories | +| File Write | `--allow-write` | Write, create, or delete files | +| Network | `--allow-net` | Make network requests and listen on ports | +| Environment | `--allow-env` | Access environment variables | +| Subprocess | `--allow-run` | Spawn child processes | +| FFI | `--allow-ffi` | Load native libraries | +| System Info | `--allow-sys` | Access system information | + +--- + +## Granular permissions + +Each permission can be scoped to specific resources. + +### File system + +Restrict file access to specific paths: + +```bash terminal icon="terminal" +# Allow reading only from ./src +bun --secure --allow-read=./src script.js + +# Allow reading from multiple paths +bun --secure --allow-read=./src,./config,/tmp script.js + +# Allow writing only to ./dist +bun --secure --allow-read --allow-write=./dist build.js +``` + +Path matching notes: +- Directory prefix matching: `--allow-read=/tmp` allows access to `/tmp/foo/bar` +- Basename matching: `--deny-read=.env` blocks any file named `.env` in any directory +- Both `/` and `\` separators are supported on Windows + +### Network + +Restrict network access to specific hosts: + +```bash terminal icon="terminal" +# Allow only api.example.com +bun --secure --allow-net=api.example.com script.js + +# Allow localhost on any port +bun --secure --allow-net=localhost script.js + +# Allow specific port +bun --secure --allow-net=localhost:3000 script.js + +# Allow port range +bun --secure --allow-net=localhost:3000-4000 script.js + +# Allow IPv6 localhost +bun --secure --allow-net=[::1]:3000 script.js + +# Allow IPv6 address +bun --secure --allow-net=[2001:db8::1]:443 script.js +``` + + +IPv6 addresses must be enclosed in square brackets when specifying a port, e.g., `[::1]:3000`. + + +#### Network wildcards + +Use wildcards to match multiple hosts: + +```bash terminal icon="terminal" +# Single-segment wildcard: matches one subdomain level +bun --secure --allow-net="*.example.com" script.js +# Matches: api.example.com, www.example.com +# Does NOT match: api.v2.example.com + +# Multi-segment wildcard: matches multiple subdomain levels +bun --secure --allow-net="**.example.com" script.js +# Matches: api.example.com, api.v2.example.com, a.b.c.example.com +``` + +### Environment variables + +Restrict access to specific variables: + +```bash terminal icon="terminal" +# Allow only DATABASE_URL +bun --secure --allow-env=DATABASE_URL script.js + +# Allow multiple variables +bun --secure --allow-env=DATABASE_URL,API_KEY,NODE_ENV script.js + +# Allow variables matching a prefix +bun --secure --allow-env=AWS_* script.js +``` + +### Subprocesses + +Restrict which commands can be spawned: + +```bash terminal icon="terminal" +# Allow only git +bun --secure --allow-run=git script.js + +# Allow multiple commands +bun --secure --allow-run=git,npm,node script.js +``` + +--- + +## Denying permissions + +Use `--deny-*` flags to explicitly block access, even when a broader permission is granted: + +```bash terminal icon="terminal" +# Allow reading everything except .env files +bun --secure --allow-read --deny-read=.env script.js + +# Allow network except internal hosts +bun --secure --allow-net --deny-net=*.internal.corp script.js +``` + +Deny rules take precedence over allow rules. + +--- + +## JavaScript API + +Query and manage permissions at runtime using `Bun.permissions`: + +```ts +// Check permission state +const status = Bun.permissions.querySync({ name: "read", path: "/tmp" }); +console.log(status.state); // "granted" | "denied" | "prompt" + +// Async query +const netStatus = await Bun.permissions.query({ + name: "net", + host: "example.com" +}); + +// Request permission (returns current state, may prompt user) +const requested = await Bun.permissions.request({ name: "env" }); + +// Revoke permission (denies the entire permission type) +const revoked = await Bun.permissions.revoke({ name: "read" }); +``` + + +`revoke()` denies the entire permission type, not just a specific resource. Calling `revoke({ name: "read", path: "/tmp" })` will deny all read access, not just to `/tmp`. + + +### Permission states + +- **`granted`**: The operation is allowed +- **`denied`**: The operation is blocked +- **`prompt`**: The user may be prompted for permission (only when running with `--secure` and without `--no-prompt`) + + +The `prompt` state only exists when `--secure` is used without `--no-prompt`. In other modes, permissions are either `granted` (default mode) or `denied` (secure mode with `--no-prompt`). + + +### request() behavior + +`request()` returns the current permission state. If the state is `prompt`, the user may be prompted interactively. If permissions are already `granted` or `denied`, no prompt is shown and the current state is returned. + +### Permission descriptors + +```ts +// File system +{ name: "read", path: "/path/to/file" } +{ name: "write", path: "/path/to/dir" } + +// Network +{ name: "net", host: "example.com" } +{ name: "net", host: "example.com:443" } + +// Environment +{ name: "env", variable: "DATABASE_URL" } + +// Subprocess +{ name: "run", command: "git" } + +// System info +{ name: "sys", kind: "hostname" } + +// FFI (CLI supports --allow-ffi=, but JS API is unscoped) +{ name: "ffi" } +``` + + +FFI permissions can be scoped to specific library paths via CLI (`--allow-ffi=/path/to/lib.so`), but the JavaScript API only supports unscoped FFI queries. + + +--- + +## Examples + +### Web server + +```bash terminal icon="terminal" +bun --secure \ + --allow-read=./public,./views \ + --allow-net=localhost:3000 \ + --allow-env=PORT,NODE_ENV \ + server.js +``` + +### API client + +```bash terminal icon="terminal" +bun --secure \ + --allow-net="https://*.api.example.com" \ + --allow-env=API_KEY \ + client.js +``` + +### Build script + +```bash terminal icon="terminal" +bun --secure \ + --allow-read=./src \ + --allow-write=./dist \ + --allow-run=esbuild,tsc \ + --allow-env=NODE_ENV \ + build.js +``` + +--- + +## Workers + +Workers inherit permissions from their parent. If the main script has `--allow-read`, workers spawned from it also have read access. + +```ts +// main.ts - run with: bun --secure --allow-read main.ts +const worker = new Worker(new URL("./worker.ts", import.meta.url)); +// worker.ts automatically has --allow-read permission +``` + +--- + +## Security model + +Bun's permissions follow a fail-closed design: + +- **Deny by default**: In `--secure` mode, all sensitive operations are denied unless explicitly allowed +- **Deny takes precedence**: `--deny-*` rules override `--allow-*` rules +- **Path matching**: `/tmp` allows access to `/tmp/foo/bar` (directory prefix matching) +- **Invalid patterns fail closed**: Malformed permission patterns result in denied access + + +Without the `--secure` flag, Bun runs in permissive mode for backwards compatibility. All operations are allowed by default. + + +### Symlink handling + +In `--secure` mode, Bun resolves symbolic links to their real paths before checking permissions. This prevents symlink-based attacks where a symlink in an allowed directory points to a forbidden location. + +```bash terminal icon="terminal" +# Example directory structure: +# /app/allowed/link -> /etc/passwd (symlink) + +# This will be DENIED because the symlink target is outside allowed paths +bun --secure --allow-read=/app/allowed script.js +# script.js tries to read /app/allowed/link +# Bun resolves it to /etc/passwd and denies access +``` + +Symlink resolution applies to all file system operations in secure mode, including nested symlink chains. The entire chain is resolved to find the final target path, which is then checked against permissions. + + +Symlink resolution only occurs in `--secure` mode. In default (permissive) mode, symlinks are not resolved before access, maintaining full backwards compatibility with no performance overhead. + diff --git a/src/bake/DevServer.zig b/src/bake/DevServer.zig index 535a2651e8bf02..dee2d8166003e7 100644 --- a/src/bake/DevServer.zig +++ b/src/bake/DevServer.zig @@ -3663,7 +3663,7 @@ pub fn writeMemoryVisualizerMessage(dev: *DevServer, payload: *std.array_list.Ma system_total: u32, }; const cost = dev.memoryCostDetailed(); - const system_total = bun.api.node.os.totalmem(); + const system_total = bun.api.node.os.totalmemImpl(); try w.writeStruct(Fields{ .incremental_graph_client = @truncate(cost.incremental_graph_client), .incremental_graph_server = @truncate(cost.incremental_graph_server), @@ -3676,7 +3676,7 @@ pub fn writeMemoryVisualizerMessage(dev: *DevServer, payload: *std.array_list.Ma else 0, .process_used = @truncate(bun.sys.selfProcessMemoryUsage() orelse 0), - .system_used = @truncate(system_total -| bun.api.node.os.freemem()), + .system_used = @truncate(system_total -| bun.api.node.os.freememImpl()), .system_total = @truncate(system_total), }); diff --git a/src/bun.js.zig b/src/bun.js.zig index bcd1af82d4dfa6..8ef57eb47f58d0 100644 --- a/src/bun.js.zig +++ b/src/bun.js.zig @@ -41,6 +41,7 @@ pub const Run = struct { .smol = ctx.runtime_options.smol, .debugger = ctx.runtime_options.debugger, .dns_result_order = DNSResolver.Order.fromStringOrDie(ctx.runtime_options.dns_result_order), + .permission_options = &ctx.runtime_options.permissions, }), .arena = arena, .ctx = ctx, @@ -189,6 +190,7 @@ pub const Run = struct { .debugger = ctx.runtime_options.debugger, .dns_result_order = DNSResolver.Order.fromStringOrDie(ctx.runtime_options.dns_result_order), .is_main_thread = true, + .permission_options = &ctx.runtime_options.permissions, }, ), .arena = arena, diff --git a/src/bun.js/VirtualMachine.zig b/src/bun.js/VirtualMachine.zig index e73dfa8ec57207..57e5d0c98ff920 100644 --- a/src/bun.js/VirtualMachine.zig +++ b/src/bun.js/VirtualMachine.zig @@ -50,6 +50,8 @@ smol: bool = false, dns_result_order: DNSResolver.Order = .verbatim, cpu_profiler_config: ?CPUProfilerConfig = null, counters: Counters = .{}, +/// Deno-compatible permission system +permissions: *permissions_module.Permissions = undefined, hot_reload: bun.cli.Command.HotReload = .none, jsc_vm: *VM = undefined, @@ -1074,6 +1076,9 @@ pub fn initWithModuleGraph( vm.configureDebugger(opts.debugger); vm.body_value_hive_allocator = Body.Value.HiveAllocator.init(bun.typedAllocator(jsc.WebCore.Body.Value)); + // Initialize permissions with default allow-all mode for standalone module graphs + vm.permissions = try initDefaultPermissions(allocator); + return vm; } @@ -1100,10 +1105,134 @@ pub const Options = struct { /// Worker VMs are always destroyed on exit, regardless of this setting. Setting this to /// true may expose bugs that would otherwise only occur using Workers. destruct_main_thread_on_exit: bool = false, + /// Permission options for Deno-compatible security model + permission_options: ?*const bun.cli.Command.PermissionOptions = null, }; pub var is_smol_mode = false; +/// Initialize permissions from CLI options +fn initPermissionsFromOptions(opts: ?*const bun.cli.Command.PermissionOptions) permissions_module.Permissions { + if (opts == null) { + // Default: allow all (backwards compatibility) + return permissions_module.Permissions.initAllowAll(); + } + + const perm_opts = opts.?; + + // If --allow-all is set or not in secure mode, grant all permissions + // but still apply no_prompt and deny flags + if (perm_opts.allow_all or !perm_opts.secure_mode) { + var perms = permissions_module.Permissions.initAllowAll(); + perms.no_prompt = perm_opts.no_prompt; + + // Apply any deny flags even in allow-all mode (deny takes precedence) + if (perm_opts.deny_read) |denied| { + perms.denyResources(.read, denied); + } + if (perm_opts.deny_write) |denied| { + perms.denyResources(.write, denied); + } + if (perm_opts.deny_net) |denied| { + perms.denyResources(.net, denied); + } + if (perm_opts.deny_env) |denied| { + perms.denyResources(.env, denied); + } + if (perm_opts.deny_sys) |denied| { + perms.denyResources(.sys, denied); + } + if (perm_opts.deny_run) |denied| { + perms.denyResources(.run, denied); + } + if (perm_opts.deny_ffi) |denied| { + perms.denyResources(.ffi, denied); + } + + return perms; + } + + // Secure mode: start with all permissions denied/prompt + var perms = permissions_module.Permissions.initSecure(); + perms.no_prompt = perm_opts.no_prompt; + + // Apply allow flags + if (perm_opts.has_allow_read) { + if (perm_opts.allow_read) |allowed| { + perms.grantWithResources(.read, allowed); + } else { + perms.grant(.read); + } + } + if (perm_opts.has_allow_write) { + if (perm_opts.allow_write) |allowed| { + perms.grantWithResources(.write, allowed); + } else { + perms.grant(.write); + } + } + if (perm_opts.has_allow_net) { + if (perm_opts.allow_net) |allowed| { + perms.grantWithResources(.net, allowed); + } else { + perms.grant(.net); + } + } + if (perm_opts.has_allow_env) { + if (perm_opts.allow_env) |allowed| { + perms.grantWithResources(.env, allowed); + } else { + perms.grant(.env); + } + } + if (perm_opts.has_allow_sys) { + if (perm_opts.allow_sys) |allowed| { + perms.grantWithResources(.sys, allowed); + } else { + perms.grant(.sys); + } + } + if (perm_opts.has_allow_run) { + if (perm_opts.allow_run) |allowed| { + perms.grantWithResources(.run, allowed); + } else { + perms.grant(.run); + } + } + if (perm_opts.has_allow_ffi) { + if (perm_opts.allow_ffi) |allowed| { + perms.grantWithResources(.ffi, allowed); + } else { + perms.grant(.ffi); + } + } + + // Apply deny flags (take precedence over allow) + if (perm_opts.deny_read) |denied| { + perms.denyResources(.read, denied); + } + if (perm_opts.deny_write) |denied| { + perms.denyResources(.write, denied); + } + if (perm_opts.deny_net) |denied| { + perms.denyResources(.net, denied); + } + if (perm_opts.deny_env) |denied| { + perms.denyResources(.env, denied); + } + if (perm_opts.deny_sys) |denied| { + perms.denyResources(.sys, denied); + } + if (perm_opts.deny_run) |denied| { + perms.denyResources(.run, denied); + } + if (perm_opts.deny_ffi) |denied| { + perms.denyResources(.ffi, denied); + } + + return perms; +} + pub fn init(opts: Options) !*VirtualMachine { jsc.markBinding(@src()); const allocator = opts.allocator; @@ -1195,6 +1324,10 @@ pub fn init(opts: Options) !*VirtualMachine { vm.smol = opts.smol; vm.dns_result_order = opts.dns_result_order; + // Initialize permissions + vm.permissions = try allocator.create(permissions_module.Permissions); + vm.permissions.* = initPermissionsFromOptions(opts.permission_options); + if (opts.smol) is_smol_mode = opts.smol; @@ -1204,6 +1337,14 @@ pub fn init(opts: Options) !*VirtualMachine { return vm; } +/// Initialize permissions with default allow-all mode. +/// Used by VM constructors that don't have permission options. +fn initDefaultPermissions(allocator: std.mem.Allocator) !*permissions_module.Permissions { + const perms = try allocator.create(permissions_module.Permissions); + perms.* = permissions_module.Permissions.initAllowAll(); + return perms; +} + pub inline fn assertOnJSThread(vm: *const VirtualMachine) void { if (Environment.allow_assert) { if (vm.debug_thread_id != std.Thread.getCurrentId()) { @@ -1360,6 +1501,10 @@ pub fn initWorker( vm.transpiler.setAllocator(allocator); vm.body_value_hive_allocator = Body.Value.HiveAllocator.init(bun.typedAllocator(jsc.WebCore.Body.Value)); + // Workers inherit permissions from their parent VM + vm.permissions = try allocator.create(permissions_module.Permissions); + vm.permissions.* = worker.parent.permissions.*; + return vm; } @@ -1451,6 +1596,9 @@ pub fn initBake(opts: Options) anyerror!*VirtualMachine { vm.configureDebugger(opts.debugger); vm.body_value_hive_allocator = Body.Value.HiveAllocator.init(bun.typedAllocator(jsc.WebCore.Body.Value)); + // Initialize permissions with default allow-all mode for bake + vm.permissions = try initDefaultPermissions(allocator); + return vm; } @@ -1961,6 +2109,10 @@ pub fn deinit(this: *VirtualMachine) void { rare_data.deinit(); } this.overridden_main.deinit(); + + // Clean up permissions + this.allocator.destroy(this.permissions); + this.has_terminated = true; } @@ -3771,3 +3923,5 @@ const ServerEntryPoint = bun.transpiler.EntryPoints.ServerEntryPoint; const webcore = bun.webcore; const Body = webcore.Body; + +const permissions_module = @import("../permissions.zig"); diff --git a/src/bun.js/api/BunObject.zig b/src/bun.js/api/BunObject.zig index 46d34214284e0e..ba61ac97ce8d72 100644 --- a/src/bun.js/api/BunObject.zig +++ b/src/bun.js/api/BunObject.zig @@ -73,6 +73,7 @@ pub const BunObject = struct { pub const hash = toJSLazyPropertyCallback(Bun.getHashObject); pub const inspect = toJSLazyPropertyCallback(Bun.getInspect); pub const origin = toJSLazyPropertyCallback(Bun.getOrigin); + pub const permissions = toJSLazyPropertyCallback(Bun.getPermissionsObject); pub const semver = toJSLazyPropertyCallback(Bun.getSemver); pub const unsafe = toJSLazyPropertyCallback(Bun.getUnsafe); pub const S3Client = toJSLazyPropertyCallback(Bun.getS3ClientConstructor); @@ -140,6 +141,7 @@ pub const BunObject = struct { @export(&BunObject.hash, .{ .name = lazyPropertyCallbackName("hash") }); @export(&BunObject.inspect, .{ .name = lazyPropertyCallbackName("inspect") }); @export(&BunObject.origin, .{ .name = lazyPropertyCallbackName("origin") }); + @export(&BunObject.permissions, .{ .name = lazyPropertyCallbackName("permissions") }); @export(&BunObject.unsafe, .{ .name = lazyPropertyCallbackName("unsafe") }); @export(&BunObject.semver, .{ .name = lazyPropertyCallbackName("semver") }); @export(&BunObject.embeddedFiles, .{ .name = lazyPropertyCallbackName("embeddedFiles") }); @@ -1012,6 +1014,22 @@ pub fn serve(globalObject: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.J break :brk config; }; + // Check net permission for server binding + { + var buf: [128]u8 = undefined; + const host_str: []const u8 = switch (config.address) { + .tcp => |tcp| blk: { + const hostname = if (tcp.hostname) |h| bun.sliceTo(h, 0) else "0.0.0.0"; + break :blk std.fmt.bufPrint(&buf, "{s}:{d}", .{ hostname, tcp.port }) catch hostname; + }, + .unix => |unix| unix, + }; + bun.permission_check.requireNet(globalObject, host_str) catch { + config.deinit(); + return .zero; + }; + } + const vm = globalObject.bunVM(); if (config.allow_hot) { @@ -1407,6 +1425,185 @@ const CSRFObject = struct { } }; +pub fn getPermissionsObject(globalObject: *jsc.JSGlobalObject, _: *jsc.JSObject) jsc.JSValue { + return PermissionsObject.create(globalObject); +} + +const PermissionsObject = struct { + pub fn create(globalThis: *jsc.JSGlobalObject) jsc.JSValue { + const object = JSValue.createEmptyObject(globalThis, 4); + + object.put( + globalThis, + ZigString.static("query"), + jsc.JSFunction.create(globalThis, "query", permissionsQuery, 1, .{}), + ); + + object.put( + globalThis, + ZigString.static("querySync"), + jsc.JSFunction.create(globalThis, "querySync", permissionsQuerySync, 1, .{}), + ); + + object.put( + globalThis, + ZigString.static("request"), + jsc.JSFunction.create(globalThis, "request", permissionsRequest, 1, .{}), + ); + + object.put( + globalThis, + ZigString.static("revoke"), + jsc.JSFunction.create(globalThis, "revoke", permissionsRevoke, 1, .{}), + ); + + return object; + } + + const ParsedDescriptor = struct { + kind: bun.permissions.Kind, + resource: ?[]const u8, + resource_slice: ?ZigString.Slice, + + pub fn deinit(self: *ParsedDescriptor) void { + if (self.resource_slice) |*slice| { + slice.deinit(); + } + } + }; + + fn parseDescriptor(globalThis: *jsc.JSGlobalObject, descriptor: jsc.JSValue) !ParsedDescriptor { + if (descriptor.isEmptyOrUndefinedOrNull() or !descriptor.isObject()) { + return globalThis.throwInvalidArguments("Expected a permission descriptor object", .{}); + } + + const name_value = try descriptor.get(globalThis, "name") orelse { + return globalThis.throwInvalidArguments("Permission descriptor must have a 'name' property", .{}); + }; + + const name_str = try name_value.getZigString(globalThis); + var slice = name_str.toSlice(bun.default_allocator); + defer slice.deinit(); + + const kind: bun.permissions.Kind = if (std.mem.eql(u8, slice.slice(), "read")) + .read + else if (std.mem.eql(u8, slice.slice(), "write")) + .write + else if (std.mem.eql(u8, slice.slice(), "net")) + .net + else if (std.mem.eql(u8, slice.slice(), "env")) + .env + else if (std.mem.eql(u8, slice.slice(), "sys")) + .sys + else if (std.mem.eql(u8, slice.slice(), "run")) + .run + else if (std.mem.eql(u8, slice.slice(), "ffi")) + .ffi + else { + return globalThis.throwInvalidArguments("Unknown permission name: {s}", .{slice.slice()}); + }; + + // Get optional path/host/variable/command + // The resource_slice owns the memory and must be cleaned up by the caller + var resource: ?[]const u8 = null; + var resource_slice: ?ZigString.Slice = null; + if (try descriptor.get(globalThis, "path")) |path_value| { + if (!path_value.isEmptyOrUndefinedOrNull()) { + const path_str = try path_value.getZigString(globalThis); + resource_slice = path_str.toSlice(bun.default_allocator); + resource = resource_slice.?.slice(); + } + } else if (try descriptor.get(globalThis, "host")) |host_value| { + if (!host_value.isEmptyOrUndefinedOrNull()) { + const host_str = try host_value.getZigString(globalThis); + resource_slice = host_str.toSlice(bun.default_allocator); + resource = resource_slice.?.slice(); + } + } else if (try descriptor.get(globalThis, "variable")) |var_value| { + if (!var_value.isEmptyOrUndefinedOrNull()) { + const var_str = try var_value.getZigString(globalThis); + resource_slice = var_str.toSlice(bun.default_allocator); + resource = resource_slice.?.slice(); + } + } else if (try descriptor.get(globalThis, "command")) |cmd_value| { + if (!cmd_value.isEmptyOrUndefinedOrNull()) { + const cmd_str = try cmd_value.getZigString(globalThis); + resource_slice = cmd_str.toSlice(bun.default_allocator); + resource = resource_slice.?.slice(); + } + } else if (try descriptor.get(globalThis, "kind")) |kind_value| { + if (!kind_value.isEmptyOrUndefinedOrNull()) { + const kind_str = try kind_value.getZigString(globalThis); + resource_slice = kind_str.toSlice(bun.default_allocator); + resource = resource_slice.?.slice(); + } + } + + return .{ .kind = kind, .resource = resource, .resource_slice = resource_slice }; + } + + fn createPermissionStatus(globalThis: *jsc.JSGlobalObject, state: bun.permissions.State) jsc.JSValue { + const result = JSValue.createEmptyObject(globalThis, 1); + const state_str = switch (state) { + .granted, .granted_partial => "granted", + .prompt => "prompt", + .denied, .denied_partial => "denied", + }; + result.put(globalThis, ZigString.static("state"), ZigString.init(state_str).withEncoding().toJS(globalThis)); + return result; + } + + fn permissionsQuerySync(globalThis: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSError!jsc.JSValue { + const args = callframe.arguments_old(1); + if (args.len < 1) { + return globalThis.throwInvalidArguments("Expected a permission descriptor", .{}); + } + + const descriptor = args.ptr[0]; + var parsed = try parseDescriptor(globalThis, descriptor); + defer parsed.deinit(); + const vm = globalThis.bunVM(); + const state = vm.permissions.check(parsed.kind, parsed.resource); + return createPermissionStatus(globalThis, state); + } + + fn permissionsQuery(globalThis: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSError!jsc.JSValue { + // For now, query is synchronous wrapped in a resolved promise + const result = try permissionsQuerySync(globalThis, callframe); + return jsc.JSPromise.resolvedPromiseValue(globalThis, result); + } + + fn permissionsRequest(globalThis: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSError!jsc.JSValue { + // For now, request is the same as query (prompts are disabled) + // In the future, this could prompt the user + const result = try permissionsQuerySync(globalThis, callframe); + return jsc.JSPromise.resolvedPromiseValue(globalThis, result); + } + + fn permissionsRevoke(globalThis: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSError!jsc.JSValue { + const args = callframe.arguments_old(1); + if (args.len < 1) { + return globalThis.throwInvalidArguments("Expected a permission descriptor", .{}); + } + + const descriptor = args.ptr[0]; + var parsed = try parseDescriptor(globalThis, descriptor); + defer parsed.deinit(); + const vm = globalThis.bunVM(); + + // Revoke the permission by denying the entire permission type. + // Note: This denies the entire permission type, not just the specific resource. + // For example, calling revoke({ name: "env", variable: "HOME" }) will deny ALL + // env access, not just access to the HOME variable. This matches the behavior + // of transitioning from any state to a more restrictive state. + vm.permissions.deny(parsed.kind); + + // Return the new state + const state = vm.permissions.check(parsed.kind, parsed.resource); + return jsc.JSPromise.resolvedPromiseValue(globalThis, createPermissionStatus(globalThis, state)); + } +}; + // This is aliased to Bun.env pub const EnvironmentVariables = struct { pub export fn Bun__getEnvCount(globalObject: *jsc.JSGlobalObject, ptr: *[*][]const u8) usize { @@ -1422,6 +1619,15 @@ pub const EnvironmentVariables = struct { } pub export fn Bun__getEnvValue(globalObject: *jsc.JSGlobalObject, name: *ZigString, value: *ZigString) bool { + // Check env permission + const vm = globalObject.bunVM(); + const name_slice = name.toSlice(vm.allocator); + defer name_slice.deinit(); + + bun.permission_check.requireEnv(globalObject, name_slice.slice()) catch { + return false; // Exception was thrown + }; + if (getEnvValue(globalObject, name.*)) |val| { value.* = val; return true; diff --git a/src/bun.js/api/JSBundler.zig b/src/bun.js/api/JSBundler.zig index 57a8fe763a12c8..a40af2f164abe8 100644 --- a/src/bun.js/api/JSBundler.zig +++ b/src/bun.js/api/JSBundler.zig @@ -1081,6 +1081,21 @@ pub const JSBundler = struct { return globalThis.throwInvalidArguments("Expected a config object to be passed to Bun.build", .{}); } + // Check permissions for Bun.build() - requires both read and write access + // The bundler needs to read source files and write output files + const vm = globalThis.bunVM(); + if (vm.permissions.secure_mode and !vm.permissions.allow_all) { + // In secure mode, check that we have read and write permissions + const has_read = vm.permissions.read.state == .granted or vm.permissions.read.state == .granted_partial; + const has_write = vm.permissions.write.state == .granted or vm.permissions.write.state == .granted_partial; + if (!has_read or !has_write) { + return globalThis.throwInvalidArguments( + "PermissionDenied: Bun.build() requires file system access, run again with --allow-read --allow-write or -A", + .{}, + ); + } + } + var plugins: ?*Plugin = null; const config = try Config.fromJS(globalThis, arguments[0], &plugins, bun.default_allocator); @@ -1750,7 +1765,7 @@ pub const BuildArtifact = struct { return ZigString.init(out).toJS(globalThis); } - pub fn getSize(this: *BuildArtifact, globalObject: *jsc.JSGlobalObject) JSValue { + pub fn getSize(this: *BuildArtifact, globalObject: *jsc.JSGlobalObject) bun.JSError!JSValue { return @call(bun.callmod_inline, Blob.getSize, .{ &this.blob, globalObject }); } diff --git a/src/bun.js/api/bun/js_bun_spawn_bindings.zig b/src/bun.js/api/bun/js_bun_spawn_bindings.zig index 45c33a5a19e44a..e77e4fd35bac98 100644 --- a/src/bun.js/api/bun/js_bun_spawn_bindings.zig +++ b/src/bun.js/api/bun/js_bun_spawn_bindings.zig @@ -282,6 +282,11 @@ pub fn spawnMaybeSync( try getArgv(globalThis, cmd_value, PATH, cwd, &argv0, allocator, &argv); + // Check run permission for subprocess spawning + if (argv0) |cmd_path| { + try bun.permission_check.requireRun(globalThis, bun.sliceTo(cmd_path, 0)); + } + if (try args.get(globalThis, "stdio")) |stdio_val| { if (!stdio_val.isEmptyOrUndefinedOrNull()) { if (stdio_val.jsType().isArray()) { @@ -422,6 +427,11 @@ pub fn spawnMaybeSync( } } else { try getArgv(globalThis, cmd_value, PATH, cwd, &argv0, allocator, &argv); + + // Check run permission for subprocess spawning + if (argv0) |cmd_path| { + try bun.permission_check.requireRun(globalThis, bun.sliceTo(cmd_path, 0)); + } } } diff --git a/src/bun.js/api/bun/socket/Listener.zig b/src/bun.js/api/bun/socket/Listener.zig index be9e10b62fb968..57a0472b03c81d 100644 --- a/src/bun.js/api/bun/socket/Listener.zig +++ b/src/bun.js/api/bun/socket/Listener.zig @@ -120,6 +120,16 @@ pub fn listen(globalObject: *jsc.JSGlobalObject, opts: JSValue) bun.JSError!JSVa const ssl_enabled = ssl != null; const socket_flags = socket_config.socketFlags(); + // Check net permission for socket listening + { + var buf: [128]u8 = undefined; + const host_str: []const u8 = if (port) |p| + std.fmt.bufPrint(&buf, "{s}:{d}", .{ hostname_or_unix.slice(), p }) catch hostname_or_unix.slice() + else + hostname_or_unix.slice(); + try bun.permission_check.requireNet(globalObject, host_str); + } + if (Environment.isWindows and port == null) { // we check if the path is a named pipe otherwise we try to connect using AF_UNIX var buf: bun.PathBuffer = undefined; @@ -577,6 +587,16 @@ pub fn connectInner(globalObject: *jsc.JSGlobalObject, prev_maybe_tcp: ?*TCPSock const ssl_enabled = ssl != null; const default_data = socket_config.default_data; + // Check net permission for socket connection + { + var buf: [128]u8 = undefined; + const host_str: []const u8 = if (port) |p| + std.fmt.bufPrint(&buf, "{s}:{d}", .{ hostname_or_unix.slice(), p }) catch hostname_or_unix.slice() + else + hostname_or_unix.slice(); + try bun.permission_check.requireNet(globalObject, host_str); + } + vm.eventLoop().ensureWaker(); var connection: Listener.UnixOrHost = blk: { diff --git a/src/bun.js/api/ffi.zig b/src/bun.js/api/ffi.zig index 24a12e6929c888..72666986d8f1a9 100644 --- a/src/bun.js/api/ffi.zig +++ b/src/bun.js/api/ffi.zig @@ -1037,6 +1037,11 @@ pub const FFI = struct { return global.toInvalidArguments("Invalid library name", .{}); } + // Check FFI permission for loading native library + bun.permission_check.requireFfi(global, name) catch { + return .zero; + }; + var symbols = bun.StringArrayHashMapUnmanaged(Function){}; if (generateSymbols(global, bun.default_allocator, &symbols, object) catch jsc.JSValue.zero) |val| { // an error while validating symbols diff --git a/src/bun.js/bindings/BunObject+exports.h b/src/bun.js/bindings/BunObject+exports.h index 96f3cd86340398..684bf13fc014bf 100644 --- a/src/bun.js/bindings/BunObject+exports.h +++ b/src/bun.js/bindings/BunObject+exports.h @@ -32,6 +32,7 @@ macro(hash) \ macro(inspect) \ macro(origin) \ + macro(permissions) \ macro(s3) \ macro(semver) \ macro(unsafe) \ diff --git a/src/bun.js/bindings/BunObject.cpp b/src/bun.js/bindings/BunObject.cpp index 28f8c7da0ea4cf..edaa6bf2d0b0a2 100644 --- a/src/bun.js/bindings/BunObject.cpp +++ b/src/bun.js/bindings/BunObject.cpp @@ -772,6 +772,7 @@ JSC_DEFINE_HOST_FUNCTION(functionFileURLToPath, (JSC::JSGlobalObject * globalObj password constructPasswordObject DontDelete|PropertyCallback pathToFileURL functionPathToFileURL DontDelete|Function 1 peek constructBunPeekObject DontDelete|PropertyCallback + permissions BunObject_lazyPropCb_wrap_permissions DontDelete|PropertyCallback plugin constructPluginObject ReadOnly|DontDelete|PropertyCallback randomUUIDv7 Bun__randomUUIDv7 DontDelete|Function 2 randomUUIDv5 Bun__randomUUIDv5 DontDelete|Function 3 diff --git a/src/bun.js/bindings/BunProcess.cpp b/src/bun.js/bindings/BunProcess.cpp index f0e77d579c5514..03f58cb4357beb 100644 --- a/src/bun.js/bindings/BunProcess.cpp +++ b/src/bun.js/bindings/BunProcess.cpp @@ -161,6 +161,9 @@ BUN_DECLARE_HOST_FUNCTION(Bun__Process__send); extern "C" void Process__emitDisconnectEvent(Zig::GlobalObject* global); extern "C" void Process__emitErrorEvent(Zig::GlobalObject* global, EncodedJSValue value); +// Node.js process.permission.has() API +extern "C" bool Bun__Process__permissionHas(JSGlobalObject* globalObject, const char* scope_ptr, size_t scope_len, const char* ref_ptr, size_t ref_len); + extern "C" void Bun__suppressCrashOnProcessKillSelfIfDesired(); static Process* getProcessObject(JSC::JSGlobalObject* lexicalGlobalObject, JSValue thisValue); @@ -2214,6 +2217,63 @@ static JSValue constructProcessReportObject(VM& vm, JSObject* processObject) return report; } +// Node.js process.permission.has(scope, reference?) implementation +JSC_DEFINE_HOST_FUNCTION(Process_functionPermissionHas, (JSGlobalObject* globalObject, JSC::CallFrame* callFrame)) +{ + VM& vm = globalObject->vm(); + auto scope = DECLARE_THROW_SCOPE(vm); + + // First argument: scope (required) + if (callFrame->argumentCount() < 1) { + throwTypeError(globalObject, scope, "process.permission.has requires a scope argument"_s); + return {}; + } + + JSValue scopeArg = callFrame->argument(0); + if (!scopeArg.isString()) { + throwTypeError(globalObject, scope, "scope must be a string"_s); + return {}; + } + + auto scopeString = scopeArg.toWTFString(globalObject); + RETURN_IF_EXCEPTION(scope, {}); + + // Second argument: reference (optional) + const char* refPtr = nullptr; + size_t refLen = 0; + CString refCString; + + if (callFrame->argumentCount() >= 2) { + JSValue refArg = callFrame->argument(1); + if (!refArg.isUndefined()) { + if (!refArg.isString()) { + throwTypeError(globalObject, scope, "reference must be a string"_s); + return {}; + } + auto refString = refArg.toWTFString(globalObject); + RETURN_IF_EXCEPTION(scope, {}); + refCString = refString.utf8(); + refPtr = refCString.data(); + refLen = refCString.length(); + } + } + + auto scopeCString = scopeString.utf8(); + bool result = Bun__Process__permissionHas(globalObject, scopeCString.data(), scopeCString.length(), refPtr, refLen); + + return JSValue::encode(jsBoolean(result)); +} + +// Node.js process.permission object +static JSValue constructProcessPermissionObject(VM& vm, JSObject* processObject) +{ + auto* globalObject = processObject->globalObject(); + auto* permission = JSC::constructEmptyObject(globalObject, globalObject->objectPrototype(), 1); + permission->putDirect(vm, JSC::Identifier::fromString(vm, "has"_s), + JSC::JSFunction::create(vm, globalObject, 1, String("has"_s), Process_functionPermissionHas, ImplementationVisibility::Public), 0); + return permission; +} + static JSValue constructProcessConfigObject(VM& vm, JSObject* processObject) { auto* globalObject = processObject->globalObject(); @@ -4023,6 +4083,7 @@ extern "C" void Process__emitErrorEvent(Zig::GlobalObject* global, EncodedJSValu nextTick constructProcessNextTickFn PropertyCallback noDeprecation processNoDeprecation CustomAccessor openStdin Process_functionOpenStdin Function 0 + permission constructProcessPermissionObject PropertyCallback pid constructPid PropertyCallback platform constructPlatform PropertyCallback ppid constructPpid PropertyCallback diff --git a/src/bun.js/bindings/JSEnvironmentVariableMap.cpp b/src/bun.js/bindings/JSEnvironmentVariableMap.cpp index f8e469c511fd9e..63cd5632654c2a 100644 --- a/src/bun.js/bindings/JSEnvironmentVariableMap.cpp +++ b/src/bun.js/bindings/JSEnvironmentVariableMap.cpp @@ -41,6 +41,7 @@ JSC_DEFINE_CUSTOM_GETTER(jsGetterEnvironmentVariable, (JSGlobalObject * globalOb return JSValue::encode(jsUndefined()); if (!Bun__getEnvValue(globalObject, &name, &value)) { + RETURN_IF_EXCEPTION(scope, {}); return JSValue::encode(jsUndefined()); } @@ -85,6 +86,7 @@ JSC_DEFINE_CUSTOM_GETTER(jsTimeZoneEnvironmentVariableGetter, (JSGlobalObject * } if (!Bun__getEnvValue(globalObject, &name, &value) || value.len == 0) { + RETURN_IF_EXCEPTION(scope, {}); return JSValue::encode(jsUndefined()); } @@ -167,6 +169,7 @@ JSC_DEFINE_CUSTOM_GETTER(jsNodeTLSRejectUnauthorizedGetter, (JSGlobalObject * gl ZigString value = { nullptr, 0 }; if (!Bun__getEnvValue(globalObject, &name, &value) || value.len == 0) { + RETURN_IF_EXCEPTION(scope, {}); return JSValue::encode(jsUndefined()); } @@ -221,6 +224,7 @@ JSC_DEFINE_CUSTOM_GETTER(jsBunConfigVerboseFetchGetter, (JSGlobalObject * global ZigString value = { nullptr, 0 }; if (!Bun__getEnvValue(globalObject, &name, &value) || value.len == 0) { + RETURN_IF_EXCEPTION(scope, {}); return JSValue::encode(jsUndefined()); } diff --git a/src/bun.js/node/node_fs_binding.zig b/src/bun.js/node/node_fs_binding.zig index 156605f22152b5..5bff24067bcc71 100644 --- a/src/bun.js/node/node_fs_binding.zig +++ b/src/bun.js/node/node_fs_binding.zig @@ -27,6 +27,11 @@ fn Bindings(comptime function_name: NodeFSFunctionEnum) type { return .zero; } + // Check permissions before executing the operation + if (comptime Arguments != void) { + try checkFsPermission(function_name, globalObject, args); + } + var result = function(&this.node_fs, args, .sync); return switch (result) { .err => |err| globalObject.throwValue(err.toJS(globalObject)), @@ -54,6 +59,8 @@ fn Bindings(comptime function_name: NodeFSFunctionEnum) type { return .zero; } + // Check abort signal before permissions to avoid surfacing permission errors + // for already-aborted operations (prevents leaking permission state info) const have_abort_signal = @hasField(Arguments, "signal"); if (have_abort_signal) check_early_abort: { const signal = args.signal orelse break :check_early_abort; @@ -63,6 +70,14 @@ fn Bindings(comptime function_name: NodeFSFunctionEnum) type { } } + // Check permissions before executing the operation + if (comptime Arguments != void) { + checkFsPermission(function_name, globalObject, args) catch |err| { + deinit = true; + return err; + }; + } + const Task = @field(node.fs.Async, @tagName(function_name)); switch (comptime function_name) { .cp => return Task.create(globalObject, this, args, globalObject.bunVM(), slice.arena), @@ -238,3 +253,219 @@ const bun = @import("bun"); const jsc = bun.jsc; const node = bun.api.node; const ArgumentsSlice = jsc.CallFrame.ArgumentsSlice; +const permission_check = bun.permission_check; +const permissions = bun.permissions; + +/// Determine what permission is required for a filesystem operation +fn getRequiredPermission(comptime function_name: NodeFSFunctionEnum) ?struct { kind: permissions.Kind, needs_path: bool } { + return switch (function_name) { + // Read operations + .access, .exists, .lstat, .stat, .readFile, .readdir, .readlink, .realpath, .realpathNonNative => .{ .kind = .read, .needs_path = true }, + .fstat, .read, .readv => .{ .kind = .read, .needs_path = false }, // FD-based, no path check + + // Write operations + .appendFile, .writeFile, .chmod, .chown, .lchmod, .lchown, .link, .mkdir, .mkdtemp, .rm, .rmdir, .symlink, .truncate, .unlink, .utimes, .lutimes, .rename => .{ .kind = .write, .needs_path = true }, + .fchmod, .fchown, .fsync, .fdatasync, .ftruncate, .futimes, .write, .writev, .close => .{ .kind = .write, .needs_path = false }, // FD-based + + // Both read and write + .copyFile, .cp => .{ .kind = .write, .needs_path = true }, // Requires both, check write as it's more restrictive + + // Open can be read or write depending on flags - handled specially in checkFsPermission + .open => null, + + // Watch operations - read permission + .watch, .watchFile, .unwatchFile => .{ .kind = .read, .needs_path = true }, + + // statfs - requires sys permission, handled specially in checkFsPermission + .statfs => null, + + // Internal helpers and other functions don't need permission checks here + else => null, + }; +} + +/// Check permission for a filesystem operation +fn checkFsPermission(comptime function_name: NodeFSFunctionEnum, globalObject: *jsc.JSGlobalObject, args: anytype) bun.JSError!void { + const ArgsType = @TypeOf(args); + + // Special handling for 'open' - check read or write based on flags + if (comptime function_name == .open) { + if (comptime @hasField(ArgsType, "path") and @hasField(ArgsType, "flags")) { + const path = args.path.slice(); + const resolved_path = resolvePath(globalObject, path, &path_resolve_buf); + const flags_int = args.flags.asInt(); + + // Check if flags indicate write intent (O_WRONLY or O_RDWR) + // O_RDONLY is 0, O_WRONLY has bit 0 set, O_RDWR has bit 1 set + const needs_write = (flags_int & (bun.O.WRONLY | bun.O.RDWR)) != 0; + + if (needs_write) { + try permission_check.requireWrite(globalObject, resolved_path); + } else { + try permission_check.requireRead(globalObject, resolved_path); + } + } + return; + } + + // Special handling for 'statfs' - requires sys permission + if (comptime function_name == .statfs) { + try permission_check.requireSys(globalObject, "statfs"); + return; + } + + // Get the required permission for this operation + const required = comptime getRequiredPermission(function_name); + if (comptime required == null) { + return; + } + + // If this is an FD-based operation, we can't easily check permissions + // because we'd need to track which FD was opened with which permissions + if (comptime !required.?.needs_path) { + return; + } + + // Handle multi-path operations (rename, link, symlink, cp, copyFile) + // These need to check both source and destination paths + if (comptime @hasField(ArgsType, "old_path") and @hasField(ArgsType, "new_path")) { + // rename, link: check write on both paths + const old_path = args.old_path.slice(); + const new_path = args.new_path.slice(); + const resolved_old = resolvePath(globalObject, old_path, &path_resolve_buf); + const resolved_new = resolvePath(globalObject, new_path, &path_resolve_buf2); + try permission_check.requireWrite(globalObject, resolved_old); + try permission_check.requireWrite(globalObject, resolved_new); + return; + } + + if (comptime @hasField(ArgsType, "target_path") and @hasField(ArgsType, "new_path")) { + // symlink: check read on target, write on new_path + const target_path = args.target_path.slice(); + const new_path = args.new_path.slice(); + const resolved_target = resolvePath(globalObject, target_path, &path_resolve_buf); + const resolved_new = resolvePath(globalObject, new_path, &path_resolve_buf2); + try permission_check.requireRead(globalObject, resolved_target); + try permission_check.requireWrite(globalObject, resolved_new); + return; + } + + if (comptime @hasField(ArgsType, "src") and @hasField(ArgsType, "dest")) { + // cp, copyFile: check read on src, write on dest + const src_path = args.src.slice(); + const dest_path = args.dest.slice(); + const resolved_src = resolvePath(globalObject, src_path, &path_resolve_buf); + const resolved_dest = resolvePath(globalObject, dest_path, &path_resolve_buf2); + try permission_check.requireRead(globalObject, resolved_src); + try permission_check.requireWrite(globalObject, resolved_dest); + return; + } + + // Extract the path from the arguments (single-path operations) + const path_slice: ?[]const u8 = blk: { + // Different argument types have different field names for the path + if (comptime @hasField(ArgsType, "path")) { + const path_field = args.path; + if (@TypeOf(path_field) == node.PathOrFileDescriptor) { + // PathOrFileDescriptor can be a path or file descriptor + if (path_field == .path) { + break :blk path_field.path.slice(); + } + // File descriptor - can't easily check permissions + break :blk null; + } else { + // PathLike or optional PathLike + if (@typeInfo(@TypeOf(path_field)) == .optional) { + if (path_field) |p| { + break :blk p.slice(); + } + break :blk null; + } else { + break :blk path_field.slice(); + } + } + } else if (comptime @hasField(ArgsType, "file")) { + const file_field = args.file; + if (@TypeOf(file_field) == node.PathOrFileDescriptor) { + if (file_field == .path) { + break :blk file_field.path.slice(); + } + break :blk null; + } + } + break :blk null; + }; + + // If we couldn't extract a path (e.g., FD-based operation), skip check + if (path_slice == null) { + return; + } + + // Resolve relative paths to absolute paths + const resolved_path = resolvePath(globalObject, path_slice.?, &path_resolve_buf); + + // Check the permission + switch (required.?.kind) { + .read => try permission_check.requireRead(globalObject, resolved_path), + .write => try permission_check.requireWrite(globalObject, resolved_path), + else => {}, + } +} + +/// Resolve a path to an absolute path using the current working directory, +/// and optionally resolve symlinks to their canonical target. +/// +/// Security note: When symlink resolution is enabled, permission checks are performed +/// on the symlink target, not the symlink path itself. This provides stronger security +/// guarantees but has a performance cost (extra syscall per operation). +/// +/// Symlink resolution is enabled when running in secure mode (--secure flag). +/// If realpath fails (e.g., file doesn't exist yet for write operations), we fall back +/// to the original absolute path. +fn resolvePath(globalObject: *jsc.JSGlobalObject, path: []const u8, buf: *[bun.MAX_PATH_BYTES]u8) []const u8 { + // First, resolve relative paths to absolute paths + const absolute_path = if (bun.path.Platform.auto.isAbsolute(path)) + path + else blk: { + const cwd = globalObject.bunVM().transpiler.fs.top_level_dir; + break :blk bun.path.joinAbsStringBuf(cwd, buf, &.{path}, .auto); + }; + + // In secure mode, resolve symlinks to get canonical path + const vm = globalObject.bunVM(); + if (vm.permissions.isSecureMode()) { + return resolveSymlinks(absolute_path, buf); + } + + return absolute_path; +} + +/// Resolve symlinks in a path to get the canonical path. +/// Falls back to the original path if realpath fails (e.g., file doesn't exist). +/// +/// Note: This adds a syscall overhead but provides stronger security guarantees +/// by checking permissions on the actual target path, not the symlink. +fn resolveSymlinks(path: []const u8, buf: *[bun.MAX_PATH_BYTES]u8) []const u8 { + // Need null-terminated path for C realpath + var path_buf: [bun.MAX_PATH_BYTES:0]u8 = undefined; + if (path.len >= bun.MAX_PATH_BYTES) { + return path; // Path too long, return original + } + @memcpy(path_buf[0..path.len], path); + path_buf[path.len] = 0; + + // Call C realpath to resolve symlinks + const result = std.c.realpath(&path_buf, buf); + if (result == null) { + // realpath failed (file doesn't exist, permission denied, etc.) + // Fall back to original path - this is important for write operations + // where the file may not exist yet + return path; + } + + // Return the resolved path + return bun.sliceTo(buf, 0); +} + +threadlocal var path_resolve_buf: [bun.MAX_PATH_BYTES]u8 = undefined; +threadlocal var path_resolve_buf2: [bun.MAX_PATH_BYTES]u8 = undefined; diff --git a/src/bun.js/node/node_os.bind.ts b/src/bun.js/node/node_os.bind.ts index db3082c4b7a6a2..0ff0214946e7ac 100644 --- a/src/bun.js/node/node_os.bind.ts +++ b/src/bun.js/node/node_os.bind.ts @@ -9,7 +9,9 @@ export const cpus = fn({ ret: t.any, }); export const freemem = fn({ - args: {}, + args: { + global: t.globalObject, + }, ret: t.u64, }); export const getPriority = fn({ @@ -48,7 +50,9 @@ export const release = fn({ ret: t.DOMString, }); export const totalmem = fn({ - args: {}, + args: { + global: t.globalObject, + }, ret: t.u64, }); export const uptime = fn({ diff --git a/src/bun.js/node/node_os.zig b/src/bun.js/node/node_os.zig index ecbf372fa6dc05..4887d4e4692663 100644 --- a/src/bun.js/node/node_os.zig +++ b/src/bun.js/node/node_os.zig @@ -34,6 +34,9 @@ const CPUTimes = struct { }; pub fn cpus(global: *jsc.JSGlobalObject) bun.JSError!jsc.JSValue { + // Check sys permission for CPU information + try bun.permission_check.requireSys(global, "cpus"); + const cpusImpl = switch (Environment.os) { .linux => cpusImplLinux, .mac => cpusImplDarwin, @@ -276,13 +279,19 @@ pub fn cpusImplWindows(globalThis: *jsc.JSGlobalObject) !jsc.JSValue { return values; } -pub fn freemem() u64 { +pub fn freememImpl() u64 { // OsBinding.cpp return @extern(*const fn () callconv(.c) u64, .{ .name = "Bun__Os__getFreeMemory", })(); } +pub fn freemem(global: *jsc.JSGlobalObject) bun.JSError!u64 { + // Check sys permission for system memory info + try bun.permission_check.requireSys(global, "systemMemoryInfo"); + return freememImpl(); +} + extern fn get_process_priority(pid: i32) i32; pub fn getPriority(global: *jsc.JSGlobalObject, pid: i32) bun.JSError!i32 { const result = get_process_priority(pid); @@ -302,6 +311,9 @@ pub fn getPriority(global: *jsc.JSGlobalObject, pid: i32) bun.JSError!i32 { } pub fn homedir(global: *jsc.JSGlobalObject) !bun.String { + // Check sys permission for home directory + try bun.permission_check.requireSys(global, "homedir"); + // In Node.js, this is a wrapper around uv_os_homedir. if (Environment.isWindows) { var out: bun.PathBuffer = undefined; @@ -380,6 +392,9 @@ pub fn homedir(global: *jsc.JSGlobalObject) !bun.String { } pub fn hostname(global: *jsc.JSGlobalObject) bun.JSError!jsc.JSValue { + // Check sys permission for hostname + try bun.permission_check.requireSys(global, "hostname"); + if (Environment.isWindows) { var name_buffer: [129:0]u16 = undefined; if (bun.windows.GetHostNameW(&name_buffer, name_buffer.len) == 0) { @@ -405,6 +420,9 @@ pub fn hostname(global: *jsc.JSGlobalObject) bun.JSError!jsc.JSValue { } pub fn loadavg(global: *jsc.JSGlobalObject) bun.JSError!jsc.JSValue { + // Check sys permission for load average + try bun.permission_check.requireSys(global, "loadavg"); + const result = switch (bun.Environment.os) { .mac => loadavg: { var avg: c.struct_loadavg = undefined; @@ -456,6 +474,9 @@ pub const networkInterfaces = switch (Environment.os) { }; fn networkInterfacesPosix(globalThis: *jsc.JSGlobalObject) bun.JSError!jsc.JSValue { + // Check sys permission for network interfaces + try bun.permission_check.requireSys(globalThis, "networkInterfaces"); + // getifaddrs sets a pointer to a linked list var interface_start: ?*c.ifaddrs = null; const rc = c.getifaddrs(&interface_start); @@ -638,6 +659,9 @@ fn networkInterfacesPosix(globalThis: *jsc.JSGlobalObject) bun.JSError!jsc.JSVal } fn networkInterfacesWindows(globalThis: *jsc.JSGlobalObject) bun.JSError!jsc.JSValue { + // Check sys permission for network interfaces + try bun.permission_check.requireSys(globalThis, "networkInterfaces"); + var ifaces: [*]libuv.uv_interface_address_t = undefined; var count: c_int = undefined; const err = libuv.uv_interface_addresses(&ifaces, &count); @@ -855,7 +879,7 @@ pub fn setPriority2(global: *jsc.JSGlobalObject, priority: i32) !void { return setPriority1(global, 0, priority); } -pub fn totalmem() u64 { +pub fn totalmemImpl() u64 { switch (bun.Environment.os) { .mac => { var memory_: [32]c_ulonglong = undefined; @@ -885,7 +909,16 @@ pub fn totalmem() u64 { } } +pub fn totalmem(global: *jsc.JSGlobalObject) bun.JSError!u64 { + // Check sys permission for system memory info + try bun.permission_check.requireSys(global, "systemMemoryInfo"); + return totalmemImpl(); +} + pub fn uptime(global: *jsc.JSGlobalObject) bun.JSError!f64 { + // Check sys permission for system uptime + try bun.permission_check.requireSys(global, "osUptime"); + switch (Environment.os) { .windows => { var uptime_value: f64 = undefined; @@ -928,6 +961,9 @@ pub fn uptime(global: *jsc.JSGlobalObject) bun.JSError!f64 { } pub fn userInfo(globalThis: *jsc.JSGlobalObject, options: gen.UserInfoOptions) bun.JSError!jsc.JSValue { + // Check sys permission for user info + try bun.permission_check.requireSys(globalThis, "userInfo"); + _ = options; // TODO: const result = jsc.JSValue.createEmptyObject(globalThis, 5); diff --git a/src/bun.js/node/node_process.zig b/src/bun.js/node/node_process.zig index fbc29403f19b83..7177ecb881e6e8 100644 --- a/src/bun.js/node/node_process.zig +++ b/src/bun.js/node/node_process.zig @@ -10,6 +10,51 @@ comptime { @export(&getExecPath, .{ .name = "Bun__Process__getExecPath" }); @export(&bun.jsc.host_fn.wrap1(createExecArgv), .{ .name = "Bun__Process__createExecArgv" }); @export(&getEval, .{ .name = "Bun__Process__getEval" }); + @export(&permissionHas, .{ .name = "Bun__Process__permissionHas" }); +} + +/// Maps Node.js permission scope names to Bun's permission kinds. +/// Uses StaticStringMap for O(1) lookup performance. +const ScopeToKindMap = std.StaticStringMap(bun.permissions.Kind).initComptime(.{ + // File system permissions + .{ "fs", .read }, + .{ "fs.read", .read }, + .{ "fs.write", .write }, + // Network permissions + .{ "net", .net }, + .{ "net.client", .net }, + .{ "net.server", .net }, + .{ "net.connect", .net }, + // Environment permissions + .{ "env", .env }, + // Subprocess permissions + .{ "child", .run }, + .{ "child.process", .run }, + .{ "run", .run }, + .{ "worker", .run }, // Workers can spawn processes + // FFI / Native addon permissions + .{ "ffi", .ffi }, + .{ "addon", .ffi }, + .{ "wasi", .ffi }, + // System info permissions + .{ "sys", .sys }, +}); + +/// Node.js-compatible process.permission.has(scope, reference?) API +/// Maps Node.js permission names to Bun's permission system. +/// See ScopeToKindMap for the full mapping. +pub fn permissionHas(globalObject: *jsc.JSGlobalObject, scope_ptr: [*]const u8, scope_len: usize, ref_ptr: ?[*]const u8, ref_len: usize) callconv(.c) bool { + const vm = globalObject.bunVM(); + const scope = scope_ptr[0..scope_len]; + const reference: ?[]const u8 = if (ref_ptr) |ptr| ptr[0..ref_len] else null; + + if (ScopeToKindMap.get(scope)) |kind| { + const state = vm.permissions.check(kind, reference); + return state.isGranted(); + } + + // Unknown scope - return false (permission not granted) + return false; } var title_mutex = bun.Mutex{}; diff --git a/src/bun.js/permission_check.zig b/src/bun.js/permission_check.zig new file mode 100644 index 00000000000000..0e1f805e9f87dc --- /dev/null +++ b/src/bun.js/permission_check.zig @@ -0,0 +1,183 @@ +//! Permission checking utilities for Deno-compatible security model. +//! +//! This module provides functions to check permissions before performing +//! sensitive operations like file I/O, network access, subprocess spawning, etc. +//! +//! Usage: +//! const checker = PermissionChecker.init(globalThis); +//! try checker.requireRead("/path/to/file"); +//! // ... perform read operation + +/// Permission checker that wraps a JSGlobalObject and provides +/// convenient methods for checking different permission types. +pub const PermissionChecker = struct { + global: *JSGlobalObject, + perms: *permissions.Permissions, + + /// Initialize a permission checker from a JSGlobalObject + pub fn init(global: *JSGlobalObject) PermissionChecker { + const vm = global.bunVM(); + return .{ + .global = global, + .perms = vm.permissions, + }; + } + + /// Check read permission for a path. Throws JS error if denied. + pub fn requireRead(self: PermissionChecker, path: []const u8) bun.JSError!void { + return self.require(.read, path); + } + + /// Check write permission for a path. Throws JS error if denied. + pub fn requireWrite(self: PermissionChecker, path: []const u8) bun.JSError!void { + return self.require(.write, path); + } + + /// Check network permission for a host. Throws JS error if denied. + pub fn requireNet(self: PermissionChecker, host: []const u8) bun.JSError!void { + return self.require(.net, host); + } + + /// Check environment variable permission. Throws JS error if denied. + pub fn requireEnv(self: PermissionChecker, variable: ?[]const u8) bun.JSError!void { + return self.require(.env, variable); + } + + /// Check system info permission. Throws JS error if denied. + pub fn requireSys(self: PermissionChecker, kind: ?[]const u8) bun.JSError!void { + return self.require(.sys, kind); + } + + /// Check run/subprocess permission. Throws JS error if denied. + pub fn requireRun(self: PermissionChecker, command: []const u8) bun.JSError!void { + return self.require(.run, command); + } + + /// Check FFI permission for a library path. Throws JS error if denied. + pub fn requireFfi(self: PermissionChecker, path: []const u8) bun.JSError!void { + return self.require(.ffi, path); + } + + /// Generic permission check. Throws JS error if denied. + pub fn require(self: PermissionChecker, kind: permissions.Kind, resource: ?[]const u8) bun.JSError!void { + const state = self.perms.check(kind, resource); + + switch (state) { + .granted, .granted_partial => return, // OK + .prompt, .denied, .denied_partial => { + // In secure mode without explicit permission, access is denied. + // The .prompt state is treated as denied (no interactive prompts). + return self.throwPermissionDenied(kind, resource); + }, + } + } + + /// Query permission state without throwing + pub fn query(self: PermissionChecker, kind: permissions.Kind, resource: ?[]const u8) permissions.State { + return self.perms.check(kind, resource); + } + + /// Check if permission is granted (convenience method) + pub fn isGranted(self: PermissionChecker, kind: permissions.Kind, resource: ?[]const u8) bool { + return self.perms.isGranted(kind, resource); + } + + /// Throw a PermissionDenied error with Deno-compatible message format + fn throwPermissionDenied(self: PermissionChecker, kind: permissions.Kind, resource: ?[]const u8) bun.JSError { + // Create error message + const kind_name = kind.toName(); + const flag_name = kind.toFlagName(); + + if (resource) |res| { + return self.global.throwInvalidArguments( + "PermissionDenied: Requires {s} access to \"{s}\", run again with the --allow-{s} flag", + .{ kind_name, res, flag_name }, + ); + } else { + return self.global.throwInvalidArguments( + "PermissionDenied: Requires {s} access, run again with the --allow-{s} flag", + .{ kind_name, flag_name }, + ); + } + } +}; + +/// Get a permission checker from a JSGlobalObject +pub fn getChecker(global: *JSGlobalObject) PermissionChecker { + return PermissionChecker.init(global); +} + +/// Quick check if read permission is granted for a path +pub fn canRead(global: *JSGlobalObject, path: []const u8) bool { + return getChecker(global).isGranted(.read, path); +} + +/// Quick check if write permission is granted for a path +pub fn canWrite(global: *JSGlobalObject, path: []const u8) bool { + return getChecker(global).isGranted(.write, path); +} + +/// Quick check if network permission is granted for a host +pub fn canNet(global: *JSGlobalObject, host: []const u8) bool { + return getChecker(global).isGranted(.net, host); +} + +/// Quick check if env permission is granted for a variable +pub fn canEnv(global: *JSGlobalObject, variable: ?[]const u8) bool { + return getChecker(global).isGranted(.env, variable); +} + +/// Quick check if sys permission is granted +pub fn canSys(global: *JSGlobalObject, kind: ?[]const u8) bool { + return getChecker(global).isGranted(.sys, kind); +} + +/// Quick check if run permission is granted for a command +pub fn canRun(global: *JSGlobalObject, command: []const u8) bool { + return getChecker(global).isGranted(.run, command); +} + +/// Quick check if FFI permission is granted for a path +pub fn canFfi(global: *JSGlobalObject, path: []const u8) bool { + return getChecker(global).isGranted(.ffi, path); +} + +/// Require read permission, throwing if denied +pub fn requireRead(global: *JSGlobalObject, path: []const u8) bun.JSError!void { + return getChecker(global).requireRead(path); +} + +/// Require write permission, throwing if denied +pub fn requireWrite(global: *JSGlobalObject, path: []const u8) bun.JSError!void { + return getChecker(global).requireWrite(path); +} + +/// Require network permission, throwing if denied +pub fn requireNet(global: *JSGlobalObject, host: []const u8) bun.JSError!void { + return getChecker(global).requireNet(host); +} + +/// Require env permission, throwing if denied +pub fn requireEnv(global: *JSGlobalObject, variable: ?[]const u8) bun.JSError!void { + return getChecker(global).requireEnv(variable); +} + +/// Require sys permission, throwing if denied +pub fn requireSys(global: *JSGlobalObject, kind: ?[]const u8) bun.JSError!void { + return getChecker(global).requireSys(kind); +} + +/// Require run permission, throwing if denied +pub fn requireRun(global: *JSGlobalObject, command: []const u8) bun.JSError!void { + return getChecker(global).requireRun(command); +} + +/// Require FFI permission, throwing if denied +pub fn requireFfi(global: *JSGlobalObject, path: []const u8) bun.JSError!void { + return getChecker(global).requireFfi(path); +} + +const bun = @import("bun"); +const permissions = @import("../permissions.zig"); +const jsc = bun.jsc; +const JSGlobalObject = jsc.JSGlobalObject; diff --git a/src/bun.js/webcore/Blob.zig b/src/bun.js/webcore/Blob.zig index 52296ccc5a2dcc..3f8cd21301343f 100644 --- a/src/bun.js/webcore/Blob.zig +++ b/src/bun.js/webcore/Blob.zig @@ -122,9 +122,20 @@ pub fn doReadFromS3(this: *Blob, comptime Function: anytype, global: *JSGlobalOb return S3BlobDownloadTask.init(global, this, WrappedFn.wrapped); } -pub fn doReadFile(this: *Blob, comptime Function: anytype, global: *JSGlobalObject) JSValue { +pub fn doReadFile(this: *Blob, comptime Function: anytype, global: *JSGlobalObject) bun.JSError!JSValue { debug("doReadFile", .{}); + // Check read permission if this is a file-backed blob + if (this.store) |store| { + if (store.data == .file) { + if (store.data.file.pathlike == .path) { + const path = store.data.file.pathlike.path.slice(); + const resolved_path = resolvePath(global, path); + try permission_check.PermissionChecker.init(global).requireRead(resolved_path); + } + } + } + const Handler = NewReadFileHandler(Function); var handler = bun.new(Handler, .{ @@ -1221,6 +1232,26 @@ pub fn writeFileInternal(globalThis: *jsc.JSGlobalObject, path_or_blob_: *PathOr return globalThis.throwInvalidArguments("Bun.write(pathOrFdOrBlob, blob) expects a Blob-y thing to write", .{}); } var path_or_blob = path_or_blob_.*; + + // Check write permission for the destination path + if (path_or_blob == .path) { + if (path_or_blob.path == .path) { + const path = path_or_blob.path.path.slice(); + const resolved_path = resolvePath(globalThis, path); + try permission_check.PermissionChecker.init(globalThis).requireWrite(resolved_path); + } + } else if (path_or_blob == .blob) { + if (path_or_blob.blob.store) |store| { + if (store.data == .file) { + if (store.data.file.pathlike == .path) { + const path = store.data.file.pathlike.path.slice(); + const resolved_path = resolvePath(globalThis, path); + try permission_check.PermissionChecker.init(globalThis).requireWrite(resolved_path); + } + } + } + } + if (path_or_blob == .blob) { const blob_store = path_or_blob.blob.store orelse { return globalThis.throwInvalidArguments("Blob is detached", .{}); @@ -2003,6 +2034,17 @@ pub fn getStream( globalThis: *jsc.JSGlobalObject, callframe: *jsc.CallFrame, ) bun.JSError!jsc.JSValue { + // Check read permission if this is a file-backed blob + if (this.store) |store| { + if (store.data == .file) { + if (store.data.file.pathlike == .path) { + const path = store.data.file.pathlike.path.slice(); + const resolved_path = resolvePath(globalThis, path); + try permission_check.PermissionChecker.init(globalThis).requireRead(resolved_path); + } + } + } + const thisValue = callframe.this(); if (js.streamGetCached(thisValue)) |cached| { return cached; @@ -2347,6 +2389,17 @@ pub fn getExists( globalThis: *jsc.JSGlobalObject, _: *jsc.CallFrame, ) bun.JSError!JSValue { + // Check read permission if this is a file-backed blob + if (this.store) |store| { + if (store.data == .file) { + if (store.data.file.pathlike == .path) { + const path = store.data.file.pathlike.path.slice(); + const resolved_path = resolvePath(globalThis, path); + try permission_check.PermissionChecker.init(globalThis).requireRead(resolved_path); + } + } + } + if (this.isS3()) { return S3File.S3BlobStatTask.exists(globalThis, this); } @@ -3121,7 +3174,18 @@ pub fn getStat(this: *Blob, globalThis: *jsc.JSGlobalObject, callback: *jsc.Call else => .js_undefined, }; } -pub fn getSize(this: *Blob, _: *jsc.JSGlobalObject) JSValue { +pub fn getSize(this: *Blob, globalThis: *jsc.JSGlobalObject) bun.JSError!JSValue { + // Check read permission if this is a file-backed blob + if (this.store) |store| { + if (store.data == .file) { + if (store.data.file.pathlike == .path) { + const path = store.data.file.pathlike.path.slice(); + const resolved_path = resolvePath(globalThis, path); + try permission_check.PermissionChecker.init(globalThis).requireRead(resolved_path); + } + } + } + if (this.size == Blob.max_size) { if (this.isS3()) { return jsc.JSValue.jsNumber(std.math.nan(f64)); @@ -3758,7 +3822,7 @@ pub fn toArrayBufferView(this: *Blob, global: *JSGlobalObject, comptime lifetime return WithBytesFn(this, global, @constCast(view_), lifetime); } -pub fn toFormData(this: *Blob, global: *JSGlobalObject, comptime lifetime: Lifetime) bun.JSTerminated!JSValue { +pub fn toFormData(this: *Blob, global: *JSGlobalObject, comptime lifetime: Lifetime) bun.JSError!JSValue { if (this.needsToReadFile()) { return this.doReadFile(toFormDataWithBytes, global); } @@ -4858,3 +4922,18 @@ const PathOrBlob = jsc.Node.PathOrBlob; const Request = jsc.WebCore.Request; const Response = jsc.WebCore.Response; +const permission_check = @import("../permission_check.zig"); + +/// Thread-local buffer for path resolution +threadlocal var path_resolve_buf: [bun.MAX_PATH_BYTES]u8 = undefined; + +/// Resolve a path to an absolute path using the current working directory +fn resolvePath(global: *JSGlobalObject, path: []const u8) []const u8 { + // If it's already an absolute path, use it directly + if (bun.path.Platform.auto.isAbsolute(path)) { + return path; + } + // Otherwise, resolve it relative to the cwd + const cwd = global.bunVM().transpiler.fs.top_level_dir; + return bun.path.joinAbsStringBuf(cwd, &path_resolve_buf, &.{path}, .auto); +} diff --git a/src/bun.js/webcore/fetch.zig b/src/bun.js/webcore/fetch.zig index e2dba55b9e5109..25f618399eabcf 100644 --- a/src/bun.js/webcore/fetch.zig +++ b/src/bun.js/webcore/fetch.zig @@ -355,6 +355,14 @@ pub fn Bun__fetch_( } url_proxy_buffer = url.href; + // Check net permission for remote URLs + if (url_type == .remote and url.host.len > 0) { + bun.permission_check.requireNet(globalThis, url.host) catch { + is_error = true; + return .zero; + }; + } + if (url_str.hasPrefixComptime("data:")) { var url_slice = url_str.toUTF8WithoutRef(allocator); defer url_slice.deinit(); diff --git a/src/bun.zig b/src/bun.zig index ca572ae8af4dfe..27a522737098c3 100644 --- a/src/bun.zig +++ b/src/bun.zig @@ -234,6 +234,8 @@ pub const csrf = @import("./csrf.zig"); pub const validators = @import("./bun.js/node/util/validators.zig"); pub const shell = @import("./shell/shell.zig"); +pub const permissions = @import("./permissions.zig"); +pub const permission_check = @import("./bun.js/permission_check.zig"); pub const Output = @import("./output.zig"); pub const Global = @import("./Global.zig"); diff --git a/src/bunfig.zig b/src/bunfig.zig index bcde33ecf35ba1..d8f4006d1f7229 100644 --- a/src/bunfig.zig +++ b/src/bunfig.zig @@ -849,6 +849,84 @@ pub const Bunfig = struct { } } } + + // Parse [permissions] section for Deno-compatible security model + if (json.get("permissions")) |permissions_expr| { + var permissions = &this.ctx.runtime_options.permissions; + + // secure = true enables secure-by-default mode + if (permissions_expr.get("secure")) |secure| { + if (secure.asBool()) |value| { + permissions.secure_mode = value; + } else { + try this.addError(secure.loc, "Expected boolean for 'secure'"); + } + } + + // allow-all = true grants all permissions (equivalent to -A) + if (permissions_expr.get("allow-all")) |allow_all| { + if (allow_all.asBool()) |value| { + permissions.allow_all = value; + } else { + try this.addError(allow_all.loc, "Expected boolean for 'allow-all'"); + } + } + + // no-prompt = true disables interactive permission prompts + if (permissions_expr.get("no-prompt")) |no_prompt| { + if (no_prompt.asBool()) |value| { + permissions.no_prompt = value; + } else { + try this.addError(no_prompt.loc, "Expected boolean for 'no-prompt'"); + } + } + + // Parse allow-* and deny-* permission fields + inline for (.{ + .{ "allow-read", "allow_read", "has_allow_read" }, + .{ "allow-write", "allow_write", "has_allow_write" }, + .{ "allow-net", "allow_net", "has_allow_net" }, + .{ "allow-env", "allow_env", "has_allow_env" }, + .{ "allow-sys", "allow_sys", "has_allow_sys" }, + .{ "allow-run", "allow_run", "has_allow_run" }, + .{ "allow-ffi", "allow_ffi", "has_allow_ffi" }, + }) |field_info| { + const toml_key = field_info[0]; + const struct_field = field_info[1]; + const has_field = field_info[2]; + + if (permissions_expr.get(toml_key)) |expr| { + // Set the has_* flag to indicate this permission was explicitly configured + @field(permissions, has_field) = true; + + // Handle boolean (allow all), string (single value), or array (multiple values) + if (expr.asBool()) |_| { + // boolean true = allow all (null means all when has_* is true) + @field(permissions, struct_field) = null; + } else { + @field(permissions, struct_field) = try this.parseStringOrArray(expr, allocator); + } + } + } + + // Parse deny-* fields (no has_* flag needed) + inline for (.{ + .{ "deny-read", "deny_read" }, + .{ "deny-write", "deny_write" }, + .{ "deny-net", "deny_net" }, + .{ "deny-env", "deny_env" }, + .{ "deny-sys", "deny_sys" }, + .{ "deny-run", "deny_run" }, + .{ "deny-ffi", "deny_ffi" }, + }) |field_info| { + const toml_key = field_info[0]; + const struct_field = field_info[1]; + + if (permissions_expr.get(toml_key)) |expr| { + @field(permissions, struct_field) = try this.parseStringOrArray(expr, allocator); + } + } + } } if (json.getObject("serve")) |serve_obj2| { @@ -1160,6 +1238,35 @@ pub const Bunfig = struct { } } + /// Parse an expression that can be either a string or an array of strings + /// Returns null if the expression is invalid + fn parseStringOrArray(this: *Parser, expr: js_ast.Expr, allocator: std.mem.Allocator) !?[]const []const u8 { + switch (expr.data) { + .e_string => |str| { + const result = try allocator.alloc([]const u8, 1); + result[0] = try str.string(allocator); + return result; + }, + .e_array => |array| { + const items = array.items.slice(); + if (items.len == 0) return null; + const result = try allocator.alloc([]const u8, items.len); + for (items, 0..) |item, i| { + if (item.data != .e_string) { + try this.addError(item.loc, "Expected string in array"); + return error.@"Invalid Bunfig"; + } + result[i] = try item.data.e_string.string(allocator); + } + return result; + }, + else => { + try this.addError(expr.loc, "Expected string or array of strings"); + return error.@"Invalid Bunfig"; + }, + } + } + pub fn expectString(this: *Parser, expr: js_ast.Expr) !void { switch (expr.data) { .e_string => {}, diff --git a/src/cli.zig b/src/cli.zig index e2a62330cce932..ce9c55f3aeda13 100644 --- a/src/cli.zig +++ b/src/cli.zig @@ -393,6 +393,60 @@ pub const Command = struct { name: []const u8 = "", dir: []const u8 = "", } = .{}, + /// Permission options for Deno-compatible security model + permissions: PermissionOptions = .{}, + }; + + /// Deno-compatible permission options + pub const PermissionOptions = struct { + /// Enable secure-by-default mode (like Deno) + secure_mode: bool = false, + /// Allow all permissions (--allow-all / -A) + allow_all: bool = false, + /// Disable interactive prompts (--no-prompt) + no_prompt: bool = false, + /// Allowed paths for read access (null = all if allow_all or not secure_mode) + allow_read: ?[]const []const u8 = null, + /// Allowed paths for write access + allow_write: ?[]const []const u8 = null, + /// Allowed hosts for network access (format: host:port or host) + allow_net: ?[]const []const u8 = null, + /// Allowed environment variable names/patterns + allow_env: ?[]const []const u8 = null, + /// Allowed system info kinds + allow_sys: ?[]const []const u8 = null, + /// Allowed commands for subprocess spawning + allow_run: ?[]const []const u8 = null, + /// Allowed paths for FFI/native addon loading + allow_ffi: ?[]const []const u8 = null, + /// Denied paths for read access (takes precedence over allow) + deny_read: ?[]const []const u8 = null, + /// Denied paths for write access + deny_write: ?[]const []const u8 = null, + /// Denied hosts for network access + deny_net: ?[]const []const u8 = null, + /// Denied environment variable names/patterns + deny_env: ?[]const []const u8 = null, + /// Denied system info kinds + deny_sys: ?[]const []const u8 = null, + /// Denied commands for subprocess spawning + deny_run: ?[]const []const u8 = null, + /// Denied paths for FFI/native addon loading + deny_ffi: ?[]const []const u8 = null, + /// Read permission flag was explicitly passed (--allow-read with no value) + has_allow_read: bool = false, + /// Write permission flag was explicitly passed + has_allow_write: bool = false, + /// Net permission flag was explicitly passed + has_allow_net: bool = false, + /// Env permission flag was explicitly passed + has_allow_env: bool = false, + /// Sys permission flag was explicitly passed + has_allow_sys: bool = false, + /// Run permission flag was explicitly passed + has_allow_run: bool = false, + /// FFI permission flag was explicitly passed + has_allow_ffi: bool = false, }; var global_cli_ctx: Context = undefined; diff --git a/src/cli/Arguments.zig b/src/cli/Arguments.zig index b8b3033d9e7daa..01a5e5ad970769 100644 --- a/src/cli/Arguments.zig +++ b/src/cli/Arguments.zig @@ -38,6 +38,41 @@ pub fn resolve_jsx_runtime(str: string) !Api.JsxRuntime { } } +/// Parse a comma-separated list of values into a slice +/// Used for permission flags like --allow-read=/tmp,/home/user +/// Panics on OOM to prevent silently broadening permissions. +pub fn parseCommaSeparated(allocator: std.mem.Allocator, value: []const u8) ?[]const []const u8 { + if (value.len == 0) return null; + + // Count commas to determine array size + var count: usize = 1; + for (value) |c| { + if (c == ',') count += 1; + } + + // OOM during permission parsing is fatal - we can't silently fail and + // risk granting broader permissions than intended + const result = allocator.alloc([]const u8, count) catch @panic("OOM"); + var iter = std.mem.splitScalar(u8, value, ','); + var i: usize = 0; + while (iter.next()) |part| { + // Trim whitespace from each part + const trimmed = std.mem.trim(u8, part, " \t"); + if (trimmed.len > 0) { + result[i] = trimmed; + i += 1; + } + } + + if (i == 0) { + allocator.free(result); + return null; + } + + // Return only the filled portion + return result[0..i]; +} + pub const ParamType = clap.Param(clap.Help); pub const base_params_ = (if (Environment.show_crash_trace) debug_params else [_]ParamType{}) ++ [_]ParamType{ @@ -118,6 +153,28 @@ pub const runtime_params_ = [_]ParamType{ clap.parseParam("--unhandled-rejections One of \"strict\", \"throw\", \"warn\", \"none\", or \"warn-with-error-code\"") catch unreachable, clap.parseParam("--console-depth Set the default depth for console.log object inspection (default: 2)") catch unreachable, clap.parseParam("--user-agent Set the default User-Agent header for HTTP requests") catch unreachable, + // Permission flags (Deno-compatible security model) + clap.parseParam("--secure Enable secure-by-default mode (like Deno)") catch unreachable, + clap.parseParam("--permission Enable secure-by-default mode (Node.js compatibility alias for --secure)") catch unreachable, + clap.parseParam("-A, --allow-all Allow all permissions") catch unreachable, + clap.parseParam("--allow-read ? Allow file system read access (optionally scoped to paths)") catch unreachable, + clap.parseParam("--allow-write ? Allow file system write access (optionally scoped to paths)") catch unreachable, + clap.parseParam("--allow-fs-read ? Allow file system read access (Node.js alias for --allow-read)") catch unreachable, + clap.parseParam("--allow-fs-write ? Allow file system write access (Node.js alias for --allow-write)") catch unreachable, + clap.parseParam("--allow-net ? Allow network access (optionally scoped to hosts)") catch unreachable, + clap.parseParam("--allow-env ? Allow environment variable access (optionally scoped to names)") catch unreachable, + clap.parseParam("--allow-sys ? Allow system info access (optionally scoped to kinds)") catch unreachable, + clap.parseParam("--allow-run ? Allow subprocess spawning (optionally scoped to commands)") catch unreachable, + clap.parseParam("--allow-child-process ? Allow subprocess spawning (Node.js alias for --allow-run)") catch unreachable, + clap.parseParam("--allow-ffi ? Allow FFI/native addon loading (optionally scoped to paths)") catch unreachable, + clap.parseParam("--deny-read ? Deny file system read access (optionally scoped to paths)") catch unreachable, + clap.parseParam("--deny-write ? Deny file system write access (optionally scoped to paths)") catch unreachable, + clap.parseParam("--deny-net ? Deny network access (optionally scoped to hosts)") catch unreachable, + clap.parseParam("--deny-env ? Deny environment variable access (optionally scoped to names)") catch unreachable, + clap.parseParam("--deny-sys ? Deny system info access (optionally scoped to kinds)") catch unreachable, + clap.parseParam("--deny-run ? Deny subprocess spawning (optionally scoped to commands)") catch unreachable, + clap.parseParam("--deny-ffi ? Deny FFI/native addon loading (optionally scoped to paths)") catch unreachable, + clap.parseParam("--no-prompt Disable interactive permission prompts (always deny)") catch unreachable, }; pub const auto_or_run_params = [_]ParamType{ @@ -879,6 +936,57 @@ pub fn parse(allocator: std.mem.Allocator, ctx: Command.Context, comptime cmd: C // Back-compat boolean used by native code until fully migrated Bun__Node__UseSystemCA = (Bun__Node__CAStore == .system); + + // Parse permission flags (Deno and Node.js compatible security model) + // Only set to true if flag is present - let bunfig values remain otherwise + if (args.flag("--secure") or args.flag("--permission")) { + ctx.runtime_options.permissions.secure_mode = true; + } + if (args.flag("--allow-all")) { + ctx.runtime_options.permissions.allow_all = true; + } + if (args.flag("--no-prompt")) { + ctx.runtime_options.permissions.no_prompt = true; + } + + // Parse --allow-* flags (each can be a flag or have optional value) + // Includes both Deno-style and Node.js-style aliases + inline for (.{ + .{ "--allow-read", "allow_read", "has_allow_read" }, + .{ "--allow-write", "allow_write", "has_allow_write" }, + .{ "--allow-fs-read", "allow_read", "has_allow_read" }, // Node.js alias + .{ "--allow-fs-write", "allow_write", "has_allow_write" }, // Node.js alias + .{ "--allow-net", "allow_net", "has_allow_net" }, + .{ "--allow-env", "allow_env", "has_allow_env" }, + .{ "--allow-sys", "allow_sys", "has_allow_sys" }, + .{ "--allow-run", "allow_run", "has_allow_run" }, + .{ "--allow-child-process", "allow_run", "has_allow_run" }, // Node.js alias + .{ "--allow-ffi", "allow_ffi", "has_allow_ffi" }, + }) |perm| { + if (args.option(perm[0])) |value| { + if (value.len > 0) { + @field(ctx.runtime_options.permissions, perm[1]) = parseCommaSeparated(allocator, value); + } + @field(ctx.runtime_options.permissions, perm[2]) = true; + } + } + + // Parse --deny-* flags + inline for (.{ + .{ "--deny-read", "deny_read" }, + .{ "--deny-write", "deny_write" }, + .{ "--deny-net", "deny_net" }, + .{ "--deny-env", "deny_env" }, + .{ "--deny-sys", "deny_sys" }, + .{ "--deny-run", "deny_run" }, + .{ "--deny-ffi", "deny_ffi" }, + }) |perm| { + if (args.option(perm[0])) |value| { + if (value.len > 0) { + @field(ctx.runtime_options.permissions, perm[1]) = parseCommaSeparated(allocator, value); + } + } + } } if (opts.port != null and opts.origin == null) { diff --git a/src/cli/test_command.zig b/src/cli/test_command.zig index 04868c37f2d944..a530230c60e486 100644 --- a/src/cli/test_command.zig +++ b/src/cli/test_command.zig @@ -1395,6 +1395,7 @@ pub const TestCommand = struct { .smol = ctx.runtime_options.smol, .debugger = ctx.runtime_options.debugger, .is_main_thread = true, + .permission_options = &ctx.runtime_options.permissions, }, ); vm.argv = ctx.passthrough; diff --git a/src/permissions.zig b/src/permissions.zig new file mode 100644 index 00000000000000..cc112f932cd15d --- /dev/null +++ b/src/permissions.zig @@ -0,0 +1,900 @@ +//! Deno-compatible permissions model for Bun. +//! +//! This module implements a security sandbox with granular permission control over: +//! - File system access (read/write) +//! - Network access (connect/listen) +//! - Environment variable access +//! - Subprocess spawning +//! - FFI/native addon loading +//! - System information access +//! +//! By default, Bun runs in "allow-all" mode for backwards compatibility. +//! Use `--secure` flag to enable secure-by-default mode (like Deno). + +const std = @import("std"); + +/// Permission types matching Deno's model +pub const Kind = enum(u8) { + read, + write, + net, + env, + sys, + run, + ffi, + + pub fn toFlag(self: Kind) []const u8 { + return switch (self) { + .read => "--allow-read", + .write => "--allow-write", + .net => "--allow-net", + .env => "--allow-env", + .sys => "--allow-sys", + .run => "--allow-run", + .ffi => "--allow-ffi", + }; + } + + pub fn toFlagName(self: Kind) []const u8 { + return @tagName(self); + } + + pub fn toName(self: Kind) []const u8 { + return switch (self) { + .net => "network", + else => @tagName(self), + }; + } + + pub fn toString(self: Kind) []const u8 { + return @tagName(self); + } +}; + +/// Permission states following Deno's model +pub const State = enum(u8) { + /// Permission is fully granted + granted = 0, + /// Permission is granted for specific resources only + granted_partial = 1, + /// Permission will prompt user (default in secure mode) + prompt = 2, + /// Permission is fully denied + denied = 3, + /// Permission is denied for specific resources, others may prompt + denied_partial = 4, + + pub fn isGranted(self: State) bool { + return self == .granted or self == .granted_partial; + } + + pub fn isDenied(self: State) bool { + return self == .denied or self == .denied_partial; + } + + pub fn toJsString(self: State) []const u8 { + return switch (self) { + .granted, .granted_partial => "granted", + .prompt => "prompt", + .denied, .denied_partial => "denied", + }; + } +}; + +/// System information kinds for --allow-sys granularity +pub const SysKind = enum { + hostname, + osRelease, + osUptime, + loadavg, + networkInterfaces, + systemMemoryInfo, + uid, + gid, + username, + cpus, + homedir, + statfs, + getPriority, + setPriority, + + pub fn fromString(str: []const u8) ?SysKind { + const map = std.StaticStringMap(SysKind).initComptime(.{ + .{ "hostname", .hostname }, + .{ "osRelease", .osRelease }, + .{ "osUptime", .osUptime }, + .{ "loadavg", .loadavg }, + .{ "networkInterfaces", .networkInterfaces }, + .{ "systemMemoryInfo", .systemMemoryInfo }, + .{ "uid", .uid }, + .{ "gid", .gid }, + .{ "username", .username }, + .{ "cpus", .cpus }, + .{ "homedir", .homedir }, + .{ "statfs", .statfs }, + .{ "getPriority", .getPriority }, + .{ "setPriority", .setPriority }, + }); + return map.get(str); + } +}; + +/// A single permission with optional resource scope +pub const Permission = struct { + state: State, + /// Allowed resources (paths, hosts, env vars, commands, etc.) + /// null means the permission applies to all resources + allowed: ?[]const []const u8 = null, + /// Explicitly denied resources (takes precedence over allowed) + denied_list: ?[]const []const u8 = null, + + /// Check if access to a specific resource is permitted + pub fn check(self: *const Permission, resource: ?[]const u8) State { + // First check if explicitly denied + if (self.denied_list) |denied| { + if (resource) |r| { + for (denied) |pattern| { + if (matchesPattern(r, pattern)) { + return .denied; + } + } + } else { + // Requesting access to all resources, but some are denied + return .denied_partial; + } + } + + // If fully granted (no resource list), allow everything + if (self.state == .granted and self.allowed == null) { + return .granted; + } + + // If granted with resource list, check if resource matches + if (self.allowed) |allowed| { + if (resource) |r| { + for (allowed) |pattern| { + if (matchesPattern(r, pattern)) { + return .granted; + } + } + } + // Resource not in allowed list + return if (self.state == .prompt) .prompt else .denied; + } + + return self.state; + } + + /// Check if the permission covers all resources (no restrictions) + pub fn isUnrestricted(self: *const Permission) bool { + return self.state == .granted and self.allowed == null and self.denied_list == null; + } +}; + +/// Network permission protocol types +pub const NetProtocol = enum { + http, + https, + ws, + wss, + + pub fn fromString(str: []const u8) ?NetProtocol { + const map = std.StaticStringMap(NetProtocol).initComptime(.{ + .{ "http", .http }, + .{ "https", .https }, + .{ "ws", .ws }, + .{ "wss", .wss }, + }); + return map.get(str); + } +}; + +/// Port pattern for network permissions +pub const PortPattern = union(enum) { + any, // * or omitted - matches any port + none, // invalid pattern - matches no ports (fail closed for security) + single: u16, // :443 - matches exactly this port + list: []const u16, // :80;443 - matches any of these ports (semicolon-separated) + range: struct { min: u16, max: u16 }, // :8000-9000 - matches ports in range + + pub fn matches(self: PortPattern, port: ?u16) bool { + return switch (self) { + .any => true, + .none => false, // Invalid patterns fail closed - deny access + .single => |p| if (port) |rp| rp == p else false, + .list => |ports| { + if (port) |rp| { + for (ports) |p| { + if (p == rp) return true; + } + } + return false; + }, + .range => |r| if (port) |rp| rp >= r.min and rp <= r.max else false, + }; + } +}; + +/// Match a resource against a permission pattern +/// Supports: +/// - Exact match +/// - Directory prefix matching for paths (e.g., "/foo" allows "/foo/bar") +/// - Wildcard prefix for env vars (e.g., "AWS_*") +/// - Host:port matching for network +/// - Network wildcards (*.example.com, **.example.com, :8000-9000, https://...) +fn matchesPattern(resource: []const u8, pattern: []const u8) bool { + // Exact match + if (std.mem.eql(u8, resource, pattern)) { + return true; + } + + // Check if this is a network pattern with advanced wildcards + if (isNetworkPattern(pattern)) { + return matchesNetworkPatternString(resource, pattern); + } + + // Wildcard suffix match (e.g., "AWS_*" matches "AWS_SECRET_KEY") + if (pattern.len > 0 and pattern[pattern.len - 1] == '*') { + const prefix = pattern[0 .. pattern.len - 1]; + if (std.mem.startsWith(u8, resource, prefix)) { + return true; + } + } + + // Directory prefix match for paths (e.g., "/foo" allows "/foo/bar", "/tmp/" allows "/tmp/foo") + // Pattern must be a directory prefix of resource + // Handle both POSIX (/...) and Windows (C:\...) absolute paths + if (pattern.len > 0 and (pattern[0] == '/' or pattern[0] == '.' or isWindowsDrivePath(pattern))) { + // Strip trailing separators from pattern for consistent matching + const trimmed_pattern = std.mem.trimRight(u8, pattern, "/\\"); + if (resource.len > trimmed_pattern.len) { + if (std.mem.startsWith(u8, resource, trimmed_pattern)) { + // Check for path separator after pattern + if (resource[trimmed_pattern.len] == '/' or resource[trimmed_pattern.len] == '\\') { + return true; + } + } + } + } + + // Host:port matching for network permissions + // Pattern "host" matches "host:port" (any port on that host) + // Pattern "host:port" requires exact match (handled above) + // Use findPortSeparator to handle IPv6 addresses correctly (e.g., [::1]:8080) + if (findPortSeparator(resource)) |colon_pos| { + const resource_host = resource[0..colon_pos]; + if (std.mem.eql(u8, resource_host, pattern)) { + return true; + } + } + + // Command basename matching for run permissions + // Pattern "cmd" matches "/usr/bin/cmd" or "C:\bin\cmd.exe" + // Only if pattern doesn't contain path separators + if (std.mem.indexOfScalar(u8, pattern, '/') == null and + std.mem.indexOfScalar(u8, pattern, '\\') == null) + { + // Find the last path separator (either / or \) + const last_sep_pos = blk: { + const last_slash = std.mem.lastIndexOfScalar(u8, resource, '/'); + const last_backslash = std.mem.lastIndexOfScalar(u8, resource, '\\'); + if (last_slash) |s| { + if (last_backslash) |b| { + break :blk @max(s, b); + } + break :blk s; + } + break :blk last_backslash; + }; + if (last_sep_pos) |pos| { + const basename = resource[pos + 1 ..]; + if (std.mem.eql(u8, basename, pattern)) { + return true; + } + } + } + + return false; +} + +/// Check if a path is a Windows drive-letter absolute path (e.g., "C:\..." or "D:/...") +fn isWindowsDrivePath(path: []const u8) bool { + if (path.len < 2) return false; + // Check for drive letter followed by colon + const first = path[0]; + if ((first >= 'A' and first <= 'Z') or (first >= 'a' and first <= 'z')) { + if (path[1] == ':') { + // Optional check for separator after colon + if (path.len == 2) return true; + return path[2] == '/' or path[2] == '\\'; + } + } + return false; +} + +/// Check if pattern uses advanced network wildcards +fn isNetworkPattern(pattern: []const u8) bool { + // Contains protocol prefix (e.g., "https://") + if (std.mem.indexOf(u8, pattern, "://") != null) return true; + // Contains domain wildcards + if (std.mem.indexOf(u8, pattern, "*.") != null) return true; + if (std.mem.indexOf(u8, pattern, "**.") != null) return true; + // Contains port wildcard + if (std.mem.endsWith(u8, pattern, ":*")) return true; + // Contains port range (e.g., :8000-9000) + if (hasPortRange(pattern)) return true; + // Contains port list (e.g., :80;443) + if (hasPortList(pattern)) return true; + return false; +} + +fn hasPortRange(pattern: []const u8) bool { + // Look for :digits-digits at the end + if (std.mem.lastIndexOfScalar(u8, pattern, ':')) |colon_pos| { + const port_part = pattern[colon_pos + 1 ..]; + if (std.mem.indexOfScalar(u8, port_part, '-')) |dash_pos| { + // Verify both sides are digits + const left = port_part[0..dash_pos]; + const right = port_part[dash_pos + 1 ..]; + if (left.len > 0 and right.len > 0) { + for (left) |c| if (!std.ascii.isDigit(c)) return false; + for (right) |c| if (!std.ascii.isDigit(c)) return false; + return true; + } + } + } + return false; +} + +fn hasPortList(pattern: []const u8) bool { + // Look for :digits;digits at the end (semicolon-separated to avoid conflict with CLI comma separator) + if (std.mem.lastIndexOfScalar(u8, pattern, ':')) |colon_pos| { + const port_part = pattern[colon_pos + 1 ..]; + if (std.mem.indexOfScalar(u8, port_part, ';') != null) { + // Verify it's semicolon-separated digits + var iter = std.mem.splitScalar(u8, port_part, ';'); + while (iter.next()) |seg| { + const trimmed = std.mem.trim(u8, seg, " "); + if (trimmed.len == 0) return false; + for (trimmed) |c| if (!std.ascii.isDigit(c)) return false; + } + return true; + } + } + return false; +} + +/// Match a resource against an advanced network pattern +fn matchesNetworkPatternString(resource: []const u8, pattern: []const u8) bool { + var pat_remaining = pattern; + var res_remaining = resource; + + // Parse protocol from pattern (if present) + var pat_protocol: ?NetProtocol = null; + if (std.mem.indexOf(u8, pat_remaining, "://")) |proto_end| { + pat_protocol = NetProtocol.fromString(pat_remaining[0..proto_end]); + pat_remaining = pat_remaining[proto_end + 3 ..]; + } + + // Parse protocol from resource (if present) + var res_protocol: ?NetProtocol = null; + if (std.mem.indexOf(u8, res_remaining, "://")) |proto_end| { + res_protocol = NetProtocol.fromString(res_remaining[0..proto_end]); + res_remaining = res_remaining[proto_end + 3 ..]; + } + + // If pattern specifies a protocol, resource must match + if (pat_protocol) |pp| { + if (res_protocol) |rp| { + if (pp != rp) return false; + } + // If resource has no protocol, allow match (backward compat) + } + + // Parse port from pattern + var pat_host = pat_remaining; + var pat_port_pattern: PortPattern = .any; + var port_list_buf: [16]u16 = undefined; // Local buffer for port list parsing + if (findPortSeparator(pat_remaining)) |colon_pos| { + pat_host = pat_remaining[0..colon_pos]; + const port_str = pat_remaining[colon_pos + 1 ..]; + pat_port_pattern = parsePortPatternString(port_str, &port_list_buf); + } + + // Parse port from resource + var res_host = res_remaining; + var res_port: ?u16 = null; + if (findPortSeparator(res_remaining)) |colon_pos| { + res_host = res_remaining[0..colon_pos]; + const port_str = res_remaining[colon_pos + 1 ..]; + res_port = std.fmt.parseInt(u16, port_str, 10) catch null; + } + + // Check port match + // If pattern is .none (invalid), always deny + if (pat_port_pattern == .none) { + return false; + } + if (!pat_port_pattern.matches(res_port)) { + // Special case: if pattern has no port spec and resource has port, + // allow match for backward compatibility + if (pat_port_pattern != .any or res_port == null) { + return false; + } + } + + // Check host match with wildcards + return matchesHostPattern(res_host, pat_host); +} + +/// Find the position of port separator, handling IPv6 addresses +fn findPortSeparator(s: []const u8) ?usize { + // IPv6 addresses are enclosed in brackets: [::1]:8080 + if (s.len > 0 and s[0] == '[') { + if (std.mem.indexOfScalar(u8, s, ']')) |bracket_end| { + if (bracket_end + 1 < s.len and s[bracket_end + 1] == ':') { + return bracket_end + 1; + } + return null; + } + return null; + } + // For regular hosts, use last colon + return std.mem.lastIndexOfScalar(u8, s, ':'); +} + +/// Parse a port pattern string into a PortPattern +/// The caller must provide a buffer for port lists to avoid thread-local state issues. +/// The returned PortPattern.list slice points into the provided buffer. +/// On parse errors, returns .none (fail closed) to avoid accidentally granting broader permissions. +fn parsePortPatternString(port_str: []const u8, port_buf: *[16]u16) PortPattern { + if (port_str.len == 0 or std.mem.eql(u8, port_str, "*")) { + return .any; + } + + // Check for range (e.g., "8000-9000") + if (std.mem.indexOfScalar(u8, port_str, '-')) |dash_pos| { + const min_str = port_str[0..dash_pos]; + const max_str = port_str[dash_pos + 1 ..]; + // Fail closed on parse errors - don't accidentally grant access + const min_port = std.fmt.parseInt(u16, min_str, 10) catch return .none; + const max_port = std.fmt.parseInt(u16, max_str, 10) catch return .none; + if (min_port <= max_port) { + return .{ .range = .{ .min = min_port, .max = max_port } }; + } + // Invalid range (min > max) - fail closed + return .none; + } + + // Check for list (e.g., "80;443") - semicolon-separated to avoid conflict with CLI comma separator + if (std.mem.indexOfScalar(u8, port_str, ';') != null) { + // Parse into caller-provided buffer (max 16 ports) in a single pass + var count: usize = 0; + var iter = std.mem.splitScalar(u8, port_str, ';'); + while (iter.next()) |seg| { + if (count >= 16) return .none; // Too many ports - fail closed + const trimmed = std.mem.trim(u8, seg, " "); + // Fail closed on parse errors + port_buf[count] = std.fmt.parseInt(u16, trimmed, 10) catch return .none; + count += 1; + } + return .{ .list = port_buf[0..count] }; + } + + // Single port - fail closed on parse errors + const port = std.fmt.parseInt(u16, port_str, 10) catch return .none; + return .{ .single = port }; +} + +/// Match a host against a pattern with wildcards +/// Supports: +/// * - matches exactly one domain segment +/// ** - matches one or more domain segments +fn matchesHostPattern(resource_host: []const u8, pattern_host: []const u8) bool { + // Split into segments + var pat_segs: [32][]const u8 = undefined; + var pat_count: usize = 0; + var pat_iter = std.mem.splitScalar(u8, pattern_host, '.'); + while (pat_iter.next()) |seg| { + if (pat_count >= 32) return false; + pat_segs[pat_count] = seg; + pat_count += 1; + } + + var res_segs: [32][]const u8 = undefined; + var res_count: usize = 0; + var res_iter = std.mem.splitScalar(u8, resource_host, '.'); + while (res_iter.next()) |seg| { + if (res_count >= 32) return false; + res_segs[res_count] = seg; + res_count += 1; + } + + // Find ** position + var double_star_pos: ?usize = null; + for (pat_segs[0..pat_count], 0..) |seg, i| { + if (std.mem.eql(u8, seg, "**")) { + double_star_pos = i; + break; + } + } + + if (double_star_pos) |ds_pos| { + return matchesWithDoubleStar(pat_segs[0..pat_count], ds_pos, res_segs[0..res_count]); + } else { + return matchesWithSingleStar(pat_segs[0..pat_count], res_segs[0..res_count]); + } +} + +/// Match with * wildcards (each * matches exactly one segment) +fn matchesWithSingleStar(pattern_segs: []const []const u8, resource_segs: []const []const u8) bool { + if (pattern_segs.len != resource_segs.len) { + return false; + } + + for (pattern_segs, resource_segs) |pat, res| { + if (std.mem.eql(u8, pat, "*")) { + continue; // * matches any single segment + } + if (!std.ascii.eqlIgnoreCase(pat, res)) { + return false; + } + } + return true; +} + +/// Match with ** wildcard (matches one or more segments) +fn matchesWithDoubleStar(pattern_segs: []const []const u8, double_star_pos: usize, resource_segs: []const []const u8) bool { + const before_star = pattern_segs[0..double_star_pos]; + const after_star = pattern_segs[double_star_pos + 1 ..]; + + // ** matches at least one segment + const min_res_len = before_star.len + after_star.len + 1; + if (resource_segs.len < min_res_len) { + return false; + } + + // Match segments before ** + for (before_star, 0..) |pat, i| { + if (std.mem.eql(u8, pat, "*")) { + continue; + } + if (!std.ascii.eqlIgnoreCase(pat, resource_segs[i])) { + return false; + } + } + + // Match segments after ** (from the end) + const res_end_start = resource_segs.len - after_star.len; + for (after_star, 0..) |pat, i| { + if (std.mem.eql(u8, pat, "*")) { + continue; + } + if (!std.ascii.eqlIgnoreCase(pat, resource_segs[res_end_start + i])) { + return false; + } + } + + return true; +} + +/// Central permissions container. +/// +/// Memory management: The `allowed` and `denied_list` slices in each Permission +/// are borrowed references to data owned by PermissionOptions (from CLI args). +/// These slices remain valid for the lifetime of the process, so no explicit +/// deallocation is needed. The Permissions struct does not own this memory. +pub const Permissions = struct { + read: Permission = .{ .state = .granted }, + write: Permission = .{ .state = .granted }, + net: Permission = .{ .state = .granted }, + env: Permission = .{ .state = .granted }, + sys: Permission = .{ .state = .granted }, + run: Permission = .{ .state = .granted }, + ffi: Permission = .{ .state = .granted }, + + /// Fast path for when all permissions are granted (default mode) + allow_all: bool = true, + + /// Whether interactive prompts are disabled (always true until prompts are implemented) + no_prompt: bool = true, + + /// Operating mode: true = secure by default, false = allow all by default + secure_mode: bool = false, + + /// Initialize with default allow-all permissions (Bun's default mode) + pub fn initAllowAll() Permissions { + return .{ + .allow_all = true, + .secure_mode = false, + }; + } + + /// Initialize with secure-by-default permissions (Deno-style) + pub fn initSecure() Permissions { + return .{ + .read = .{ .state = .prompt }, + .write = .{ .state = .prompt }, + .net = .{ .state = .prompt }, + .env = .{ .state = .prompt }, + .sys = .{ .state = .prompt }, + .run = .{ .state = .prompt }, + .ffi = .{ .state = .prompt }, + .allow_all = false, + .secure_mode = true, + }; + } + + /// Check if running in secure mode (--secure flag) + pub fn isSecureMode(self: *const Permissions) bool { + return self.secure_mode; + } + + /// Check permission with fast path for allow_all + pub fn check(self: *const Permissions, kind: Kind, resource: ?[]const u8) State { + // Fast path: if allow_all is true, skip all checks + if (self.allow_all) { + return .granted; + } + + const perm = switch (kind) { + .read => &self.read, + .write => &self.write, + .net => &self.net, + .env => &self.env, + .sys => &self.sys, + .run => &self.run, + .ffi => &self.ffi, + }; + + return perm.check(resource); + } + + /// Check if permission is granted (convenience wrapper) + pub fn isGranted(self: *const Permissions, kind: Kind, resource: ?[]const u8) bool { + return self.check(kind, resource).isGranted(); + } + + /// Set permission to fully granted + pub fn grant(self: *Permissions, kind: Kind) void { + const perm = self.getPermissionMut(kind); + perm.state = .granted; + perm.allowed = null; + } + + /// Set permission to granted with resource list. + /// If resources is empty, the permission is denied (no access granted). + pub fn grantWithResources(self: *Permissions, kind: Kind, resources: []const []const u8) void { + const perm = self.getPermissionMut(kind); + if (resources.len == 0) { + // Empty resource list means no access granted - treat as denied + perm.state = .denied; + perm.allowed = null; + } else { + perm.state = .granted_partial; + perm.allowed = resources; + } + self.allow_all = false; + } + + /// Set permission to denied + pub fn deny(self: *Permissions, kind: Kind) void { + const perm = self.getPermissionMut(kind); + perm.state = .denied; + self.allow_all = false; + } + + /// Add resources to deny list + pub fn denyResources(self: *Permissions, kind: Kind, resources: []const []const u8) void { + const perm = self.getPermissionMut(kind); + perm.denied_list = resources; + self.allow_all = false; + } + + fn getPermissionMut(self: *Permissions, kind: Kind) *Permission { + return switch (kind) { + .read => &self.read, + .write => &self.write, + .net => &self.net, + .env => &self.env, + .sys => &self.sys, + .run => &self.run, + .ffi => &self.ffi, + }; + } + + pub fn getPermission(self: *const Permissions, kind: Kind) *const Permission { + return switch (kind) { + .read => &self.read, + .write => &self.write, + .net => &self.net, + .env => &self.env, + .sys => &self.sys, + .run => &self.run, + .ffi => &self.ffi, + }; + } +}; + +/// Error type for permission denials +pub const PermissionError = error{ + PermissionDenied, +}; + +/// Format a permission denied error message (Deno-compatible format) +pub fn formatDeniedMessage( + writer: anytype, + kind: Kind, + resource: ?[]const u8, +) !void { + try writer.print("PermissionDenied: Requires {s} access", .{kind.toString()}); + if (resource) |r| { + try writer.print(" to \"{s}\"", .{r}); + } + try writer.print(", run again with the {s} flag", .{kind.toFlag()}); +} + +test "permission matching - exact" { + const perm = Permission{ + .state = .granted_partial, + .allowed = &.{ "/tmp", "/home/user" }, + }; + + try std.testing.expectEqual(State.granted, perm.check("/tmp")); + try std.testing.expectEqual(State.granted, perm.check("/home/user")); + try std.testing.expectEqual(State.denied, perm.check("/etc")); +} + +test "permission matching - directory prefix" { + const perm = Permission{ + .state = .granted_partial, + .allowed = &.{"/tmp"}, + }; + + try std.testing.expectEqual(State.granted, perm.check("/tmp")); + try std.testing.expectEqual(State.granted, perm.check("/tmp/foo")); + try std.testing.expectEqual(State.granted, perm.check("/tmp/foo/bar")); + try std.testing.expectEqual(State.denied, perm.check("/tmpfoo")); // Not a subdir +} + +test "permission matching - wildcard" { + const perm = Permission{ + .state = .granted_partial, + .allowed = &.{"AWS_*"}, + }; + + try std.testing.expectEqual(State.granted, perm.check("AWS_SECRET_KEY")); + try std.testing.expectEqual(State.granted, perm.check("AWS_ACCESS_KEY_ID")); + try std.testing.expectEqual(State.denied, perm.check("PATH")); +} + +test "permission deny takes precedence" { + const perm = Permission{ + .state = .granted, + .allowed = null, + .denied_list = &.{"/etc/passwd"}, + }; + + try std.testing.expectEqual(State.granted, perm.check("/tmp/foo")); + try std.testing.expectEqual(State.denied, perm.check("/etc/passwd")); +} + +test "permissions - allow all fast path" { + const perms = Permissions.initAllowAll(); + try std.testing.expect(perms.allow_all); + try std.testing.expectEqual(State.granted, perms.check(.read, "/etc/passwd")); + try std.testing.expectEqual(State.granted, perms.check(.net, "example.com:443")); +} + +test "permissions - secure mode" { + const perms = Permissions.initSecure(); + try std.testing.expect(!perms.allow_all); + try std.testing.expect(perms.secure_mode); + try std.testing.expectEqual(State.prompt, perms.check(.read, "/etc/passwd")); + try std.testing.expectEqual(State.prompt, perms.check(.net, "example.com:443")); +} + +test "network wildcard - single segment *" { + // *.example.com should match api.example.com + try std.testing.expect(matchesPattern("api.example.com", "*.example.com")); + try std.testing.expect(matchesPattern("www.example.com", "*.example.com")); + // *.example.com should NOT match api.v2.example.com (too deep) + try std.testing.expect(!matchesPattern("api.v2.example.com", "*.example.com")); + // *.example.com should NOT match example.com (too shallow) + try std.testing.expect(!matchesPattern("example.com", "*.example.com")); +} + +test "network wildcard - double segment **" { + // **.example.com should match api.example.com + try std.testing.expect(matchesPattern("api.example.com", "**.example.com")); + // **.example.com should match api.v2.example.com + try std.testing.expect(matchesPattern("api.v2.example.com", "**.example.com")); + // **.example.com should match a.b.c.example.com + try std.testing.expect(matchesPattern("a.b.c.example.com", "**.example.com")); + // **.example.com should NOT match example.com (** needs at least one segment) + try std.testing.expect(!matchesPattern("example.com", "**.example.com")); +} + +test "network wildcard - middle position" { + // api.*.example.com should match api.v1.example.com + try std.testing.expect(matchesPattern("api.v1.example.com", "api.*.example.com")); + // api.*.example.com should NOT match api.v1.v2.example.com + try std.testing.expect(!matchesPattern("api.v1.v2.example.com", "api.*.example.com")); +} + +test "network wildcard - port patterns" { + // :* matches any port + try std.testing.expect(matchesPattern("example.com:443", "example.com:*")); + try std.testing.expect(matchesPattern("example.com:8080", "example.com:*")); + // :443 matches only 443 + try std.testing.expect(matchesPattern("example.com:443", "example.com:443")); + try std.testing.expect(!matchesPattern("example.com:80", "example.com:443")); + // :80;443 matches 80 or 443 (semicolon-separated) + try std.testing.expect(matchesPattern("example.com:80", "example.com:80;443")); + try std.testing.expect(matchesPattern("example.com:443", "example.com:80;443")); + try std.testing.expect(!matchesPattern("example.com:8080", "example.com:80;443")); + // :8000-9000 matches range + try std.testing.expect(matchesPattern("example.com:8000", "example.com:8000-9000")); + try std.testing.expect(matchesPattern("example.com:8500", "example.com:8000-9000")); + try std.testing.expect(matchesPattern("example.com:9000", "example.com:8000-9000")); + try std.testing.expect(!matchesPattern("example.com:7999", "example.com:8000-9000")); + try std.testing.expect(!matchesPattern("example.com:9001", "example.com:8000-9000")); +} + +test "network wildcard - protocol prefix" { + // https:// matches only https + try std.testing.expect(matchesPattern("https://example.com", "https://example.com")); + try std.testing.expect(!matchesPattern("http://example.com", "https://example.com")); + // Combined with wildcards + try std.testing.expect(matchesPattern("https://api.example.com", "https://*.example.com")); + try std.testing.expect(!matchesPattern("http://api.example.com", "https://*.example.com")); +} + +test "network wildcard - backward compatibility" { + // Plain host still matches host:port + try std.testing.expect(matchesPattern("example.com:443", "example.com")); + try std.testing.expect(matchesPattern("127.0.0.1:3000", "127.0.0.1")); +} + +test "network wildcard - case insensitive" { + try std.testing.expect(matchesPattern("API.Example.COM", "*.example.com")); + try std.testing.expect(matchesPattern("api.example.com", "*.EXAMPLE.COM")); +} + +test "path matching - trailing separator" { + // Pattern with trailing slash should match files in that directory + try std.testing.expect(matchesPattern("/tmp/foo", "/tmp/")); + try std.testing.expect(matchesPattern("/tmp/foo/bar", "/tmp/")); + // Pattern without trailing slash should also work + try std.testing.expect(matchesPattern("/tmp/foo", "/tmp")); + // Exact match should still work + try std.testing.expect(matchesPattern("/tmp/", "/tmp/")); +} + +test "path matching - Windows drive paths" { + // Windows absolute paths + try std.testing.expect(matchesPattern("C:\\foo\\bar", "C:\\foo")); + try std.testing.expect(matchesPattern("C:\\foo\\bar\\baz", "C:\\foo")); + // With trailing backslash + try std.testing.expect(matchesPattern("C:\\foo\\bar", "C:\\foo\\")); + // Mixed separators (Windows allows both) + try std.testing.expect(matchesPattern("C:/foo/bar", "C:/foo")); +} + +test "path matching - Windows basename" { + // Pattern without path separators should match Windows paths + try std.testing.expect(matchesPattern("C:\\Windows\\System32\\cmd.exe", "cmd.exe")); + try std.testing.expect(matchesPattern("D:\\bin\\node.exe", "node.exe")); + // POSIX paths should still work + try std.testing.expect(matchesPattern("/usr/bin/node", "node")); +} + +test "isWindowsDrivePath" { + try std.testing.expect(isWindowsDrivePath("C:\\foo")); + try std.testing.expect(isWindowsDrivePath("D:/bar")); + try std.testing.expect(isWindowsDrivePath("c:\\lowercase")); + try std.testing.expect(isWindowsDrivePath("Z:\\")); + try std.testing.expect(!isWindowsDrivePath("/unix/path")); + try std.testing.expect(!isWindowsDrivePath("relative/path")); + try std.testing.expect(!isWindowsDrivePath("C")); // Too short +} diff --git a/test/js/bun/permissions/benchmark-permissions.ts b/test/js/bun/permissions/benchmark-permissions.ts new file mode 100644 index 00000000000000..fd5c7e1dac4867 --- /dev/null +++ b/test/js/bun/permissions/benchmark-permissions.ts @@ -0,0 +1,257 @@ +/** + * Permission system performance benchmark + * + * Run with: + * bun ./test/js/bun/permissions/benchmark-permissions.ts + * bun --secure --allow-all ./test/js/bun/permissions/benchmark-permissions.ts + * + * Or use the runner script: + * bun ./test/js/bun/permissions/run-benchmark.ts + */ + +const ITERATIONS = 10000; +const WARMUP = 1000; + +interface BenchResult { + name: string; + totalMs: number; + avgNs: number; + opsPerSec: number; +} + +function computeResult(name: string, totalNs: number): BenchResult { + const totalMs = totalNs / 1_000_000; + const avgNs = totalNs / ITERATIONS; + const opsPerSec = Math.round(1_000_000_000 / avgNs); + return { name, totalMs, avgNs, opsPerSec }; +} + +/** Benchmark for async operations */ +async function benchAsync(name: string, fn: () => Promise): Promise { + // Warmup + for (let i = 0; i < WARMUP; i++) { + await fn(); + } + + // Benchmark + const start = Bun.nanoseconds(); + for (let i = 0; i < ITERATIONS; i++) { + await fn(); + } + const end = Bun.nanoseconds(); + + return computeResult(name, end - start); +} + +/** Benchmark for sync operations - avoids Promise/await overhead */ +function benchSync(name: string, fn: () => void): BenchResult { + // Warmup + for (let i = 0; i < WARMUP; i++) { + fn(); + } + + // Benchmark + const start = Bun.nanoseconds(); + for (let i = 0; i < ITERATIONS; i++) { + fn(); + } + const end = Bun.nanoseconds(); + + return computeResult(name, end - start); +} + +async function runBenchmarks() { + // Detect secure mode by checking if permissions API reports restricted state + let isSecure = false; + try { + const status = Bun.permissions.querySync({ name: "read" }); + // In normal mode, status is "granted". In secure mode with --allow-all, it's also "granted" + // but we can detect secure mode by checking if the permissions object behavior differs + // Better approach: check an env var we set + isSecure = process.env.BUN_BENCHMARK_SECURE === "1"; + } catch { + isSecure = false; + } + const mode = isSecure ? "SECURE MODE" : "NORMAL MODE"; + + console.log(`\n${"=".repeat(60)}`); + console.log(` Permission Benchmark - ${mode}`); + console.log(` Iterations: ${ITERATIONS.toLocaleString()}`); + console.log(`${"=".repeat(60)}\n`); + + const results: BenchResult[] = []; + + // Create temp files for benchmarks + const tempDir = (await Bun.file("/tmp/bun-perm-bench").exists()) + ? "/tmp/bun-perm-bench" + : (() => { + Bun.spawnSync({ cmd: ["mkdir", "-p", "/tmp/bun-perm-bench"] }); + return "/tmp/bun-perm-bench"; + })(); + + await Bun.write(`${tempDir}/test.txt`, "hello world"); + await Bun.write(`${tempDir}/test.json`, '{"key": "value"}'); + + // Async benchmarks + // Benchmark 1: Bun.file().text() - file read (async) + results.push( + await benchAsync("Bun.file().text()", async () => { + await Bun.file(`${tempDir}/test.txt`).text(); + }), + ); + + // Benchmark 2: Bun.file().exists() (async) + results.push( + await benchAsync("Bun.file().exists()", async () => { + await Bun.file(`${tempDir}/test.txt`).exists(); + }), + ); + + // Benchmark 3: Bun.file().json() (async) + results.push( + await benchAsync("Bun.file().json()", async () => { + await Bun.file(`${tempDir}/test.json`).json(); + }), + ); + + // Benchmark 4: Bun.write() (async) + results.push( + await benchAsync("Bun.write()", async () => { + await Bun.write(`${tempDir}/output.txt`, "test content"); + }), + ); + + // Sync benchmarks - no await overhead + const fs = await import("node:fs"); + + // Benchmark 5: Bun.file().size (sync property) + results.push( + benchSync("Bun.file().size", () => { + const _ = Bun.file(`${tempDir}/test.txt`).size; + }), + ); + + // Benchmark 6: fs.readFileSync (sync) + results.push( + benchSync("fs.readFileSync()", () => { + fs.readFileSync(`${tempDir}/test.txt`, "utf8"); + }), + ); + + // Benchmark 7: fs.writeFileSync (sync) + results.push( + benchSync("fs.writeFileSync()", () => { + fs.writeFileSync(`${tempDir}/output2.txt`, "test content"); + }), + ); + + // Benchmark 8: fs.existsSync (sync) + results.push( + benchSync("fs.existsSync()", () => { + fs.existsSync(`${tempDir}/test.txt`); + }), + ); + + // Benchmark 9: fs.statSync (sync) + results.push( + benchSync("fs.statSync()", () => { + fs.statSync(`${tempDir}/test.txt`); + }), + ); + + // Benchmark 10: process.env access (sync) + results.push( + benchSync("process.env.HOME", () => { + const _ = process.env.HOME; + }), + ); + + // Benchmark 11: Bun.env access (sync) + results.push( + benchSync("Bun.env.HOME", () => { + const _ = Bun.env.HOME; + }), + ); + + // Symlink resolution benchmarks (only in secure mode) + if (isSecure) { + // Create symlink for testing + const symlinkDir = `${tempDir}/symlink-test`; + Bun.spawnSync({ cmd: ["rm", "-rf", symlinkDir] }); + Bun.spawnSync({ cmd: ["mkdir", "-p", symlinkDir] }); + await Bun.write(`${symlinkDir}/target.txt`, "symlink target content"); + Bun.spawnSync({ cmd: ["ln", "-sf", `${symlinkDir}/target.txt`, `${symlinkDir}/link.txt`] }); + + // Create a chain of symlinks + Bun.spawnSync({ cmd: ["ln", "-sf", `${symlinkDir}/link.txt`, `${symlinkDir}/link2.txt`] }); + Bun.spawnSync({ cmd: ["ln", "-sf", `${symlinkDir}/link2.txt`, `${symlinkDir}/link3.txt`] }); + + // Benchmark 12: Read through symlink (includes realpath overhead) + results.push( + benchSync("fs.readFileSync (symlink)", () => { + fs.readFileSync(`${symlinkDir}/link.txt`, "utf8"); + }), + ); + + // Benchmark 13: Read direct file (baseline for comparison) + results.push( + benchSync("fs.readFileSync (direct)", () => { + fs.readFileSync(`${symlinkDir}/target.txt`, "utf8"); + }), + ); + + // Benchmark 14: Read through symlink chain (3 levels) + results.push( + benchSync("fs.readFileSync (3 symlinks)", () => { + fs.readFileSync(`${symlinkDir}/link3.txt`, "utf8"); + }), + ); + + // Benchmark 15: stat through symlink + results.push( + benchSync("fs.statSync (symlink)", () => { + fs.statSync(`${symlinkDir}/link.txt`); + }), + ); + + // Benchmark 16: stat direct file + results.push( + benchSync("fs.statSync (direct)", () => { + fs.statSync(`${symlinkDir}/target.txt`); + }), + ); + } + + // Print results + console.log("Results:"); + console.log("-".repeat(60)); + console.log( + `${"Operation".padEnd(25)} ${"Total (ms)".padStart(12)} ${"Avg (ns)".padStart(12)} ${"ops/sec".padStart(12)}`, + ); + console.log("-".repeat(60)); + + for (const r of results) { + console.log( + `${r.name.padEnd(25)} ${r.totalMs.toFixed(2).padStart(12)} ${r.avgNs.toFixed(0).padStart(12)} ${r.opsPerSec.toLocaleString().padStart(12)}`, + ); + } + + console.log("-".repeat(60)); + + // Output JSON for comparison script + const jsonOutput = { + mode, + iterations: ITERATIONS, + results: results.map(r => ({ + name: r.name, + avgNs: r.avgNs, + opsPerSec: r.opsPerSec, + })), + }; + + await Bun.write(`/tmp/bun-perm-bench-${isSecure ? "secure" : "normal"}.json`, JSON.stringify(jsonOutput, null, 2)); + + console.log(`\nResults saved to /tmp/bun-perm-bench-${isSecure ? "secure" : "normal"}.json`); +} + +runBenchmarks().catch(console.error); diff --git a/test/js/bun/permissions/permissions-api.test.ts b/test/js/bun/permissions/permissions-api.test.ts new file mode 100644 index 00000000000000..cf9b49c9fe2309 --- /dev/null +++ b/test/js/bun/permissions/permissions-api.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, test } from "bun:test"; +import { bunEnv, bunExe } from "harness"; + +describe("Bun.permissions API", () => { + test("Bun.permissions.query returns permission status", async () => { + const status = await Bun.permissions.query({ name: "read" }); + expect(status).toBeDefined(); + expect(status.state).toBeDefined(); + expect(["granted", "denied", "prompt"]).toContain(status.state); + }); + + test("Bun.permissions.querySync returns permission status synchronously", () => { + const status = Bun.permissions.querySync({ name: "write" }); + expect(status).toBeDefined(); + expect(status.state).toBeDefined(); + expect(["granted", "denied", "prompt"]).toContain(status.state); + }); + + test("Bun.permissions.query with path returns permission status", async () => { + const status = await Bun.permissions.query({ name: "read", path: "/tmp" }); + expect(status).toBeDefined(); + expect(status.state).toBeDefined(); + }); + + test("Bun.permissions.query with host returns permission status", async () => { + const status = await Bun.permissions.query({ name: "net", host: "localhost:3000" }); + expect(status).toBeDefined(); + expect(status.state).toBeDefined(); + }); + + test("Bun.permissions.query with variable returns permission status", async () => { + const status = await Bun.permissions.query({ name: "env", variable: "PATH" }); + expect(status).toBeDefined(); + expect(status.state).toBeDefined(); + }); + + test("Bun.permissions.query with command returns permission status", async () => { + const status = await Bun.permissions.query({ name: "run", command: "/bin/ls" }); + expect(status).toBeDefined(); + expect(status.state).toBeDefined(); + }); + + test("Bun.permissions.query supports all permission types", async () => { + const types = ["read", "write", "net", "env", "sys", "run", "ffi"]; + for (const name of types) { + const status = await Bun.permissions.query({ name }); + expect(status.state).toBeDefined(); + } + }); + + test("Bun.permissions.query throws on invalid name", () => { + expect(() => Bun.permissions.query({ name: "invalid" })).toThrow("Unknown permission name"); + }); + + test("Bun.permissions.query throws on missing name", () => { + expect(() => Bun.permissions.query({} as any)).toThrow("'name' property"); + }); + + test("Bun.permissions.request returns permission status", async () => { + const status = await Bun.permissions.request({ name: "read" }); + expect(status).toBeDefined(); + expect(status.state).toBeDefined(); + }); + + // Run revoke test in child process to avoid affecting other tests + test("Bun.permissions.revoke returns denied status", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + const status = await Bun.permissions.revoke({ name: "read", path: "/nonexistent/path/for/test" }); + if (!status) { + console.error("status is undefined"); + process.exit(1); + } + if (status.state !== "denied") { + console.error("expected denied, got", status.state); + process.exit(1); + } + console.log("success"); + `, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout.trim()).toBe("success"); + expect(exitCode).toBe(0); + }); +}); diff --git a/test/js/bun/permissions/permissions-bun-file.test.ts b/test/js/bun/permissions/permissions-bun-file.test.ts new file mode 100644 index 00000000000000..c50e5c3580b9d1 --- /dev/null +++ b/test/js/bun/permissions/permissions-bun-file.test.ts @@ -0,0 +1,444 @@ +import { describe, expect, test } from "bun:test"; +import { bunEnv, bunExe, tempDir } from "harness"; + +describe("Bun.file() permissions", () => { + describe("read permissions", () => { + test("Bun.file().text() blocked without --allow-read", async () => { + using dir = tempDir("bun-file-read", { + "secret.txt": "secret data", + "test.ts": ` + try { + const content = await Bun.file("./secret.txt").text(); + console.log("READ_SUCCESS:" + content); + } catch (e) { + console.log("READ_BLOCKED:" + e.message.includes("PermissionDenied")); + } + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--secure", "--no-prompt", "test.ts"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout).toContain("READ_BLOCKED:true"); + expect(exitCode).toBe(0); + }); + + test("Bun.file().text() allowed with --allow-read", async () => { + using dir = tempDir("bun-file-read-allowed", { + "allowed.txt": "allowed data", + "test.ts": ` + try { + const content = await Bun.file("./allowed.txt").text(); + console.log("READ_SUCCESS:" + content.trim()); + } catch (e) { + console.log("READ_BLOCKED:" + e.message); + } + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--secure", `--allow-read=${String(dir)}`, "test.ts"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout).toContain("READ_SUCCESS:allowed data"); + expect(exitCode).toBe(0); + }); + + test("Bun.file().arrayBuffer() blocked without --allow-read", async () => { + using dir = tempDir("bun-file-arraybuffer", { + "data.bin": "binary data", + "test.ts": ` + try { + const buffer = await Bun.file("./data.bin").arrayBuffer(); + console.log("READ_SUCCESS:" + buffer.byteLength); + } catch (e) { + console.log("READ_BLOCKED:" + e.message.includes("PermissionDenied")); + } + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--secure", "--no-prompt", "test.ts"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout).toContain("READ_BLOCKED:true"); + expect(exitCode).toBe(0); + }); + + test("Bun.file().arrayBuffer() allowed with --allow-read", async () => { + using dir = tempDir("bun-file-arraybuffer-allowed", { + "data.bin": "binary data", + "test.ts": ` + try { + const buffer = await Bun.file("./data.bin").arrayBuffer(); + console.log("READ_SUCCESS:" + buffer.byteLength); + } catch (e) { + console.log("READ_BLOCKED:" + e.message); + } + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--secure", `--allow-read=${String(dir)}`, "test.ts"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout).toContain("READ_SUCCESS:11"); + expect(exitCode).toBe(0); + }); + + test("Bun.file().stream() blocked without --allow-read", async () => { + using dir = tempDir("bun-file-stream", { + "stream.txt": "stream data", + "test.ts": ` + try { + const stream = Bun.file("./stream.txt").stream(); + const reader = stream.getReader(); + const { value } = await reader.read(); + console.log("READ_SUCCESS:" + new TextDecoder().decode(value)); + } catch (e) { + console.log("READ_BLOCKED:" + e.message.includes("PermissionDenied")); + } + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--secure", "--no-prompt", "test.ts"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout).toContain("READ_BLOCKED:true"); + expect(exitCode).toBe(0); + }); + + test("Bun.file().stream() allowed with --allow-read", async () => { + using dir = tempDir("bun-file-stream-allowed", { + "stream.txt": "stream data", + "test.ts": ` + try { + const stream = Bun.file("./stream.txt").stream(); + const reader = stream.getReader(); + const { value } = await reader.read(); + console.log("READ_SUCCESS:" + new TextDecoder().decode(value).trim()); + } catch (e) { + console.log("READ_BLOCKED:" + e.message); + } + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--secure", `--allow-read=${String(dir)}`, "test.ts"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout).toContain("READ_SUCCESS:stream data"); + expect(exitCode).toBe(0); + }); + + test("Bun.file().json() blocked without --allow-read", async () => { + using dir = tempDir("bun-file-json", { + "data.json": '{"key": "value"}', + "test.ts": ` + try { + const data = await Bun.file("./data.json").json(); + console.log("READ_SUCCESS:" + data.key); + } catch (e) { + console.log("READ_BLOCKED:" + e.message.includes("PermissionDenied")); + } + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--secure", "--no-prompt", "test.ts"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout).toContain("READ_BLOCKED:true"); + expect(exitCode).toBe(0); + }); + + test("Bun.file().json() allowed with --allow-read", async () => { + using dir = tempDir("bun-file-json-allowed", { + "data.json": '{"key": "value"}', + "test.ts": ` + try { + const data = await Bun.file("./data.json").json(); + console.log("READ_SUCCESS:" + data.key); + } catch (e) { + console.log("READ_BLOCKED:" + e.message); + } + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--secure", `--allow-read=${String(dir)}`, "test.ts"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout).toContain("READ_SUCCESS:value"); + expect(exitCode).toBe(0); + }); + }); + + describe("write permissions", () => { + test("Bun.write() to Bun.file() blocked without --allow-write", async () => { + using dir = tempDir("bun-file-write", { + "test.ts": ` + try { + await Bun.write(Bun.file("./output.txt"), "new content"); + console.log("WRITE_SUCCESS"); + } catch (e) { + console.log("WRITE_BLOCKED:" + e.message.includes("PermissionDenied")); + } + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--secure", "--no-prompt", "test.ts"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout).toContain("WRITE_BLOCKED:true"); + expect(exitCode).toBe(0); + }); + + test("Bun.write() to Bun.file() allowed with --allow-write", async () => { + using dir = tempDir("bun-file-write-allowed", { + "test.ts": ` + try { + await Bun.write(Bun.file("./output.txt"), "new content"); + const content = await Bun.file("./output.txt").text(); + console.log("WRITE_SUCCESS:" + content); + } catch (e) { + console.log("WRITE_BLOCKED:" + e.message); + } + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--secure", `--allow-write=${String(dir)}`, `--allow-read=${String(dir)}`, "test.ts"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout).toContain("WRITE_SUCCESS:new content"); + expect(exitCode).toBe(0); + }); + + test("Bun.write() to path string blocked without --allow-write", async () => { + using dir = tempDir("bun-write-path", { + "test.ts": ` + try { + await Bun.write("./output.txt", "content"); + console.log("WRITE_SUCCESS"); + } catch (e) { + console.log("WRITE_BLOCKED:" + e.message.includes("PermissionDenied")); + } + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--secure", "--no-prompt", "test.ts"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout).toContain("WRITE_BLOCKED:true"); + expect(exitCode).toBe(0); + }); + + test("Bun.write() to path string allowed with --allow-write", async () => { + using dir = tempDir("bun-write-path-allowed", { + "test.ts": ` + try { + await Bun.write("./output.txt", "written content"); + const content = await Bun.file("./output.txt").text(); + console.log("WRITE_SUCCESS:" + content); + } catch (e) { + console.log("WRITE_BLOCKED:" + e.message); + } + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--secure", `--allow-write=${String(dir)}`, `--allow-read=${String(dir)}`, "test.ts"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout).toContain("WRITE_SUCCESS:written content"); + expect(exitCode).toBe(0); + }); + }); + + describe("Bun.file() properties", () => { + test("Bun.file().size blocked without --allow-read", async () => { + using dir = tempDir("bun-file-size", { + "data.txt": "some content here", + "test.ts": ` + try { + const size = Bun.file("./data.txt").size; + console.log("SIZE_SUCCESS:" + size); + } catch (e) { + console.log("SIZE_BLOCKED:" + e.message.includes("PermissionDenied")); + } + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--secure", "--no-prompt", "test.ts"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout).toContain("SIZE_BLOCKED:true"); + expect(exitCode).toBe(0); + }); + + test("Bun.file().size allowed with --allow-read", async () => { + using dir = tempDir("bun-file-size-allowed", { + "data.txt": "some content here", + "test.ts": ` + try { + const size = Bun.file("./data.txt").size; + console.log("SIZE_SUCCESS:" + size); + } catch (e) { + console.log("SIZE_BLOCKED:" + e.message); + } + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--secure", `--allow-read=${String(dir)}`, "test.ts"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout).toContain("SIZE_SUCCESS:17"); + expect(exitCode).toBe(0); + }); + + test("Bun.file().exists() blocked without --allow-read", async () => { + using dir = tempDir("bun-file-exists", { + "data.txt": "content", + "test.ts": ` + try { + const exists = await Bun.file("./data.txt").exists(); + console.log("EXISTS_SUCCESS:" + exists); + } catch (e) { + console.log("EXISTS_BLOCKED:" + e.message.includes("PermissionDenied")); + } + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--secure", "--no-prompt", "test.ts"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout).toContain("EXISTS_BLOCKED:true"); + expect(exitCode).toBe(0); + }); + + test("Bun.file().exists() allowed with --allow-read", async () => { + using dir = tempDir("bun-file-exists-allowed", { + "data.txt": "content", + "test.ts": ` + try { + const exists = await Bun.file("./data.txt").exists(); + console.log("EXISTS_SUCCESS:" + exists); + } catch (e) { + console.log("EXISTS_BLOCKED:" + e.message); + } + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--secure", `--allow-read=${String(dir)}`, "test.ts"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout).toContain("EXISTS_SUCCESS:true"); + expect(exitCode).toBe(0); + }); + }); +}); diff --git a/test/js/bun/permissions/permissions-bunfig.test.ts b/test/js/bun/permissions/permissions-bunfig.test.ts new file mode 100644 index 00000000000000..1f33bc9a75f594 --- /dev/null +++ b/test/js/bun/permissions/permissions-bunfig.test.ts @@ -0,0 +1,447 @@ +import { describe, expect, test } from "bun:test"; +import { bunEnv, bunExe, tempDir } from "harness"; + +describe("bunfig.toml permissions", () => { + describe.concurrent("basic permissions", () => { + test("secure = true enables secure mode", async () => { + using dir = tempDir("perm-bunfig-secure", { + "bunfig.toml": ` +[permissions] +secure = true +no-prompt = true +`, + "test.ts": ` + try { + console.log("BUN_SECURE_VAR:", process.env.BUN_SECURE_VAR); + } catch (e) { + console.log("ERROR:", e.message); + process.exit(1); + } + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "test.ts"], + cwd: String(dir), + env: { ...bunEnv, BUN_SECURE_VAR: "test_value" }, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout + stderr).toContain("PermissionDenied"); + expect(exitCode).not.toBe(0); + }); + + test("allow-all = true grants all permissions", async () => { + using dir = tempDir("perm-bunfig-allow-all", { + "bunfig.toml": ` +[permissions] +secure = true +allow-all = true +`, + "test.ts": ` + console.log("BUN_ALLOW_ALL_VAR:", process.env.BUN_ALLOW_ALL_VAR); + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "test.ts"], + cwd: String(dir), + env: { ...bunEnv, BUN_ALLOW_ALL_VAR: "allowed_value" }, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout).toContain("BUN_ALLOW_ALL_VAR: allowed_value"); + expect(exitCode).toBe(0); + }); + }); + + describe.concurrent("allow-env", () => { + test("allow-env = true allows all env vars", async () => { + using dir = tempDir("perm-bunfig-env-all", { + "bunfig.toml": ` +[permissions] +secure = true +allow-env = true +`, + "test.ts": ` + console.log("BUN_TEST_VAR:", process.env.BUN_TEST_VAR); + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "test.ts"], + cwd: String(dir), + env: { ...bunEnv, BUN_TEST_VAR: "hello_from_bunfig" }, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout).toContain("BUN_TEST_VAR: hello_from_bunfig"); + expect(exitCode).toBe(0); + }); + + test("allow-env array allows specific vars", async () => { + using dir = tempDir("perm-bunfig-env-array", { + "bunfig.toml": ` +[permissions] +secure = true +allow-env = ["BUN_ALLOWED_VAR"] +no-prompt = true +`, + "test.ts": ` + console.log("BUN_ALLOWED_VAR:", process.env.BUN_ALLOWED_VAR); + try { + console.log("BUN_DENIED_VAR:", process.env.BUN_DENIED_VAR); + } catch (e) { + console.log("DENIED:", e.message); + } + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "test.ts"], + cwd: String(dir), + env: { ...bunEnv, BUN_ALLOWED_VAR: "allowed", BUN_DENIED_VAR: "denied" }, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout).toContain("BUN_ALLOWED_VAR: allowed"); + expect(stdout + stderr).toContain("PermissionDenied"); + // exitCode is 0 because the script catches the error and continues + expect(exitCode).toBe(0); + }); + + test("allow-env string allows single var", async () => { + using dir = tempDir("perm-bunfig-env-string", { + "bunfig.toml": ` +[permissions] +secure = true +allow-env = "BUN_SINGLE_VAR" +`, + "test.ts": ` + console.log("BUN_SINGLE_VAR:", process.env.BUN_SINGLE_VAR); + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "test.ts"], + cwd: String(dir), + env: { ...bunEnv, BUN_SINGLE_VAR: "single_value" }, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout).toContain("BUN_SINGLE_VAR: single_value"); + expect(exitCode).toBe(0); + }); + }); + + describe.concurrent("allow-read", () => { + test("allow-read = true allows all file reads", async () => { + using dir = tempDir("perm-bunfig-read-all", { + "bunfig.toml": ` +[permissions] +secure = true +allow-read = true +`, + "data.txt": "hello world", + "test.ts": ` + const content = await Bun.file("data.txt").text(); + console.log("content:", content); + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "test.ts"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout).toContain("content: hello world"); + expect(exitCode).toBe(0); + }); + + test("allow-read array allows specific paths", async () => { + using dir = tempDir("perm-bunfig-read-array", { + "bunfig.toml": "", + "allowed.txt": "allowed content", + "denied.txt": "denied content", + "test.ts": "", + }); + + // Write bunfig with actual path + await Bun.write( + `${String(dir)}/bunfig.toml`, + ` +[permissions] +secure = true +allow-read = ["${String(dir)}/allowed.txt"] +no-prompt = true +`, + ); + + await Bun.write( + `${String(dir)}/test.ts`, + ` + const allowed = await Bun.file("${String(dir)}/allowed.txt").text(); + console.log("allowed:", allowed); + try { + const denied = await Bun.file("${String(dir)}/denied.txt").text(); + console.log("denied:", denied); + } catch (e) { + console.log("DENIED:", e.message); + } + `, + ); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "test.ts"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout).toContain("allowed: allowed content"); + expect(stdout + stderr).toContain("PermissionDenied"); + // exitCode is 0 because the script catches the error and continues + expect(exitCode).toBe(0); + }); + }); + + describe.concurrent("deny-* overrides allow-*", () => { + test("deny-env overrides allow-env", async () => { + using dir = tempDir("perm-bunfig-deny-env", { + "bunfig.toml": ` +[permissions] +secure = true +allow-env = true +deny-env = ["BUN_SECRET"] +no-prompt = true +`, + "test.ts": ` + console.log("BUN_PUBLIC:", process.env.BUN_PUBLIC); + try { + console.log("BUN_SECRET:", process.env.BUN_SECRET); + } catch (e) { + console.log("DENIED:", e.message); + } + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "test.ts"], + cwd: String(dir), + env: { ...bunEnv, BUN_PUBLIC: "public", BUN_SECRET: "secret" }, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout).toContain("BUN_PUBLIC: public"); + expect(stdout + stderr).toContain("PermissionDenied"); + // exitCode is 0 because the script catches the error and continues + expect(exitCode).toBe(0); + }); + }); + + describe.concurrent("allow-net", () => { + test("allow-net array allows specific hosts", async () => { + using dir = tempDir("perm-bunfig-net", { + "bunfig.toml": ` +[permissions] +secure = true +allow-net = ["example.com"] +`, + "test.ts": ` + const perm = Bun.permissions.querySync({ name: "net", host: "example.com:443" }); + console.log("example.com:", perm.state); + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "test.ts"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout).toContain("example.com: granted"); + expect(exitCode).toBe(0); + }); + }); + + describe.concurrent("allow-sys", () => { + test("allow-sys array allows specific kinds", async () => { + using dir = tempDir("perm-bunfig-sys", { + "bunfig.toml": ` +[permissions] +secure = true +allow-sys = ["hostname"] +`, + "test.ts": ` + import os from "os"; + console.log("hostname:", os.hostname()); + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "test.ts"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout).toContain("hostname:"); + expect(exitCode).toBe(0); + }); + }); + + describe.concurrent("allow-run", () => { + test("allow-run array allows specific commands", async () => { + using dir = tempDir("perm-bunfig-run", { + "bunfig.toml": "", + "test.ts": ` + const result = Bun.spawnSync([process.execPath, "--version"]); + console.log("exit:", result.exitCode); + `, + }); + + // Get bun basename for allow-run using path.basename for cross-platform support + const { basename } = await import("node:path"); + const bunBasename = basename(bunExe()); + + await Bun.write( + `${String(dir)}/bunfig.toml`, + ` +[permissions] +secure = true +allow-run = ["${bunBasename}"] +`, + ); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "test.ts"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout).toContain("exit: 0"); + expect(exitCode).toBe(0); + }); + }); + + describe.concurrent("CLI flags override bunfig", () => { + test("--allow-all overrides bunfig restrictions", async () => { + using dir = tempDir("perm-bunfig-override", { + "bunfig.toml": ` +[permissions] +secure = true +no-prompt = true +# No allow-env = script should fail without CLI override +`, + "test.ts": ` + console.log("BUN_TEST:", process.env.BUN_TEST); + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "-A", "test.ts"], + cwd: String(dir), + env: { ...bunEnv, BUN_TEST: "override_works" }, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout).toContain("BUN_TEST: override_works"); + expect(exitCode).toBe(0); + }); + + test("--secure flag works without bunfig", async () => { + using dir = tempDir("perm-cli-secure", { + "test.ts": ` + try { + console.log("BUN_CLI_SECURE_VAR:", process.env.BUN_CLI_SECURE_VAR); + } catch (e) { + console.log("ERROR:", e.message); + process.exit(1); + } + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--secure", "--no-prompt", "test.ts"], + cwd: String(dir), + env: { ...bunEnv, BUN_CLI_SECURE_VAR: "test_value" }, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout + stderr).toContain("PermissionDenied"); + expect(exitCode).not.toBe(0); + }); + }); + + describe.concurrent("bunfig.json support", () => { + test("permissions work in bunfig.json", async () => { + using dir = tempDir("perm-bunfig-json", { + "bunfig.json": JSON.stringify({ + permissions: { + secure: true, + "allow-env": ["BUN_JSON_VAR"], + }, + }), + "test.ts": ` + console.log("BUN_JSON_VAR:", process.env.BUN_JSON_VAR); + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "test.ts"], + cwd: String(dir), + env: { ...bunEnv, BUN_JSON_VAR: "json_works" }, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout).toContain("BUN_JSON_VAR: json_works"); + expect(exitCode).toBe(0); + }); + }); +}); diff --git a/test/js/bun/permissions/permissions-edge-cases.test.ts b/test/js/bun/permissions/permissions-edge-cases.test.ts new file mode 100644 index 00000000000000..c0e6b3c111981d --- /dev/null +++ b/test/js/bun/permissions/permissions-edge-cases.test.ts @@ -0,0 +1,284 @@ +import { describe, expect, test } from "bun:test"; +import { bunEnv, bunExe, tempDir } from "harness"; + +describe("Permission edge cases", () => { + describe.concurrent("env behavior", () => { + test("can set and read new env vars in secure mode without --allow-env", async () => { + // This is expected behavior: scripts can create their own env vars + // but cannot read inherited/system env vars + using dir = tempDir("perm-env-set", { + "test.ts": ` + process.env.MY_NEW_VAR = "my_value"; + console.log("MY_NEW_VAR:", process.env.MY_NEW_VAR); + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--secure", "--no-prompt", "test.ts"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout).toContain("MY_NEW_VAR: my_value"); + expect(exitCode).toBe(0); + }); + + test("cannot read inherited env vars in secure mode without --allow-env", async () => { + using dir = tempDir("perm-env-inherited", { + "test.ts": ` + try { + console.log("HOME:", process.env.HOME); + } catch (e) { + console.log("ERROR:", e.message); + process.exit(1); + } + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--secure", "--no-prompt", "test.ts"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout + stderr).toContain("PermissionDenied"); + expect(exitCode).not.toBe(0); + }); + }); + + describe.concurrent("async vs sync spawn", () => { + test("Bun.spawn (async) requires run permission", async () => { + using dir = tempDir("perm-spawn-async", { + "test.ts": ` + try { + const proc = Bun.spawn([process.execPath, "--version"]); + await proc.exited; + console.log("SUCCESS"); + } catch (e) { + console.log("ERROR:", e.message); + process.exit(1); + } + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--secure", "--no-prompt", "test.ts"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout + stderr).toContain("PermissionDenied"); + expect(exitCode).not.toBe(0); + }); + + test("Bun.spawnSync requires run permission", async () => { + using dir = tempDir("perm-spawn-sync", { + "test.ts": ` + try { + Bun.spawnSync([process.execPath, "--version"]); + console.log("SUCCESS"); + } catch (e) { + console.log("ERROR:", e.message); + process.exit(1); + } + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--secure", "--no-prompt", "test.ts"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout + stderr).toContain("PermissionDenied"); + expect(exitCode).not.toBe(0); + }); + }); + + describe.concurrent("server permissions", () => { + test("Bun.serve requires net permission", async () => { + using dir = tempDir("perm-serve", { + "test.ts": ` + try { + const server = Bun.serve({ + port: 0, + fetch() { return new Response("hi"); } + }); + console.log("port:", server.port); + server.stop(); + } catch (e) { + console.log("ERROR:", e.message); + process.exit(1); + } + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--secure", "--no-prompt", "test.ts"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout + stderr).toContain("PermissionDenied"); + expect(exitCode).not.toBe(0); + }); + + test("Bun.serve works with --allow-net", async () => { + using dir = tempDir("perm-serve-allow", { + "test.ts": ` + const server = Bun.serve({ + port: 0, + fetch() { return new Response("hi"); } + }); + console.log("port:", server.port); + server.stop(); + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--secure", "--allow-net", "test.ts"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout).toContain("port:"); + expect(exitCode).toBe(0); + }); + }); + + describe.concurrent("multiple sys kinds", () => { + test("os.uptime requires sys permission", async () => { + using dir = tempDir("perm-sys-uptime", { + "test.ts": ` + import os from "os"; + try { + console.log("uptime:", os.uptime()); + } catch (e) { + console.log("ERROR:", e.message); + process.exit(1); + } + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--secure", "--no-prompt", "test.ts"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout + stderr).toContain("PermissionDenied"); + expect(exitCode).not.toBe(0); + }); + + test("os.freemem requires sys permission", async () => { + using dir = tempDir("perm-sys-freemem", { + "test.ts": ` + import os from "os"; + try { + console.log("freemem:", os.freemem()); + } catch (e) { + console.log("ERROR:", e.message); + process.exit(1); + } + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--secure", "--no-prompt", "test.ts"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout + stderr).toContain("PermissionDenied"); + expect(exitCode).not.toBe(0); + }); + + test("os.homedir requires sys permission", async () => { + using dir = tempDir("perm-sys-homedir", { + "test.ts": ` + import os from "os"; + try { + console.log("homedir:", os.homedir()); + } catch (e) { + console.log("ERROR:", e.message); + process.exit(1); + } + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--secure", "--no-prompt", "test.ts"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout + stderr).toContain("PermissionDenied"); + expect(exitCode).not.toBe(0); + }); + }); + + describe.concurrent("backwards compatibility", () => { + test("without --secure flag, all permissions are granted", async () => { + using dir = tempDir("perm-compat", { + "test.ts": ` + import os from "os"; + console.log("hostname:", os.hostname()); + console.log("HOME:", process.env.HOME); + const r = Bun.spawnSync([process.execPath, "--version"]); + console.log("spawn exit:", r.exitCode); + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "test.ts"], // No --secure flag + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout).toContain("hostname:"); + expect(stdout).toContain("HOME:"); + expect(stdout).toContain("spawn exit: 0"); + expect(exitCode).toBe(0); + }); + }); +}); diff --git a/test/js/bun/permissions/permissions-env.test.ts b/test/js/bun/permissions/permissions-env.test.ts new file mode 100644 index 00000000000000..62dfb84bffee41 --- /dev/null +++ b/test/js/bun/permissions/permissions-env.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, test } from "bun:test"; +import { bunEnv, bunExe, tempDir } from "harness"; + +describe("Environment variable permissions", () => { + test("process.env access denied in secure mode without --allow-env", async () => { + using dir = tempDir("perm-env-test", { + "test.ts": ` + try { + console.log("TEST_VAR:", process.env.BUN_TEST_ENV_VAR); + } catch (e) { + console.log("ERROR:", e.message); + process.exit(1); + } + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--secure", "--no-prompt", "test.ts"], + cwd: String(dir), + env: { ...bunEnv, BUN_TEST_ENV_VAR: "test_value" }, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout + stderr).toContain("PermissionDenied"); + expect(exitCode).not.toBe(0); + }); + + test("process.env access allowed with --allow-env", async () => { + using dir = tempDir("perm-env-allow", { + "test.ts": ` + console.log("TEST_VAR:", process.env.BUN_TEST_ENV_VAR); + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--secure", "--allow-env", "test.ts"], + cwd: String(dir), + env: { ...bunEnv, BUN_TEST_ENV_VAR: "allowed_value" }, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout).toContain("TEST_VAR: allowed_value"); + expect(exitCode).toBe(0); + }); + + test("granular --allow-env= works", async () => { + using dir = tempDir("perm-env-granular", { + "test.ts": ` + console.log("GRANULAR_VAR:", process.env.BUN_GRANULAR_VAR); + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--secure", "--allow-env=BUN_GRANULAR_VAR", "test.ts"], + cwd: String(dir), + env: { ...bunEnv, BUN_GRANULAR_VAR: "granular_value" }, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout).toContain("GRANULAR_VAR: granular_value"); + expect(exitCode).toBe(0); + }); +}); diff --git a/test/js/bun/permissions/permissions-ffi.test.ts b/test/js/bun/permissions/permissions-ffi.test.ts new file mode 100644 index 00000000000000..8818e9e8608535 --- /dev/null +++ b/test/js/bun/permissions/permissions-ffi.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, test } from "bun:test"; +import { bunEnv, bunExe, tempDir } from "harness"; + +describe("FFI permissions", () => { + test("dlopen denied in secure mode without --allow-ffi", async () => { + using dir = tempDir("perm-ffi-test", { + "test.ts": ` + import { dlopen } from "bun:ffi"; + try { + dlopen("libtest.so", {}); + } catch (e) { + console.log("ERROR:", e.message); + process.exit(1); + } + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--secure", "--no-prompt", "test.ts"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout + stderr).toContain("PermissionDenied"); + expect(stdout + stderr).toContain("ffi"); + expect(exitCode).not.toBe(0); + }); + + test("dlopen allowed with --allow-ffi", async () => { + using dir = tempDir("perm-ffi-allow", { + "test.ts": ` + import { dlopen } from "bun:ffi"; + try { + // This will fail with "library not found" (not permission denied) + dlopen("libnonexistent12345.so", {}); + console.log("LOADED"); + } catch (e) { + // We expect a library-not-found error, NOT a permission error + if (e.message.includes("PermissionDenied")) { + console.log("PERMISSION_ERROR"); + process.exit(2); + } + console.log("LIBRARY_ERROR:", e.message); + process.exit(0); // This is expected + } + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--secure", "--allow-ffi", "test.ts"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + // Should get library error, not permission error + expect(stdout + stderr).not.toContain("PermissionDenied"); + expect(stdout).toContain("LIBRARY_ERROR"); + expect(exitCode).toBe(0); + }); + + test("granular --allow-ffi= works for allowed path", async () => { + using dir = tempDir("perm-ffi-granular", {}); + const libPath = `${String(dir)}/allowed.so`; + + // Write the test file with the actual path interpolated (use JSON.stringify for Windows path escaping) + await Bun.write( + `${String(dir)}/test.ts`, + ` + import { dlopen } from "bun:ffi"; + try { + // This will fail with "library not found" (not permission denied) + dlopen(${JSON.stringify(libPath)}, {}); + console.log("LOADED"); + } catch (e) { + if (e.message.includes("PermissionDenied")) { + console.log("PERMISSION_ERROR"); + process.exit(2); + } + console.log("LIBRARY_ERROR"); + process.exit(0); + } + `, + ); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--secure", `--allow-ffi=${libPath}`, "test.ts"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + // Should get library error, not permission error + expect(stdout + stderr).not.toContain("PermissionDenied"); + expect(exitCode).toBe(0); + }); + + test("granular --allow-ffi= denies other paths", async () => { + using dir = tempDir("perm-ffi-deny-other", {}); + const allowedPath = `${String(dir)}/allowed.so`; + const forbiddenPath = `${String(dir)}/forbidden.so`; + + await Bun.write( + `${String(dir)}/test.ts`, + ` + import { dlopen } from "bun:ffi"; + try { + dlopen(${JSON.stringify(forbiddenPath)}, {}); + console.log("LOADED"); + } catch (e) { + console.log("ERROR:", e.message); + process.exit(1); + } + `, + ); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--secure", "--no-prompt", `--allow-ffi=${allowedPath}`, "test.ts"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout + stderr).toContain("PermissionDenied"); + expect(exitCode).not.toBe(0); + }); + + test("-A allows FFI access", async () => { + using dir = tempDir("perm-ffi-all", { + "test.ts": ` + import { dlopen } from "bun:ffi"; + try { + dlopen("libnonexistent.so", {}); + console.log("LOADED"); + } catch (e) { + if (e.message.includes("PermissionDenied")) { + console.log("PERMISSION_ERROR"); + process.exit(2); + } + console.log("LIBRARY_ERROR"); + process.exit(0); + } + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--secure", "-A", "test.ts"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout + stderr).not.toContain("PermissionDenied"); + expect(exitCode).toBe(0); + }); +}); diff --git a/test/js/bun/permissions/permissions-fs.test.ts b/test/js/bun/permissions/permissions-fs.test.ts new file mode 100644 index 00000000000000..a2ee1f45e52fe4 --- /dev/null +++ b/test/js/bun/permissions/permissions-fs.test.ts @@ -0,0 +1,485 @@ +import { describe, expect, test } from "bun:test"; +import { bunEnv, bunExe, tempDir } from "harness"; + +describe("File system permissions", () => { + test("fs.readFile denied in secure mode without --allow-read", async () => { + using dir = tempDir("perm-fs-test", { + "test.ts": ` + import { readFileSync } from "fs"; + try { + console.log(readFileSync("./secret.txt", "utf8")); + } catch (e) { + console.log("ERROR:", e.message); + process.exit(1); + } + `, + "secret.txt": "secret data", + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--secure", "--no-prompt", "test.ts"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout + stderr).toContain("PermissionDenied"); + expect(exitCode).not.toBe(0); + }); + + test("fs.readFile allowed with --allow-read", async () => { + using dir = tempDir("perm-fs-test-allow", { + "test.ts": ` + import { readFileSync } from "fs"; + console.log(readFileSync("./secret.txt", "utf8")); + `, + "secret.txt": "secret data", + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--secure", "--allow-read", "test.ts"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout.trim()).toBe("secret data"); + expect(exitCode).toBe(0); + }); + + test("fs.writeFile denied in secure mode without --allow-write", async () => { + using dir = tempDir("perm-fs-write-test", { + "test.ts": ` + import { writeFileSync } from "fs"; + try { + writeFileSync("./output.txt", "test data"); + console.log("SUCCESS"); + } catch (e) { + console.log("ERROR:", e.message); + process.exit(1); + } + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--secure", "--no-prompt", "test.ts"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout + stderr).toContain("PermissionDenied"); + expect(exitCode).not.toBe(0); + }); + + test("fs.writeFile allowed with --allow-write", async () => { + using dir = tempDir("perm-fs-write-allow", { + "test.ts": ` + import { writeFileSync, readFileSync } from "fs"; + writeFileSync("./output.txt", "test data"); + console.log(readFileSync("./output.txt", "utf8")); + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--secure", "--allow-write", "--allow-read", "test.ts"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout.trim()).toBe("test data"); + expect(exitCode).toBe(0); + }); + + test("granular --allow-read= works", async () => { + using dir = tempDir("perm-fs-granular", { + "test.ts": ` + import { readFileSync } from "fs"; + console.log(readFileSync("./allowed.txt", "utf8")); + `, + "allowed.txt": "allowed content", + "forbidden.txt": "forbidden content", + }); + + // Allow read only for allowed.txt + await using proc = Bun.spawn({ + cmd: [bunExe(), "--secure", `--allow-read=${String(dir)}/allowed.txt`, "test.ts"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout.trim()).toBe("allowed content"); + expect(exitCode).toBe(0); + }); + + test("--deny-read takes precedence over --allow-read", async () => { + using dir = tempDir("perm-fs-deny", { + "test.ts": ` + import { readFileSync } from "fs"; + try { + console.log(readFileSync("./secret.txt", "utf8")); + } catch (e) { + console.log("ERROR:", e.message); + process.exit(1); + } + `, + "secret.txt": "secret data", + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--secure", "--allow-read", `--deny-read=${String(dir)}/secret.txt`, "test.ts"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout + stderr).toContain("PermissionDenied"); + expect(exitCode).not.toBe(0); + }); + + test("-A allows all permissions", async () => { + using dir = tempDir("perm-fs-all", { + "test.ts": ` + import { readFileSync, writeFileSync } from "fs"; + writeFileSync("./output.txt", "written"); + console.log(readFileSync("./output.txt", "utf8")); + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--secure", "-A", "test.ts"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout.trim()).toBe("written"); + expect(exitCode).toBe(0); + }); + + test("without --secure, permissions are allowed by default", async () => { + using dir = tempDir("perm-fs-default", { + "test.ts": ` + import { readFileSync } from "fs"; + console.log(readFileSync("./secret.txt", "utf8")); + `, + "secret.txt": "default allowed", + }); + + // No --secure flag + await using proc = Bun.spawn({ + cmd: [bunExe(), "test.ts"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout.trim()).toBe("default allowed"); + expect(exitCode).toBe(0); + }); + + describe("fs.open permission checks", () => { + test("fs.open with 'r' flag requires read permission", async () => { + using dir = tempDir("perm-fs-open-read", { + "test.ts": ` + import { openSync, closeSync } from "fs"; + try { + const fd = openSync("./data.txt", "r"); + closeSync(fd); + console.log("SUCCESS"); + } catch (e) { + console.log("ERROR:", e.message); + process.exit(1); + } + `, + "data.txt": "test data", + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--secure", "--no-prompt", "test.ts"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout + stderr).toContain("PermissionDenied"); + expect(exitCode).not.toBe(0); + }); + + test("fs.open with 'r' flag allowed with --allow-read", async () => { + using dir = tempDir("perm-fs-open-read-allow", { + "test.ts": ` + import { openSync, closeSync, readSync } from "fs"; + const fd = openSync("./data.txt", "r"); + const buf = Buffer.alloc(9); + readSync(fd, buf); + closeSync(fd); + console.log(buf.toString()); + `, + "data.txt": "test data", + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--secure", "--allow-read", "test.ts"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout.trim()).toBe("test data"); + expect(exitCode).toBe(0); + }); + + test("fs.open with 'w' flag requires write permission", async () => { + using dir = tempDir("perm-fs-open-write", { + "test.ts": ` + import { openSync, closeSync } from "fs"; + try { + const fd = openSync("./output.txt", "w"); + closeSync(fd); + console.log("SUCCESS"); + } catch (e) { + console.log("ERROR:", e.message); + process.exit(1); + } + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--secure", "--no-prompt", "test.ts"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout + stderr).toContain("PermissionDenied"); + expect(exitCode).not.toBe(0); + }); + + test("fs.open with 'w' flag allowed with --allow-write", async () => { + using dir = tempDir("perm-fs-open-write-allow", { + "test.ts": ` + import { openSync, closeSync, writeSync, readFileSync } from "fs"; + const fd = openSync("./output.txt", "w"); + writeSync(fd, "written data"); + closeSync(fd); + console.log(readFileSync("./output.txt", "utf8")); + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--secure", "--allow-write", "--allow-read", "test.ts"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout.trim()).toBe("written data"); + expect(exitCode).toBe(0); + }); + + test("fs.open with 'r+' flag requires write permission", async () => { + using dir = tempDir("perm-fs-open-rw", { + "test.ts": ` + import { openSync, closeSync } from "fs"; + try { + const fd = openSync("./data.txt", "r+"); + closeSync(fd); + console.log("SUCCESS"); + } catch (e) { + console.log("ERROR:", e.message); + process.exit(1); + } + `, + "data.txt": "test data", + }); + + // --allow-read should NOT be enough for r+ since it also writes + await using proc = Bun.spawn({ + cmd: [bunExe(), "--secure", "--allow-read", "--no-prompt", "test.ts"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout + stderr).toContain("PermissionDenied"); + expect(exitCode).not.toBe(0); + }); + }); + + describe("fs.statfs permission checks", () => { + test("fs.statfs requires sys permission", async () => { + using dir = tempDir("perm-fs-statfs", { + "test.ts": ` + import { statfsSync } from "fs"; + try { + const stats = statfsSync("."); + console.log("bsize:", stats.bsize); + } catch (e) { + console.log("ERROR:", e.message); + process.exit(1); + } + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--secure", "--no-prompt", "test.ts"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout + stderr).toContain("PermissionDenied"); + expect(exitCode).not.toBe(0); + }); + + test("fs.statfs allowed with --allow-sys", async () => { + using dir = tempDir("perm-fs-statfs-allow", { + "test.ts": ` + import { statfsSync } from "fs"; + const stats = statfsSync("."); + console.log("bsize:", stats.bsize); + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--secure", "--allow-sys", "test.ts"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout).toContain("bsize:"); + expect(exitCode).toBe(0); + }); + + test("fs.statfs allowed with --allow-sys=statfs", async () => { + using dir = tempDir("perm-fs-statfs-granular", { + "test.ts": ` + import { statfsSync } from "fs"; + const stats = statfsSync("."); + console.log("bsize:", stats.bsize); + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--secure", "--allow-sys=statfs", "test.ts"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout).toContain("bsize:"); + expect(exitCode).toBe(0); + }); + }); + + describe("Bun.build permission checks", () => { + test("Bun.build requires read and write permissions in secure mode", async () => { + using dir = tempDir("perm-bundler-secure", { + "test.ts": ` + try { + await Bun.build({ + entrypoints: ["./entry.ts"], + outdir: "./dist", + }); + console.log("SUCCESS"); + } catch (e) { + console.log("ERROR:", e.message); + process.exit(1); + } + `, + "entry.ts": "console.log('hello');", + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--secure", "--no-prompt", "test.ts"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout + stderr).toContain("PermissionDenied"); + expect(exitCode).not.toBe(0); + }); + + test("Bun.build works with --allow-read --allow-write", async () => { + using dir = tempDir("perm-bundler-allow", { + "test.ts": ` + const result = await Bun.build({ + entrypoints: ["./entry.ts"], + outdir: "./dist", + }); + console.log("success:", result.success); + `, + "entry.ts": "console.log('hello');", + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--secure", "--allow-read", "--allow-write", "test.ts"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout).toContain("success: true"); + expect(exitCode).toBe(0); + }); + }); +}); diff --git a/test/js/bun/permissions/permissions-granular.test.ts b/test/js/bun/permissions/permissions-granular.test.ts new file mode 100644 index 00000000000000..ec502b1c1160b7 --- /dev/null +++ b/test/js/bun/permissions/permissions-granular.test.ts @@ -0,0 +1,284 @@ +import { describe, expect, test } from "bun:test"; +import { bunEnv, bunExe, isWindows, tempDir } from "harness"; + +describe("Granular permissions", () => { + describe.concurrent("env wildcards", () => { + test("--allow-env=BUN_TEST_* allows matching env vars", async () => { + using dir = tempDir("perm-env-wildcard", { + "test.ts": ` + console.log("BUN_TEST_VAR1:", process.env.BUN_TEST_VAR1); + console.log("BUN_TEST_VAR2:", process.env.BUN_TEST_VAR2); + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--secure", "--allow-env=BUN_TEST_*", "test.ts"], + cwd: String(dir), + env: { ...bunEnv, BUN_TEST_VAR1: "value1", BUN_TEST_VAR2: "value2" }, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout).toContain("BUN_TEST_VAR1: value1"); + expect(stdout).toContain("BUN_TEST_VAR2: value2"); + expect(exitCode).toBe(0); + }); + + test("--allow-env=BUN_TEST_* denies OTHER_VAR", async () => { + using dir = tempDir("perm-env-wildcard-deny", { + "test.ts": ` + try { + console.log("OTHER_VAR:", process.env.OTHER_VAR); + } catch (e) { + console.log("ERROR:", e.message); + process.exit(1); + } + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--secure", "--no-prompt", "--allow-env=BUN_TEST_*", "test.ts"], + cwd: String(dir), + env: { ...bunEnv, OTHER_VAR: "other_value" }, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout + stderr).toContain("PermissionDenied"); + expect(exitCode).not.toBe(0); + }); + }); + + describe.concurrent("multiple values", () => { + test("--allow-env=VAR1,VAR2,VAR3 allows all three", async () => { + using dir = tempDir("perm-env-multi", { + "test.ts": ` + console.log("VAR1:", process.env.VAR1); + console.log("VAR2:", process.env.VAR2); + console.log("VAR3:", process.env.VAR3); + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--secure", "--allow-env=VAR1,VAR2,VAR3", "test.ts"], + cwd: String(dir), + env: { ...bunEnv, VAR1: "a", VAR2: "b", VAR3: "c" }, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout).toContain("VAR1: a"); + expect(stdout).toContain("VAR2: b"); + expect(stdout).toContain("VAR3: c"); + expect(exitCode).toBe(0); + }); + + test("--allow-net=example.com,httpbin.org allows both hosts", async () => { + using dir = tempDir("perm-net-multi", { + "test.ts": ` + // Use querySync to check permissions without making actual network requests + const perm1 = Bun.permissions.querySync({ name: "net", host: "example.com:443" }); + const perm2 = Bun.permissions.querySync({ name: "net", host: "httpbin.org:443" }); + console.log("example.com:", perm1.state); + console.log("httpbin.org:", perm2.state); + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--secure", "--allow-net=example.com,httpbin.org", "test.ts"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout).toContain("example.com: granted"); + expect(stdout).toContain("httpbin.org: granted"); + expect(exitCode).toBe(0); + }); + + test.skipIf(isWindows)("--allow-run=echo,ls allows both commands", async () => { + using dir = tempDir("perm-run-multi", { + "test.ts": ` + const r1 = Bun.spawnSync(["echo", "hello"]); + console.log("echo exit:", r1.exitCode); + const r2 = Bun.spawnSync(["ls"]); + console.log("ls exit:", r2.exitCode); + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--secure", "--allow-run=echo,ls", "test.ts"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout).toContain("echo exit: 0"); + expect(stdout).toContain("ls exit: 0"); + expect(exitCode).toBe(0); + }); + }); + + describe.concurrent("sys granular kinds", () => { + test("--allow-sys=hostname allows only hostname", async () => { + using dir = tempDir("perm-sys-hostname-only", { + "test.ts": ` + import os from "os"; + console.log("hostname:", os.hostname()); + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--secure", "--allow-sys=hostname", "test.ts"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout).toContain("hostname:"); + expect(exitCode).toBe(0); + }); + + test("--allow-sys=hostname denies cpus", async () => { + using dir = tempDir("perm-sys-hostname-deny-cpus", { + "test.ts": ` + import os from "os"; + try { + console.log("cpus:", os.cpus()[0].model); + } catch (e) { + console.log("ERROR:", e.message); + process.exit(1); + } + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--secure", "--allow-sys=hostname", "test.ts"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout + stderr).toContain("PermissionDenied"); + expect(exitCode).not.toBe(0); + }); + + test("--allow-sys=hostname,cpus allows both", async () => { + using dir = tempDir("perm-sys-multi", { + "test.ts": ` + import os from "os"; + console.log("hostname:", os.hostname()); + console.log("cpus:", os.cpus().length); + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--secure", "--allow-sys=hostname,cpus", "test.ts"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout).toContain("hostname:"); + expect(stdout).toContain("cpus:"); + expect(exitCode).toBe(0); + }); + }); + + describe.concurrent("run command matching", () => { + test("--allow-run= matches spawned process", async () => { + // Use bun itself for cross-platform testing + using dir = tempDir("perm-run-basename", { + "test.ts": ` + const result = Bun.spawnSync([process.execPath, "--version"]); + console.log("exit:", result.exitCode); + `, + }); + + // Get the basename of the bun executable for the allow-run flag + const bunBasename = bunExe().split("/").pop()?.split("\\").pop() || "bun"; + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--secure", `--allow-run=${bunBasename}`, "test.ts"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout).toContain("exit: 0"); + expect(exitCode).toBe(0); + }); + + test("--allow-run= matches spawned process", async () => { + // Use bun itself for cross-platform testing + using dir = tempDir("perm-run-exact", { + "test.ts": ` + const result = Bun.spawnSync([process.execPath, "--version"]); + console.log("exit:", result.exitCode); + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--secure", `--allow-run=${bunExe()}`, "test.ts"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout).toContain("exit: 0"); + expect(exitCode).toBe(0); + }); + }); + + describe.concurrent("net host matching", () => { + test("--allow-net=example.com matches example.com:443", async () => { + using dir = tempDir("perm-net-host-port", { + "test.ts": ` + // Use querySync to check permissions without making actual network requests + const perm = Bun.permissions.querySync({ name: "net", host: "example.com:443" }); + console.log("permission:", perm.state); + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--secure", "--allow-net=example.com", "test.ts"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout).toContain("permission: granted"); + expect(exitCode).toBe(0); + }); + }); +}); diff --git a/test/js/bun/permissions/permissions-net-wildcards.test.ts b/test/js/bun/permissions/permissions-net-wildcards.test.ts new file mode 100644 index 00000000000000..2ae08ba0ed7840 --- /dev/null +++ b/test/js/bun/permissions/permissions-net-wildcards.test.ts @@ -0,0 +1,983 @@ +import { describe, expect, test } from "bun:test"; +import { bunEnv, bunExe } from "harness"; + +describe.concurrent("Network permission wildcards", () => { + describe("Single-segment wildcard (*)", () => { + test("*.example.com matches api.example.com", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "--secure", + "--allow-net=*.example.com", + "-e", + ` + const result = Bun.permissions.querySync({ name: "net", host: "api.example.com" }); + console.log(result.state); + `, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); // Verify no errors + expect(stdout.trim()).toBe("granted"); + expect(exitCode).toBe(0); + }); + + test("*.example.com does NOT match a.b.example.com", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "--secure", + "--allow-net=*.example.com", + "-e", + ` + const result = Bun.permissions.querySync({ name: "net", host: "a.b.example.com" }); + console.log(result.state); + `, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); // Verify no errors + expect(stdout.trim()).toBe("denied"); + expect(exitCode).toBe(0); + }); + + test("*.example.com does NOT match example.com", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "--secure", + "--allow-net=*.example.com", + "-e", + ` + const result = Bun.permissions.querySync({ name: "net", host: "example.com" }); + console.log(result.state); + `, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); // Verify no errors + expect(stdout.trim()).toBe("denied"); + expect(exitCode).toBe(0); + }); + + test("api.*.example.com matches api.v1.example.com", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "--secure", + "--allow-net=api.*.example.com", + "-e", + ` + const result = Bun.permissions.querySync({ name: "net", host: "api.v1.example.com" }); + console.log(result.state); + `, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); // Verify no errors + expect(stdout.trim()).toBe("granted"); + expect(exitCode).toBe(0); + }); + }); + + describe("Multi-segment wildcard (**)", () => { + test("**.example.com matches api.example.com", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "--secure", + "--allow-net=**.example.com", + "-e", + ` + const result = Bun.permissions.querySync({ name: "net", host: "api.example.com" }); + console.log(result.state); + `, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); // Verify no errors + expect(stdout.trim()).toBe("granted"); + expect(exitCode).toBe(0); + }); + + test("**.example.com matches a.b.c.example.com", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "--secure", + "--allow-net=**.example.com", + "-e", + ` + const result = Bun.permissions.querySync({ name: "net", host: "a.b.c.example.com" }); + console.log(result.state); + `, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); // Verify no errors + expect(stdout.trim()).toBe("granted"); + expect(exitCode).toBe(0); + }); + + test("**.example.com does NOT match example.com (requires at least one segment)", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "--secure", + "--allow-net=**.example.com", + "-e", + ` + const result = Bun.permissions.querySync({ name: "net", host: "example.com" }); + console.log(result.state); + `, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); // Verify no errors + expect(stdout.trim()).toBe("denied"); + expect(exitCode).toBe(0); + }); + }); + + describe("Port patterns", () => { + test("example.com:* matches any port", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "--secure", + "--allow-net=example.com:*", + "-e", + ` + const result1 = Bun.permissions.querySync({ name: "net", host: "example.com:80" }); + const result2 = Bun.permissions.querySync({ name: "net", host: "example.com:443" }); + const result3 = Bun.permissions.querySync({ name: "net", host: "example.com:8080" }); + console.log(result1.state, result2.state, result3.state); + `, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); // Verify no errors + expect(stdout.trim()).toBe("granted granted granted"); + expect(exitCode).toBe(0); + }); + + test("example.com:443 matches only port 443", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "--secure", + "--allow-net=example.com:443", + "-e", + ` + const result1 = Bun.permissions.querySync({ name: "net", host: "example.com:443" }); + const result2 = Bun.permissions.querySync({ name: "net", host: "example.com:80" }); + console.log(result1.state, result2.state); + `, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); // Verify no errors + expect(stdout.trim()).toBe("granted denied"); + expect(exitCode).toBe(0); + }); + + test("example.com:80;443 matches ports 80 and 443", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "--secure", + "--allow-net=example.com:80;443", + "-e", + ` + const result1 = Bun.permissions.querySync({ name: "net", host: "example.com:80" }); + const result2 = Bun.permissions.querySync({ name: "net", host: "example.com:443" }); + const result3 = Bun.permissions.querySync({ name: "net", host: "example.com:8080" }); + console.log(result1.state, result2.state, result3.state); + `, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); // Verify no errors + expect(stdout.trim()).toBe("granted granted denied"); + expect(exitCode).toBe(0); + }); + + test("example.com:8000-9000 matches port range", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "--secure", + "--allow-net=example.com:8000-9000", + "-e", + ` + const result1 = Bun.permissions.querySync({ name: "net", host: "example.com:8500" }); + const result2 = Bun.permissions.querySync({ name: "net", host: "example.com:8000" }); + const result3 = Bun.permissions.querySync({ name: "net", host: "example.com:9000" }); + const result4 = Bun.permissions.querySync({ name: "net", host: "example.com:7999" }); + const result5 = Bun.permissions.querySync({ name: "net", host: "example.com:9001" }); + console.log(result1.state, result2.state, result3.state, result4.state, result5.state); + `, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); // Verify no errors + expect(stdout.trim()).toBe("granted granted granted denied denied"); + expect(exitCode).toBe(0); + }); + }); + + describe("Protocol prefixes", () => { + test("https://example.com matches HTTPS requests", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "--secure", + "--allow-net=https://example.com", + "-e", + ` + const result = Bun.permissions.querySync({ name: "net", host: "example.com:443" }); + console.log(result.state); + `, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); // Verify no errors + expect(stdout.trim()).toBe("granted"); + expect(exitCode).toBe(0); + }); + + test("https://*.example.com with wildcard and protocol", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "--secure", + "--allow-net=https://*.example.com", + "-e", + ` + const result = Bun.permissions.querySync({ name: "net", host: "api.example.com:443" }); + console.log(result.state); + `, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); // Verify no errors + expect(stdout.trim()).toBe("granted"); + expect(exitCode).toBe(0); + }); + }); + + describe("Combined patterns", () => { + test("*.example.com:8000-9000 combines wildcard and port range", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "--secure", + "--allow-net=*.example.com:8000-9000", + "-e", + ` + const result1 = Bun.permissions.querySync({ name: "net", host: "api.example.com:8500" }); + const result2 = Bun.permissions.querySync({ name: "net", host: "api.example.com:80" }); + const result3 = Bun.permissions.querySync({ name: "net", host: "example.com:8500" }); + console.log(result1.state, result2.state, result3.state); + `, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); // Verify no errors + expect(stdout.trim()).toBe("granted denied denied"); + expect(exitCode).toBe(0); + }); + + test("https://**.example.com:443 combines all features", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "--secure", + "--allow-net=https://**.example.com:443", + "-e", + ` + const result1 = Bun.permissions.querySync({ name: "net", host: "a.b.c.example.com:443" }); + const result2 = Bun.permissions.querySync({ name: "net", host: "api.example.com:443" }); + console.log(result1.state, result2.state); + `, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); // Verify no errors + expect(stdout.trim()).toBe("granted granted"); + expect(exitCode).toBe(0); + }); + }); + + describe("Multiple patterns (comma-separated)", () => { + test("multiple hosts allowed via comma separation", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "--secure", + "--allow-net=example.com,localhost,api.test.com", + "-e", + ` + const result1 = Bun.permissions.querySync({ name: "net", host: "example.com" }); + const result2 = Bun.permissions.querySync({ name: "net", host: "localhost" }); + const result3 = Bun.permissions.querySync({ name: "net", host: "api.test.com" }); + const result4 = Bun.permissions.querySync({ name: "net", host: "other.com" }); + console.log(result1.state, result2.state, result3.state, result4.state); + `, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); // Verify no errors + expect(stdout.trim()).toBe("granted granted granted denied"); + expect(exitCode).toBe(0); + }); + + test("multiple wildcards allowed via comma separation", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "--secure", + "--allow-net=*.example.com,*.test.org", + "-e", + ` + const result1 = Bun.permissions.querySync({ name: "net", host: "api.example.com" }); + const result2 = Bun.permissions.querySync({ name: "net", host: "www.test.org" }); + const result3 = Bun.permissions.querySync({ name: "net", host: "api.other.com" }); + console.log(result1.state, result2.state, result3.state); + `, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); // Verify no errors + expect(stdout.trim()).toBe("granted granted denied"); + expect(exitCode).toBe(0); + }); + }); + + describe("IPv6 addresses", () => { + test("[::1] localhost IPv6 matching", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "--secure", + "--allow-net=[::1]", + "-e", + ` + const result1 = Bun.permissions.querySync({ name: "net", host: "[::1]" }); + const result2 = Bun.permissions.querySync({ name: "net", host: "[::1]:8080" }); + console.log(result1.state, result2.state); + `, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); // Verify no errors + expect(stdout.trim()).toBe("granted granted"); + expect(exitCode).toBe(0); + }); + + test("[::1]:8080 with specific port", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "--secure", + "--allow-net=[::1]:8080", + "-e", + ` + const result1 = Bun.permissions.querySync({ name: "net", host: "[::1]:8080" }); + const result2 = Bun.permissions.querySync({ name: "net", host: "[::1]:9000" }); + console.log(result1.state, result2.state); + `, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); // Verify no errors + expect(stdout.trim()).toBe("granted denied"); + expect(exitCode).toBe(0); + }); + }); + + describe("TLD and suffix wildcards", () => { + test("*.com matches any .com domain", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "--secure", + "--allow-net=*.com", + "-e", + ` + const result1 = Bun.permissions.querySync({ name: "net", host: "example.com" }); + const result2 = Bun.permissions.querySync({ name: "net", host: "test.com" }); + const result3 = Bun.permissions.querySync({ name: "net", host: "example.org" }); + const result4 = Bun.permissions.querySync({ name: "net", host: "sub.example.com" }); + console.log(result1.state, result2.state, result3.state, result4.state); + `, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + // *.com matches exactly one segment before .com + expect(stderr).toBe(""); // Verify no errors + expect(stdout.trim()).toBe("granted granted denied denied"); + expect(exitCode).toBe(0); + }); + + test("**.com matches any depth of .com domain", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "--secure", + "--allow-net=**.com", + "-e", + ` + const result1 = Bun.permissions.querySync({ name: "net", host: "example.com" }); + const result2 = Bun.permissions.querySync({ name: "net", host: "sub.example.com" }); + const result3 = Bun.permissions.querySync({ name: "net", host: "a.b.c.example.com" }); + const result4 = Bun.permissions.querySync({ name: "net", host: "example.org" }); + console.log(result1.state, result2.state, result3.state, result4.state); + `, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); // Verify no errors + expect(stdout.trim()).toBe("granted granted granted denied"); + expect(exitCode).toBe(0); + }); + }); + + describe("WebSocket and HTTP protocols", () => { + test("ws://localhost matches WebSocket", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "--secure", + "--allow-net=ws://localhost", + "-e", + ` + const result = Bun.permissions.querySync({ name: "net", host: "localhost" }); + console.log(result.state); + `, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); // Verify no errors + expect(stdout.trim()).toBe("granted"); + expect(exitCode).toBe(0); + }); + + test("wss://*.example.com matches secure WebSocket with wildcard", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "--secure", + "--allow-net=wss://*.example.com", + "-e", + ` + const result = Bun.permissions.querySync({ name: "net", host: "api.example.com" }); + console.log(result.state); + `, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); // Verify no errors + expect(stdout.trim()).toBe("granted"); + expect(exitCode).toBe(0); + }); + + test("http://example.com matches HTTP protocol", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "--secure", + "--allow-net=http://example.com", + "-e", + ` + const result = Bun.permissions.querySync({ name: "net", host: "example.com:80" }); + console.log(result.state); + `, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); // Verify no errors + expect(stdout.trim()).toBe("granted"); + expect(exitCode).toBe(0); + }); + }); + + describe("Double star in middle position", () => { + test("api.**.example.com matches nested subdomains", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "--secure", + "--allow-net=api.**.example.com", + "-e", + ` + const result1 = Bun.permissions.querySync({ name: "net", host: "api.v1.example.com" }); + const result2 = Bun.permissions.querySync({ name: "net", host: "api.v1.v2.example.com" }); + const result3 = Bun.permissions.querySync({ name: "net", host: "www.example.com" }); + console.log(result1.state, result2.state, result3.state); + `, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); // Verify no errors + expect(stdout.trim()).toBe("granted granted denied"); + expect(exitCode).toBe(0); + }); + }); + + describe("Port list with more than 2 ports", () => { + test("example.com:80;443;8080 matches multiple ports", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "--secure", + "--allow-net=example.com:80;443;8080", + "-e", + ` + const result1 = Bun.permissions.querySync({ name: "net", host: "example.com:80" }); + const result2 = Bun.permissions.querySync({ name: "net", host: "example.com:443" }); + const result3 = Bun.permissions.querySync({ name: "net", host: "example.com:8080" }); + const result4 = Bun.permissions.querySync({ name: "net", host: "example.com:9000" }); + console.log(result1.state, result2.state, result3.state, result4.state); + `, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); // Verify no errors + expect(stdout.trim()).toBe("granted granted granted denied"); + expect(exitCode).toBe(0); + }); + }); + + describe("Actual network requests", () => { + test("fetch is blocked without permission", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "--secure", + "--no-prompt", + "-e", + ` + try { + await fetch("https://example.com"); + console.log("SUCCESS"); + } catch (e) { + console.log("BLOCKED:", e.code || e.name); + } + `, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); // Verify no errors + expect(stdout.trim()).toContain("BLOCKED"); + expect(exitCode).toBe(0); + }); + + test("fetch is allowed with matching wildcard", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "--secure", + "--allow-net=*.cloudflare.com", + "-e", + ` + try { + const res = await fetch("https://workers.cloudflare.com/cf.json", { signal: AbortSignal.timeout(5000) }); + console.log("STATUS:", res.status); + } catch (e) { + console.log("ERROR:", e.code || e.name); + } + `, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + // Should get a status code, not be blocked + expect(stderr).toBe(""); // Verify no errors + expect(stdout.trim()).toMatch(/STATUS: \d+/); + expect(exitCode).toBe(0); + }); + + test("Bun.serve is blocked without permission", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "--secure", + "--no-prompt", + "-e", + ` + try { + const server = Bun.serve({ + port: 0, + fetch: () => new Response("ok"), + }); + console.log("SERVER STARTED"); + server.stop(); + } catch (e) { + console.log("BLOCKED:", e.code || e.name); + } + `, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); // Verify no errors + expect(stdout.trim()).toContain("BLOCKED"); + expect(exitCode).toBe(0); + }); + + test("Bun.serve is allowed with localhost permission", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "--secure", + // Bun.serve binds to 0.0.0.0 by default, so we need to allow it + "--allow-net=localhost,127.0.0.1,0.0.0.0", + "-e", + ` + try { + const server = Bun.serve({ + port: 0, + fetch: () => new Response("ok"), + }); + console.log("SERVER STARTED on port", server.port); + server.stop(); + } catch (e) { + console.log("ERROR:", e.code || e.name); + } + `, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); // Verify no errors + expect(stdout.trim()).toMatch(/SERVER STARTED on port \d+/); + expect(exitCode).toBe(0); + }); + }); + + describe("Edge cases", () => { + test("pattern with no wildcard still works as exact match", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "--secure", + "--allow-net=specific.example.com:443", + "-e", + ` + const result1 = Bun.permissions.querySync({ name: "net", host: "specific.example.com:443" }); + const result2 = Bun.permissions.querySync({ name: "net", host: "other.example.com:443" }); + console.log(result1.state, result2.state); + `, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); // Verify no errors + expect(stdout.trim()).toBe("granted denied"); + expect(exitCode).toBe(0); + }); + + test("wildcard with port list", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "--secure", + "--allow-net=*.example.com:80;443", + "-e", + ` + const result1 = Bun.permissions.querySync({ name: "net", host: "api.example.com:80" }); + const result2 = Bun.permissions.querySync({ name: "net", host: "api.example.com:443" }); + const result3 = Bun.permissions.querySync({ name: "net", host: "api.example.com:8080" }); + console.log(result1.state, result2.state, result3.state); + `, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); // Verify no errors + expect(stdout.trim()).toBe("granted granted denied"); + expect(exitCode).toBe(0); + }); + + test("0.0.0.0 matches all interfaces", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "--secure", + "--allow-net=0.0.0.0", + "-e", + ` + const result = Bun.permissions.querySync({ name: "net", host: "0.0.0.0:3000" }); + console.log(result.state); + `, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); // Verify no errors + expect(stdout.trim()).toBe("granted"); + expect(exitCode).toBe(0); + }); + }); + + describe("Backward compatibility", () => { + test("exact host match still works", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "--secure", + "--allow-net=example.com", + "-e", + ` + const result = Bun.permissions.querySync({ name: "net", host: "example.com" }); + console.log(result.state); + `, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); // Verify no errors + expect(stdout.trim()).toBe("granted"); + expect(exitCode).toBe(0); + }); + + test("host without port matches host with port", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "--secure", + "--allow-net=example.com", + "-e", + ` + const result = Bun.permissions.querySync({ name: "net", host: "example.com:443" }); + console.log(result.state); + `, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); // Verify no errors + expect(stdout.trim()).toBe("granted"); + expect(exitCode).toBe(0); + }); + + test("localhost works with various ports", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "--secure", + "--allow-net=localhost", + "-e", + ` + const result1 = Bun.permissions.querySync({ name: "net", host: "localhost" }); + const result2 = Bun.permissions.querySync({ name: "net", host: "localhost:3000" }); + const result3 = Bun.permissions.querySync({ name: "net", host: "localhost:8080" }); + console.log(result1.state, result2.state, result3.state); + `, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); // Verify no errors + expect(stdout.trim()).toBe("granted granted granted"); + expect(exitCode).toBe(0); + }); + + test("IP address matching still works", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "--secure", + "--allow-net=127.0.0.1", + "-e", + ` + const result1 = Bun.permissions.querySync({ name: "net", host: "127.0.0.1" }); + const result2 = Bun.permissions.querySync({ name: "net", host: "127.0.0.1:3000" }); + console.log(result1.state, result2.state); + `, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); // Verify no errors + expect(stdout.trim()).toBe("granted granted"); + expect(exitCode).toBe(0); + }); + }); +}); diff --git a/test/js/bun/permissions/permissions-net.test.ts b/test/js/bun/permissions/permissions-net.test.ts new file mode 100644 index 00000000000000..dac677fe88bda1 --- /dev/null +++ b/test/js/bun/permissions/permissions-net.test.ts @@ -0,0 +1,137 @@ +import { test, expect, describe } from "bun:test"; +import { bunEnv, bunExe, tempDir } from "harness"; + +describe("Network permissions", () => { + test("fetch denied in secure mode without --allow-net", async () => { + using dir = tempDir("perm-net-test", { + "test.ts": ` + try { + const response = await fetch("https://example.com"); + console.log("SUCCESS:", response.status); + } catch (e) { + console.log("ERROR:", e.message); + process.exit(1); + } + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--secure", "--no-prompt", "test.ts"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([ + proc.stdout.text(), + proc.stderr.text(), + proc.exited, + ]); + + expect(stdout + stderr).toContain("PermissionDenied"); + expect(exitCode).not.toBe(0); + }); + + test("Bun.serve denied in secure mode without --allow-net", async () => { + using dir = tempDir("perm-serve-test", { + "test.ts": ` + try { + const server = Bun.serve({ + port: 0, + fetch(req) { + return new Response("Hello"); + }, + }); + console.log("SUCCESS: Server started on port", server.port); + server.stop(); + } catch (e) { + console.log("ERROR:", e.message); + process.exit(1); + } + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--secure", "--no-prompt", "test.ts"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([ + proc.stdout.text(), + proc.stderr.text(), + proc.exited, + ]); + + expect(stdout + stderr).toContain("PermissionDenied"); + expect(exitCode).not.toBe(0); + }); + + test("Bun.serve allowed with --allow-net", async () => { + using dir = tempDir("perm-serve-allow", { + "test.ts": ` + const server = Bun.serve({ + port: 0, + fetch(req) { + return new Response("Hello"); + }, + }); + console.log("SUCCESS: Server started"); + server.stop(); + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--secure", "--allow-net", "test.ts"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([ + proc.stdout.text(), + proc.stderr.text(), + proc.exited, + ]); + + expect(stdout).toContain("SUCCESS"); + expect(exitCode).toBe(0); + }); + + test("granular --allow-net= works", async () => { + using dir = tempDir("perm-net-granular", { + "test.ts": ` + const server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch(req) { + return new Response("Hello"); + }, + }); + console.log("SUCCESS: Server started on port", server.port); + server.stop(); + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--secure", "--allow-net=127.0.0.1", "test.ts"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([ + proc.stdout.text(), + proc.stderr.text(), + proc.exited, + ]); + + expect(stdout).toContain("SUCCESS"); + expect(exitCode).toBe(0); + }); +}); diff --git a/test/js/bun/permissions/permissions-nodejs.test.ts b/test/js/bun/permissions/permissions-nodejs.test.ts new file mode 100644 index 00000000000000..0b4d8c2adce87c --- /dev/null +++ b/test/js/bun/permissions/permissions-nodejs.test.ts @@ -0,0 +1,320 @@ +import { describe, expect, test } from "bun:test"; +import { bunEnv, bunExe, tempDir } from "harness"; + +describe("Node.js permission compatibility", () => { + describe("CLI flag aliases", () => { + test("--permission flag works like --secure", async () => { + using dir = tempDir("perm-nodejs-permission", { + "test.ts": ` + import { readFileSync } from "fs"; + try { + console.log(readFileSync("./secret.txt", "utf8")); + } catch (e) { + console.log("ERROR:", e.message); + process.exit(1); + } + `, + "secret.txt": "secret data", + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--permission", "--no-prompt", "test.ts"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout + stderr).toContain("PermissionDenied"); + expect(exitCode).not.toBe(0); + }); + + test("--allow-fs-read flag works like --allow-read", async () => { + using dir = tempDir("perm-nodejs-fsread", { + "test.ts": ` + import { readFileSync } from "fs"; + console.log(readFileSync("./secret.txt", "utf8")); + `, + "secret.txt": "secret data", + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--permission", "--allow-fs-read", "test.ts"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout.trim()).toBe("secret data"); + expect(exitCode).toBe(0); + }); + + test("--allow-fs-write flag works like --allow-write", async () => { + using dir = tempDir("perm-nodejs-fswrite", { + "test.ts": ` + import { writeFileSync, readFileSync } from "fs"; + writeFileSync("./output.txt", "written data"); + console.log(readFileSync("./output.txt", "utf8")); + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--permission", "--allow-fs-write", "--allow-fs-read", "test.ts"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout.trim()).toBe("written data"); + expect(exitCode).toBe(0); + }); + + test("--allow-fs-read with path restriction works", async () => { + using dir = tempDir("perm-nodejs-fsread-path", { + "test.ts": ` + import { readFileSync } from "fs"; + console.log(readFileSync("./allowed.txt", "utf8")); + `, + "allowed.txt": "allowed content", + "forbidden.txt": "forbidden content", + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--permission", `--allow-fs-read=${String(dir)}/allowed.txt`, "test.ts"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout.trim()).toBe("allowed content"); + expect(exitCode).toBe(0); + }); + + test("--allow-child-process flag works like --allow-run", async () => { + using dir = tempDir("perm-nodejs-child", { + "test.ts": ` + const proc = Bun.spawn(["echo", "hello"]); + const text = await Bun.readableStreamToText(proc.stdout); + console.log(text.trim()); + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--permission", "--allow-child-process", "test.ts"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout.trim()).toBe("hello"); + expect(exitCode).toBe(0); + }); + }); + + describe("process.permission.has() API", () => { + test("returns true for granted permissions", async () => { + using dir = tempDir("perm-nodejs-api-granted", { + "test.ts": ` + // In non-secure mode, all permissions are granted + console.log(process.permission.has("fs.read")); + console.log(process.permission.has("fs.write")); + console.log(process.permission.has("net")); + console.log(process.permission.has("child")); + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "test.ts"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout.trim()).toBe("true\ntrue\ntrue\ntrue"); + expect(exitCode).toBe(0); + }); + + test("returns false for denied permissions in secure mode", async () => { + using dir = tempDir("perm-nodejs-api-denied", { + "test.ts": ` + console.log(process.permission.has("fs.read")); + console.log(process.permission.has("fs.write")); + console.log(process.permission.has("net")); + console.log(process.permission.has("child")); + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--permission", "--no-prompt", "test.ts"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout.trim()).toBe("false\nfalse\nfalse\nfalse"); + expect(exitCode).toBe(0); + }); + + test("returns true for specifically allowed permissions", async () => { + using dir = tempDir("perm-nodejs-api-specific", { + "test.ts": ` + console.log(process.permission.has("fs.read")); + console.log(process.permission.has("fs.write")); + console.log(process.permission.has("net")); + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--permission", "--allow-fs-read", "test.ts"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout.trim()).toBe("true\nfalse\nfalse"); + expect(exitCode).toBe(0); + }); + + test("works with reference parameter for fs permissions", async () => { + using dir = tempDir("perm-nodejs-api-ref", { + "test.ts": ` + const allowedPath = process.argv[2]; + const forbiddenPath = process.argv[3]; + console.log(process.permission.has("fs.read", allowedPath)); + console.log(process.permission.has("fs.read", forbiddenPath)); + `, + "allowed.txt": "allowed", + "forbidden.txt": "forbidden", + }); + + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "--permission", + `--allow-fs-read=${String(dir)}/allowed.txt`, + "test.ts", + `${String(dir)}/allowed.txt`, + `${String(dir)}/forbidden.txt`, + ], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout.trim()).toBe("true\nfalse"); + expect(exitCode).toBe(0); + }); + + test("supports Node.js permission scope names", async () => { + using dir = tempDir("perm-nodejs-scopes", { + "test.ts": ` + // Test various Node.js-style scope names + console.log("fs:", process.permission.has("fs")); + console.log("fs.read:", process.permission.has("fs.read")); + console.log("fs.write:", process.permission.has("fs.write")); + console.log("child:", process.permission.has("child")); + console.log("child.process:", process.permission.has("child.process")); + console.log("worker:", process.permission.has("worker")); + console.log("net:", process.permission.has("net")); + console.log("env:", process.permission.has("env")); + console.log("ffi:", process.permission.has("ffi")); + console.log("addon:", process.permission.has("addon")); + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "test.ts"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + // In non-secure mode, all permissions should be granted + const lines = stdout.trim().split("\n"); + for (const line of lines) { + expect(line).toContain("true"); + } + expect(exitCode).toBe(0); + }); + + test("returns false for unknown scopes", async () => { + using dir = tempDir("perm-nodejs-unknown", { + "test.ts": ` + console.log(process.permission.has("unknown_scope")); + console.log(process.permission.has("")); + console.log(process.permission.has("foo.bar.baz")); + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "test.ts"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout.trim()).toBe("false\nfalse\nfalse"); + expect(exitCode).toBe(0); + }); + + test("throws on missing scope argument", async () => { + using dir = tempDir("perm-nodejs-noscope", { + "test.ts": ` + try { + // @ts-ignore - testing runtime error + process.permission.has(); + console.log("NO_ERROR"); + } catch (e) { + console.log("ERROR:", e.message); + } + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "test.ts"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout).toContain("ERROR:"); + expect(stdout).not.toContain("NO_ERROR"); + expect(exitCode).toBe(0); + }); + }); +}); diff --git a/test/js/bun/permissions/permissions-run.test.ts b/test/js/bun/permissions/permissions-run.test.ts new file mode 100644 index 00000000000000..7c608d5d8226f9 --- /dev/null +++ b/test/js/bun/permissions/permissions-run.test.ts @@ -0,0 +1,87 @@ +import { test, expect, describe } from "bun:test"; +import { bunEnv, bunExe, tempDir } from "harness"; + +describe("Subprocess permissions", () => { + test("Bun.spawn denied in secure mode without --allow-run", async () => { + using dir = tempDir("perm-run-test", { + "test.ts": ` + try { + const proc = Bun.spawnSync(["echo", "hello"]); + console.log("SUCCESS:", proc.stdout.toString()); + } catch (e) { + console.log("ERROR:", e.message); + process.exit(1); + } + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--secure", "--no-prompt", "test.ts"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([ + proc.stdout.text(), + proc.stderr.text(), + proc.exited, + ]); + + expect(stdout + stderr).toContain("PermissionDenied"); + expect(exitCode).not.toBe(0); + }); + + test("Bun.spawn allowed with --allow-run", async () => { + using dir = tempDir("perm-run-allow", { + "test.ts": ` + const proc = Bun.spawnSync(["echo", "hello"]); + console.log("OUTPUT:", proc.stdout.toString().trim()); + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--secure", "--allow-run", "test.ts"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([ + proc.stdout.text(), + proc.stderr.text(), + proc.exited, + ]); + + expect(stdout).toContain("OUTPUT: hello"); + expect(exitCode).toBe(0); + }); + + test("granular --allow-run= works", async () => { + using dir = tempDir("perm-run-granular", { + "test.ts": ` + const proc = Bun.spawnSync(["echo", "hello"]); + console.log("OUTPUT:", proc.stdout.toString().trim()); + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--secure", "--allow-run=echo", "test.ts"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([ + proc.stdout.text(), + proc.stderr.text(), + proc.exited, + ]); + + expect(stdout).toContain("OUTPUT: hello"); + expect(exitCode).toBe(0); + }); +}); diff --git a/test/js/bun/permissions/permissions-symlink.test.ts b/test/js/bun/permissions/permissions-symlink.test.ts new file mode 100644 index 00000000000000..5c0a8eb6725df4 --- /dev/null +++ b/test/js/bun/permissions/permissions-symlink.test.ts @@ -0,0 +1,296 @@ +import { describe, expect, test } from "bun:test"; +import { mkdirSync, symlinkSync, writeFileSync } from "fs"; +import { bunEnv, bunExe, tempDir } from "harness"; +import { join } from "path"; + +describe("Symlink permission resolution", () => { + describe("symlink to forbidden path is denied", () => { + test("reading through symlink to forbidden directory is denied", async () => { + using dir = tempDir("perm-symlink-read-denied", { + "allowed/link-placeholder": "", // placeholder, we'll create symlink + "forbidden/secret.txt": "secret content", + }); + + // Create symlink: allowed/link -> ../forbidden/secret.txt + const linkPath = join(String(dir), "allowed/link"); + const targetPath = join(String(dir), "forbidden/secret.txt"); + + // Remove placeholder and create symlink + await Bun.$`rm ${linkPath}-placeholder`; + symlinkSync(targetPath, linkPath); + + // Write test script + await Bun.write( + join(String(dir), "test.ts"), + ` + import { readFileSync } from "fs"; + try { + // Try to read through symlink + const content = readFileSync("./allowed/link", "utf8"); + console.log("READ:", content); + } catch (e) { + console.log("ERROR:", e.message); + process.exit(1); + } + `, + ); + + // Run with only allowed/ directory permitted + await using proc = Bun.spawn({ + cmd: [bunExe(), "--secure", `--allow-read=${String(dir)}/allowed`, "test.ts"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + // Should be denied because symlink target is in forbidden/ + expect(stdout + stderr).toContain("PermissionDenied"); + expect(exitCode).not.toBe(0); + }); + + test("writing through symlink to forbidden directory is denied", async () => { + using dir = tempDir("perm-symlink-write-denied", {}); + + // Create directories + mkdirSync(join(String(dir), "allowed")); + mkdirSync(join(String(dir), "forbidden")); + + // Create existing file in forbidden + writeFileSync(join(String(dir), "forbidden/target.txt"), "original"); + + // Create symlink: allowed/link -> ../forbidden/target.txt + const linkPath = join(String(dir), "allowed/link"); + const targetPath = join(String(dir), "forbidden/target.txt"); + symlinkSync(targetPath, linkPath); + + await Bun.write( + join(String(dir), "test.ts"), + ` + import { writeFileSync } from "fs"; + try { + writeFileSync("./allowed/link", "hacked!"); + console.log("WROTE"); + } catch (e) { + console.log("ERROR:", e.message); + process.exit(1); + } + `, + ); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--secure", `--allow-write=${String(dir)}/allowed`, `--allow-read=${String(dir)}`, "test.ts"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout + stderr).toContain("PermissionDenied"); + expect(exitCode).not.toBe(0); + }); + }); + + describe("symlink to allowed path is permitted", () => { + test("reading through symlink to allowed directory succeeds", async () => { + using dir = tempDir("perm-symlink-read-allowed", {}); + + // Create directories + mkdirSync(join(String(dir), "links")); + mkdirSync(join(String(dir), "data")); + + // Create target file + writeFileSync(join(String(dir), "data/file.txt"), "allowed content"); + + // Create symlink: links/link -> ../data/file.txt + symlinkSync(join(String(dir), "data/file.txt"), join(String(dir), "links/link")); + + await Bun.write( + join(String(dir), "test.ts"), + ` + import { readFileSync } from "fs"; + const content = readFileSync("./links/link", "utf8"); + console.log("CONTENT:", content); + `, + ); + + // Allow both the link directory and target directory + await using proc = Bun.spawn({ + cmd: [bunExe(), "--secure", `--allow-read=${String(dir)}`, "test.ts"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout).toContain("CONTENT: allowed content"); + expect(exitCode).toBe(0); + }); + }); + + describe("symlink chains are resolved", () => { + test("nested symlinks are fully resolved", async () => { + using dir = tempDir("perm-symlink-chain", {}); + + // Create directories + mkdirSync(join(String(dir), "allowed")); + mkdirSync(join(String(dir), "forbidden")); + + // Create target file in forbidden + writeFileSync(join(String(dir), "forbidden/secret.txt"), "top secret"); + + // Create chain: allowed/link1 -> allowed/link2 -> ../forbidden/secret.txt + symlinkSync(join(String(dir), "forbidden/secret.txt"), join(String(dir), "allowed/link2")); + symlinkSync(join(String(dir), "allowed/link2"), join(String(dir), "allowed/link1")); + + await Bun.write( + join(String(dir), "test.ts"), + ` + import { readFileSync } from "fs"; + try { + const content = readFileSync("./allowed/link1", "utf8"); + console.log("READ:", content); + } catch (e) { + console.log("ERROR:", e.message); + process.exit(1); + } + `, + ); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--secure", `--allow-read=${String(dir)}/allowed`, "test.ts"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + // Should be denied because final target is in forbidden/ + expect(stdout + stderr).toContain("PermissionDenied"); + expect(exitCode).not.toBe(0); + }); + }); + + describe("non-existent symlink targets", () => { + test("writing to new file through symlink in allowed dir works", async () => { + using dir = tempDir("perm-symlink-new-file", {}); + + mkdirSync(join(String(dir), "allowed")); + + await Bun.write( + join(String(dir), "test.ts"), + ` + import { writeFileSync, readFileSync } from "fs"; + // Write to a new file (doesn't exist yet) + writeFileSync("./allowed/newfile.txt", "new content"); + const content = readFileSync("./allowed/newfile.txt", "utf8"); + console.log("CONTENT:", content); + `, + ); + + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "--secure", + `--allow-read=${String(dir)}/allowed`, + `--allow-write=${String(dir)}/allowed`, + "test.ts", + ], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout).toContain("CONTENT: new content"); + expect(exitCode).toBe(0); + }); + }); + + describe("relative symlinks", () => { + test("relative symlink escaping allowed directory is denied", async () => { + using dir = tempDir("perm-symlink-relative", {}); + + mkdirSync(join(String(dir), "allowed")); + mkdirSync(join(String(dir), "forbidden")); + writeFileSync(join(String(dir), "forbidden/secret.txt"), "secret"); + + // Create relative symlink that escapes: allowed/link -> ../forbidden/secret.txt + symlinkSync("../forbidden/secret.txt", join(String(dir), "allowed/link")); + + await Bun.write( + join(String(dir), "test.ts"), + ` + import { readFileSync } from "fs"; + try { + const content = readFileSync("./allowed/link", "utf8"); + console.log("READ:", content); + } catch (e) { + console.log("ERROR:", e.message); + process.exit(1); + } + `, + ); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--secure", `--allow-read=${String(dir)}/allowed`, "test.ts"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout + stderr).toContain("PermissionDenied"); + expect(exitCode).not.toBe(0); + }); + }); + + describe("symlink resolution only in secure mode", () => { + test("symlinks are NOT resolved in non-secure mode (default)", async () => { + using dir = tempDir("perm-symlink-nonsecure", {}); + + mkdirSync(join(String(dir), "allowed")); + mkdirSync(join(String(dir), "forbidden")); + writeFileSync(join(String(dir), "forbidden/secret.txt"), "secret content"); + + // Create symlink escaping to forbidden + symlinkSync(join(String(dir), "forbidden/secret.txt"), join(String(dir), "allowed/link")); + + await Bun.write( + join(String(dir), "test.ts"), + ` + import { readFileSync } from "fs"; + const content = readFileSync("./allowed/link", "utf8"); + console.log("CONTENT:", content); + `, + ); + + // Run WITHOUT --secure flag (default mode) + await using proc = Bun.spawn({ + cmd: [bunExe(), "test.ts"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + // Should succeed in non-secure mode + expect(stdout).toContain("CONTENT: secret content"); + expect(exitCode).toBe(0); + }); + }); +}); diff --git a/test/js/bun/permissions/permissions-sys.test.ts b/test/js/bun/permissions/permissions-sys.test.ts new file mode 100644 index 00000000000000..3df352785c0007 --- /dev/null +++ b/test/js/bun/permissions/permissions-sys.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, test } from "bun:test"; +import { bunEnv, bunExe, tempDir } from "harness"; + +describe("System info permissions", () => { + test("os.hostname denied in secure mode without --allow-sys", async () => { + using dir = tempDir("perm-sys-test", { + "test.ts": ` + import os from "os"; + try { + console.log("HOSTNAME:", os.hostname()); + } catch (e) { + console.log("ERROR:", e.message); + process.exit(1); + } + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--secure", "--no-prompt", "test.ts"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout + stderr).toContain("PermissionDenied"); + expect(exitCode).not.toBe(0); + }); + + test("os.hostname allowed with --allow-sys", async () => { + using dir = tempDir("perm-sys-allow", { + "test.ts": ` + import os from "os"; + console.log("HOSTNAME:", os.hostname()); + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--secure", "--allow-sys", "test.ts"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout).toContain("HOSTNAME:"); + expect(exitCode).toBe(0); + }); + + test("os.cpus denied in secure mode without --allow-sys", async () => { + using dir = tempDir("perm-sys-cpus", { + "test.ts": ` + import os from "os"; + try { + // Must access a property like .model to trigger the native call + // because os.cpus() uses lazy evaluation + console.log("CPUS:", os.cpus()[0].model); + } catch (e) { + console.log("ERROR:", e.message); + process.exit(1); + } + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--secure", "--no-prompt", "test.ts"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout + stderr).toContain("PermissionDenied"); + expect(exitCode).not.toBe(0); + }); + + test("os.networkInterfaces denied in secure mode without --allow-sys", async () => { + using dir = tempDir("perm-sys-net", { + "test.ts": ` + import os from "os"; + try { + console.log("INTERFACES:", Object.keys(os.networkInterfaces()).length); + } catch (e) { + console.log("ERROR:", e.message); + process.exit(1); + } + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--secure", "--no-prompt", "test.ts"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout + stderr).toContain("PermissionDenied"); + expect(exitCode).not.toBe(0); + }); + + test("granular --allow-sys= works", async () => { + using dir = tempDir("perm-sys-granular", { + "test.ts": ` + import os from "os"; + console.log("HOSTNAME:", os.hostname()); + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--secure", "--allow-sys=hostname", "test.ts"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout).toContain("HOSTNAME:"); + expect(exitCode).toBe(0); + }); +}); diff --git a/test/js/bun/permissions/permissions-worker.test.ts b/test/js/bun/permissions/permissions-worker.test.ts new file mode 100644 index 00000000000000..89f6f256e61284 --- /dev/null +++ b/test/js/bun/permissions/permissions-worker.test.ts @@ -0,0 +1,315 @@ +import { describe, expect, test } from "bun:test"; +import { bunEnv, bunExe, tempDir } from "harness"; + +describe("Worker permission inheritance", () => { + test("Worker inherits read permission from parent", async () => { + using dir = tempDir("perm-worker-read", { + "main.ts": ` + const worker = new Worker(new URL("./worker.ts", import.meta.url).href); + worker.onmessage = (e) => { + console.log("RESULT:", e.data); + worker.terminate(); + }; + worker.onerror = (e) => { + console.log("ERROR:", e.message); + worker.terminate(); + }; + `, + "worker.ts": ` + import { readFileSync } from "fs"; + try { + const content = readFileSync("./data.txt", "utf8"); + postMessage("READ:" + content); + } catch (e) { + postMessage("DENIED:" + e.message); + } + `, + "data.txt": "secret content", + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--secure", "--allow-read", "main.ts"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout).toContain("RESULT: READ:secret content"); + expect(exitCode).toBe(0); + }); + + test("Worker inherits permission denial from parent", async () => { + using dir = tempDir("perm-worker-denied", { + "main.ts": ` + const worker = new Worker(new URL("./worker.ts", import.meta.url).href); + worker.onmessage = (e) => { + console.log("RESULT:", e.data); + worker.terminate(); + }; + worker.onerror = (e) => { + console.log("ERROR:", e.message); + worker.terminate(); + }; + `, + "worker.ts": ` + import { readFileSync } from "fs"; + try { + const content = readFileSync("./data.txt", "utf8"); + postMessage("READ:" + content); + } catch (e) { + postMessage("DENIED:" + e.message); + } + `, + "data.txt": "secret content", + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--secure", "--no-prompt", "main.ts"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout + stderr).toContain("DENIED"); + expect(stdout + stderr).toContain("PermissionDenied"); + expect(exitCode).toBe(0); + }); + + test("Worker inherits write permission from parent", async () => { + using dir = tempDir("perm-worker-write", { + "main.ts": ` + const worker = new Worker(new URL("./worker.ts", import.meta.url).href); + worker.onmessage = (e) => { + console.log("RESULT:", e.data); + worker.terminate(); + }; + worker.onerror = (e) => { + console.log("ERROR:", e.message); + worker.terminate(); + }; + `, + "worker.ts": ` + import { writeFileSync, readFileSync } from "fs"; + try { + writeFileSync("./output.txt", "written by worker"); + const content = readFileSync("./output.txt", "utf8"); + postMessage("WROTE:" + content); + } catch (e) { + postMessage("DENIED:" + e.message); + } + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--secure", "--allow-read", "--allow-write", "main.ts"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout).toContain("RESULT: WROTE:written by worker"); + expect(exitCode).toBe(0); + }); + + test("Worker inherits net permission from parent", async () => { + using dir = tempDir("perm-worker-net", { + "main.ts": ` + const worker = new Worker(new URL("./worker.ts", import.meta.url).href); + worker.onmessage = (e) => { + console.log("RESULT:", e.data); + worker.terminate(); + }; + worker.onerror = (e) => { + console.log("ERROR:", e.message); + worker.terminate(); + }; + `, + "worker.ts": ` + try { + // Just try to create a server on port 0 (random) + const server = Bun.serve({ + port: 0, + fetch() { return new Response("ok"); } + }); + const port = server.port; + server.stop(); + postMessage("SERVED:" + port); + } catch (e) { + postMessage("DENIED:" + e.message); + } + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--secure", "--allow-net", "main.ts"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout).toContain("RESULT: SERVED:"); + expect(exitCode).toBe(0); + }); + + test("Worker inherits env permission from parent", async () => { + using dir = tempDir("perm-worker-env", { + "main.ts": ` + const worker = new Worker(new URL("./worker.ts", import.meta.url).href); + worker.onmessage = (e) => { + console.log("RESULT:", e.data); + worker.terminate(); + }; + worker.onerror = (e) => { + console.log("ERROR:", e.message); + worker.terminate(); + }; + `, + "worker.ts": ` + try { + const home = Bun.env.HOME || Bun.env.USERPROFILE || "unknown"; + postMessage("ENV:" + (home ? "found" : "empty")); + } catch (e) { + postMessage("DENIED:" + e.message); + } + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--secure", "--allow-env", "main.ts"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout).toContain("RESULT: ENV:found"); + expect(exitCode).toBe(0); + }); + + test("Worker inherits granular path permissions from parent", async () => { + using dir = tempDir("perm-worker-granular", { + "main.ts": ` + const worker = new Worker(new URL("./worker.ts", import.meta.url).href); + worker.onmessage = (e) => { + console.log("RESULT:", e.data); + worker.terminate(); + }; + worker.onerror = (e) => { + console.log("ERROR:", e.message); + worker.terminate(); + }; + `, + "worker.ts": ` + import { readFileSync } from "fs"; + const results = []; + + // Try to read allowed file + try { + readFileSync("./allowed.txt", "utf8"); + results.push("allowed:ok"); + } catch (e) { + results.push("allowed:denied"); + } + + // Try to read forbidden file + try { + readFileSync("./forbidden.txt", "utf8"); + results.push("forbidden:ok"); + } catch (e) { + results.push("forbidden:denied"); + } + + postMessage(results.join(",")); + `, + "allowed.txt": "allowed content", + "forbidden.txt": "forbidden content", + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--secure", `--allow-read=${String(dir)}/allowed.txt`, "main.ts"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout).toContain("allowed:ok"); + expect(stdout).toContain("forbidden:denied"); + expect(exitCode).toBe(0); + }); + + test("-A grants all permissions to Worker", async () => { + using dir = tempDir("perm-worker-all", { + "main.ts": ` + const worker = new Worker(new URL("./worker.ts", import.meta.url).href); + worker.onmessage = (e) => { + console.log("RESULT:", e.data); + worker.terminate(); + }; + worker.onerror = (e) => { + console.log("ERROR:", e.message); + worker.terminate(); + }; + `, + "worker.ts": ` + import { readFileSync, writeFileSync } from "fs"; + const results = []; + + try { + const content = readFileSync("./data.txt", "utf8"); + results.push("read:ok"); + } catch (e) { + results.push("read:denied"); + } + + try { + writeFileSync("./output.txt", "test"); + results.push("write:ok"); + } catch (e) { + results.push("write:denied"); + } + + try { + const home = Bun.env.HOME || Bun.env.USERPROFILE; + results.push("env:" + (home ? "ok" : "empty")); + } catch (e) { + results.push("env:denied"); + } + + postMessage(results.join(",")); + `, + "data.txt": "test data", + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--secure", "-A", "main.ts"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout).toContain("read:ok"); + expect(stdout).toContain("write:ok"); + expect(stdout).toContain("env:ok"); + expect(exitCode).toBe(0); + }); +}); diff --git a/test/js/bun/permissions/run-benchmark.ts b/test/js/bun/permissions/run-benchmark.ts new file mode 100644 index 00000000000000..d555ebca6677bf --- /dev/null +++ b/test/js/bun/permissions/run-benchmark.ts @@ -0,0 +1,113 @@ +#!/usr/bin/env bun +/** + * Benchmark runner - compares normal mode vs secure mode performance + * + * Usage: + * bun ./test/js/bun/permissions/run-benchmark.ts + * + * Or with debug build: + * bun bd ./test/js/bun/permissions/run-benchmark.ts + */ + +import { bunEnv, bunExe } from "harness"; + +const BENCHMARK_FILE = import.meta.dir + "/benchmark-permissions.ts"; + +async function run() { + console.log("🚀 Permission System Performance Benchmark\n"); + + // Run normal mode + console.log("Running benchmark in NORMAL mode..."); + const normalProc = Bun.spawnSync({ + cmd: [bunExe(), BENCHMARK_FILE], + env: bunEnv, + stdout: "inherit", + stderr: "inherit", + }); + + if (normalProc.exitCode !== 0) { + console.error("Normal mode benchmark failed"); + process.exit(1); + } + + console.log("\n"); + + // Run secure mode with --allow-all + console.log("Running benchmark in SECURE mode (--allow-all)..."); + const secureProc = Bun.spawnSync({ + cmd: [bunExe(), "--secure", "--allow-all", BENCHMARK_FILE], + env: { ...bunEnv, BUN_BENCHMARK_SECURE: "1" }, + stdout: "inherit", + stderr: "inherit", + }); + + if (secureProc.exitCode !== 0) { + console.error("Secure mode benchmark failed"); + process.exit(1); + } + + // Compare results + console.log("\n"); + console.log("=".repeat(70)); + console.log(" COMPARISON: Normal vs Secure Mode"); + console.log("=".repeat(70)); + + try { + const normalResults = await Bun.file("/tmp/bun-perm-bench-normal.json").json(); + const secureResults = await Bun.file("/tmp/bun-perm-bench-secure.json").json(); + + console.log("\n"); + console.log( + `${"Operation".padEnd(25)} ${"Normal (ns)".padStart(12)} ${"Secure (ns)".padStart(12)} ${"Overhead".padStart(12)} ${"% Change".padStart(10)}`, + ); + console.log("-".repeat(70)); + + let totalNormalNs = 0; + let totalSecureNs = 0; + + for (const normalOp of normalResults.results) { + const secureOp = secureResults.results.find((r: any) => r.name === normalOp.name); + if (!secureOp) continue; + + const overheadNs = secureOp.avgNs - normalOp.avgNs; + const pctChange = ((secureOp.avgNs - normalOp.avgNs) / normalOp.avgNs) * 100; + + totalNormalNs += normalOp.avgNs; + totalSecureNs += secureOp.avgNs; + + const overheadStr = overheadNs >= 0 ? `+${overheadNs.toFixed(0)}` : overheadNs.toFixed(0); + const pctStr = pctChange >= 0 ? `+${pctChange.toFixed(1)}%` : `${pctChange.toFixed(1)}%`; + const color = pctChange > 10 ? "🔴" : pctChange > 5 ? "🟡" : "🟢"; + + console.log( + `${normalOp.name.padEnd(25)} ${normalOp.avgNs.toFixed(0).padStart(12)} ${secureOp.avgNs.toFixed(0).padStart(12)} ${overheadStr.padStart(12)} ${(color + " " + pctStr).padStart(12)}`, + ); + } + + console.log("-".repeat(70)); + + const totalOverhead = totalSecureNs - totalNormalNs; + const totalPctChange = ((totalSecureNs - totalNormalNs) / totalNormalNs) * 100; + + console.log( + `${"TOTAL".padEnd(25)} ${totalNormalNs.toFixed(0).padStart(12)} ${totalSecureNs.toFixed(0).padStart(12)} ${(totalOverhead >= 0 ? "+" : "") + totalOverhead.toFixed(0).padStart(11)} ${(totalPctChange >= 0 ? "+" : "") + totalPctChange.toFixed(1)}%`, + ); + + console.log("\n"); + console.log("Legend: 🟢 < 5% overhead | 🟡 5-10% overhead | 🔴 > 10% overhead"); + console.log("\n"); + + // Summary + if (totalPctChange < 5) { + console.log("✅ RESULT: Minimal performance impact (< 5% overhead)"); + } else if (totalPctChange < 10) { + console.log("⚠️ RESULT: Moderate performance impact (5-10% overhead)"); + } else { + console.log("❌ RESULT: Significant performance impact (> 10% overhead)"); + } + } catch (e) { + console.error("Failed to compare results:", e); + } +} + +run().catch(console.error);