CVE-2026-47715
Description
Bugsink is a self-hosted error tracking tool. Prior to 2.2.0, Bugsink issue event pages accept a direct event identifier from the URL and, in affected versions, look up that event without also requiring it to belong to the issue in the URL. This is a project-boundary authorization issue: a logged-in user with access to one project can view another project’s event data through an issue they are allowed to access. The affected views include the stacktrace, details, and breadcrumbs pages for an issue event. This vulnerability is fixed in 2.2.0.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Bugsink prior to 2.2.0 allows authenticated users to view events from other projects if they know the event UUID, due to missing authorization checks.
Vulnerability
Bugsink prior to version 2.2.0 contains a project-boundary authorization vulnerability in its issue event views. The stacktrace, details, and breadcrumbs pages accept a direct event identifier from the URL and look up that event without verifying it belongs to the issue or project in the URL [2]. This allows a logged-in user with access to one project to view event data from another project if they know the target event UUID. The vulnerability affects all versions before 2.2.0.
Exploitation
An attacker must be an authenticated user with access to at least one project in Bugsink. They also need prior knowledge of a valid event UUID from another project; there is no enumeration path, and guessing UUIDs is impractical [2]. The attacker can navigate to an issue event page (e.g., stacktrace) within their authorized project and replace the event UUID in the URL with the known UUID from the other project. The page will then display the event data from the unauthorized project because the lookup does not enforce project or issue boundaries.
Impact
Successful exploitation results in low-severity cross-project information disclosure. The attacker can view event details such as stacktraces, breadcrumbs, and other event metadata from another project. No write access or remote code execution is possible. The impact is further mitigated by the fact that Bugsink is often self-hosted within a single trust domain, and Hosted Bugsink provides separate instances per tenant [2].
Mitigation
The vulnerability is fixed in Bugsink version 2.2.0, released on 21 May 2026 [1]. Users should upgrade to 2.2.0 or later. No workarounds are documented. The fix ensures that direct event lookups require the event to belong to both the authorized issue and the project [1][2].
AI Insight generated on May 26, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected products
2Patches
49128661cc450Scope issue event lookup to authorized issue
2 files changed · +27 −3
issues/tests.py+23 −0 modified@@ -441,6 +441,29 @@ def test_issue_details(self): response = self.client.get(f"/issues/issue/{self.issue.id}/event/{self.event.id}/details/") self.assertContains(response, self.issue.title()) + def test_issue_event_views_do_not_show_events_from_other_projects(self): + other_project = Project.objects.create(name="other") + other_issue, _ = get_or_create_issue(other_project) + other_event = create_event(other_project, other_issue, event_data={ + "event_id": uuid.uuid4().hex, + "timestamp": datetime.now(timezone.utc).isoformat(), + "platform": "python", + "exception": {"values": [{"type": "OtherProjectError", "value": "other project stack value"}]}, + "request": {"headers": {"X-Secret": "other-project-header-value"}}, + "breadcrumbs": {"values": [{"category": "other-project", "message": "other project breadcrumb"}]}, + }) + + cases = [ + (f"/issues/issue/{self.issue.id}/event/{other_event.id}/", "other project stack value"), + (f"/issues/issue/{self.issue.id}/event/{other_event.id}/details/", "other-project-header-value"), + (f"/issues/issue/{self.issue.id}/event/{other_event.id}/breadcrumbs/", "other project breadcrumb"), + ] + for url, marker in cases: + with self.subTest(url=url): + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertNotContains(response, marker) + def test_issue_tags(self): response = self.client.get(f"/issues/issue/{self.issue.id}/tags/") self.assertContains(response, self.issue.title())
issues/views.py+4 −3 modified@@ -422,11 +422,12 @@ def _get_event(qs, issue, event_pk, digest_order, nav, bounds): elif event_pk is not None: # we match on both internal and external id, trying internal first try: - return Event.objects.get(pk=event_pk) + return Event.objects.get(issue=issue, pk=event_pk) except Event.DoesNotExist: # we match on external id "for user ergonomics"; notes as in `event_by_id` apply, except for the fact that - # in this case we have project availab, guaranteeing uniqueness & fast lookup. - return Event.objects.get(project=issue.project, event_id=event_pk) + # in this case we have the project available, guaranteeing uniqueness & fast lookup. + # We also filter by issue to guarantee we do not escape the URL's issue scope. + return Event.objects.get(project=issue.project, issue=issue, event_id=event_pk) elif digest_order is not None: # "ergonomics" when people type this in the URL bar
2321b37c6100Scope minidump debug files to projects
5 files changed · +121 −41
files/minidump.py+8 −7 modified@@ -4,7 +4,7 @@ from sentry_sdk_extensions import capture_or_log_exception from bugsink.utils import assert_ -from .models import FileMetadata +from .models import get_file_metadata_for_debug_id def get_single_object(archive): @@ -15,7 +15,7 @@ def get_single_object(archive): return objects[0] -def build_cfi_map_from_minidump_bytes(minidump_bytes): +def build_cfi_map_from_minidump_bytes(minidump_bytes, project): process_state = symbolic.minidump.ProcessState.from_minidump_buffer(minidump_bytes) frame_info_map = symbolic.minidump.FrameInfoMap.new() @@ -25,10 +25,11 @@ def build_cfi_map_from_minidump_bytes(minidump_bytes): continue dashed_debug_id = symbolic.debuginfo.id_from_breakpad(module.debug_id) - if FileMetadata.objects.filter(debug_id=dashed_debug_id, file_type="dbg").count() == 0: + file_metadata = get_file_metadata_for_debug_id(project, dashed_debug_id, "dbg") + if file_metadata is None: continue - dif_bytes = FileMetadata.objects.get(debug_id=dashed_debug_id, file_type="dbg").file.get_raw_data() + dif_bytes = file_metadata.file.get_raw_data() archive = symbolic.debuginfo.Archive.from_bytes(dif_bytes) debug_object = get_single_object(archive) @@ -86,7 +87,7 @@ def _find_module_for_address(process_state, abs_addr: int): return None -def event_threads_for_process_state(process_state): +def event_threads_for_process_state(process_state, project): threads = [] for thread_index, symbolic_thread in enumerate(process_state.threads()): frames = [] @@ -99,7 +100,7 @@ def event_threads_for_process_state(process_state): if module and module.debug_id: dashed_debug_id = symbolic.debuginfo.id_from_breakpad(module.debug_id) - file_metadata = FileMetadata.objects.filter(debug_id=dashed_debug_id, file_type="dbg").first() + file_metadata = get_file_metadata_for_debug_id(project, dashed_debug_id, "dbg") if file_metadata: dif_bytes = file_metadata.file.get_raw_data() @@ -121,7 +122,7 @@ def event_threads_for_process_state(process_state): frame["filename"] = line_info.filename frame["lineno"] = line_info.line - src_meta = FileMetadata.objects.filter(debug_id=dashed_debug_id, file_type="src").first() + src_meta = get_file_metadata_for_debug_id(project, dashed_debug_id, "src") if src_meta and line_info.filename and line_info.line: frame["pre_context"], frame["context_line"], frame["post_context"] = extract_source_context( src_meta.file.get_raw_data(), line_info.filename, line_info.line)
files/tests.py+81 −0 modified@@ -10,6 +10,7 @@ import subprocess import tempfile from pathlib import Path +from types import SimpleNamespace from zipfile import ZipFile, ZIP_DEFLATED from django.test import tag @@ -30,6 +31,7 @@ from bugsink.streams import MaxLengthExceeded from .models import Chunk, File, FileMetadata, get_file_metadata_for_debug_ids +from .minidump import event_threads_for_process_state from .storage_registry import override_object_storages from .tasks import assemble_file from .views import CHUNK_UPLOAD_SIZE @@ -449,6 +451,85 @@ def test_get_file_metadata_for_debug_ids_uses_project_scope_before_legacy_fallba self.assertEqual(scoped_metadata, result[scoped_debug_id]) self.assertEqual(legacy_metadata, result[legacy_debug_id]) + @patch("files.views.extract_dif_metadata") + def test_difs_assemble_stores_project_scoped_metadata(self, mock_extract_dif_metadata): + debug_id = uuid4() + data = b"debug" + checksum = sha1(data, usedforsecurity=False).hexdigest() + Chunk.objects.create(checksum=checksum, size=len(data), data=data) + mock_extract_dif_metadata.return_value = {"kind": "dbg"} + + with bugsink_override_settings(FEATURE_MINIDUMPS=True): + response = self.client.post( + f"/api/0/projects/anyorg/{self.project.slug}/files/difs/assemble/", + json.dumps({ + checksum: { + "chunks": [checksum], + "debug_id": str(debug_id), + "name": "debug-file", + }, + }), + content_type="application/json", + headers=self.token_headers, + ) + + self.assertEqual(200, response.status_code) + metadata = FileMetadata.objects.get(debug_id=debug_id, file_type="dbg") + self.assertEqual(self.project, metadata.project) + self.assertEqual("debug-file", metadata.file.filename) + + @patch("files.minidump.extract_source_context") + @patch("files.minidump.symbolic.debuginfo.Archive.from_bytes") + @patch("files.minidump.symbolic.debuginfo.id_from_breakpad") + def test_minidump_symbolication_uses_project_scoped_debug_files( + self, + mock_id_from_breakpad, + mock_archive_from_bytes, + mock_extract_source_context, + ): + debug_id = uuid4() + other_project = Project.objects.create(name="other") + dbg_file = File.objects.create(checksum="d" * 40, filename="debug-file", size=0, data=b"debug") + src_file = File.objects.create(checksum="e" * 40, filename="source-bundle", size=0, data=b"source") + + FileMetadata.objects.create(project=other_project, debug_id=debug_id, file_type="dbg", file=dbg_file) + FileMetadata.objects.create(project=other_project, debug_id=debug_id, file_type="src", file=src_file) + + line_info = SimpleNamespace(function_name="otherFunction", filename="other.c", line=7) + symcache = SimpleNamespace(lookup=lambda rel: [line_info]) + debug_object = SimpleNamespace(make_symcache=lambda: symcache) + archive = SimpleNamespace(iter_objects=lambda: [debug_object]) + mock_id_from_breakpad.return_value = str(debug_id) + mock_archive_from_bytes.return_value = archive + mock_extract_source_context.return_value = (["pre"], "context", ["post"]) + + module = SimpleNamespace(addr=1000, size=100, debug_id="breakpad-debug-id") + frame = SimpleNamespace(instruction=1010) + thread = SimpleNamespace(thread_id=1, frames=lambda: [frame]) + process_state = SimpleNamespace( + modules=lambda: [module], + threads=lambda: [thread], + requesting_thread=0, + ) + + # Positive case: the debug files work for the project they were uploaded to. + threads = event_threads_for_process_state(process_state, other_project) + rendered_frame = threads[0]["stacktrace"]["frames"][0] + self.assertEqual("otherFunction", rendered_frame["function"]) + self.assertEqual("other.c", rendered_frame["filename"]) + self.assertEqual(7, rendered_frame["lineno"]) + self.assertEqual("context", rendered_frame["context_line"]) + + # Negative case: the same debug ID does not resolve across project boundaries. + mock_archive_from_bytes.reset_mock() + mock_extract_source_context.reset_mock() + + threads = event_threads_for_process_state(process_state, self.project) + rendered_frame = threads[0]["stacktrace"]["frames"][0] + self.assertEqual({"instruction_addr": "0x3f2"}, rendered_frame) + mock_archive_from_bytes.assert_not_called() + mock_extract_source_context.assert_not_called() + @tag("samples") def test_assemble_artifact_bundle(self): SAMPLES_DIR = os.getenv("SAMPLES_DIR", "../event-samples")
files/views.py+27 −29 modified@@ -263,6 +263,10 @@ def difs_assemble(request, organization_slug, project_slug): if not get_settings().FEATURE_MINIDUMPS: return JsonResponse({"detail": "minidumps not enabled"}, status=404) + project = Project.objects.filter(slug=project_slug, is_deleted=False).first() + if project is None: + return JsonResponse({"detail": "Project not found: %s" % project_slug}, status=404) + # TODO move to tasks.something.delay # TODO think about the right transaction around this data = json.loads(request.body) @@ -288,39 +292,33 @@ def difs_assemble(request, organization_slug, project_slug): for file_checksum, file_info in data.items(): if file_checksum in existing_files: - response[file_checksum] = { - "state": ChunkFileState.OK, - "missingChunks": [], - # if it is ever needed, we could add something akin to the below, but so far we've not seen client-side - # actually using this; let's add it on-demand. - # "dif": json_repr_with_key_info_about(existing_files[file_checksum]), - } - continue - - file_chunks = file_info.get("chunks", []) - - # the sentry-cli sends an empty "chunks" list when just polling for file existence; since we already handled the - # case of existing files above, we can simply return NOT_FOUND here. - if not file_chunks: - response[file_checksum] = { - "state": ChunkFileState.NOT_FOUND, - "missingChunks": [], - } - continue - - missing_chunks = [c for c in file_chunks if c not in available_chunks] - if missing_chunks: - response[file_checksum] = { - "state": ChunkFileState.NOT_FOUND, - "missingChunks": missing_chunks, - } - continue - - file, _ = assemble_file(file_checksum, file_chunks, filename=file_info["name"]) + file = existing_files[file_checksum] + else: + file_chunks = file_info.get("chunks", []) + + # the sentry-cli sends an empty "chunks" list when just polling for file existence; since we already handled + # the case of existing files above, we can simply return NOT_FOUND here. + if not file_chunks: + response[file_checksum] = { + "state": ChunkFileState.NOT_FOUND, + "missingChunks": [], + } + continue + + missing_chunks = [c for c in file_chunks if c not in available_chunks] + if missing_chunks: + response[file_checksum] = { + "state": ChunkFileState.NOT_FOUND, + "missingChunks": missing_chunks, + } + continue + + file, _ = assemble_file(file_checksum, file_chunks, filename=file_info["name"]) symbolic_metadata = extract_dif_metadata(file.get_raw_data()) FileMetadata.objects.get_or_create( + project=project, debug_id=file_info.get("debug_id"), # TODO : .get implies "no debug_id", but in that case it's useless file_type=symbolic_metadata["kind"], # NOTE: symbolic's kind goes into file_type... defaults={
ingest/views.py+2 −2 modified@@ -248,7 +248,7 @@ def process_minidump(cls, ingested_at, ingestion_id, minidump_bytes, project, re event_data["platform"] = "native" event_data["errors"] = [] - merge_minidump_event(event_data, minidump_bytes) + merge_minidump_event(event_data, minidump_bytes, project) # write the event data to disk: filename = get_filename_for_event_id(ingestion_id) @@ -374,7 +374,7 @@ def digest_event(cls, event_metadata, event_data, digested_at=None, minidump_byt # we merge after validation: validation is about what's provided _externally_, not our own merging. # TODO error handling # TODO should not be inside immediate_atomic if it turns out to be slow - merge_minidump_event(event_data, minidump_bytes) + merge_minidump_event(event_data, minidump_bytes, project) # I resisted the temptation to put `get_denormalized_fields_for_data` in an if-statement: you basically "always" # need this info... except when duplicate event-ids are sent. But the latter is the exception, and putting this
sentry/minidump.py+3 −3 modified@@ -6,8 +6,8 @@ from files.minidump import build_cfi_map_from_minidump_bytes, event_threads_for_process_state -def merge_minidump_event(data, minidump_bytes): - frame_info_map = build_cfi_map_from_minidump_bytes(minidump_bytes) +def merge_minidump_event(data, minidump_bytes, project): + frame_info_map = build_cfi_map_from_minidump_bytes(minidump_bytes, project) process_state = symbolic.ProcessState.from_minidump_buffer(minidump_bytes, frame_infos=frame_info_map) data['level'] = 'fatal' if process_state.crashed else 'info' @@ -26,7 +26,7 @@ def merge_minidump_event(data, minidump_bytes): os['version'] = info.os_version device['arch'] = info.cpu_family - threads = event_threads_for_process_state(process_state) + threads = event_threads_for_process_state(process_state, project) data.setdefault("threads", {})["values"] = threads if process_state.requesting_thread > -1:
a761c6d912eeScope sourcemap metadata to projects
11 files changed · +365 −46
events/markdown_stacktrace.py+1 −1 modified@@ -136,7 +136,7 @@ def _select_frames(frames, in_app_only): def render_stacktrace_md(event, in_app_only=False, include_locals=True): parsed = event.get_parsed_data() try: - apply_sourcemaps(parsed) + apply_sourcemaps(parsed, event.project) except Exception as e: if settings.DEBUG or settings.I_AM_RUNNING == "TEST": # when developing/testing, I _do_ want to get notified
events/utils.py+5 −13 modified@@ -10,7 +10,7 @@ from compat.timestamp import format_timestamp -from files.models import FileMetadata +from files.models import get_file_metadata_for_debug_ids from files.tasks import record_file_accesses @@ -113,7 +113,7 @@ def _postgres_fix(memoryview_or_bytes): return memoryview_or_bytes -def apply_sourcemaps(event_data): +def apply_sourcemaps(event_data, project): images = event_data.get("debug_meta", {}).get("images", []) if not images: return @@ -124,11 +124,7 @@ def apply_sourcemaps(event_data): if "debug_id" in image and "code_file" in image and image["type"] == "sourcemap" } - metadata_obj_lookup = { - metadata_obj.debug_id: metadata_obj - for metadata_obj in FileMetadata.objects.filter( - debug_id__in=debug_id_for_filename.values(), file_type="source_map").select_related("file") - } + metadata_obj_lookup = get_file_metadata_for_debug_ids(project, debug_id_for_filename.values(), "source_map") metadata_ids = [metadata_obj.id for metadata_obj in metadata_obj_lookup.values()] delay_on_commit(record_file_accesses, metadata_ids, format_timestamp(datetime.now(timezone.utc))) @@ -190,7 +186,7 @@ def apply_sourcemaps(event_data): frame["debug_id"] = str(debug_id_for_filename[frame["filename"]]) -def get_sourcemap_images(event_data): +def get_sourcemap_images(event_data, project): # NOTE: butchered copy/paste of apply_sourcemaps; refactoring for DRY is a TODO images = event_data.get("debug_meta", {}).get("images", []) if not images: @@ -202,11 +198,7 @@ def get_sourcemap_images(event_data): if "debug_id" in image and "code_file" in image and image["type"] == "sourcemap" } - metadata_obj_lookup = { - metadata_obj.debug_id: metadata_obj - for metadata_obj in FileMetadata.objects.filter( - debug_id__in=debug_id_for_filename.values(), file_type="source_map").select_related("file") - } + metadata_obj_lookup = get_file_metadata_for_debug_ids(project, debug_id_for_filename.values(), "source_map") return [ (basename(filename),
files/admin.py+3 −3 modified@@ -27,6 +27,6 @@ def download_link(self, obj): @admin.register(FileMetadata) class FileMetadataAdmin(admin.ModelAdmin): - list_display = ('debug_id', 'file_type', 'file', 'created_at') - search_fields = ('file__checksum', 'debug_id', 'file_type') - readonly_fields = ('file', 'debug_id', 'file_type', 'data', 'created_at') + list_display = ('debug_id', 'file_type', 'project', 'file', 'created_at') + search_fields = ('file__checksum', 'debug_id', 'file_type', 'project__name', 'project__slug') + readonly_fields = ('file', 'project', 'debug_id', 'file_type', 'data', 'created_at')
files/management/commands/delete_legacy_sourcemaps.py+30 −0 added@@ -0,0 +1,30 @@ +from django.core.management.base import BaseCommand + +from bugsink.transaction import immediate_atomic +from files.models import File, FileMetadata + + +class Command(BaseCommand): + """Delete projectless sourcemaps kept only for the project-scoping transition. + + After sourcemap metadata became project-scoped, old projectless metadata + still works as a compatibility fallback. Run this command when you prefer + to remove that fallback immediately and force sourcemaps to be re-uploaded + with explicit project slugs. + """ + + help = "Delete legacy projectless sourcemaps." + + def handle(self, *args, **options): + with immediate_atomic(): + legacy_metadata = FileMetadata.objects.filter(project__isnull=True, file_type="source_map") + file_ids = list(legacy_metadata.values_list("file_id", flat=True)) + metadata_count = legacy_metadata.count() + + legacy_metadata.delete() + + orphan_files = File.objects.filter(id__in=file_ids, metadatas__isnull=True) + file_count = orphan_files.count() + orphan_files.delete() + + self.stdout.write(f"Deleted {metadata_count} legacy sourcemap metadata rows and {file_count} files.")
files/migrations/0004_alter_filemetadata_unique_together_and_more.py+32 −0 added@@ -0,0 +1,32 @@ +# Generated by Django 5.2.12 on 2026-05-19 07:49 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('files', '0003_file_storage_backend'), + ('projects', '0017_project_issue_count'), + ] + + operations = [ + migrations.AlterUniqueTogether( + name='filemetadata', + unique_together=set(), + ), + migrations.AddField( + model_name='filemetadata', + name='project', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='projects.project'), + ), + migrations.AddConstraint( + model_name='filemetadata', + constraint=models.UniqueConstraint(condition=models.Q(('debug_id__isnull', False), ('file_type__isnull', False), ('project__isnull', False)), fields=('project', 'debug_id', 'file_type'), name='filemeta_project_debug_type'), + ), + migrations.AddConstraint( + model_name='filemetadata', + constraint=models.UniqueConstraint(condition=models.Q(('debug_id__isnull', False), ('file_type__isnull', False), ('project__isnull', True)), fields=('debug_id', 'file_type'), name='filemeta_legacy_debug_type'), + ), + ]
files/models.py+67 −4 modified@@ -3,6 +3,7 @@ from io import BytesIO from django.db import models from django.db import transaction +from django.db.models import Q from functools import partial @@ -137,6 +138,11 @@ def _cleanup_objects_on_storage(todos): class FileMetadata(models.Model): file = models.ForeignKey(File, null=False, on_delete=models.CASCADE, related_name="metadatas") + # FileMetadata as provided by the client (e.g. in a manifest); security-wise any facts noted here are not guaranteed + # to be correct / cannot be trusted. Our security boundary is: FileMetadata is bound to a Project, so you can only + # pollute your own Project's FileMetadata. + project = models.ForeignKey("projects.Project", null=True, blank=True, on_delete=models.CASCADE) + # debug_id & file_type nullability: such data exists in manifest.json; we are future-proof for it although we # currently don't store it as such. debug_id = models.UUIDField(max_length=40, null=True, blank=True) @@ -149,7 +155,64 @@ def __str__(self): return f"debug_id: {self.debug_id} ({self.file_type})" class Meta: - # it's _imaginable_ that the below does not actually hold (we just trust the CLI, after all), but that wouldn't - # make any sense, so we just enforce a property that makes sense. Pro: lookups work. Con: if the client sends - # garbage, this is not exposed. - unique_together = (("debug_id", "file_type"),) + # The below is basically the ["project", "debug_id", "file_type"] uniqueness constraint we want, but with a + # twist to allow legacy data without project. We can remove the legacy constraint after a long transition + # period, e.g. May 2027, at which point the first constraint can be simplified to a normal uniqueness constraint + # on the three fields. (just the single unique_together constraint doesn't work because of the nullability of + # project, which would allow multiple entries with the same debug_id and file_type but null project because + # nulls are not considered equal in SQL) + constraints = [ + models.UniqueConstraint( + fields=["project", "debug_id", "file_type"], + condition=Q(project__isnull=False, debug_id__isnull=False, file_type__isnull=False), + name="filemeta_project_debug_type", + ), + models.UniqueConstraint( + fields=["debug_id", "file_type"], + condition=Q(project__isnull=True, debug_id__isnull=False, file_type__isnull=False), + name="filemeta_legacy_debug_type", + ), + ] + + +def get_file_metadata_for_debug_ids(project, debug_ids, file_type): + """Return {debug_id: FileMetadata} for debug files visible to project.""" + debug_ids = set(debug_ids) + if not debug_ids: + return {} + + result = { + metadata.debug_id: metadata + for metadata in FileMetadata.objects.filter( + project=project, + debug_id__in=debug_ids, + file_type=file_type, + ).select_related("file") + } + + missing_debug_ids = debug_ids - set(result) + if missing_debug_ids: + # Compatibility for sourcemaps/debug files uploaded before project-scoped metadata existed. This keeps old + # installs working for now, but should be removed after a long transition period, e.g. May 2027. + result.update({ + metadata.debug_id: metadata + for metadata in FileMetadata.objects.filter( + project__isnull=True, + debug_id__in=missing_debug_ids, + file_type=file_type, + ).select_related("file") + }) + + return result + + +def get_file_metadata_for_debug_id(project, debug_id, file_type): + result = list(get_file_metadata_for_debug_ids(project, [debug_id], file_type).values()) + if len(result) == 0: + return None + + if len(result) > 1: + # Should be prevented by database constraints; getting here would mean our lookup logic is wrong. + raise RuntimeError("Multiple FileMetadata objects found for one debug_id") + + return result[0]
files/tasks.py+17 −10 modified@@ -109,7 +109,7 @@ def find_in_code_debug_ids(local_file): @shared_task -def assemble_artifact_bundle(bundle_checksum, chunk_checksums): +def assemble_artifact_bundle(bundle_checksum, chunk_checksums, project_ids): # arguably, you could just wrap-around each operation, "around everything" guarantees a fully consistent update on # the data and we don't do this that often that it's assumed to matter. with immediate_atomic(): @@ -119,6 +119,7 @@ def assemble_artifact_bundle(bundle_checksum, chunk_checksums): # support that, but if we ever were to support it we'd need a separate method/param to distinguish it. bundle_file, _ = assemble_file(bundle_checksum, chunk_checksums, filename=f"{bundle_checksum}.zip") + max_file_size = get_settings().MAX_FILE_SIZE with tempfile.TemporaryDirectory() as tempdir: with bundle_file.open_for_read() as f: @@ -153,14 +154,16 @@ def assemble_artifact_bundle(bundle_checksum, chunk_checksums): continue - FileMetadata.objects.get_or_create( - debug_id=debug_id, - file_type=file_type, - defaults={ - "file": file, - "data": json.dumps(manifest_entry), - } - ) + for project_id in project_ids: + FileMetadata.objects.get_or_create( + project_id=project_id, + debug_id=debug_id, + file_type=file_type, + defaults={ + "file": file, + "data": json.dumps(manifest_entry), + } + ) # the in-code regexes show up in the _minified_ source only (the sourcemap's original source # code will not have been "polluted" with it yet, since it's the original). @@ -188,7 +191,11 @@ def assemble_file(checksum, chunk_checksums, filename): # NOTE: unimplemented checks/tricks # * total file-size v.s. some max # * explicit check chunk availability - # * skip this whole thing when the (whole-file) checksum exists + + try: + return File.objects.get(checksum=checksum), False + except File.DoesNotExist: + pass # i.e. continue below chunks = Chunk.objects.filter(checksum__in=chunk_checksums) chunks_dicts = {chunk.checksum: chunk for chunk in chunks}
files/tests.py+87 −10 modified@@ -29,7 +29,7 @@ from bugsink.app_settings import override_settings as bugsink_override_settings from bugsink.streams import MaxLengthExceeded -from .models import Chunk, File, FileMetadata +from .models import Chunk, File, FileMetadata, get_file_metadata_for_debug_ids from .storage_registry import override_object_storages from .tasks import assemble_file from .views import CHUNK_UPLOAD_SIZE @@ -58,7 +58,7 @@ class FilesTests(TransactionTestCase): def setUp(self): super().setUp() self.user = User.objects.create_user(username='test', password='test') - self.project = Project.objects.create() + self.project = Project.objects.create(name="test") ProjectMembership.objects.create(project=self.project, user=self.user) self.client.force_login(self.user) self.auth_token = AuthToken.objects.create() @@ -206,7 +206,7 @@ def test_artifact_bundle_rejects_extracted_file_larger_than_max_file_size(self): with self.assertRaises(MaxLengthExceeded): self.client.post( "/api/0/organizations/anyorg/artifactbundle/assemble/", - json.dumps({"checksum": checksum, "chunks": [checksum], "projects": ["unused"]}), + json.dumps({"checksum": checksum, "chunks": [checksum], "projects": [self.project.slug]}), content_type="application/json", headers=self.token_headers, ) @@ -222,7 +222,11 @@ def test_artifact_bundle_assemble_does_not_use_checksum_as_temp_filename(self): try: self.client.post( "/api/0/organizations/anyorg/artifactbundle/assemble/", - json.dumps({"checksum": str(probe_path), "chunks": [real_checksum], "projects": ["unused"]}), + json.dumps({ + "checksum": str(probe_path), + "chunks": [real_checksum], + "projects": [self.project.slug], + }), content_type="application/json", headers=self.token_headers, ) @@ -421,6 +425,30 @@ def test_uuid_behavior_of_django(self): fms = FileMetadata.objects.filter(debug_id__in=[test_with]) self.assertEqual(1, fms.count()) + def test_get_file_metadata_for_debug_ids_uses_project_scope_before_legacy_fallback(self): + scoped_debug_id = uuid4() + legacy_debug_id = uuid4() + file = File.objects.create(checksum="a" * 40, filename="scoped.js.map", size=0) + legacy_file = File.objects.create(checksum="b" * 40, filename="legacy.js.map", size=0) + + scoped_metadata = FileMetadata.objects.create( + project=self.project, + debug_id=scoped_debug_id, + file_type="source_map", + file=file, + ) + FileMetadata.objects.create(debug_id=scoped_debug_id, file_type="source_map", file=legacy_file) + legacy_metadata = FileMetadata.objects.create(debug_id=legacy_debug_id, file_type="source_map", file=file) + + result = get_file_metadata_for_debug_ids( + self.project, + [scoped_debug_id, legacy_debug_id], + "source_map", + ) + + self.assertEqual(scoped_metadata, result[scoped_debug_id]) + self.assertEqual(legacy_metadata, result[legacy_debug_id]) + @tag("samples") def test_assemble_artifact_bundle(self): SAMPLES_DIR = os.getenv("SAMPLES_DIR", "../event-samples") @@ -461,7 +489,7 @@ def test_assemble_artifact_bundle(self): checksum, # single-chunk upload, so this works ], "projects": [ - "unused_for_now" + self.project.slug ] } @@ -551,7 +579,7 @@ def test_assemble_artifact_bundle_small_chunks(self): "checksum": checksum, "chunks": seen_checksums, "projects": [ - "unused_for_now" + self.project.slug ] } @@ -587,6 +615,7 @@ def setUp(self): super().setUp() auth = AuthToken.objects.create(description="test token") self.token = auth.token + self.project = Project.objects.create(name="test") # sentry-cli asks _us_ for our own URL... self.enterContext(bugsink_override_settings(BASE_URL=self.live_server_url)) @@ -597,7 +626,7 @@ def setUp(self): self.enterContext(override_settings(DEBUG_PROPAGATE_EXCEPTIONS=True)) self.tempdir = self.enterContext(tempfile.TemporaryDirectory()) - def _run(self, args): + def _run(self, args, expect_success=True): sp = subprocess.run( args, cwd=self.tempdir, @@ -608,8 +637,12 @@ def _run(self, args): stdout=subprocess.PIPE, stderr=subprocess.STDOUT, # merge stderr into stdout so we can see it in test failures ) - if sp.returncode != 0: - raise Exception(f"Command {args} failed with output:\n{sp.stdout.decode('utf-8')}") + output = sp.stdout.decode('utf-8') + if expect_success and sp.returncode != 0: + raise Exception(f"Command {args} failed with output:\n{output}") + if not expect_success and sp.returncode == 0: + raise Exception(f"Command {args} unexpectedly succeeded with output:\n{output}") + return output def test_sentry_cli_upload(self): # Test-the-test (these things should live in your env, as per requirements.development.txt) @@ -647,7 +680,7 @@ def test_sentry_cli_upload(self): "--org", "bugsinkhasnoorgs", "--project", - "ignoredfornow", + self.project.slug, "upload", str(map_path), ]) @@ -660,6 +693,50 @@ def test_sentry_cli_upload(self): self.assertEqual( UUID('9b40e0f3-8084-5931-94d4-8d941780a177'), FileMetadata.objects.get(file__filename="captureException.js.map").debug_id) + self.assertEqual(self.project, FileMetadata.objects.get(file__filename="captureException.js.map").project) + + # sentry-cli sourcemaps upload with bogus project + output = self._run([ + sentry_cli, + "--log-level=debug", + "--url", + self.live_server_url, + "sourcemaps", + "--org", + "bugsinkhasnoorgs", + "--project", + "intentionallynonexistentproject", + "upload", + str(map_path), + ], expect_success=False) + self.assertIn("Unknown project(s): intentionallynonexistentproject", output) + + # sentry-cli sourcemaps upload with multiple projects + other_project = Project.objects.create(name="other") + self._run([ + sentry_cli, + "--log-level=debug", + "--url", + self.live_server_url, + "sourcemaps", + "--org", + "bugsinkhasnoorgs", + "--project", + self.project.slug, + "--project", + other_project.slug, + "upload", + str(map_path), + ]) + + self.assertEqual( + {self.project.id, other_project.id}, + set( + FileMetadata.objects + .filter(file__filename="captureException.js.map") + .values_list("project", flat=True) + ), + ) MINIMAL_JS = """\
files/views.py+26 −1 modified@@ -15,6 +15,7 @@ from bugsink.transaction import durable_atomic, immediate_atomic from bugsink.streams import handle_request_content_encoding, copy_stream_limited, MaxLengthExceeded from bsmain.models import AuthToken +from projects.models import Project from .models import Chunk, File, FileMetadata from .tasks import assemble_artifact_bundle, assemble_file @@ -26,6 +27,10 @@ _KIBIBYTE = 1024 _MEBIBYTE = 1024 * _KIBIBYTE CHUNK_UPLOAD_SIZE = 2 * _MEBIBYTE +PROJECT_REQUIRED_MESSAGE = ( + "Starting with Bugsink 2.2.0, sourcemap uploads must name existing Bugsink project slugs. " + "Use sentry-cli --project <project-slug>." +) def get_chunk_upload_settings(request, organization_slug): @@ -135,6 +140,23 @@ def first_require_auth_token(request, *args, **kwargs): return first_require_auth_token +def get_artifact_bundle_projects(data): + project_slugs = data.get("projects") or [] + if not isinstance(project_slugs, list): + return None, PROJECT_REQUIRED_MESSAGE + + if not project_slugs: + return None, PROJECT_REQUIRED_MESSAGE + + projects = list(Project.objects.filter(slug__in=project_slugs, is_deleted=False)) + projects_by_slug = {project.slug: project for project in projects} + unknown_slugs = sorted(set(project_slugs) - set(projects_by_slug)) + if unknown_slugs: + return None, PROJECT_REQUIRED_MESSAGE + " Unknown project(s): %s." % ", ".join(unknown_slugs) + + return [projects_by_slug[slug] for slug in dict.fromkeys(project_slugs)], None + + @csrf_exempt @requires_auth_token def chunk_upload(request, organization_slug): @@ -214,6 +236,9 @@ def artifact_bundle_assemble(request, organization_slug): data = json.loads(request.body) checksum = data["checksum"] chunk_checksums = data["chunks"] + projects, error = get_artifact_bundle_projects(data) + if error is not None: + return JsonResponse({"error": error}, status=400) # sentry-cli >= 3.x calls this endpoint before uploading chunks (to learn which ones are missing), then uploads # only the missing chunks, and then polls this endpoint again. We must return the actual missing chunks; returning @@ -226,7 +251,7 @@ def artifact_bundle_assemble(request, organization_slug): if missing_chunks: return JsonResponse({"state": ChunkFileState.NOT_FOUND, "missingChunks": missing_chunks}) - assemble_artifact_bundle.delay(checksum, chunk_checksums) + assemble_artifact_bundle.delay(checksum, chunk_checksums, [project.id for project in projects]) # In the ALWAYS_EAGER setup, we process the bundle inline, so arguably we could return "OK" here too; "CREATED" is # what sentry returns though, so for faithful mimicking it's the safest bet. return JsonResponse({"state": ChunkFileState.CREATED, "missingChunks": []})
issues/tests.py+95 −2 modified@@ -4,11 +4,13 @@ import uuid import json import hashlib -from io import StringIO +import gzip +from io import BytesIO, StringIO from glob import glob from unittest import TestCase as RegularTestCase from unittest.mock import patch from datetime import datetime, timezone +from zipfile import ZIP_DEFLATED, ZipFile from django.test import TestCase as DjangoTestCase from django.contrib.auth import get_user_model @@ -24,6 +26,7 @@ from bsmain.management.commands.send_json import Command as SendJsonCommand from compat.dsn import get_header_value from events.models import Event +from bsmain.models import AuthToken from ingest.views import BaseIngestAPIView from issues.factories import get_or_create_issue from tags.models import store_tags @@ -407,7 +410,7 @@ class ViewTests(TransactionTestCase): def setUp(self): super().setUp() self.user = User.objects.create_user(username='test', password='test') - self.project = Project.objects.create() + self.project = Project.objects.create(name="test") ProjectMembership.objects.create(project=self.project, user=self.user) self.issue, _ = get_or_create_issue(self.project) self.event = create_event(self.project, self.issue, project_digest_order=1) @@ -607,6 +610,96 @@ def lookup_left(self, line, column): self.assertContains(response, "good-source.ts") self.assertContains(response, "mappedFunction</span> line <span class=\"font-bold\">11</span>") + @patch("events.utils.ecma426.loads") + def test_sourcemap_uploads_are_project_scoped_when_rendering_events(self, mock_ecma426_loads): + debug_id = uuid.uuid4() + auth_token = AuthToken.objects.create() + other_project = Project.objects.create(name="other") + ProjectMembership.objects.create(project=other_project, user=self.user) + other_issue, _ = get_or_create_issue(other_project) + sourcemap = json.dumps({ + "version": 3, + "sources": ["other-project-source.ts"], + "sourcesContent": ["other project source"], + "names": [], + "mappings": "", + }) + bundle = BytesIO() + with ZipFile(bundle, "w", compression=ZIP_DEFLATED) as zf: + zf.writestr("manifest.json", json.dumps({ + "files": { + "~/app.js.map": { + "url": "~/app.js.map", + "type": "source_map", + "headers": {"debug-id": str(debug_id)}, + }, + }, + })) + zf.writestr("~/app.js.map", sourcemap) + + bundle_data = bundle.getvalue() + checksum = hashlib.sha1(bundle_data, usedforsecurity=False).hexdigest() + upload = BytesIO(gzip.compress(bundle_data)) + upload.name = checksum + + response = self.client.post( + "/api/0/organizations/anyorg/chunk-upload/", + data={"file_gzip": upload}, + headers={"Authorization": f"Bearer {auth_token.token}"}, + ) + self.assertEqual(200, response.status_code) + + response = self.client.post( + "/api/0/organizations/anyorg/artifactbundle/assemble/", + json.dumps({"checksum": checksum, "chunks": [checksum], "projects": [other_project.slug]}), + content_type="application/json", + headers={"Authorization": f"Bearer {auth_token.token}"}, + ) + self.assertEqual(200, response.status_code) + + class FakeMapping: + source = "other-project-source.ts" + original_line = 0 + name = "mappedFunction" + + class GoodSourceMap: + def lookup_left(self, line, column): + if (line, column) == (5, 12): + return FakeMapping() + + mock_ecma426_loads.return_value = GoodSourceMap() + + event_data = { + "event_id": uuid.uuid4().hex, + "timestamp": datetime.now(timezone.utc).isoformat(), + "platform": "javascript", + "exception": { + "values": [{ + "type": "Error", + "value": "test", + "stacktrace": {"frames": [{"filename": "good.js", "lineno": 6, "colno": 12, "in_app": True}]}, + }] + }, + "debug_meta": { + "images": [{"type": "sourcemap", "code_file": "good.js", "debug_id": str(debug_id)}] + }, + } + + # Positive case: the sourcemap works for the project it was uploaded to. + other_event = create_event(other_project, other_issue, event_data=event_data, project_digest_order=1) + response = self.client.get(f"/issues/issue/{other_issue.id}/event/{other_event.id}/") + self.assertEqual(200, response.status_code) + self.assertContains(response, "other-project-source.ts") + self.assertContains(response, "mappedFunction</span> line <span class=\"font-bold\">1</span>") + + # Negative case: the same debug ID does not resolve across project boundaries. + event = create_event(self.project, self.issue, event_data=event_data, project_digest_order=2) + + response = self.client.get(f"/issues/issue/{self.issue.id}/event/{event.id}/") + self.assertEqual(200, response.status_code) + self.assertContains(response, f"No sourcemaps found for Debug ID {debug_id}") + self.assertNotContains(response, "other project source") + @tag("samples") @tag("integration")
issues/views.py+2 −2 modified@@ -482,7 +482,7 @@ def issue_event_stacktrace(request, issue, event_pk=None, digest_order=None, nav sentry_sdk.capture_exception(e) try: - apply_sourcemaps(parsed_data) + apply_sourcemaps(parsed_data, issue.project) except Exception as e: if settings.DEBUG or settings.I_AM_RUNNING == "TEST": # when developing/testing, I _do_ want to get notified @@ -683,7 +683,7 @@ def issue_event_details(request, issue, event_pk=None, digest_order=None, nav=No contexts = get_contexts_enriched_with_ua(parsed_data) try: - sourcemaps_images = get_sourcemap_images(parsed_data) + sourcemaps_images = get_sourcemap_images(parsed_data, issue.project) except Exception as e: if settings.DEBUG or settings.I_AM_RUNNING == "TEST": # when developing/testing, I _do_ want to get notified
1a98424a87ccScope issue-list bulk actions to authorized project
2 files changed · +14 −1
issues/tests.py+13 −0 modified@@ -420,6 +420,19 @@ def test_issue_list_view(self): response = self.client.get(f"/issues/{self.project.id}/") self.assertContains(response, self.issue.title()) + def test_issue_list_bulk_action_ignores_issues_from_other_projects(self): + other_project = Project.objects.create(name="other") + other_issue, _ = get_or_create_issue(other_project) + + response = self.client.post( + f"/issues/{self.project.id}/", + {"issue_ids[]": [str(other_issue.id)], "action": "resolve"}, + ) + + self.assertEqual(response.status_code, 200) + other_issue.refresh_from_db() + self.assertFalse(other_issue.is_resolved) + def test_issue_stacktrace(self): response = self.client.get(f"/issues/issue/{self.issue.id}/event/{self.event.id}/") self.assertContains(response, self.issue.title())
issues/views.py+1 −1 modified@@ -291,7 +291,7 @@ def issue_list(request, project_pk, state_filter="open"): def _issue_list_pt_1(request, project, state_filter="open"): if request.method == "POST": issue_ids = request.POST.getlist('issue_ids[]') - issue_qs = Issue.objects.filter(pk__in=issue_ids) + issue_qs = Issue.objects.filter(project=project, is_deleted=False, pk__in=issue_ids) illegal_conditions = _q_for_invalid_for_action(request.POST["action"]) # list() is necessary because we need to evaluate the qs before any actions are actually applied (if we don't, # actions are always marked as illegal, because they are applied first, then checked (and applying twice is
Vulnerability mechanics
Root cause
"Missing project-boundary authorization check: event and issue lookups did not verify that the requested resource belongs to the project authorized through the URL."
Attack vector
An authenticated attacker who is a member of Project A can craft a URL such as `/issues/issue/{Project-A-Issue-ID}/event/{Project-B-Event-UUID}/` to view event data (stacktrace, details, breadcrumbs) from Project B, provided they know the target event's UUID [patch_id=2565636]. The attacker must be logged in and have access to at least one issue in their own project. The event UUID is not easily guessable, raising the attack complexity (CVSS:3.1/AV:N/AC:H). Similarly, a project member can send a POST to `/issues/{Project-A-ID}/` with `issue_ids[]` containing an issue UUID from another project to alter that issue's state (e.g., resolve it) [patch_id=2565634].
Affected code
The vulnerability spans three views in `issues/views.py`: the stacktrace, details, and breadcrumbs pages for an issue event. The `_get_event` function performed a direct event UUID lookup without requiring the event to belong to the issue in the URL [patch_id=2565636]. Additionally, the issue-list bulk action view in `issues/views.py` applied actions to issue IDs without scoping them to the authorized project [patch_id=2565634].
What the fix does
The fix in `issues/views.py` adds `issue=issue` to the `Event.objects.get()` calls in `_get_event`, ensuring the looked-up event belongs to the issue in the URL [patch_id=2565636]. For the bulk action view, the fix scopes the issue queryset to `Issue.objects.filter(project=project, is_deleted=False, pk__in=issue_ids)` so that actions cannot affect issues outside the authorized project [patch_id=2565634]. Regression tests were added for both cases to confirm cross-project event data and state changes are rejected.
Preconditions
- authAttacker must be a logged-in user with membership in at least one project
- inputAttacker must know the target event's UUID (for event views) or target issue's UUID (for bulk actions)
- networkAttacker must have network access to the Bugsink instance
Generated on May 26, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
2News mentions
0No linked articles in our index yet.