terminal/kitty: restrict temporary image file paths

Require temporary image file paths to match complete directory
components when checking /tmp, /dev/shm, the configured temporary
directory, and its resolved path.

The previous byte-prefix checks accepted similarly named sibling
directories such as /tmpX. A temporary-file transmission could read
and unlink a file outside the permitted temporary directories.

Add a component-boundary helper and regression coverage for built-in
and configured directory prefixes. An integration test also verifies
that a rejected file remains on disk.
This commit is contained in:
Mitchell Hashimoto
2026-08-05 07:28:54 -07:00
parent d866fa4553
commit 803ab9f814

View File

@@ -18,6 +18,27 @@ const max_dimension = 10000;
/// Maximum size in bytes, taken from Kitty.
const max_size = 400 * 1024 * 1024; // 400MB
/// Returns true if `path` is `dir` or is contained within it, requiring a
/// path-separator boundary so similarly prefixed directories do not match.
fn isPathInDir(dir: []const u8, path: []const u8) bool {
if (dir.len == 0 or !std.mem.startsWith(u8, path, dir)) return false;
if (path.len == dir.len or std.fs.path.isSep(dir[dir.len - 1])) return true;
return std.fs.path.isSep(path[dir.len]);
}
test "temporary file path must be inside directory" {
const testing = std.testing;
try testing.expect(isPathInDir("/tmp", "/tmp/tty-graphics-protocol-image.data"));
try testing.expect(isPathInDir("/tmp/", "/tmp/tty-graphics-protocol-image.data"));
try testing.expect(isPathInDir("/tmp", "/tmp"));
try testing.expect(!isPathInDir("", "/tmp/tty-graphics-protocol-image.data"));
try testing.expect(!isPathInDir("/tmp", "/tmpX/tty-graphics-protocol-image.data"));
try testing.expect(!isPathInDir("/dev/shm", "/dev/shm-evil/tty-graphics-protocol-image.data"));
try testing.expect(!isPathInDir("/custom/tmp", "/custom/tmp-suffix/tty-graphics-protocol-image.data"));
}
/// An image that is still being loaded. The image should be initialized
/// using init on the first chunk and then addData for each subsequent
/// chunk. Once all chunks have been added, complete should be called
@@ -357,9 +378,9 @@ pub const LoadingImage = struct {
/// Returns true if path appears to be in a temporary directory.
/// Copies logic from Kitty.
fn isPathInTempDir(io: std.Io, dir: []const u8, path: []const u8) bool {
if (std.mem.startsWith(u8, path, "/tmp")) return true;
if (std.mem.startsWith(u8, path, "/dev/shm")) return true;
if (std.mem.startsWith(u8, path, dir)) return true;
if (isPathInDir("/tmp", path)) return true;
if (isPathInDir("/dev/shm", path)) return true;
if (isPathInDir(dir, path)) return true;
// The temporary dir is sometimes a symlink. On macOS for
// example /tmp is /private/var/...
@@ -369,7 +390,7 @@ pub const LoadingImage = struct {
dir,
&buf,
) catch return false];
if (std.mem.startsWith(u8, path, real_dir)) return true;
if (isPathInDir(real_dir, path)) return true;
return false;
}
@@ -861,6 +882,54 @@ test "image load: temporary file without correct path" {
try tmp_dir.dir.access(testing.io, path, .{});
}
test "image load: temporary file outside directory prefix is rejected" {
const testing = std.testing;
const alloc = testing.allocator;
const io = testing.io;
var tmp_dir = testing.tmpDir(.{});
defer tmp_dir.cleanup();
try tmp_dir.dir.createDir(io, "temp", .default_dir);
try tmp_dir.dir.createDir(io, "temp-suffix", .default_dir);
var trusted_dir = try tmp_dir.dir.openDir(io, "temp", .{});
defer trusted_dir.close(io);
var outside_dir = try tmp_dir.dir.openDir(io, "temp-suffix", .{});
defer outside_dir.close(io);
const filename = "tty-graphics-protocol-image.data";
const data = @embedFile("testdata/image-rgb-none-20x15-2147483647-raw.data");
try outside_dir.writeFile(io, .{
.sub_path = filename,
.data = data,
});
var trusted_path_buf: [std.fs.max_path_bytes]u8 = undefined;
const trusted_path = trusted_path_buf[0..try trusted_dir.realPath(io, &trusted_path_buf)];
var outside_path_buf: [std.fs.max_path_bytes]u8 = undefined;
const outside_path = outside_path_buf[0..try outside_dir.realPathFile(io, filename, &outside_path_buf)];
var cmd: command.Command = .{
.control = .{ .transmit = .{
.format = .rgb,
.medium = .temporary_file,
.compression = .none,
.width = 20,
.height = 15,
.image_id = 31,
} },
.data = try alloc.dupe(u8, outside_path),
};
defer cmd.deinit(alloc);
try testing.expectError(
error.TemporaryFileNotInTempDir,
LoadingImage.init(io, alloc, &cmd, .allWithTempDir(trusted_path)),
);
// Rejection must happen before temporary-file cleanup is armed.
try outside_dir.access(io, filename, .{});
}
test "image load: rgb, not compressed, temporary file" {
const testing = std.testing;
const alloc = testing.allocator;