Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 16 additions & 5 deletions src/core/dsl/builtins/process.zig
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,9 @@ pub fn system(ctx: ExecCtx, _: ?Value, args: []const Value) BuiltinError!Value {
// The lint above waves bare/system-dir argv0 through; the real write
// containment is the sandbox-exec fence wrapping the spawn (parity with
// the --use-system-ruby path).
const spawn_argv = sandbox.fenceArgv(ctx.allocator, argv_slice, ctx.cellar_path, ctx.malt_prefix, .{}) catch |e| switch (e) {
const spawn_argv = sandbox.fenceArgv(ctx.allocator, argv_slice, ctx.cellar_path, ctx.malt_prefix, .{
.home = std.process.Environ.getPosix(ctx.environ, "HOME"),
}) catch |e| switch (e) {
error.OutOfMemory => return BuiltinError.OutOfMemory,
error.PathSandboxViolation => return BuiltinError.PathSandboxViolation,
};
Expand Down Expand Up @@ -291,7 +293,9 @@ pub fn safePopenRead(ctx: ExecCtx, _: ?Value, args: []const Value) BuiltinError!
// capturing stdout must not be a way to spawn unconfined.
sandbox.validateArgv(argv_slice, ctx.cellar_path, ctx.malt_prefix) catch
return BuiltinError.PathSandboxViolation;
const spawn_argv = sandbox.fenceArgv(ctx.allocator, argv_slice, ctx.cellar_path, ctx.malt_prefix, .{}) catch |e| switch (e) {
const spawn_argv = sandbox.fenceArgv(ctx.allocator, argv_slice, ctx.cellar_path, ctx.malt_prefix, .{
.home = std.process.Environ.getPosix(ctx.environ, "HOME"),
}) catch |e| switch (e) {
error.OutOfMemory => return BuiltinError.OutOfMemory,
error.PathSandboxViolation => return BuiltinError.PathSandboxViolation,
};
Expand Down Expand Up @@ -789,8 +793,12 @@ test "system drains child output larger than a pipe buffer" {
var lio: std.Io.Threaded = .init(alloc, .{});
defer lio.deinit();

const fixture = try std.fmt.allocPrint(alloc, "/tmp/malt_bigout_{d}", .{std.c.getpid()});
defer std.Io.Dir.cwd().deleteFile(lio.io(), fixture) catch {};
const root = try std.fmt.allocPrint(alloc, "/tmp/malt_bigout_{d}", .{std.c.getpid()});
std.Io.Dir.cwd().deleteTree(lio.io(), root) catch {};
defer std.Io.Dir.cwd().deleteTree(lio.io(), root) catch {};
const cellar = try std.fmt.allocPrint(alloc, "{s}/Cellar/foo/1.0", .{root});
try std.Io.Dir.cwd().createDirPath(lio.io(), cellar);
const fixture = try std.fmt.allocPrint(alloc, "{s}/payload", .{root});
{
const f = try std.Io.Dir.createFileAbsolute(lio.io(), fixture, .{ .truncate = true });
defer f.close(lio.io());
Expand All @@ -802,7 +810,10 @@ test "system drains child output larger than a pipe buffer" {
}

var cap = try FdCapture.start(alloc, lio.io(), std.c.STDOUT_FILENO, "big");
_ = system(sanitizeTestCtx(alloc, lio.io()), null, &.{
var ctx = sanitizeTestCtx(alloc, lio.io());
ctx.cellar_path = cellar;
ctx.malt_prefix = root;
_ = system(ctx, null, &.{
.{ .string = "/bin/cat" },
.{ .string = fixture },
}) catch {};
Expand Down
52 changes: 51 additions & 1 deletion src/core/post_install_steps.zig
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
//! router downgrades to the loud partial-skip envelope.

const std = @import("std");
const builtin = @import("builtin");
const sandbox = @import("dsl/sandbox.zig");
const sandbox_macos = @import("sandbox/macos.zig");
const fallback_log = @import("dsl/fallback_log.zig");
Expand Down Expand Up @@ -1181,7 +1182,9 @@ fn spawnFenced(ctx: StepsCtx, argv: []const []const u8, env_map: ?*const std.pro
logViolation(ctx, argv[0]);
return false;
};
const fenced = sandbox.fenceArgv(ctx.allocator, argv, ctx.keg_path, ctx.prefix, opts) catch {
var profile_opts = opts;
profile_opts.home = std.process.Environ.getPosix(ctx.environ, "HOME");
const fenced = sandbox.fenceArgv(ctx.allocator, argv, ctx.keg_path, ctx.prefix, profile_opts) catch {
logViolation(ctx, argv[0]);
return false;
};
Expand Down Expand Up @@ -2556,6 +2559,53 @@ test "run executes the formula's own helper with its arguments expanded" {
try testing.expect(dirExists(h.io, try std.fmt.allocPrint(a, "{s}/var/lib/dbus", .{h.prefix})));
}

test "run can read a standard user font without opening the rest of HOME" {
if (builtin.os.tag != .macos) return error.SkipZigTest;

var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
var h = try TestHarness.init();
defer h.deinit();
h.io = threaded.io();
const a = h.arena.allocator();

const home = try std.fmt.allocPrint(a, "{s}_home", .{h.prefix});
defer std.Io.Dir.cwd().deleteTree(h.io, home) catch {};
const fonts = try std.fmt.allocPrint(a, "{s}/Library/Fonts", .{home});
const source = try std.fmt.allocPrint(a, "{s}/Probe.ttf", .{fonts});
const copied = try std.fmt.allocPrint(a, "{s}/share/Probe.ttf", .{h.prefix});
try std.Io.Dir.cwd().createDirPath(h.io, fonts);
try std.Io.Dir.cwd().createDirPath(h.io, try std.fmt.allocPrint(a, "{s}/share", .{h.prefix}));
{
const f = try std.Io.Dir.createFileAbsolute(h.io, source, .{});
defer f.close(h.io);
try f.writeStreamingAll(h.io, "FONT");
}

const libexec = try std.fmt.allocPrint(a, "{s}/libexec", .{h.keg});
try std.Io.Dir.cwd().createDirPath(h.io, libexec);
try std.Io.Dir.symLinkAbsolute(h.io, "/bin/cp", try std.fmt.allocPrint(a, "{s}/copy-font", .{libexec}), .{});

const home_var = try std.fmt.allocPrintSentinel(a, "HOME={s}", .{home}, 0);
var env_entries = [_:null]?[*:0]const u8{home_var.ptr};
h.environ = .{ .block = .{ .slice = &env_entries } };

const steps = try std.fmt.allocPrint(
a,
\\[{{"type":"run","command":{{"base":"libexec","path":"copy-font"}},
\\ "args":["{s}","{s}"]}}]
,
.{ source, copied },
);
try testing.expect(execute(h.ctx(), try testFormulaJson(&h, steps)));
try testing.expect(!h.flog.hasErrors());
const copied_file = try std.Io.Dir.openFileAbsolute(h.io, copied, .{});
defer copied_file.close(h.io);
var buf: [4]u8 = undefined;
const n = try copied_file.readPositionalAll(h.io, &buf, 0);
try testing.expectEqualStrings("FONT", buf[0..n]);
}

test "run refuses the execution fields malt does not honour" {
// Compaction drops defaults, so a key that survives into the JSON is one
// the formula meant. Running without honouring it would be a truncated
Expand Down
89 changes: 80 additions & 9 deletions src/core/sandbox/macos.zig
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,13 @@ pub fn validatePathForProfile(p: []const u8) SandboxError!void {
/// initialisers only — every other fenced child stays IPC-free.
pub const ProfileOpts = struct {
allow_ipc: bool = false,
/// Formula tools such as fontconfig legitimately scan the standard user
/// font directories. The renderer grants only those descendants, never
/// the rest of HOME.
home: ?[]const u8 = null,
/// Individual files needed by this spawn, such as the generated Ruby
/// wrapper. These are literal rules, not directory grants.
read_files: []const []const u8 = &.{},
};

/// Render the deny-by-default SCL profile; writes limited to `cellar_path`
Expand All @@ -74,6 +81,7 @@ pub fn renderRubyProfile(
) SandboxError![]const u8 {
try validatePathForProfile(cellar_path);
try validatePathForProfile(malt_prefix);
if (opts.home) |home| try validatePathForProfile(home);

var buf: std.ArrayList(u8) = .empty;
errdefer buf.deinit(allocator);
Expand All @@ -87,7 +95,6 @@ pub fn renderRubyProfile(
\\(allow sysctl-read)
\\(allow mach-lookup)
\\(allow iokit-open)
\\(allow file-read*)
\\(deny network*)
\\(allow file-write-data
\\ (regex #"^/dev/(null|dtracehelper|tty|stdout|stderr)$")
Expand All @@ -98,29 +105,83 @@ pub fn renderRubyProfile(
;
buf.appendSlice(allocator, header) catch return SandboxError.ProfileBuildFailed;

for (opts.read_files) |path| try validatePathForProfile(path);

var aw: std.Io.Writer.Allocating = .fromArrayList(allocator, &buf);
const w = &aw.writer;
// macOS executables need read operations beyond file contents during
// startup. Keep those operations available, then deny file contents in
// every mutable or user-data root. More-specific grants below restore
// contents only for Malt and a small set of platform runtime paths.
w.writeAll(
\\(allow file-read*)
\\(deny file-read-data
) catch return SandboxError.ProfileBuildFailed;
for ([_][]const u8{
"/Applications",
"/Library",
"/Users",
"/Volumes",
"/cores",
"/dev",
"/opt",
"/private",
"/usr/local",
"/System/Volumes/Data",
}) |root| w.print("\n (subpath \"{s}\")", .{root}) catch
return SandboxError.ProfileBuildFailed;
w.writeAll(")\n(allow file-read-data") catch return SandboxError.ProfileBuildFailed;
for ([_][]const u8{
"/Library/Apple",
"/private/var/db/timezone",
"/dev/null",
"/dev/random",
"/dev/urandom",
"/dev/zero",
"/dev/tty",
}) |path| w.print("\n (literal \"{s}\")", .{path}) catch
return SandboxError.ProfileBuildFailed;
w.writeAll("\n (subpath \"/Library/Fonts\")") catch return SandboxError.ProfileBuildFailed;
var cellar_real_buf: [std.fs.max_path_bytes]u8 = undefined;
var prefix_real_buf: [std.fs.max_path_bytes]u8 = undefined;
var home_real_buf: [std.fs.max_path_bytes]u8 = undefined;
writeCellarRule(w, cellar_path) catch return SandboxError.ProfileBuildFailed;
if (resolvedForProfile(cellar_path, &cellar_real_buf)) |real|
writeCellarRule(w, real) catch return SandboxError.ProfileBuildFailed;
writeCellarRule(w, malt_prefix) catch return SandboxError.ProfileBuildFailed;
if (resolvedForProfile(malt_prefix, &prefix_real_buf)) |real|
writeCellarRule(w, real) catch return SandboxError.ProfileBuildFailed;
if (opts.home) |home| {
writeHomeFontRules(w, home) catch return SandboxError.ProfileBuildFailed;
if (resolvedForProfile(home, &home_real_buf)) |real|
writeHomeFontRules(w, real) catch return SandboxError.ProfileBuildFailed;
}
for (opts.read_files) |path| {
w.print("\n (literal \"{s}\")", .{path}) catch return SandboxError.ProfileBuildFailed;
var real_buf: [std.fs.max_path_bytes]u8 = undefined;
if (resolvedForProfile(path, &real_buf)) |real|
w.print("\n (literal \"{s}\")", .{real}) catch return SandboxError.ProfileBuildFailed;
}
w.writeAll(")\n") catch return SandboxError.ProfileBuildFailed;

// Database initialisers (postgres bootstrap) allocate SysV/POSIX shared
// memory + SysV semaphores; without these grants initdb dies in
// shmget/semctl. Scoped to `init_data_dir` so no other fenced child
// (DSL system, cache-regen tools, Ruby fallback) gets IPC access.
if (opts.allow_ipc) {
buf.appendSlice(allocator,
w.writeAll(
\\(allow ipc-sysv-shm)
\\(allow ipc-sysv-sem)
\\(allow ipc-posix-shm)
\\
) catch return SandboxError.ProfileBuildFailed;
}

var aw: std.Io.Writer.Allocating = .fromArrayList(allocator, &buf);
const w = &aw.writer;
w.writeAll("(allow file-write*") catch return SandboxError.ProfileBuildFailed;

// The kernel matches subpath filters against resolved vnode paths;
// a symlinked root (macOS /tmp → /private/tmp) needs its resolved
// form granted too or writes under it are silently denied.
var cellar_real_buf: [std.fs.max_path_bytes]u8 = undefined;
var prefix_real_buf: [std.fs.max_path_bytes]u8 = undefined;

writeCellarRule(w, cellar_path) catch return SandboxError.ProfileBuildFailed;
if (resolvedForProfile(cellar_path, &cellar_real_buf)) |real|
writeCellarRule(w, real) catch return SandboxError.ProfileBuildFailed;
Expand All @@ -136,6 +197,13 @@ fn writeCellarRule(w: *std.Io.Writer, root: []const u8) !void {
try w.print("\n (subpath \"{s}\")", .{root});
}

fn writeHomeFontRules(w: *std.Io.Writer, home: []const u8) !void {
const root = std.mem.trimEnd(u8, home, "/");
if (root.len == 0) return;
for ([_][]const u8{ "Library/Fonts", ".fonts", ".local/share/fonts" }) |sub|
try w.print("\n (subpath \"{s}/{s}\")", .{ root, sub });
}

fn writePrefixRules(w: *std.Io.Writer, prefix: []const u8) !void {
// `<prefix>/lib` is the cross-formula symlink farm — granting all of it
// would let one formula's post-install overwrite another's dylib. The
Expand Down Expand Up @@ -264,11 +332,14 @@ pub fn runRubySandboxed(
) SandboxError!u8 {
if (builtin.os.tag != .macos) return SandboxError.SandboxUnsupported;

const profile = try renderRubyProfile(allocator, cellar_path, malt_prefix, .{});
const profile = try renderRubyProfile(allocator, cellar_path, malt_prefix, .{
.home = env.home,
.read_files = &.{script_path},
});
defer allocator.free(profile);

const argv = [_][]const u8{
"/usr/bin/sandbox-exec", "-p", profile, ruby_path, script_path,
"/usr/bin/sandbox-exec", "-p", profile, ruby_path, "--disable-gems", script_path,
};
const argv_z = try buildArgv(allocator, argv[0..]);
defer freeArgv(allocator, argv_z);
Expand Down
78 changes: 78 additions & 0 deletions tests/sandbox_macos_test.zig
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ test "renderRubyProfile deny-by-default, network denied, cellar + prefix subpath
try testing.expect(std.mem.indexOf(u8, profile, "(deny default)") != null);
try testing.expect(std.mem.indexOf(u8, profile, "(deny network*)") != null);
try testing.expect(std.mem.indexOf(u8, profile, "(allow file-read*)") != null);
try testing.expect(std.mem.indexOf(u8, profile, "(deny file-read-data") != null);
try testing.expect(std.mem.indexOf(u8, profile, "(subpath \"/Users\")") != null);
try testing.expect(std.mem.indexOf(u8, profile, "(subpath \"/System/Volumes/Data\")") != null);
try testing.expect(std.mem.indexOf(u8, profile, "(subpath \"/opt/malt/Cellar/foo/1.0\")") != null);
try testing.expect(std.mem.indexOf(u8, profile, "(subpath \"/opt/malt/etc\")") != null);
try testing.expect(std.mem.indexOf(u8, profile, "(subpath \"/opt/malt/var\")") != null);
Expand All @@ -69,6 +72,21 @@ test "renderRubyProfile grants IPC only under allow_ipc" {
try testing.expect(std.mem.indexOf(u8, profile, "(allow ipc-sysv-sem)") != null);
}

test "renderRubyProfile grants standard font directories but not all of HOME" {
const profile = try sandbox.renderRubyProfile(
testing.allocator,
"/opt/malt/Cellar/fontconfig/2.18.3",
"/opt/malt",
.{ .home = "/test-home" },
);
defer testing.allocator.free(profile);
try testing.expect(std.mem.indexOf(u8, profile, "(subpath \"/Library/Fonts\")") != null);
try testing.expect(std.mem.indexOf(u8, profile, "(subpath \"/test-home/Library/Fonts\")") != null);
try testing.expect(std.mem.indexOf(u8, profile, "(subpath \"/test-home/.fonts\")") != null);
try testing.expect(std.mem.indexOf(u8, profile, "(subpath \"/test-home/.local/share/fonts\")") != null);
try testing.expect(std.mem.indexOf(u8, profile, "(subpath \"/test-home\")") == null);
}

test "renderRubyProfile refuses unsafe cellar path" {
try testing.expectError(
error.UnsafePath,
Expand Down Expand Up @@ -100,6 +118,66 @@ test "sandbox_path restricts to system directories only" {
try testing.expectEqualStrings("/usr/bin:/bin:/usr/sbin:/sbin", sandbox.sandbox_path);
}

test "sandbox profile refuses copying a source from outside the prefix" {
if (builtin.os.tag != .macos) return error.SkipZigTest;
try test_io.skipIfNoSubprocess();

const root = try test_io.uniqueTempPath(testing.allocator, "sandbox_macos", "read_source");
defer testing.allocator.free(root);
test_io.deleteTreeAbsolute(std.Options.debug_io, root) catch {};
defer test_io.deleteTreeAbsolute(std.Options.debug_io, root) catch {};

const prefix = try std.fmt.allocPrint(testing.allocator, "{s}/prefix", .{root});
defer testing.allocator.free(prefix);
const keg = try std.fmt.allocPrint(testing.allocator, "{s}/Cellar/foo/1.0", .{prefix});
defer testing.allocator.free(keg);
const share = try std.fmt.allocPrint(testing.allocator, "{s}/share", .{prefix});
defer testing.allocator.free(share);
const home = try std.fmt.allocPrint(testing.allocator, "{s}/home", .{root});
defer testing.allocator.free(home);
const victim = try std.fmt.allocPrint(testing.allocator, "{s}/private", .{home});
defer testing.allocator.free(victim);
const leaked = try std.fmt.allocPrint(testing.allocator, "{s}/leaked", .{share});
defer testing.allocator.free(leaked);
try test_io.cwd().createDirPath(std.Options.debug_io, keg);
try test_io.cwd().createDirPath(std.Options.debug_io, share);
try test_io.cwd().createDirPath(std.Options.debug_io, home);
{
const f = try test_io.createFileAbsolute(std.Options.debug_io, victim, .{});
defer f.close(std.Options.debug_io);
try f.writeStreamingAll(std.Options.debug_io, "PRIVATE");
}

const profile = try sandbox.renderRubyProfile(testing.allocator, keg, prefix, .{ .home = home });
defer testing.allocator.free(profile);
const argv = [_][]const u8{
"/usr/bin/sandbox-exec", "-p", profile, "/bin/cp", victim, leaked,
};
const argv_z = try sandbox.buildArgv(testing.allocator, &argv);
defer sandbox.freeArgv(testing.allocator, argv_z);
const envp = try sandbox.buildEnvp(testing.allocator, .{
.home = prefix,
.path = sandbox.sandbox_path,
.malt_prefix = prefix,
.tmpdir = prefix,
});
defer sandbox.freeEnvp(testing.allocator, envp);

const sink = test_io.testSink();
const exit_code = try sandbox.spawnFilteredWithHooks(
argv_z,
envp,
.{},
.{ .out = sink.handle, .err = sink.handle },
.{},
);
try testing.expectError(
error.FileNotFound,
test_io.accessAbsolute(std.Options.debug_io, leaked, .{}),
);
try testing.expect(exit_code != 0);
}

test "std.posix.rlimit_resource tags resolve on macOS for CPU/FSIZE/AS" {
// Pin: the three rlimit tags applyRlimits relies on must round-trip
// through the kernel on macOS. If a stdlib rename ever loses one of
Expand Down
Loading