VYPR
Low severity3.1NVD Advisory· Published May 26, 2026

CVE-2026-47715

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

2
  • Bugsink/Bugsinkreferences2 versions
    (expand)+ 1 more
    • (no CPE)
    • (no CPE)range: <2.2.0

Patches

4
9128661cc450

Scope issue event lookup to authorized issue

https://github.com/bugsink/bugsinkKlaas van SchelvenMay 19, 2026Fixed in 2.2.0via llm-release-walk
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
    
2321b37c6100

Scope minidump debug files to projects

https://github.com/bugsink/bugsinkKlaas van SchelvenMay 19, 2026Fixed in 2.2.0via llm-release-walk
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:
    
a761c6d912ee

Scope sourcemap metadata to projects

https://github.com/bugsink/bugsinkKlaas van SchelvenMay 19, 2026Fixed in 2.2.0via llm-release-walk
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
    
1a98424a87cc

Scope issue-list bulk actions to authorized project

https://github.com/bugsink/bugsinkKlaas van SchelvenMay 19, 2026Fixed in 2.2.0via llm-release-walk
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

2

News mentions

0

No linked articles in our index yet.