Skip to content

Commit 15b311a

Browse files
committed
Harden sandboxed install phases
- Reduce exposure to user home configuration during builds - Keep cask completion generation offline before running it - Mask denied read paths where the Linux sandbox can enforce them
1 parent 10a163a commit 15b311a

12 files changed

Lines changed: 196 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: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,11 @@ def terminal_ioctl_request
239239
TIOCSCTTY
240240
end
241241

242+
sig { returns(T::Boolean) }
243+
def deny_read_supported?
244+
true
245+
end
246+
242247
private
243248

244249
sig { returns(Symbol) }
@@ -281,6 +286,7 @@ def bubblewrap_sandbox_available?(bubblewrap)
281286
sig { params(args: T.any(String, ::Pathname)).void }
282287
def run(*args)
283288
@prepared_writable_paths = T.let([], T.nilable(T::Array[::Pathname]))
289+
@masked_read_paths = T.let([], T.nilable(T::Array[::Pathname]))
284290
old_report_on_exception = T.let(Thread.report_on_exception, T.nilable(T::Boolean))
285291
Thread.report_on_exception = false
286292
super
@@ -292,6 +298,8 @@ def run(*args)
292298
nil
293299
end
294300
@prepared_writable_paths = nil
301+
@masked_read_paths&.reverse_each { |path| FileUtils.rm_rf(path) }
302+
@masked_read_paths = nil
295303
end
296304

297305
private
@@ -328,6 +336,16 @@ def bubblewrap_args(tmpdir)
328336
args += ["--ro-bind", path, path]
329337
end
330338

339+
denied_read_paths.each do |path|
340+
next unless File.exist?(path)
341+
342+
args += if File.directory?(path)
343+
["--bind", masked_read_path, path]
344+
else
345+
["--ro-bind", File::NULL, path]
346+
end
347+
end
348+
331349
args += ["--bind", tmpdir, tmpdir, "--chdir", tmpdir]
332350

333351
args
@@ -367,6 +385,23 @@ def denied_write_paths
367385
end.uniq
368386
end
369387

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

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,17 @@ def available?
7171
def terminal_ioctl_request
7272
TIOCSCTTY
7373
end
74+
75+
sig { returns(T::Boolean) }
76+
def deny_read_supported?
77+
system(
78+
SANDBOX_EXEC,
79+
"-p", '(version 1) (deny file-read* (subpath "/var/empty")) (allow default)',
80+
"/usr/bin/true",
81+
out: File::NULL,
82+
err: File::NULL
83+
) == true
84+
end
7485
end
7586

7687
private

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: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,11 @@ def self.configuration_commands = []
9898
sig { returns(T::Array[String]) }
9999
def self.configuration_command_messages = []
100100

101+
sig { returns(T::Boolean) }
102+
def self.deny_read_supported?
103+
false
104+
end
105+
101106
sig { void }
102107
def self.configure!
103108
ensure_sandbox_installed!
@@ -177,6 +182,28 @@ def allow_read(path:, type: :literal)
177182
add_rule allow: true, operation: "file-read*", filter: path_filter(path, type)
178183
end
179184

185+
sig { params(path: T.any(String, Pathname), type: Symbol).void }
186+
def deny_read(path:, type: :literal)
187+
add_rule allow: false, operation: "file-read*", filter: path_filter(path, type)
188+
end
189+
190+
sig { params(path: T.any(String, Pathname)).void }
191+
def deny_read_path(path)
192+
deny_read path:, type: :subpath
193+
end
194+
195+
sig { void }
196+
def deny_read_home
197+
return unless self.class.deny_read_supported?
198+
199+
home = Pathname(Dir.home(ENV.fetch("USER"))).realpath
200+
return if [HOMEBREW_TEMP, HOMEBREW_CACHE, HOMEBREW_LOGS].any? do |path|
201+
(path.exist? ? path.realpath : path.expand_path).ascend.include?(home)
202+
end
203+
204+
deny_read_path home
205+
end
206+
180207
sig { params(path: T.nilable(T.any(String, Pathname)), type: Symbol).void }
181208
def allow_read_if_exists(path:, type: :literal)
182209
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))

0 commit comments

Comments
 (0)