diff --git a/src/appengine/handlers/fuzzers.py b/src/appengine/handlers/fuzzers.py index e1f5e1e1466..f3c144f02d9 100644 --- a/src/appengine/handlers/fuzzers.py +++ b/src/appengine/handlers/fuzzers.py @@ -173,8 +173,9 @@ def apply_fuzzer_changes(self, fuzzer, upload_info): jobs = request.get('jobs', []) timeout = self._get_integer_value('timeout') max_testcases = self._get_integer_value('max_testcases') - external_contribution = request.get('external_contribution', False) - differential = request.get('differential', False) + external_contribution = bool(request.get('external_contribution', False)) + trusted = bool(request.get('trusted', False)) + differential = bool(request.get('differential', False)) environment_string = request.get('additional_environment_string') data_bundle_name = request.get('data_bundle_name') @@ -192,8 +193,9 @@ def apply_fuzzer_changes(self, fuzzer, upload_info): fuzzer.result = None fuzzer.sample_testcase = None fuzzer.console_output = None - fuzzer.external_contribution = bool(external_contribution) - fuzzer.differential = bool(differential) + fuzzer.external_contribution = external_contribution + fuzzer.trusted = trusted + fuzzer.differential = differential fuzzer.additional_environment_string = environment_string fuzzer.timestamp = datetime.datetime.now(tz=datetime.timezone.utc).replace( tzinfo=None) @@ -208,6 +210,13 @@ def apply_fuzzer_changes(self, fuzzer, upload_info): if launcher_script: fuzzer.launcher_script = launcher_script + if not fuzzer.trusted: + for job_name in jobs: + try: + data_handler.check_job_supports_untrusted_workloads(job_name) + except ValueError as e: + raise helpers.EarlyExitError(str(e), 400) + fuzzer.put() fuzzer_selection.update_mappings_for_fuzzer(fuzzer) diff --git a/src/appengine/handlers/upload_testcase.py b/src/appengine/handlers/upload_testcase.py index 41f65d5a652..691e9b5711a 100644 --- a/src/appengine/handlers/upload_testcase.py +++ b/src/appengine/handlers/upload_testcase.py @@ -28,7 +28,6 @@ from clusterfuzz._internal.base import memoize from clusterfuzz._internal.base import tasks from clusterfuzz._internal.base import utils -from clusterfuzz._internal.base.tasks import task_utils from clusterfuzz._internal.crash_analysis.stack_parsing import stack_analyzer from clusterfuzz._internal.datastore import data_handler from clusterfuzz._internal.datastore import data_types @@ -410,17 +409,14 @@ def _handle_upload(self, helpers.log(f'User {email} does not have access', helpers.VIEW_OPERATION) raise helpers.AccessDeniedError() - # Chrome is the only ClusterFuzz deployment where there are trusted bots - # running utasks. This check also fails on oss-fuzz because of the way it - # abuses platform. - if (not trusted_agreement_signed and utils.is_chromium() and - task_utils.is_remotely_executing_utasks() and - ((platform_id and platform_id != 'Linux') or - job.platform.lower() != 'linux')): - # Trusted agreement was not signed even though the job has privileges and - # there are other jobs that don't have privileges. - raise helpers.EarlyExitError( - 'Sign the trusted job statement or upload to a trusted job.', 400) + # Ensure the job supports untrusted workloads if the uploader hasn't signed + # a trusted agreement. + if not trusted_agreement_signed: + try: + data_handler.check_job_supports_untrusted_workloads( + job_type, platform_id) + except ValueError as e: + raise helpers.EarlyExitError(str(e), 400) crash_data = None if job.is_external(): diff --git a/src/appengine/private/components/fuzzers-page/edit-form.html b/src/appengine/private/components/fuzzers-page/edit-form.html index fb1e41886e8..5757976fe31 100644 --- a/src/appengine/private/components/fuzzers-page/edit-form.html +++ b/src/appengine/private/components/fuzzers-page/edit-form.html @@ -220,11 +220,16 @@
External contribution +
+
+ Trusted +
- + --> None: + """Checks that the named job can run untrusted testcases or fuzzers. + + Raises: + ValueError: if the job can run on privileged bots and should never run + an untrusted workload. + """ + job = data_types.Job.query(data_types.Job.name == job_name).get() + if not job: + raise ValueError(f'Job "{job_name}" not found.') + + # 1. This restriction is currently specific to Chromium deployments. + if not utils.is_chromium(): + return + + # 2. It only applies when running with Remote Utasks (e.g., GCP Batch), + # where privilege separation (tworkers vs uworkers) is enforced. + if not task_utils.is_remotely_executing_utasks(): + return + + # 3. In remote mode, unprivileged uworkers (where untrusted code must run) + # are implemented as Linux containers. Thus, non-Linux jobs cannot support + # untrusted workloads in this setup. + + if (platform_id or job.platform).lower() != 'linux': + raise ValueError( + f'Job "{job_name}" does not support running untrusted workloads. ' + 'Untrusted workloads on Chrome are only supported on Linux.') + + @memoize.wrap(memoize.Memcache(MEMCACHE_TTL_IN_SECONDS)) def get_component_name(job_type): """Gets component name for a job type.""" diff --git a/src/clusterfuzz/_internal/datastore/data_types.py b/src/clusterfuzz/_internal/datastore/data_types.py index 24ad730341e..c8c0394cccc 100644 --- a/src/clusterfuzz/_internal/datastore/data_types.py +++ b/src/clusterfuzz/_internal/datastore/data_types.py @@ -353,6 +353,13 @@ class Fuzzer(Model): # Does it run un-trusted content ? Examples including running live sites. untrusted_content = ndb.BooleanProperty(default=False) + # Whether this fuzzer is trusted or not. + # Untrusted fuzzers can only execute on unprivileged bots, as + # they might produce malicious outputs. All the testcases they + # produce are also treated as untrusted. + # See also `data_handler.check_job_supports_untrusted_workloads()`. + trusted = ndb.BooleanProperty(default=True) + # Data bundle name. data_bundle_name = ndb.StringProperty(default='') diff --git a/src/clusterfuzz/_internal/tests/appengine/handlers/fuzzers_test.py b/src/clusterfuzz/_internal/tests/appengine/handlers/fuzzers_test.py index 14268e5eecb..de764fb02d3 100644 --- a/src/clusterfuzz/_internal/tests/appengine/handlers/fuzzers_test.py +++ b/src/clusterfuzz/_internal/tests/appengine/handlers/fuzzers_test.py @@ -125,6 +125,8 @@ def setUp(self): 'handlers.fuzzers.EditHandler.get_upload', 'handlers.fuzzers.EditHandler._get_executable_path', 'handlers.fuzzers.EditHandler._get_launcher_script', + 'clusterfuzz._internal.base.utils.is_chromium', + 'clusterfuzz._internal.base.tasks.task_utils.is_remotely_executing_utasks', ]) self.mock.has_access.return_value = True self.mock.get_current_user().email = 'editor@example.com' @@ -134,6 +136,8 @@ def setUp(self): self.mock._get_executable_path.return_value = 'executable' self.mock._get_launcher_script.return_value = 'launcher' + self.mock.is_chromium.return_value = True + self.mock.is_remotely_executing_utasks.return_value = True self.mock_time = datetime.datetime(2026, 1, 1, tzinfo=None) self.mock.datetime.datetime.now.return_value = self.mock_time @@ -179,6 +183,7 @@ def test_update_fuzzer(self): 'additional_environment_string': 'args=123', 'data_bundle_name': 'test_bundle', 'external_contribution': True, + 'trusted': False, 'executable_path': 'executable', 'last_edited_by': 'editor@example.com', 'launcher_script': 'launcher', @@ -188,3 +193,70 @@ def test_update_fuzzer(self): 'source': 'original@example.com', 'timeout': 30, }) + + def test_update_fuzzer_untrusted_success(self): + """Test updating a fuzzer to untrusted with a Linux job succeeds.""" + # Create a Linux job. + job = data_types.Job(name='linux_job', platform='LINUX') + job.put() + + fuzzer_name = 'test_fuzzer' + fuzzer = data_types.Fuzzer( + name=fuzzer_name, + jobs=[], + revision=1, + source='original@example.com', + timeout=10, + trusted=True, + ) + fuzzer.put() + + request_payload = { + 'csrf_token': form.generate_csrf_token(), + 'key': fuzzer.key.id(), + 'name': fuzzer_name, + 'trusted': False, + 'jobs': ['linux_job'], + } + + resp = self.app.post_json('/fuzzers/edit', request_payload) + self.assertEqual(302, resp.status_int) # Redirects to /fuzzers + + fuzzer = fuzzer.key.get() + self.assertFalse(fuzzer.trusted) + self.assertEqual(fuzzer.jobs, ['linux_job']) + + def test_update_fuzzer_untrusted_failure_non_linux(self): + """Test updating a fuzzer to untrusted with a non-Linux job fails.""" + # Create a Windows job. + job = data_types.Job(name='windows_job', platform='WINDOWS') + job.put() + + fuzzer_name = 'test_fuzzer' + fuzzer = data_types.Fuzzer( + name=fuzzer_name, + jobs=[], + revision=1, + source='original@example.com', + timeout=10, + trusted=True, + ) + fuzzer.put() + + request_payload = { + 'csrf_token': form.generate_csrf_token(), + 'key': fuzzer.key.id(), + 'name': fuzzer_name, + 'trusted': False, + 'jobs': ['windows_job'], + } + + resp = self.app.post_json( + '/fuzzers/edit', request_payload, expect_errors=True) + self.assertEqual(400, resp.status_int) + self.assertIn('does not support running untrusted workloads', + resp.normal_body.decode()) + + # Verify fuzzer was not updated (remained trusted). + fuzzer = fuzzer.key.get() + self.assertTrue(fuzzer.trusted) diff --git a/src/clusterfuzz/_internal/tests/core/datastore/data_types_test.py b/src/clusterfuzz/_internal/tests/core/datastore/data_types_test.py index 489c74ce165..541b6631d53 100644 --- a/src/clusterfuzz/_internal/tests/core/datastore/data_types_test.py +++ b/src/clusterfuzz/_internal/tests/core/datastore/data_types_test.py @@ -56,6 +56,7 @@ def test_get_config_dict(self): 'revision': 3, 'source': 'author', 'last_edited_by': 'editor', + 'trusted': True, }, config_dict, )