Skip to content

Commit 7e152c2

Browse files
committed
Harden sandboxed install phases
- Deny real home reads unless Homebrew paths live there - Run cask completion generation with a fresh fake `HOME` - Keep cask completion generation offline before running it
1 parent 10a163a commit 7e152c2

11 files changed

Lines changed: 181 additions & 35 deletions

File tree

Library/Homebrew/cask/artifact/abstract_artifact.rb

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
require "env_config"
66
require "sandbox"
77
require "tempfile"
8+
require "tmpdir"
89
require "utils/output"
910

1011
module Cask
@@ -199,6 +200,7 @@ def cask_sandbox
199200
Sandbox.new.tap do |sandbox|
200201
sandbox.allow_read(path: cask.staged_path, type: :subpath)
201202
sandbox.allow_write_temp_and_cache
203+
sandbox.deny_read_home
202204
sandbox.deny_all_network
203205
end
204206
end
@@ -207,9 +209,11 @@ def cask_sandbox
207209
params(
208210
env: T::Hash[String, T.any(String, T::Boolean, PATH)],
209211
args: T::Array[T.any(String, Pathname)],
212+
home: String,
210213
).returns(T::Array[T.any(String, Pathname)])
211214
}
212-
def cask_sandbox_command(env, args)
215+
def cask_sandbox_command(env, args, home:)
216+
env = { "HOME" => home }.merge(env)
213217
["/usr/bin/env", *env.map { |key, value| "#{key}=#{value}" }, *args]
214218
end
215219

Library/Homebrew/cask/artifact/generated_completion.rb

Lines changed: 16 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -136,19 +136,22 @@ def generate_completion_output(completion_commands, shell_parameter, env)
136136
end
137137

138138
Tempfile.create("homebrew-cask-completions", HOMEBREW_TEMP) do |output|
139-
sandbox.run(
140-
*cask_sandbox_command(
141-
env,
142-
[
143-
"/bin/sh",
144-
"-c",
145-
"output=$1; shift; exec \"$@\" > \"$output\"#{" 2>/dev/null" unless ENV["HOMEBREW_STDERR"]}",
146-
"sh",
147-
output.path,
148-
*(completion_commands + Array(shell_parameter)),
149-
],
150-
),
151-
)
139+
Dir.mktmpdir("homebrew-cask-home") do |home|
140+
sandbox.run(
141+
*cask_sandbox_command(
142+
env,
143+
[
144+
"/bin/sh",
145+
"-c",
146+
"output=$1; shift; exec \"$@\" > \"$output\"#{" 2>/dev/null" unless ENV["HOMEBREW_STDERR"]}",
147+
"sh",
148+
output.path,
149+
*(completion_commands + Array(shell_parameter)),
150+
],
151+
home:,
152+
),
153+
)
154+
end
152155
output.read
153156
end
154157
end

Library/Homebrew/dev-cmd/test.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ def run
9191
sandbox.allow_write_log(f)
9292
sandbox.allow_write_xcode
9393
sandbox.allow_write_path(HOMEBREW_PREFIX/"var/homebrew/locks")
94+
sandbox.deny_read_home
9495
optional_prefix_var_dirs.each do |dir|
9596
sandbox.allow_write_path_if_exists HOMEBREW_PREFIX/dir
9697
end

Library/Homebrew/extend/os/linux/sandbox.rb

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,7 @@ def bubblewrap_sandbox_available?(bubblewrap)
281281
sig { params(args: T.any(String, ::Pathname)).void }
282282
def run(*args)
283283
@prepared_writable_paths = T.let([], T.nilable(T::Array[::Pathname]))
284+
@masked_read_paths = T.let([], T.nilable(T::Array[::Pathname]))
284285
old_report_on_exception = T.let(Thread.report_on_exception, T.nilable(T::Boolean))
285286
Thread.report_on_exception = false
286287
super
@@ -292,6 +293,8 @@ def run(*args)
292293
nil
293294
end
294295
@prepared_writable_paths = nil
296+
@masked_read_paths&.reverse_each { |path| FileUtils.rm_rf(path) }
297+
@masked_read_paths = nil
295298
end
296299

297300
private
@@ -328,6 +331,16 @@ def bubblewrap_args(tmpdir)
328331
args += ["--ro-bind", path, path]
329332
end
330333

334+
denied_read_paths.each do |path|
335+
next unless File.exist?(path)
336+
337+
args += if File.directory?(path)
338+
["--bind", masked_read_path, path]
339+
else
340+
["--ro-bind", File::NULL, path]
341+
end
342+
end
343+
331344
args += ["--bind", tmpdir, tmpdir, "--chdir", tmpdir]
332345

333346
args
@@ -367,6 +380,23 @@ def denied_write_paths
367380
end.uniq
368381
end
369382

383+
sig { returns(T::Array[String]) }
384+
def denied_read_paths
385+
profile.rules.filter_map do |rule|
386+
next if rule.allow || !rule.operation.start_with?("file-read")
387+
388+
filter = rule.filter
389+
filter.path if filter && [:literal, :subpath].include?(filter.type)
390+
end.uniq
391+
end
392+
393+
sig { returns(String) }
394+
def masked_read_path
395+
path = ::Pathname.new(Dir.mktmpdir("homebrew-sandbox-deny-read", HOMEBREW_TEMP))
396+
@masked_read_paths&.<< path
397+
path.to_s
398+
end
399+
370400
sig { params(path: String, type: Symbol).void }
371401
def prepare_writable_path(path, type)
372402
pathname = ::Pathname.new(path)

Library/Homebrew/formula.rb

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1659,12 +1659,18 @@ def run_post_install
16591659
PATH: PATH.new(ORIGINAL_PATHS),
16601660
}
16611661

1662-
with_env(new_env) do
1663-
ENV.clear_sensitive_environment!
1664-
ENV.activate_extensions!
1662+
Dir.mktmpdir("#{name}-postinstall-") do |home|
1663+
postinstall_home = Pathname(home)
1664+
new_env[:HOME] = postinstall_home.to_s
1665+
setup_home postinstall_home
16651666

1666-
with_logging("post_install") do
1667-
post_install
1667+
with_env(new_env) do
1668+
ENV.clear_sensitive_environment!
1669+
ENV.activate_extensions!
1670+
1671+
with_logging("post_install") do
1672+
post_install
1673+
end
16681674
end
16691675
end
16701676
ensure
@@ -3245,8 +3251,7 @@ def run_test(keep_tmp: false)
32453251
PATH: PATH.new(ENV.fetch("PATH"), HOMEBREW_PREFIX/"bin"),
32463252
HOMEBREW_TERM: ENV.fetch("TERM", nil),
32473253
HOMEBREW_PATH: nil,
3248-
}.merge(common_stage_test_env)
3249-
test_env[:_JAVA_OPTIONS] += " -Djava.io.tmpdir=#{HOMEBREW_TEMP}"
3254+
}
32503255

32513256
ENV.clear_sensitive_environment!
32523257
Utils::Git.set_name_email!
@@ -3255,6 +3260,8 @@ def run_test(keep_tmp: false)
32553260
staging.retain! if keep_tmp
32563261
@testpath = T.let(staging.tmpdir, T.nilable(Pathname))
32573262
test_env[:HOME] = @testpath
3263+
test_env.merge!(common_stage_test_env(T.must(@testpath)))
3264+
test_env[:_JAVA_OPTIONS] += " -Djava.io.tmpdir=#{HOMEBREW_TEMP}"
32583265
setup_home T.must(@testpath)
32593266
begin
32603267
with_logging("test") do
@@ -3706,15 +3713,15 @@ def exec_cmd(cmd, args, out, log_filename)
37063713
end
37073714

37083715
# Common environment variables used at both build and test time.
3709-
sig { returns(T::Hash[Symbol, String]) }
3710-
def common_stage_test_env
3716+
sig { params(home: Pathname).returns(T::Hash[Symbol, String]) }
3717+
def common_stage_test_env(home)
37113718
{
37123719
_JAVA_OPTIONS: "-Duser.home=#{HOMEBREW_CACHE}/java_cache",
37133720
GOCACHE: "#{HOMEBREW_CACHE}/go_cache",
37143721
GOPATH: "#{HOMEBREW_CACHE}/go_mod_cache",
37153722
CARGO_HOME: "#{HOMEBREW_CACHE}/cargo_cache",
37163723
PIP_CACHE_DIR: "#{HOMEBREW_CACHE}/pip_cache",
3717-
CURL_HOME: ENV.fetch("CURL_HOME") { Dir.home },
3724+
CURL_HOME: ENV.fetch("CURL_HOME") { home.to_s },
37183725
PYTHONDONTWRITEBYTECODE: "1",
37193726
}
37203727
end
@@ -3733,7 +3740,7 @@ def stage(interactive: false, debug_symbols: false, &_block)
37333740

37343741
unless interactive
37353742
stage_env[:HOME] = env_home
3736-
stage_env.merge!(common_stage_test_env)
3743+
stage_env.merge!(common_stage_test_env(env_home))
37373744
end
37383745

37393746
setup_home env_home

Library/Homebrew/formula_installer.rb

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1136,7 +1136,11 @@ def build
11361136
sandbox.allow_read_if_exists path: formula_path
11371137
formula.logs.mkpath
11381138
sandbox.record_log(formula.logs/"build.sandbox.log")
1139-
sandbox.allow_write_path(Dir.home) if interactive?
1139+
if interactive?
1140+
sandbox.allow_write_path(Dir.home)
1141+
else
1142+
sandbox.deny_read_home
1143+
end
11401144
sandbox.allow_write_temp_and_cache
11411145
sandbox.allow_write_log(formula)
11421146
sandbox.allow_cvs
@@ -1383,6 +1387,7 @@ def post_install
13831387
sandbox.allow_write_log(formula)
13841388
sandbox.allow_write_xcode
13851389
sandbox.deny_write_homebrew_repository
1390+
sandbox.deny_read_home
13861391
sandbox.allow_write_cellar(formula)
13871392
sandbox.deny_all_network unless formula.network_access_allowed?(:postinstall)
13881393
Keg.keg_link_directories.each do |dir|

Library/Homebrew/sandbox.rb

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,26 @@ def allow_read(path:, type: :literal)
177177
add_rule allow: true, operation: "file-read*", filter: path_filter(path, type)
178178
end
179179

180+
sig { params(path: T.any(String, Pathname), type: Symbol).void }
181+
def deny_read(path:, type: :literal)
182+
add_rule allow: false, operation: "file-read*", filter: path_filter(path, type)
183+
end
184+
185+
sig { params(path: T.any(String, Pathname)).void }
186+
def deny_read_path(path)
187+
deny_read path:, type: :subpath
188+
end
189+
190+
sig { void }
191+
def deny_read_home
192+
home = Pathname(Dir.home(ENV.fetch("USER"))).realpath
193+
return if [HOMEBREW_PREFIX, HOMEBREW_REPOSITORY, HOMEBREW_CACHE, HOMEBREW_TEMP].any? do |path|
194+
(path.exist? ? path.realpath : path.expand_path).ascend.include?(home)
195+
end
196+
197+
deny_read_path home
198+
end
199+
180200
sig { params(path: T.nilable(T.any(String, Pathname)), type: Symbol).void }
181201
def allow_read_if_exists(path:, type: :literal)
182202
return unless path

Library/Homebrew/test/cask/artifact/generated_completion_spec.rb

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,10 @@
4040
instance_double(Sandbox).tap do |sandbox|
4141
allow(sandbox).to receive(:allow_read)
4242
allow(sandbox).to receive(:allow_write_temp_and_cache)
43+
allow(sandbox).to receive(:deny_read_home)
4344
allow(sandbox).to receive(:deny_all_network)
4445
allow(sandbox).to receive(:run) do |*args|
45-
Pathname(args.fetch(6)).write("#{args.fetch(1).delete_prefix("SHELL=")} completion output")
46+
Pathname(args.fetch(7)).write("#{args.grep(/^SHELL=/).first.delete_prefix("SHELL=")} completion output")
4647
end
4748
end
4849
end
@@ -57,24 +58,34 @@
5758
expect((fish_dir/"foo.fish").read).to eq("fish completion output")
5859
end
5960

60-
it "sandboxes completion generation" do
61+
it "sandboxes completion generation without network access" do
6162
artifact = cask.artifacts.grep(klass).first
6263
sandboxes = []
64+
calls = []
65+
homes = []
6366

6467
allow(Sandbox).to receive_messages(ensure_sandbox_installed!: nil, available?: true)
6568
allow(Sandbox).to receive(:new) do
6669
instance_double(Sandbox).tap do |sandbox|
6770
expect(sandbox).to receive(:allow_read).with(path: staged_path, type: :subpath)
6871
expect(sandbox).to receive(:allow_write_temp_and_cache)
69-
expect(sandbox).to receive(:deny_all_network)
70-
allow(sandbox).to receive(:run) { |*args| Pathname(args.fetch(6)).write("completion") }
72+
expect(sandbox).to receive(:deny_read_home)
73+
expect(sandbox).to receive(:deny_all_network) { calls << :deny_all_network }
74+
allow(sandbox).to receive(:run) do |*args|
75+
calls << :run
76+
homes << Pathname(args.grep(/^HOME=/).first.delete_prefix("HOME="))
77+
Pathname(args.fetch(7)).write("completion")
78+
end
7179
sandboxes << sandbox
7280
end
7381
end
7482

7583
artifact.install_phase
7684

7785
expect(sandboxes.length).to eq(3)
86+
expect(calls).to eq([:deny_all_network, :run, :deny_all_network, :run, :deny_all_network, :run])
87+
expect(homes.uniq.length).to eq(3)
88+
expect(homes).to all(satisfy { |home| !home.exist? })
7889
end
7990

8091
it "does not sandbox when HOMEBREW_NO_SANDBOX_CASK is set" do
@@ -101,11 +112,12 @@
101112
instance_double(Sandbox).tap do |sandbox|
102113
allow(sandbox).to receive(:allow_read)
103114
allow(sandbox).to receive(:allow_write_temp_and_cache)
115+
allow(sandbox).to receive(:deny_read_home)
104116
allow(sandbox).to receive(:deny_all_network)
105117
allow(sandbox).to receive(:run) do |*args|
106-
raise "boom" if args.fetch(1) == "SHELL=bash"
118+
raise "boom" if args.include?("SHELL=bash")
107119

108-
Pathname(args.fetch(6)).write("zsh completion")
120+
Pathname(args.fetch(7)).write("zsh completion")
109121
end
110122
end
111123
end
@@ -159,18 +171,19 @@
159171
instance_double(Sandbox).tap do |sandbox|
160172
allow(sandbox).to receive(:allow_read)
161173
allow(sandbox).to receive(:allow_write_temp_and_cache)
174+
allow(sandbox).to receive(:deny_read_home)
162175
allow(sandbox).to receive(:deny_all_network)
163176
allow(sandbox).to receive(:run) do |*args|
164177
captured_args = args
165-
Pathname(args.fetch(6)).write("zsh completion")
178+
Pathname(args.fetch(7)).write("zsh completion")
166179
end
167180
end
168181
end
169182

170183
artifact.install_phase
171184

172185
expect(captured_args).to include("--shell=zsh")
173-
expect(captured_args.fetch(4)).to end_with(" 2>/dev/null")
186+
expect(captured_args.fetch(5)).to end_with(" 2>/dev/null")
174187
expect(zsh_dir/"_bar").to be_a_file
175188
expect(bash_dir/"bar").not_to exist
176189
expect(fish_dir/"bar.fish").not_to exist

Library/Homebrew/test/formula_installer_spec.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1008,7 +1008,8 @@ class #{Formulary.class_s(f_name)} < Formula
10081008
allow(Sandbox).to receive_messages(ensure_sandbox_installed!: nil, available?: true, new: sandbox)
10091009
allow(sandbox).to receive_messages(record_log: nil, allow_read_if_exists: nil, allow_write_temp_and_cache: nil,
10101010
allow_write_log: nil, allow_cvs: nil, allow_fossil: nil,
1011-
allow_write_xcode: nil, allow_write_cellar: nil, run: nil)
1011+
allow_write_xcode: nil, allow_write_cellar: nil, deny_read_home: nil,
1012+
run: nil)
10121013
allow(formula).to receive_messages(logs: mktmpdir, update_head_version: nil, prefix: mktmpdir,
10131014
network_access_allowed?: true)
10141015
allow(Keg).to receive(:new).and_return(instance_double(Keg, empty_installation?: false))

Library/Homebrew/test/sandbox_linux_spec.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,14 @@ def expect_sandbox_configuration_command(sandbox_class, assignment, result:)
202202
expect(args.index("--ro-bind")).to be < args.index("--dev")
203203
end
204204

205+
it "masks denied read directories" do
206+
sandbox.deny_read_path dir
207+
208+
bind = args.each_cons(3).find { |arg| arg.fetch(0) == "--bind" && arg.fetch(2) == dir.to_s }
209+
expect(bind).not_to be_nil
210+
expect(Pathname(bind.fetch(1)).children).to be_empty
211+
end
212+
205213
it "overlays Linux runtime filesystems" do
206214
expect(args.each_cons(2)).to include(["--dev", "/dev"], ["--proc", "/proc"])
207215
end

0 commit comments

Comments
 (0)