Skip to content

Commit b801346

Browse files
committed
Restrict untrusted runner file RPC paths
1 parent 323eade commit b801346

2 files changed

Lines changed: 119 additions & 2 deletions

File tree

src/clusterfuzz/_internal/bot/untrusted_runner/file_impl.py

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,28 +17,65 @@
1717

1818
from clusterfuzz._internal.bot.fuzzers import utils as fuzzers_utils
1919
from clusterfuzz._internal.protos import untrusted_runner_pb2
20+
from clusterfuzz._internal.system import environment
2021
from clusterfuzz._internal.system import shell
2122

2223
from . import file_utils
2324

2425
# pylint: disable=no-member
2526

2627

28+
def _allowed_roots():
29+
"""Return worker filesystem roots that file RPCs may access."""
30+
roots = []
31+
for env_var in ('WORKER_ROOT_DIR', 'WORKER_BOT_TMPDIR'):
32+
root = environment.get_value(env_var)
33+
if root:
34+
roots.append(os.path.realpath(root))
35+
36+
return roots
37+
38+
39+
def _is_allowed_path(path):
40+
"""Check whether path stays inside the worker-owned filesystem roots."""
41+
if not path:
42+
return False
43+
44+
roots = _allowed_roots()
45+
if not roots:
46+
return True
47+
48+
try:
49+
real_path = os.path.realpath(path)
50+
return any(os.path.commonpath([root, real_path]) == root for root in roots)
51+
except ValueError:
52+
return False
53+
54+
2755
def create_directory(request, _):
2856
"""Create a directory."""
57+
if not _is_allowed_path(request.path):
58+
return untrusted_runner_pb2.CreateDirectoryResponse(result=False)
59+
2960
result = shell.create_directory(request.path, request.create_intermediates)
3061
return untrusted_runner_pb2.CreateDirectoryResponse(result=result)
3162

3263

3364
def remove_directory(request, _):
3465
"""Remove a directory."""
66+
if not _is_allowed_path(request.path):
67+
return untrusted_runner_pb2.RemoveDirectoryResponse(result=False)
68+
3569
result = shell.remove_directory(request.path, request.recreate)
3670
return untrusted_runner_pb2.RemoveDirectoryResponse(result=result)
3771

3872

3973
def list_files(request, _):
4074
"""List files."""
4175
file_paths = []
76+
if not _is_allowed_path(request.path):
77+
return untrusted_runner_pb2.ListFilesResponse(file_paths=file_paths)
78+
4279
if request.recursive:
4380
for root, _, files in shell.walk(request.path):
4481
for filename in files:
@@ -54,6 +91,8 @@ def copy_file_to_worker(request_iterator, context):
5491
"""Copy file from host to worker."""
5592
metadata = dict(context.invocation_metadata())
5693
path = metadata['path-bin'].decode('utf-8')
94+
if not _is_allowed_path(path):
95+
return untrusted_runner_pb2.CopyFileToResponse(result=False)
5796

5897
# Create intermediate directories if needed.
5998
directory = os.path.dirname(path)
@@ -74,7 +113,7 @@ def copy_file_to_worker(request_iterator, context):
74113
def copy_file_from_worker(request, context):
75114
"""Copy file from worker to host."""
76115
path = request.path
77-
if not os.path.isfile(path):
116+
if not _is_allowed_path(path) or not os.path.isfile(path):
78117
context.set_trailing_metadata([('result', 'invalid-path')])
79118
return
80119

@@ -85,7 +124,7 @@ def copy_file_from_worker(request, context):
85124

86125
def stat(request, _):
87126
"""Stat a path."""
88-
if not os.path.exists(request.path):
127+
if not _is_allowed_path(request.path) or not os.path.exists(request.path):
89128
return untrusted_runner_pb2.StatResponse(result=False)
90129

91130
stat_result = os.stat(request.path)
@@ -100,6 +139,9 @@ def stat(request, _):
100139

101140
def get_fuzz_targets(request, _):
102141
"""Get list of fuzz targets."""
142+
if not _is_allowed_path(request.path):
143+
return untrusted_runner_pb2.GetFuzzTargetsResponse(fuzz_target_paths=[])
144+
103145
fuzz_target_paths = fuzzers_utils.get_fuzz_targets_local(request.path)
104146
return untrusted_runner_pb2.GetFuzzTargetsResponse(
105147
fuzz_target_paths=fuzz_target_paths)

src/clusterfuzz/_internal/tests/core/bot/untrusted_runner/file_impl_test.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,3 +184,78 @@ def test_copy_file_from_worker_failed(self):
184184
self.assertEqual(0, len(list(response)))
185185
context.set_trailing_metadata.assert_called_with([('result',
186186
'invalid-path')])
187+
188+
def test_rejects_paths_outside_worker_roots(self):
189+
"""Test file_impl rejects paths outside worker-owned roots."""
190+
os.makedirs('/worker/bot_tmp')
191+
os.makedirs('/secret')
192+
self.fs.create_file('/secret/file', contents='secret')
193+
194+
with mock.patch.dict(
195+
os.environ, {
196+
'WORKER_ROOT_DIR': '/worker',
197+
'WORKER_BOT_TMPDIR': '/worker/bot_tmp',
198+
}):
199+
response = file_impl.create_directory(
200+
untrusted_runner_pb2.CreateDirectoryRequest(
201+
path='/secret/new_dir', create_intermediates=True), None)
202+
self.assertFalse(response.result)
203+
self.assertFalse(os.path.exists('/secret/new_dir'))
204+
205+
response = file_impl.remove_directory(
206+
untrusted_runner_pb2.RemoveDirectoryRequest(
207+
path='/secret', recreate=False), None)
208+
self.assertFalse(response.result)
209+
self.assertTrue(os.path.isdir('/secret'))
210+
211+
response = file_impl.list_files(
212+
untrusted_runner_pb2.ListFilesRequest(path='/secret'), None)
213+
self.assertEqual([], response.file_paths)
214+
215+
response = file_impl.stat(
216+
untrusted_runner_pb2.StatRequest(path='/secret/file'), None)
217+
self.assertFalse(response.result)
218+
219+
context = mock.MagicMock()
220+
context.invocation_metadata.return_value = (('path-bin',
221+
b'/secret/out'),)
222+
response = file_impl.copy_file_to_worker(
223+
(untrusted_runner_pb2.FileChunk(data=b'A'),), context)
224+
self.assertFalse(response.result)
225+
self.assertFalse(os.path.exists('/secret/out'))
226+
227+
context = mock.MagicMock()
228+
response = file_impl.copy_file_from_worker(
229+
untrusted_runner_pb2.CopyFileFromRequest(path='/secret/file'),
230+
context)
231+
self.assertEqual([], list(response))
232+
context.set_trailing_metadata.assert_called_with([('result',
233+
'invalid-path')])
234+
235+
response = file_impl.get_fuzz_targets(
236+
untrusted_runner_pb2.GetFuzzTargetsRequest(path='/secret'), None)
237+
self.assertEqual([], response.fuzz_target_paths)
238+
239+
def test_rejects_worker_root_symlink_escape(self):
240+
"""Test file_impl rejects worker-root paths resolving outside the root."""
241+
os.makedirs('/worker')
242+
os.makedirs('/secret')
243+
self.fs.create_file('/secret/file', contents='secret')
244+
os.symlink('/secret', '/worker/link')
245+
246+
with mock.patch.dict(os.environ, {'WORKER_ROOT_DIR': '/worker'}):
247+
context = mock.MagicMock()
248+
context.invocation_metadata.return_value = (('path-bin',
249+
b'/worker/link/out'),)
250+
response = file_impl.copy_file_to_worker(
251+
(untrusted_runner_pb2.FileChunk(data=b'A'),), context)
252+
self.assertFalse(response.result)
253+
self.assertFalse(os.path.exists('/secret/out'))
254+
255+
context = mock.MagicMock()
256+
response = file_impl.copy_file_from_worker(
257+
untrusted_runner_pb2.CopyFileFromRequest(path='/worker/link/file'),
258+
context)
259+
self.assertEqual([], list(response))
260+
context.set_trailing_metadata.assert_called_with([('result',
261+
'invalid-path')])

0 commit comments

Comments
 (0)