From b26d3ad0c8f5b4cd94f95d43f641ebfcd20015ec Mon Sep 17 00:00:00 2001 From: herdiyana256 Date: Sat, 15 Aug 2026 01:32:03 +0700 Subject: [PATCH] Require job access in coverage_report before redirecting GET /coverage-report resolved a job's coverage report URL and redirected to it without an access check. The handler only uses @handler.oauth, which runs even when the Authorization header is absent, so the route is reachable without authentication, and get_report_url validated only the job name and date formats. Coverage reports are per-job data. The other job-scoped handlers already gate on it (fuzzer_stats uses access.has_access(job_type=...)), so any caller, including an unauthenticated one, could resolve the coverage report location and job-to-project mapping for jobs they cannot access. Add access.has_access(job_type=job) after the job name validation, plus a test that a caller without access gets AccessDeniedError. --- src/appengine/handlers/coverage_report.py | 8 ++++++++ .../appengine/handlers/coverage_report_test.py | 13 +++++++++++++ 2 files changed, 21 insertions(+) diff --git a/src/appengine/handlers/coverage_report.py b/src/appengine/handlers/coverage_report.py index 7c67cf55935..85ab06d1435 100644 --- a/src/appengine/handlers/coverage_report.py +++ b/src/appengine/handlers/coverage_report.py @@ -20,6 +20,7 @@ from clusterfuzz._internal.datastore import data_types from clusterfuzz._internal.metrics import fuzzer_stats from handlers import base_handler +from libs import access from libs import handler from libs import helpers @@ -60,6 +61,13 @@ def get_report_url(report_type, argument, date): if not data_types.Job.VALID_NAME_REGEX.match(job): raise helpers.EarlyExitError('Invalid job name.', 400) + # Coverage reports are per-job data; gate on job access like the other + # job-scoped handlers (e.g. fuzzer_stats). @handler.oauth does not require + # authentication, so without this any caller could resolve the report URL + # for an arbitrary job. + if not access.has_access(job_type=job): + raise helpers.AccessDeniedError() + if not date or not VALID_DATE_REGEX.match(date): raise helpers.EarlyExitError('Invalid date.', 400) diff --git a/src/clusterfuzz/_internal/tests/appengine/handlers/coverage_report_test.py b/src/clusterfuzz/_internal/tests/appengine/handlers/coverage_report_test.py index df6414d3631..24b432c1edf 100644 --- a/src/clusterfuzz/_internal/tests/appengine/handlers/coverage_report_test.py +++ b/src/clusterfuzz/_internal/tests/appengine/handlers/coverage_report_test.py @@ -19,6 +19,7 @@ from clusterfuzz._internal.tests.test_libs import helpers from clusterfuzz._internal.tests.test_libs import test_utils from handlers import coverage_report +from libs import helpers as libs_helpers @test_utils.with_cloud_emulators('datastore') @@ -27,6 +28,10 @@ class CoverageReportTest(unittest.TestCase): def setUp(self): helpers.patch_environ(self) + helpers.patch(self, ['libs.access.has_access']) + # Default to a caller that is allowed to access the job; the access-denied + # path is exercised explicitly in test_no_access. + self.mock.has_access.return_value = True self.today = datetime.datetime.utcnow().date() self.today_minus_2 = self.today - datetime.timedelta(days=2) @@ -63,3 +68,11 @@ def test_get_none(self): report_url = coverage_report.get_report_url('job', 'fake_job', 'latest') expected_url = None self.assertEqual(expected_url, report_url) + + def test_no_access(self): + """Tests that a caller without access to the job is denied instead of + being handed the coverage report location.""" + self.mock.has_access.return_value = False + with self.assertRaises(libs_helpers.AccessDeniedError): + coverage_report.get_report_url('job', 'job1', 'latest') + self.mock.has_access.assert_called_with(job_type='job1')