From 0d5a3b144bdcf922d19d0844c3b74dca6cc659af Mon Sep 17 00:00:00 2001 From: Jonathan Metzman Date: Wed, 24 Jun 2026 12:08:06 -0400 Subject: [PATCH 1/3] Make testcases public when bug is made public --- src/appengine/handlers/download.py | 33 ++++++++++ .../google_issue_tracker/issue_tracker.py | 11 ++++ .../issue_management/issue_tracker.py | 5 ++ .../tests/appengine/handlers/download_test.py | 62 +++++++++++++++++++ .../google_issue_tracker_test.py | 57 +++++++++++++++++ 5 files changed, 168 insertions(+) diff --git a/src/appengine/handlers/download.py b/src/appengine/handlers/download.py index 540e5d5bc23..b1643939528 100644 --- a/src/appengine/handlers/download.py +++ b/src/appengine/handlers/download.py @@ -91,6 +91,31 @@ def check_public_testcase(self, blob_info, testcase): return True + def check_derestricted_testcase(self, blob_info, testcase): + """Check if a testcase's associated bug has been derestricted (made public). + + For Chromium deployments, checks if the corresponding bug tracker issue has + no view restrictions, indicating it has been derestricted. Only allows + access to the minimized testcase. Gated on is_chromium() to avoid exposing + testcases on internal deployments where LIMIT_NONE doesn't mean truly + public. + """ + if not utils.is_chromium(): + return False + + if blob_info.key() != testcase.minimized_keys: + return False + + if not testcase.bug_information: + return False + + issue_tracker = issue_tracker_utils.get_issue_tracker_for_testcase(testcase) + issue = issue_tracker.get_issue(testcase.bug_information) + if not issue: + return False + + return issue.is_unrestricted + def get(self, resource=None): """Handle a get request with resource.""" testcase = None @@ -132,6 +157,14 @@ def get(self, resource=None): is_minimized=True, fuzzer_binary_name=fuzzer_binary_name) + if testcase and self.check_derestricted_testcase(blob_info, testcase): + # Testcase for a derestricted (public) Chromium bug. + return self._send_blob( + blob_info, + testcase.key.id(), + is_minimized=True, + fuzzer_binary_name=fuzzer_binary_name) + is_minimized = testcase and blob_info.key() == testcase.minimized_keys if access.has_access(): # User has general access. diff --git a/src/clusterfuzz/_internal/issue_management/google_issue_tracker/issue_tracker.py b/src/clusterfuzz/_internal/issue_management/google_issue_tracker/issue_tracker.py index 44f63093cc0..443e63236fc 100644 --- a/src/clusterfuzz/_internal/issue_management/google_issue_tracker/issue_tracker.py +++ b/src/clusterfuzz/_internal/issue_management/google_issue_tracker/issue_tracker.py @@ -240,6 +240,17 @@ def __init__(self, data, is_new, tracker): self._changed = set() self._issue_access_limit = IssueAccessLevel.LIMIT_NONE + @property + def is_unrestricted(self): + """Whether the issue has no view restrictions (i.e. is public). + + Checks the accessLimit field from the Buganizer API response. An issue + with accessLevel == LIMIT_NONE has no view restrictions. + """ + access_limit = self._data.get('issueState', {}).get('accessLimit', + {}).get('accessLevel') + return access_limit == IssueAccessLevel.LIMIT_NONE + def _get_component_tags(self): """Returns the value of the Component Tags custom field.""" custom_fields = self._data['issueState'].get('customFields', []) diff --git a/src/clusterfuzz/_internal/issue_management/issue_tracker.py b/src/clusterfuzz/_internal/issue_management/issue_tracker.py index 44bcd66d964..b9f1bb852bb 100644 --- a/src/clusterfuzz/_internal/issue_management/issue_tracker.py +++ b/src/clusterfuzz/_internal/issue_management/issue_tracker.py @@ -202,6 +202,11 @@ def save(self, new_comment=None, notify=True): """Save the issue.""" raise NotImplementedError + @property + def is_unrestricted(self): + """Whether the issue has no view restrictions (i.e. is public).""" + return False + # pylint: disable=unused-argument def apply_extension_fields(self, extension_fields): """Applies _ext_ prefixed extension fields to the issue.""" diff --git a/src/clusterfuzz/_internal/tests/appengine/handlers/download_test.py b/src/clusterfuzz/_internal/tests/appengine/handlers/download_test.py index 2170a8c7391..3c8001c3e6b 100644 --- a/src/clusterfuzz/_internal/tests/appengine/handlers/download_test.py +++ b/src/clusterfuzz/_internal/tests/appengine/handlers/download_test.py @@ -44,6 +44,7 @@ def setUp(self): self.testcase.put() test_helpers.patch(self, [ + 'clusterfuzz._internal.base.utils.is_chromium', 'clusterfuzz._internal.base.utils.is_oss_fuzz', 'clusterfuzz._internal.google_cloud_utils.blobs.get_blob_info', 'libs.access.can_user_access_testcase', @@ -53,6 +54,7 @@ def setUp(self): 'clusterfuzz._internal.issue_management.issue_tracker_utils.' 'get_issue_tracker_for_testcase', ]) + self.mock.is_chromium.return_value = False self.mock.is_oss_fuzz.return_value = False self.mock.can_user_access_testcase.return_value = False self.mock.has_access.return_value = False @@ -208,6 +210,66 @@ def test_public_download_chromium(self): expect_status=302, expect_blob=False) + def test_public_download_derestricted_chromium(self): + """Test public downloading of derestricted Chromium testcases.""" + self.mock.get_user_email.return_value = '' + self.mock.is_chromium.return_value = True + self.mock.is_oss_fuzz.return_value = False + mock_issue = self.mock.get_issue_tracker_for_testcase(None).get_issue() + mock_issue.labels = issue_tracker.LabelStore([]) + mock_issue.is_unrestricted = True + + # Minimized testcase should be downloadable. + self._test_download( + self.minimized_key, + testcase_id=self.testcase.key.id(), + expect_filename='clusterfuzz-testcase-minimized-1.ext') + # Only minimized testcase, not fuzzed. + self._test_download( + self.fuzzed_key, + testcase_id=self.testcase.key.id(), + expect_status=302, + expect_blob=False) + + def test_public_download_restricted_chromium(self): + """Test public downloading of still-restricted Chromium testcases fails.""" + self.mock.get_user_email.return_value = '' + self.mock.is_chromium.return_value = True + self.mock.is_oss_fuzz.return_value = False + mock_issue = self.mock.get_issue_tracker_for_testcase(None).get_issue() + mock_issue.labels = issue_tracker.LabelStore([]) + mock_issue.is_unrestricted = False + + self._test_download( + self.minimized_key, + testcase_id=self.testcase.key.id(), + expect_status=302, + expect_blob=False) + self._test_download( + self.fuzzed_key, + testcase_id=self.testcase.key.id(), + expect_status=302, + expect_blob=False) + + def test_public_download_non_chromium_unrestricted_bug(self): + """Test that unrestricted bugs on non-Chromium deployments don't expose + testcases. This guards against internal deployments where LIMIT_NONE means + 'accessible to Googlers' rather than 'truly public'.""" + self.mock.get_user_email.return_value = '' + self.mock.is_chromium.return_value = False + self.mock.is_oss_fuzz.return_value = False + mock_issue = self.mock.get_issue_tracker_for_testcase(None).get_issue() + mock_issue.labels = issue_tracker.LabelStore([]) + mock_issue.is_unrestricted = True + + # Even though the bug is unrestricted, non-Chromium deployments should + # not serve the testcase publicly. + self._test_download( + self.minimized_key, + testcase_id=self.testcase.key.id(), + expect_status=302, + expect_blob=False) + def test_public_download_oss_fuzz(self): """Test public downloading OSS-Fuzz testcases.""" self.mock.get_user_email.return_value = '' diff --git a/src/clusterfuzz/_internal/tests/appengine/libs/issue_management/google_issue_tracker/google_issue_tracker_test.py b/src/clusterfuzz/_internal/tests/appengine/libs/issue_management/google_issue_tracker/google_issue_tracker_test.py index 08c0dccdef5..77e0a037d61 100644 --- a/src/clusterfuzz/_internal/tests/appengine/libs/issue_management/google_issue_tracker/google_issue_tracker_test.py +++ b/src/clusterfuzz/_internal/tests/appengine/libs/issue_management/google_issue_tracker/google_issue_tracker_test.py @@ -154,6 +154,63 @@ def test_closed(self): self.assertEqual( datetime.datetime(2019, 6, 24, 6, 40, 7, 672), issue.closed_time) + def test_is_unrestricted_limit_none(self): + """Test is_unrestricted returns True when accessLevel is LIMIT_NONE.""" + self.client.issues().get().execute.return_value = { + 'issueId': '68828938', + 'issueState': { + 'componentId': '29002', + 'type': 'BUG', + 'status': 'NEW', + 'priority': 'P2', + 'severity': 'S2', + 'title': 'test', + 'accessLimit': { + 'accessLevel': 'LIMIT_NONE' + }, + }, + 'createdTime': '2019-06-25T01:29:30.021Z', + } + issue = self.issue_tracker.get_issue(68828938) + self.assertTrue(issue.is_unrestricted) + + def test_is_unrestricted_limit_view(self): + """Test is_unrestricted returns False when accessLevel is LIMIT_VIEW.""" + self.client.issues().get().execute.return_value = { + 'issueId': '68828938', + 'issueState': { + 'componentId': '29002', + 'type': 'BUG', + 'status': 'NEW', + 'priority': 'P2', + 'severity': 'S2', + 'title': 'test', + 'accessLimit': { + 'accessLevel': 'LIMIT_VIEW' + }, + }, + 'createdTime': '2019-06-25T01:29:30.021Z', + } + issue = self.issue_tracker.get_issue(68828938) + self.assertFalse(issue.is_unrestricted) + + def test_is_unrestricted_no_access_limit(self): + """Test is_unrestricted returns False when no accessLimit field.""" + self.client.issues().get().execute.return_value = { + 'issueId': '68828938', + 'issueState': { + 'componentId': '29002', + 'type': 'BUG', + 'status': 'NEW', + 'priority': 'P2', + 'severity': 'S2', + 'title': 'test', + }, + 'createdTime': '2019-06-25T01:29:30.021Z', + } + issue = self.issue_tracker.get_issue(68828938) + self.assertFalse(issue.is_unrestricted) + def test_get_labels(self): """Test getting labels.""" self.client.issues().get().execute.return_value = { From c56b8302c9648015cde70059b861b7f4698e67fd Mon Sep 17 00:00:00 2001 From: Jonathan Metzman Date: Wed, 24 Jun 2026 12:38:06 -0400 Subject: [PATCH 2/3] fix --- .../google_issue_tracker/issue_tracker.py | 4 +- .../google_issue_tracker_test.py | 52 ++++--------------- 2 files changed, 12 insertions(+), 44 deletions(-) diff --git a/src/clusterfuzz/_internal/issue_management/google_issue_tracker/issue_tracker.py b/src/clusterfuzz/_internal/issue_management/google_issue_tracker/issue_tracker.py index 443e63236fc..9827c84cf2d 100644 --- a/src/clusterfuzz/_internal/issue_management/google_issue_tracker/issue_tracker.py +++ b/src/clusterfuzz/_internal/issue_management/google_issue_tracker/issue_tracker.py @@ -247,8 +247,8 @@ def is_unrestricted(self): Checks the accessLimit field from the Buganizer API response. An issue with accessLevel == LIMIT_NONE has no view restrictions. """ - access_limit = self._data.get('issueState', {}).get('accessLimit', - {}).get('accessLevel') + access_limit = self._data['issueState'].get('accessLimit', + {}).get('accessLevel') return access_limit == IssueAccessLevel.LIMIT_NONE def _get_component_tags(self): diff --git a/src/clusterfuzz/_internal/tests/appengine/libs/issue_management/google_issue_tracker/google_issue_tracker_test.py b/src/clusterfuzz/_internal/tests/appengine/libs/issue_management/google_issue_tracker/google_issue_tracker_test.py index 77e0a037d61..4d8e629cec3 100644 --- a/src/clusterfuzz/_internal/tests/appengine/libs/issue_management/google_issue_tracker/google_issue_tracker_test.py +++ b/src/clusterfuzz/_internal/tests/appengine/libs/issue_management/google_issue_tracker/google_issue_tracker_test.py @@ -13,6 +13,7 @@ # limitations under the License. """Tests for issue_tracker.""" +import copy import datetime import unittest from unittest import mock @@ -156,58 +157,25 @@ def test_closed(self): def test_is_unrestricted_limit_none(self): """Test is_unrestricted returns True when accessLevel is LIMIT_NONE.""" - self.client.issues().get().execute.return_value = { - 'issueId': '68828938', - 'issueState': { - 'componentId': '29002', - 'type': 'BUG', - 'status': 'NEW', - 'priority': 'P2', - 'severity': 'S2', - 'title': 'test', - 'accessLimit': { - 'accessLevel': 'LIMIT_NONE' - }, - }, - 'createdTime': '2019-06-25T01:29:30.021Z', - } + issue_data = copy.deepcopy(BASIC_ISSUE) + issue_data['issueState']['accessLimit'] = {'accessLevel': 'LIMIT_NONE'} + self.client.issues().get().execute.return_value = issue_data issue = self.issue_tracker.get_issue(68828938) self.assertTrue(issue.is_unrestricted) def test_is_unrestricted_limit_view(self): """Test is_unrestricted returns False when accessLevel is LIMIT_VIEW.""" - self.client.issues().get().execute.return_value = { - 'issueId': '68828938', - 'issueState': { - 'componentId': '29002', - 'type': 'BUG', - 'status': 'NEW', - 'priority': 'P2', - 'severity': 'S2', - 'title': 'test', - 'accessLimit': { - 'accessLevel': 'LIMIT_VIEW' - }, - }, - 'createdTime': '2019-06-25T01:29:30.021Z', - } + issue_data = copy.deepcopy(BASIC_ISSUE) + issue_data['issueState']['accessLimit'] = {'accessLevel': 'LIMIT_VIEW'} + self.client.issues().get().execute.return_value = issue_data issue = self.issue_tracker.get_issue(68828938) self.assertFalse(issue.is_unrestricted) def test_is_unrestricted_no_access_limit(self): """Test is_unrestricted returns False when no accessLimit field.""" - self.client.issues().get().execute.return_value = { - 'issueId': '68828938', - 'issueState': { - 'componentId': '29002', - 'type': 'BUG', - 'status': 'NEW', - 'priority': 'P2', - 'severity': 'S2', - 'title': 'test', - }, - 'createdTime': '2019-06-25T01:29:30.021Z', - } + issue_data = copy.deepcopy(BASIC_ISSUE) + issue_data['issueState'].pop('accessLimit', None) + self.client.issues().get().execute.return_value = issue_data issue = self.issue_tracker.get_issue(68828938) self.assertFalse(issue.is_unrestricted) From 1bc585c59869b5b8b40420889d2ae5a075bd5991 Mon Sep 17 00:00:00 2001 From: Jonathan Metzman Date: Mon, 13 Jul 2026 17:18:39 -0400 Subject: [PATCH 3/3] fix --- src/appengine/handlers/download.py | 70 +++++++++++++----------------- 1 file changed, 31 insertions(+), 39 deletions(-) diff --git a/src/appengine/handlers/download.py b/src/appengine/handlers/download.py index 01ec36bc9f6..bf66f4c88f2 100644 --- a/src/appengine/handlers/download.py +++ b/src/appengine/handlers/download.py @@ -63,16 +63,11 @@ def _send_blob(self, response.headers['Content-disposition'] = content_disposition return response - def check_public_testcase(self, blob_info, testcase): - """Check public testcase.""" + def check_public_testcase(self, issue, blob_info, testcase): + """Check public testcase. |issue| is the already-fetched issue (or None).""" if blob_info.key() != testcase.minimized_keys: return False - if not testcase.bug_information: - return False - - issue_tracker = issue_tracker_utils.get_issue_tracker_for_testcase(testcase) - issue = issue_tracker.get_issue(testcase.bug_information) if not issue: return False @@ -91,14 +86,14 @@ def check_public_testcase(self, blob_info, testcase): return True - def check_derestricted_testcase(self, blob_info, testcase): + def check_derestricted_testcase(self, issue, blob_info, testcase): """Check if a testcase's associated bug has been derestricted (made public). For Chromium deployments, checks if the corresponding bug tracker issue has no view restrictions, indicating it has been derestricted. Only allows access to the minimized testcase. Gated on is_chromium() to avoid exposing testcases on internal deployments where LIMIT_NONE doesn't mean truly - public. + public. |issue| is the already-fetched issue (or None). """ if not utils.is_chromium(): return False @@ -106,11 +101,6 @@ def check_derestricted_testcase(self, blob_info, testcase): if blob_info.key() != testcase.minimized_keys: return False - if not testcase.bug_information: - return False - - issue_tracker = issue_tracker_utils.get_issue_tracker_for_testcase(testcase) - issue = issue_tracker.get_issue(testcase.bug_information) if not issue: return False @@ -148,36 +138,38 @@ def get(self, resource=None): testcase.minimized_keys != blob_info.key()): raise helpers.EarlyExitError('Invalid testcase.', 400) - if (utils.is_oss_fuzz() and testcase and - self.check_public_testcase(blob_info, testcase)): - # Public OSS-Fuzz testcase. - return self._send_blob( - blob_info, - testcase.key.id(), - is_minimized=True, - fuzzer_binary_name=fuzzer_binary_name) - - if testcase and self.check_derestricted_testcase(blob_info, testcase): - # Testcase for a derestricted (public) Chromium bug. - return self._send_blob( - blob_info, - testcase.key.id(), - is_minimized=True, - fuzzer_binary_name=fuzzer_binary_name) - is_minimized = testcase and blob_info.key() == testcase.minimized_keys - # Testcase blobs require testcase-level access (which enforces the - # security_flag privileged-access requirement); general access alone is - # only sufficient for non-testcase blobs. - if testcase: - if not access.can_user_access_testcase(testcase): - raise helpers.AccessDeniedError() + if not testcase: + # Non-testcase blob. General access is sufficient. + if access.has_access(): + return self._send_blob(blob_info, testcase_id, is_minimized, + fuzzer_binary_name) + raise helpers.AccessDeniedError() + + # Testcase blob. Check testcase-level access first (this enforces the + # security_flag privileged-access requirement). Authorized users are served + # without an issue-tracker call. + if access.can_user_access_testcase(testcase): return self._send_blob(blob_info, testcase_id, is_minimized, fuzzer_binary_name) - if access.has_access(): - return self._send_blob(blob_info, testcase_id, is_minimized, + # Unauthorized: allow a public download if the testcase's bug has been made + # public, either via the OSS-Fuzz public reproducer policy or a derestricted + # (LIMIT_NONE) Chromium bug. Fetch the issue once and reuse it below. + issue = None + if testcase.bug_information: + issue_tracker = issue_tracker_utils.get_issue_tracker_for_testcase( + testcase) + issue = issue_tracker.get_issue(testcase.bug_information) + + if utils.is_oss_fuzz() and self.check_public_testcase( + issue, blob_info, testcase): + return self._send_blob(blob_info, testcase.key.id(), is_minimized, + fuzzer_binary_name) + + if self.check_derestricted_testcase(issue, blob_info, testcase): + return self._send_blob(blob_info, testcase.key.id(), is_minimized, fuzzer_binary_name) raise helpers.AccessDeniedError()