Skip to content
Merged
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
147 changes: 140 additions & 7 deletions src/core/cask.zig
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const sqlite = @import("../db/sqlite.zig");
const client_mod = @import("../net/client.zig");
const archive_mod = @import("../fs/archive.zig");
const path_component = @import("../fs/path_component.zig");
const confined_source = @import("../fs/confined_source.zig");
const hash_mod = @import("hash.zig");
const child_mod = @import("child.zig");
const cask_font = @import("cask_font.zig");
Expand Down Expand Up @@ -1263,15 +1264,21 @@ pub const CaskInstaller = struct {
src_name: []const u8,
link_name: []const u8,
) ![]const u8 {
const abs_bin = try self.resolveCaskBinaryPath(caskroom_ver, src_name);
defer self.allocator.free(abs_bin);
const candidate = try self.resolveCaskBinaryPath(caskroom_ver, src_name);
defer self.allocator.free(candidate);

var source = confined_source.openFile(
self.io,
self.allocator,
caskroom_ver,
candidate,
.read_write,
) catch return error.InstallFailed;
defer source.deinit(self.io);

// Archives sometimes land without the x-bit when built on CI.
const exec_file = std.Io.Dir.openFileAbsolute(self.io, abs_bin, .{ .mode = .read_write }) catch
return error.InstallFailed;
// chmod may fail on FUSE/NFS mounts; symlink still works if bit was set.
exec_file.setPermissions(self.io, std.Io.File.Permissions.fromMode(0o755)) catch {};
exec_file.close(self.io);
source.file.setPermissions(self.io, std.Io.File.Permissions.fromMode(0o755)) catch {};

// The link name is one entry in `<prefix>/bin`; a `target` carrying a
// separator would delete and re-create somewhere else entirely.
Expand All @@ -1287,7 +1294,7 @@ pub const CaskInstaller = struct {

// stale link may not exist (fresh install); symLink below is authoritative.
std.Io.Dir.cwd().deleteFile(self.io, link_path) catch {};
std.Io.Dir.symLinkAbsolute(self.io, abs_bin, link_path, .{}) catch return error.InstallFailed;
std.Io.Dir.symLinkAbsolute(self.io, source.path, link_path, .{}) catch return error.InstallFailed;
return link_path;
}

Expand Down Expand Up @@ -1841,6 +1848,132 @@ test "parseCask accepts legitimate token and version" {
}
}

test "linkCaskBinary refuses a source symlink outside Caskroom" {
const io = std.Options.debug_io;
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const a = arena.allocator();
const base = try std.fmt.allocPrintSentinel(a, "/tmp/malt_cask_binary_source_{d}", .{std.c.getpid()}, 0);
std.Io.Dir.cwd().deleteTree(io, base) catch {};
defer std.Io.Dir.cwd().deleteTree(io, base) catch {};

const prefix = try std.fmt.allocPrintSentinel(a, "{s}/prefix", .{base}, 0);
const root = try std.fmt.allocPrint(a, "{s}/Caskroom/tool/1.0", .{prefix});
const victim = try std.fmt.allocPrint(a, "{s}/private", .{base});
const bin_dir = try std.fmt.allocPrint(a, "{s}/bin", .{root});
const link = try std.fmt.allocPrint(a, "{s}/tool", .{bin_dir});
try std.Io.Dir.cwd().createDirPath(io, bin_dir);
{
const f = try std.Io.Dir.createFileAbsolute(io, victim, .{});
defer f.close(io);
try f.writeStreamingAll(io, "PRIVATE");
}
try std.Io.Dir.symLinkAbsolute(io, victim, link, .{});

var installer: CaskInstaller = .{
.allocator = a,
.io = io,
.environ = .empty,
.prefix = prefix,
.db = undefined,
.progress = null,
};
try std.testing.expectError(
error.InstallFailed,
installer.linkCaskBinary(root, "bin/tool", "tool"),
);
}

test "linkCaskBinary refuses a prefix path outside its Caskroom version" {
const io = std.Options.debug_io;
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const a = arena.allocator();
const base = try std.fmt.allocPrintSentinel(a, "/tmp/malt_cask_binary_prefix_{d}", .{std.c.getpid()}, 0);
std.Io.Dir.cwd().deleteTree(io, base) catch {};
defer std.Io.Dir.cwd().deleteTree(io, base) catch {};

const prefix = try std.fmt.allocPrintSentinel(a, "{s}/prefix", .{base}, 0);
const root = try std.fmt.allocPrint(a, "{s}/Caskroom/tool/1.0", .{prefix});
const victim = try std.fmt.allocPrint(a, "{s}/etc/private", .{prefix});
try std.Io.Dir.cwd().createDirPath(io, root);
if (std.fs.path.dirname(victim)) |parent| try std.Io.Dir.cwd().createDirPath(io, parent);
{
const f = try std.Io.Dir.createFileAbsolute(io, victim, .{});
defer f.close(io);
try f.writeStreamingAll(io, "PRIVATE");
}

var installer: CaskInstaller = .{
.allocator = a,
.io = io,
.environ = .empty,
.prefix = prefix,
.db = undefined,
.progress = null,
};
try std.testing.expectError(
error.InstallFailed,
installer.linkCaskBinary(root, "$HOMEBREW_PREFIX/etc/private", "tool"),
);
}

test "linkCaskBinary links regular relative and in-prefix Caskroom sources" {
const io = std.Options.debug_io;
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const a = arena.allocator();
const base = try std.fmt.allocPrintSentinel(a, "/tmp/malt_cask_binary_regular_{d}", .{std.c.getpid()}, 0);
std.Io.Dir.cwd().deleteTree(io, base) catch {};
defer std.Io.Dir.cwd().deleteTree(io, base) catch {};

const prefix = try std.fmt.allocPrintSentinel(a, "{s}/prefix", .{base}, 0);
const root = try std.fmt.allocPrint(a, "{s}/Caskroom/tool/1.0", .{prefix});
const bin_dir = try std.fmt.allocPrint(a, "{s}/bin", .{root});
try std.Io.Dir.cwd().createDirPath(io, bin_dir);

var installer: CaskInstaller = .{
.allocator = a,
.io = io,
.environ = .empty,
.prefix = prefix,
.db = undefined,
.progress = null,
};
const cases = [_]struct {
src_name: []const u8,
source_leaf: []const u8,
link_name: []const u8,
}{
.{ .src_name = "bin/relative-tool", .source_leaf = "relative-tool", .link_name = "relative-tool" },
.{
.src_name = "$HOMEBREW_PREFIX/Caskroom/tool/1.0/bin/prefix-tool",
.source_leaf = "prefix-tool",
.link_name = "prefix-tool",
},
};

for (cases) |case| {
const source = try std.fmt.allocPrint(a, "{s}/{s}", .{ bin_dir, case.source_leaf });
{
const f = try std.Io.Dir.createFileAbsolute(io, source, .{});
defer f.close(io);
try f.writeStreamingAll(io, "binary");
}

const linked = try installer.linkCaskBinary(root, case.src_name, case.link_name);
const expected_link = try std.fmt.allocPrint(a, "{s}/bin/{s}", .{ prefix, case.link_name });
try std.testing.expectEqualStrings(expected_link, linked);
var target_buf: [std.fs.max_path_bytes]u8 = undefined;
const target_len = try std.Io.Dir.readLinkAbsolute(io, linked, &target_buf);
var source_real_buf: [std.fs.max_path_bytes]u8 = undefined;
const source_real_len = try std.Io.Dir.cwd().realPathFile(io, source, &source_real_buf);
try std.testing.expectEqualStrings(source_real_buf[0..source_real_len], target_buf[0..target_len]);
const stat = try std.Io.Dir.cwd().statFile(io, source, .{});
try std.testing.expectEqual(@as(std.posix.mode_t, 0o755), stat.permissions.toMode() & 0o777);
}
}

test "parseCask does not length-cap a clean version" {
const a = std.testing.allocator;
// Versions have no length convention, so the guard must stay length-
Expand Down
32 changes: 30 additions & 2 deletions src/core/cask_font.zig
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@
//! Self-contained font-artifact parsing, destination policy, name
//! sanitization, file placement, and manifest I/O. `cask.zig` only
//! dispatches here; this module imports `std` alone so the security-
//! sensitive path handling lives in one auditable, std-only place.
//! sensitive path handling lives in one auditable leaf.

const std = @import("std");
const confined_source = @import("../fs/confined_source.zig");

/// One placed font: the archive-relative `source` to copy and the cask's
/// optional `target` whose *basename* is honoured as a rename hint. The
Expand Down Expand Up @@ -144,7 +145,9 @@ pub fn placeFonts(
const dest = try std.fmt.allocPrint(alloc, "{s}/{s}", .{ fonts_dir, name });
defer alloc.free(dest);

try std.Io.Dir.copyFileAbsolute(src, dest, io, .{});
var source = try confined_source.openFile(io, alloc, extract_root, src, .read_only);
defer source.deinit(io);
try source.copyToAbsolute(io, dest);

if (manifest.items.len != 0) try manifest.append(alloc, '\n');
try manifest.appendSlice(alloc, dest);
Expand Down Expand Up @@ -269,6 +272,31 @@ test "placeFonts copies nested and bare sources and manifests their dest paths"
try expectFile(io, s.p("/fonts/HackNerdFont-Regular.ttf"), "HACK");
}

test "placeFonts refuses a source symlink outside the extraction root" {
const io = std.Options.debug_io;
var s = try Scratch.init("cask_font_source_symlink");
defer s.deinit();

const root = s.p("/extract");
const fonts = s.p("/fonts");
const victim = s.p("/private-font");
const link = s.p("/extract/leak.ttf");
try putFile(io, victim, "PRIVATE FONT DATA");
try std.Io.Dir.cwd().createDirPath(io, root);
try std.Io.Dir.symLinkAbsolute(io, victim, link, .{});

const result = placeFonts(io, testing.allocator, root, fonts, &.{.{
.source = "leak.ttf",
.target = null,
}});
if (result) |manifest| testing.allocator.free(manifest) else |_| {}

try testing.expectError(
error.FileNotFound,
std.Io.Dir.accessAbsolute(io, s.p("/fonts/leak.ttf"), .{}),
);
}

test "placeFonts skips unsafe entries and omits them from the manifest" {
const io = std.Options.debug_io;
var s = try Scratch.init("cask_font_place_unsafe");
Expand Down
149 changes: 149 additions & 0 deletions src/fs/confined_source.zig
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
//! Open an existing file only after proving its resolved path stays below a
//! trusted source root. The component-by-component reopen keeps the check and
//! use tied to directory handles and refuses symlinks introduced after the
//! canonical-path check.

const std = @import("std");

pub const OpenedFile = struct {
allocator: std.mem.Allocator,
path: [:0]u8,
file: std.Io.File,

pub fn deinit(self: *OpenedFile, io: std.Io) void {
self.file.close(io);
self.allocator.free(self.path);
}

/// Atomically replace `dest_path` with a copy of the already-confined file.
pub fn copyToAbsolute(self: *OpenedFile, io: std.Io, dest_path: []const u8) !void {
const stat = try self.file.stat(io);
if (stat.kind != .file) return error.NotFile;

var reader: std.Io.File.Reader = .init(self.file, io, &.{});
reader.size = stat.size;

var atomic_file = try std.Io.Dir.cwd().createFileAtomic(io, dest_path, .{
.permissions = stat.permissions,
.replace = true,
});
defer atomic_file.deinit(io);

var buffer: [1024]u8 = undefined;
var writer = atomic_file.file.writer(io, &buffer);
_ = writer.interface.sendFileAll(&reader, .unlimited) catch |err| switch (err) {
error.ReadFailed => return reader.err.?,
error.WriteFailed => return writer.err.?,
};
try writer.flush();
try atomic_file.replace(io);
}
};

/// Resolve `candidate`, require the result to be below `root`, then reopen the
/// canonical path without following any component symlinks. The returned path
/// and file refer to the checked in-root object.
pub fn openFile(
io: std.Io,
allocator: std.mem.Allocator,
root: []const u8,
candidate: []const u8,
mode: std.Io.Dir.OpenFileOptions.Mode,
) !OpenedFile {
const root_real = try std.Io.Dir.realPathFileAbsoluteAlloc(io, root, allocator);
defer allocator.free(root_real);
const source_real = try std.Io.Dir.realPathFileAbsoluteAlloc(io, candidate, allocator);
errdefer allocator.free(source_real);

if (!pathHasPrefix(source_real, root_real) or source_real.len == root_real.len) {
return error.AccessDenied;
}
const relative = source_real[root_real.len + 1 ..];

var dir = try openCanonicalDirNoFollow(io, root_real);
defer dir.close(io);

if (std.fs.path.dirname(relative)) |parent| {
var components = std.mem.tokenizeScalar(u8, parent, '/');
while (components.next()) |component| {
const next = try dir.openDir(io, component, .{ .follow_symlinks = false });
dir.close(io);
dir = next;
}
}

const file = try dir.openFile(io, std.fs.path.basename(relative), .{
.mode = mode,
.allow_directory = false,
.follow_symlinks = false,
.resolve_beneath = true,
});
errdefer file.close(io);
if ((try file.stat(io)).kind != .file) return error.NotFile;

return .{
.allocator = allocator,
.path = source_real,
.file = file,
};
}

/// Open every component of an already-canonical absolute directory path with
/// symlink following disabled. Opening only the final component would leave an
/// intermediate-directory swap between `realPath` and `open` exploitable.
fn openCanonicalDirNoFollow(io: std.Io, path: []const u8) !std.Io.Dir {
if (!std.fs.path.isAbsolute(path)) return error.BadPathName;

var dir = try std.Io.Dir.openDirAbsolute(io, "/", .{ .follow_symlinks = false });
errdefer dir.close(io);
var components = std.mem.tokenizeScalar(u8, path, '/');
while (components.next()) |component| {
const next = try dir.openDir(io, component, .{ .follow_symlinks = false });
dir.close(io);
dir = next;
}
return dir;
}

fn pathHasPrefix(path: []const u8, prefix: []const u8) bool {
if (prefix.len == 0 or !std.mem.startsWith(u8, path, prefix)) return false;
return path.len == prefix.len or prefix[prefix.len - 1] == '/' or path[prefix.len] == '/';
}

test "openFile allows an internal symlink but rejects an external one" {
const io = std.Options.debug_io;
const a = std.testing.allocator;
const base = try std.fmt.allocPrintSentinel(a, "/tmp/malt_confined_source_{d}", .{std.c.getpid()}, 0);
defer a.free(base);
std.Io.Dir.cwd().deleteTree(io, base) catch {};
defer std.Io.Dir.cwd().deleteTree(io, base) catch {};

const root = try std.fmt.allocPrint(a, "{s}/root", .{base});
defer a.free(root);
const real = try std.fmt.allocPrint(a, "{s}/real", .{root});
defer a.free(real);
const inside = try std.fmt.allocPrint(a, "{s}/inside", .{root});
defer a.free(inside);
const outside = try std.fmt.allocPrint(a, "{s}/outside", .{base});
defer a.free(outside);
const escaped = try std.fmt.allocPrint(a, "{s}/escaped", .{root});
defer a.free(escaped);

try std.Io.Dir.cwd().createDirPath(io, root);
{
const f = try std.Io.Dir.createFileAbsolute(io, real, .{});
defer f.close(io);
try f.writeStreamingAll(io, "inside");
}
{
const f = try std.Io.Dir.createFileAbsolute(io, outside, .{});
defer f.close(io);
try f.writeStreamingAll(io, "outside");
}
try std.Io.Dir.symLinkAbsolute(io, real, inside, .{});
try std.Io.Dir.symLinkAbsolute(io, outside, escaped, .{});

var opened = try openFile(io, a, root, inside, .read_only);
opened.deinit(io);
try std.testing.expectError(error.AccessDenied, openFile(io, a, root, escaped, .read_only));
}
Loading