- Type: unsafe trust-boundary violation
- Severity: high
- Confidence: certain
src/Package/Fetch.zig:526src/Package/Fetch.zig:963src/main.zig:5343lib/std/Build/Cache/Path.zig:40
Recursive package fetching accepts dependencies.<name>.path values from a fetched package's manifest and resolves them with .. segments, but the containment check is tied to the wrong root directory and therefore does not stop the resolved path from escaping the parent package directory. A fetched package can make Zig read arbitrary files and directories reachable from the build root instead of remaining confined to its own package contents.
- A build uses recursive package fetching (
src/main.zig:5343setsrecursive = trueforzig build). - A fetched package contains a
build.zig.zonmanifest with a path dependency (src/Package/Fetch.zig:891,src/Package/Fetch.zig:960). - The attacker controls that fetched package's manifest contents.
- Input/source/state origin
queueJobsForDepsiterates over manifest dependencies from the fetched package and handles.pathentries by taking the manifest-controlledrel_pathstring (src/Package/Fetch.zig:891,src/Package/Fetch.zig:960). - Control-flow and data-flow path
The path is resolved with
f.package_root.resolvePosix(parent_arena, rel_path)(src/Package/Fetch.zig:963).resolvePosixpreserves leading..components when they escape the base path, yielding paths outside the package root while keeping the same root directory handle (lib/std/Build/Cache/Path.zig:40,lib/std/fs/path.zig:1107). The resultingCache.Pathis stored as a new fetch job's.relative_pathlocation (src/Package/Fetch.zig:975). - Failing condition or violated invariant
When that fetch job runs, the only escape check is inside
if (pkg_root.root_dir.eql(local_cache_root.root_dir))(src/Package/Fetch.zig:537). During recursive builds,local_cache_rootis.zig-cache(src/main.zig:5343), while fetched packages and their path dependencies live underroot_pkg_path = build_root.directory + "zig-pkg"(src/main.zig:5344). Because these root directory handles differ, the guard is false and no containment check runs. The code then acceptspkg_rootand continues withloadManifest/checkBuildFileExistence/queueJobsForDeps(src/Package/Fetch.zig:563-567). - Resulting impact
Subsequent operations open files relative to the escaped
pkg_root, for exampleloadManifestreadsbuild.zig.zonfrom that path andinitResourceopensfile:URLs relative tof.parent_package_rootwith no additional containment enforcement (src/Package/Fetch.zig:563,src/Package/Fetch.zig:1187). Therefore a fetched package can cause Zig to consume manifests and package files from arbitrary sibling or ancestor paths reachable from the build root instead of only from its own unpacked contents. - Why this is reachable in the current code
The recursive fetch path is the normal
zig buildpackage-resolution path (src/main.zig:5328-5412). The resolver explicitly allows.pathdependencies from manifests and expects invalid paths to be checked only at the start ofrun()(src/Package/Fetch.zig:961-963). In the current configuration, that check does not run for the actual package root used by fetched dependencies because it compares against.zig-cache, notzig-pkg.
This is a concrete containment failure in reachable production code. The manifest-controlled path is resolved, accepted, and dereferenced by the current implementation. No environment-specific assumption is needed beyond invoking normal recursive dependency resolution. The bug is not stylistic: the code intends to reject dependencies "outside project" (src/Package/Fetch.zig:559) but fails to do so for the actual fetched-package storage root used in recursive builds.
A code change is required because the current guard checks the wrong directory identity and does not enforce that a non-root path dependency stays within the parent package root. Without a fix, .. traversal in fetched manifests remains effective.
The patch replaces the root-directory-specific check with a direct descendant check against f.parent_package_root for recursive path dependencies. It accepts the parent directory itself and any separator-delimited child path, while rejecting sibling-prefix tricks such as hash_evil when the parent is hash. This is the minimal local fix because it changes only the containment decision and preserves all other fetch behavior.
Reference: patch_001.diff
Found by Swival security scanner.
--
diff --git a/src/Package/Fetch.zig b/src/Package/Fetch.zig --- a/src/Package/Fetch.zig +++ b/src/Package/Fetch.zig @@ -532,29 +532,31 @@ pub fn run(f: *Fetch) RunError!void { hash_tok, try eb.addString("path-based dependencies are not hashed"), );
-
// Packages fetched by URL may not use relative paths to escape outside the -
// fetched package directory from within the package cache. -
if (pkg_root.root_dir.eql(local_cache_root.root_dir)) { -
// `parent_package_root.sub_path` contains a path like this: -
// "p/$hash", or -
// "p/$hash/foo", with possibly more directories after "foo". -
// We want to fail unless the resolved relative path has a -
// prefix of "p/$hash/". -
const prefix_len: usize = if (job_queue.read_only) 0 else "p/".len; -
const parent_sub_path = f.parent_package_root.sub_path; -
const end = find_end: { -
if (parent_sub_path.len > prefix_len) { -
// Use `isSep` instead of `indexOfScalarPos` to account for -
// Windows accepting both `\\` and `/` as path separators. -
for (parent_sub_path[prefix_len..], prefix_len..) |c, i| { -
if (std.fs.path.isSep(c)) break :find_end i; -
}
-
// Packages fetched by URL may not use relative paths to escape outside the -
// fetched package directory from within the package cache. -
if (f.parent_manifest_ast != null and pkg_root.root_dir.eql(f.parent_package_root.root_dir)) { -
const parent_sub_path = f.parent_package_root.sub_path; -
const within_parent = std.mem.eql(u8, pkg_root.sub_path, parent_sub_path) or blk: { -
if (!std.mem.startsWith(u8, pkg_root.sub_path, parent_sub_path)) break :blk false; -
if (parent_sub_path.len == 0) break :blk true; -
if (pkg_root.sub_path.len == parent_sub_path.len) break :blk true; -
break :blk std.fs.path.isSep(pkg_root.sub_path[parent_sub_path.len]); -
}; -
if (!within_parent) { -
return f.fail( -
f.location_tok, -
try eb.printString("dependency path outside project: '{f}'", .{pkg_root}), -
); -
} -
} else if (pkg_root.root_dir.eql(local_cache_root.root_dir)) { -
// `parent_package_root.sub_path` contains a path like this: -
// "p/$hash", or -
// "p/$hash/foo", with possibly more directories after "foo". -
// We want to fail unless the resolved relative path has a -
// prefix of "p/$hash/". -
const prefix_len: usize = if (job_queue.read_only) 0 else "p/".len; -
const parent_sub_path = f.parent_package_root.sub_path; -
const end = find_end: { -
if (parent_sub_path.len > prefix_len) { -
// Use `isSep` instead of `indexOfScalarPos` to account for -
// Windows accepting both `\\` and `/` as path separators. -
for (parent_sub_path[prefix_len..], prefix_len..) |c, i| { -
if (std.fs.path.isSep(c)) break :find_end i; -
} }
-
break :find_end parent_sub_path.len; -
}; -
const expected_prefix = parent_sub_path[0..end]; -
if (!std.mem.startsWith(u8, pkg_root.sub_path, expected_prefix)) { -
return f.fail( -
f.location_tok, -
try eb.printString("dependency path outside project: '{f}'", .{pkg_root}), -
);
-
break :find_end parent_sub_path.len; -
}; -
const expected_prefix = parent_sub_path[0..end]; -
if (!std.mem.startsWith(u8, pkg_root.sub_path, expected_prefix)) { -
return f.fail( -
f.location_tok, -
try eb.printString("dependency path outside project: '{f}'", .{pkg_root}), -
); } } f.package_root = pkg_root;