CVE-2026-47728
Description
Bugsink is a self-hosted error tracking tool. Prior to 2.2.0, Bugsink resolved sourcemaps and debug files by debug ID without scoping that lookup to the project that owned the uploaded metadata. An authenticated user with access to one project could cause event processing in that project to use sourcemap/debug-file metadata uploaded for another project in the same Bugsink instance, if the same debug ID was referenced. This vulnerability is fixed in 2.2.0.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Bugsink before 2.2.0 allowed cross-project sourcemap/debug-file metadata reuse via debug ID, potentially leaking source context.
Vulnerability
Bugsink prior to version 2.2.0 resolved sourcemaps and debug files by debug ID without scoping that lookup to the project that owned the uploaded metadata [1]. This means that when processing an event, the system would use any uploaded sourcemap or debug file with a matching debug ID, regardless of which project it was uploaded to. The affected versions are 2.1.3 and earlier [1]. For minidumps/debug files specifically, the functionality required the FEATURE_MINIDUMPS flag to be enabled, which was marked experimental [1].
Exploitation
An authenticated user with access to one project could cause event processing in that project to use sourcemap/debug-file metadata uploaded for another project in the same Bugsink instance, if the same debug ID was referenced [1]. The attacker would need to know or guess a debug ID from another project, or cause an event in their own project to reference a debug ID that exists in another project's uploaded metadata. For sourcemaps, the documented upload flow using sentry-cli sourcemaps upload did not treat the project parameter as meaningful ownership, which could lead users to expect project boundaries that were not enforced [1].
Impact
Successful exploitation could disclose source context or symbolication-derived context from another project on the same Bugsink instance [1]. The practical impact is limited by Bugsink's deployment model: self-hosted instances are commonly operated within a single organization/trust domain, and Hosted Bugsink uses separate instances per tenant, so the issue does not cross tenant boundaries [1].
Mitigation
The vulnerability is fixed in Bugsink version 2.2.0, released on 21 May 2026 [1][2]. Users should upgrade to 2.2.0. After upgrading, sourcemaps and debug files should be uploaded with project information. To immediately remove legacy projectless sourcemap metadata, administrators can run bugsink-manage delete_legacy_sourcemaps after upgrading [1].
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
42321b37c6100Scope 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
9128661cc450Scope 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
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-scoping in debug-ID lookups allowed cross-project use of sourcemap and debug-file metadata."
Attack vector
An authenticated attacker with access to project A uploads a sourcemap or debug file with a specific debug ID. They then craft an event in project A that references the same debug ID, but the debug file was actually uploaded for project B. Because the pre-patch code resolved debug IDs globally without scoping to the owning project, the event processing in project A would use project B's uploaded sourcemap/debug-file metadata [patch_id=2565632][patch_id=2565630]. The attacker must know or guess a debug ID used by another project, and must have valid credentials for at least one project on the same Bugsink instance. The vulnerability is exploitable over the network via the standard event ingestion and sourcemap/debug-file upload APIs.
Affected code
The core defect is in `files/models.py` where `FileMetadata` lacked a `project` foreign key, making debug-ID lookups global. The patch adds a `project` field to `FileMetadata` and introduces `get_file_metadata_for_debug_ids()` and `get_file_metadata_for_debug_id()` helper functions that scope lookups to the event's project, with a legacy fallback for pre-existing unscoped metadata [patch_id=2565632]. The `files/views.py` `difs_assemble` view now resolves the project from the URL and stores it on `FileMetadata` [patch_id=2565630]. The `files/minidump.py` functions `build_cfi_map_from_minidump_bytes` and `event_threads_for_process_state` now accept a `project` parameter and use the scoped lookup [patch_id=2565630]. The `sentry/minidump.py` and `ingest/views.py` callers pass the project through [patch_id=2565630].
What the fix does
The fix adds a `project` foreign key to the `FileMetadata` model and introduces two new lookup functions — `get_file_metadata_for_debug_ids()` and `get_file_metadata_for_debug_id()` — that first search for metadata scoped to the event's project, then fall back to legacy unscoped metadata for backward compatibility [patch_id=2565632]. The `difs_assemble` view now resolves the project from the URL slug and stores it on the created `FileMetadata` [patch_id=2565630]. All minidump symbolication code paths (`build_cfi_map_from_minidump_bytes`, `event_threads_for_process_state`, `merge_minidump_event`) now accept and pass a `project` parameter to use the scoped lookup [patch_id=2565630]. Database constraints enforce uniqueness of `(project, debug_id, file_type)` for scoped entries and `(debug_id, file_type)` for legacy unscoped entries [patch_id=2565632]. A management command `delete_legacy_sourcemaps` is provided for installations that want to remove the legacy fallback immediately [patch_id=2565632].
Preconditions
- authAttacker must have valid authentication credentials for at least one project on the Bugsink instance
- inputAttacker must know or guess a debug ID used by another project's uploaded sourcemap or debug file
- configThe target project must have uploaded sourcemap or debug-file metadata with a debug ID that the attacker can reference
- networkNetwork access to the Bugsink instance's API endpoints for event ingestion and file upload
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.