From 48c88cf386a92000e7c4a312a029afb8f5a6835e Mon Sep 17 00:00:00 2001 From: Matthew Mongeau Date: Sat, 26 Sep 2026 12:07:19 +0900 Subject: [PATCH] Keep the build context inside the build directory Docker::Util could pull files from outside the build directory into the build context in two ways. create_relative_dir_tar stats and opens each path with File.stat and File.open, both of which follow symlinks, so a link sitting in the build directory was packed with its target's bytes under the link's own name. Separately, docker_context joins each .dockerignore negation pattern onto the build directory with File.join and hands the result to Dir.glob with nothing checking that it stays underneath, so a pattern containing ../ resolved outside. Either way the file landed in the tar sent to the daemon and could be read from inside the build. docker build does not work this way: moby matches .dockerignore against paths it gets from walking the context root, so a pattern cannot name anything outside it, and a COPY that leaves the context is refused. Archive symlinks as symlinks instead of following them, as PR #530 has proposed since 2018, and check that regular files really resolve under the build directory. Both are needed: lstat does not stop a ../ pattern, and validating the pattern string does not stop a symlink or a file reached through a symlinked directory. The check sits in the tar loop rather than in docker_context so that file_hash_from_paths is covered too, which reaches the same code through Container#archive_in and Image#insert_local. Using lstat also clears up the Errno::ENOENT that a dangling symlink previously raised out of build_from_dir. Co-Authored-By: Claude Opus 5 --- lib/docker/util.rb | 23 ++++++++++++++++++-- spec/docker/util_spec.rb | 46 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 2 deletions(-) diff --git a/lib/docker/util.rb b/lib/docker/util.rb index c8549232..04f00f3d 100644 --- a/lib/docker/util.rb +++ b/lib/docker/util.rb @@ -173,14 +173,25 @@ def docker_context(directory) end def create_relative_dir_tar(directory, output) + root = File.realpath(directory) + Gem::Package::TarWriter.new(output) do |tar| files = docker_context(directory) files.each do |prefixed_file_name| - stat = File.stat(prefixed_file_name) + stat = File.lstat(prefixed_file_name) + unprefixed_file_name = prefixed_file_name[directory.length..-1] + + if stat.symlink? + tar.add_symlink( + unprefixed_file_name, File.readlink(prefixed_file_name), stat.mode + ) + next + end next unless stat.file? - unprefixed_file_name = prefixed_file_name[directory.length..-1] + next unless contained?(prefixed_file_name, root) + add_file_to_tar( tar, unprefixed_file_name, stat.mode, stat.size, stat.mtime ) do |tar_file| @@ -190,6 +201,14 @@ def create_relative_dir_tar(directory, output) end end + # True when path resolves, with every symlink and '..' expanded, to + # something underneath root. + def contained?(path, root) + File.realpath(path).start_with?(root + File::SEPARATOR) + rescue SystemCallError + false + end + def add_file_to_tar(tar, name, mode, size, mtime) tar.check_closed diff --git a/spec/docker/util_spec.rb b/spec/docker/util_spec.rb index 5fcbfb6b..b82e802f 100644 --- a/spec/docker/util_spec.rb +++ b/spec/docker/util_spec.rb @@ -226,6 +226,52 @@ def expect_tar_entries(*entries) expect(files_in_tar(tar)).to eq ['.dockerignore', 'zig'] end end + + describe 'build context containment' do + attr_accessor :outside + + def entries_in_tar(tar) + Gem::Package::TarReader.new(tar) do |content| + return content.map { |e| [e.full_name, e.header.typeflag, e.read.to_s] } + end + end + + around do |example| + Dir.mktmpdir do |dir| + self.outside = dir + File.write("#{dir}/secret", 'OUTSIDE') + example.call + end + end + + it 'archives a symlink as a symlink rather than following it' do + File.symlink("#{outside}/secret", "#{tmpdir}/innocent.txt") + + name, typeflag, body = entries_in_tar(tar).first + expect(name).to eq 'innocent.txt' + expect(typeflag).to eq '2' + expect(body).to_not include 'OUTSIDE' + end + + it 'does not raise on a dangling symlink' do + File.symlink("#{tmpdir}/no_such_target", "#{tmpdir}/dangling") + + expect { files_in_tar(tar) }.to_not raise_error + end + + it 'does not pack a file a negation pattern reaches outside the build directory' do + File.write("#{tmpdir}/.dockerignore", "!#{'../' * 12}#{outside[1..]}/secret") + + expect(files_in_tar(tar)).to eq ['.dockerignore'] + end + + it 'does not pack a file reached through a symlinked directory' do + File.symlink(outside, "#{tmpdir}/a_link") + File.write("#{tmpdir}/.dockerignore", '!a_link/secret') + + expect(files_in_tar(tar)).to eq ['.dockerignore', 'a_link'] + end + end end describe '.build_auth_header' do