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 @@
+
-
+ -->
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,
)