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

CVE-2026-47716

CVE-2026-47716

Description

Bugsink is a self-hosted error tracking tool. Prior to 2.2.0, In affected versions, the issue list view authorizes access through the project in the URL, but applies the requested bulk action to the submitted issue IDs without also requiring those issues to belong to that project. 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 perform bulk actions on issues from other projects if they know the issue UUID.

Vulnerability

In Bugsink versions prior to 2.2.0, the issue list view authorizes access through the project in the URL but applies the requested bulk action to the submitted issue IDs without verifying that those issues belong to that project [1][2]. This allows a logged-in user with access to one project to modify the state (e.g., resolve or mute) of issues in another project, provided they know the target issue's UUID [2].

Exploitation

An attacker must be an authenticated user with access to at least one project in Bugsink. They need to know a valid issue UUID from another project; there is no enumeration path, and guessing UUIDs is not practical [2]. The attacker can then use the issue list view of their authorized project, submit a bulk action request containing the known UUID, and the action will be applied to the foreign issue without proper authorization checks [2].

Impact

Successful exploitation allows an attacker to change the state (e.g., resolve, mute) of issues in other projects, violating project boundaries [2]. The impact is limited to state modification; no data disclosure, privilege escalation, or remote code execution is possible. The severity is low because the attacker must already know a valid issue UUID, and Bugsink is commonly self-hosted within a single trust domain [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, and the issue is not listed on CISA's Known Exploited Vulnerabilities catalog.

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

3
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
    
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
    
1b79253ab79b

Prove views do not cross project boundaries

https://github.com/bugsink/bugsinkKlaas van SchelvenMay 19, 2026Fixed in 2.2.0via llm-release-walk
5 files changed · +101 3
  • events/tests.py+10 0 modified
    @@ -55,6 +55,16 @@ def test_event_plaintext(self):
             self.assertEqual(response.status_code, 200)
             self.assertEqual(response['Content-Type'], 'text/plain')
     
    +    def test_event_views_forbid_events_from_other_projects(self):
    +        other_project = Project.objects.create(name="other")
    +        other_issue, _ = get_or_create_issue(project=other_project)
    +        other_event = create_event(other_project, other_issue)
    +
    +        for suffix in ["download/", "raw/", "plain/", "md/"]:
    +            with self.subTest(suffix=suffix):
    +                response = self.client.get(f"/events/event/{other_event.pk}/{suffix}")
    +                self.assertEqual(response.status_code, 403)
    +
     
     class TimeZoneTestCase(DjangoTestCase):
         """This class contains some tests that formalize my understanding of how Django works; they are not strictly tests
    
  • issues/tests.py+23 1 modified
    @@ -22,7 +22,7 @@
     from bugsink.utils import get_model_topography
     from projects.models import Project, ProjectMembership
     from releases.models import create_release_if_needed
    -from events.factories import create_event
    +from events.factories import create_event, create_event_data
     from bsmain.management.commands.send_json import Command as SendJsonCommand
     from compat.dsn import get_header_value
     from events.models import Event
    @@ -476,6 +476,28 @@ def test_issue_history(self):
             response = self.client.get(f"/issues/issue/{self.issue.id}/history/")
             self.assertContains(response, self.issue.title())
     
    +    def test_history_comment_edit_and_delete_scope_to_issue(self):
    +        other_issue, _ = get_or_create_issue(self.project, create_event_data(exception_type="OtherIssue"))
    +        other_comment = TurningPoint.objects.create(
    +            project=self.project,
    +            issue=other_issue,
    +            kind=TurningPointKind.MANUAL_ANNOTATION,
    +            user=self.user,
    +            comment="leave me alone",
    +            timestamp=datetime.now(timezone.utc),
    +        )
    +
    +        response = self.client.post(
    +            f"/issues/issue/{self.issue.id}/history/comment/{other_comment.id}/",
    +            {"comment": "changed"},
    +        )
    +        self.assertEqual(response.status_code, 404)
    +
    +        response = self.client.post(f"/issues/issue/{self.issue.id}/history/comment/{other_comment.id}/delete/")
    +        self.assertEqual(response.status_code, 404)
    +        other_comment.refresh_from_db()
    +        self.assertEqual(other_comment.comment, "leave me alone")
    +
         def test_issue_event_list(self):
             response = self.client.get(f"/issues/issue/{self.issue.id}/events/")
             self.assertContains(response, self.issue.title())
    
  • issues/views.py+1 0 modified
    @@ -355,6 +355,7 @@ def event_by_id(request, event_pk):
         # needing that event's issue id when rendering the link, and [b] For external id, it's a useful way to construct
         # links: the external id is all that's known SDK-side and may show up in a log or be stored in a DB.
         # Note that no Auth is needed here because nothing is actually shown.
    +    # The redirect may reveal an issue UUID, but does not render event data; issue UUIDs are not treated as secrets.
         try:
             event = Event.objects.get(pk=event_pk)
         except Event.DoesNotExist:
    
  • projects/tests.py+37 1 modified
    @@ -5,7 +5,7 @@
     
     from bugsink.test_utils import TransactionTestCase25251 as TransactionTestCase
     from bugsink.utils import get_model_topography
    -from projects.models import Project, ProjectMembership
    +from projects.models import Project, ProjectMembership, ProjectRole
     from events.factories import create_event
     from issues.factories import get_or_create_issue, denormalized_issue_fields
     from tags.models import store_tags
    @@ -176,3 +176,39 @@ def test_project_list_skips_open_issue_query_when_over_threshold(self):
     
             issue_filter.assert_not_called()
             self.assertNotContains(response, "open issues")
    +
    +
    +class ProjectScopedActionTestCase(TransactionTestCase):
    +
    +    def setUp(self):
    +        super().setUp()
    +        self.user = User.objects.create_user(username="project-admin", password="test")
    +        self.project = Project.objects.create(name="owned")
    +        ProjectMembership.objects.create(
    +            project=self.project, user=self.user, role=ProjectRole.ADMIN, accepted=True)
    +        self.client.force_login(self.user)
    +
    +    def test_member_remove_scopes_to_project(self):
    +        other_user = User.objects.create_user(username="other", password="test")
    +        other_project = Project.objects.create(name="other")
    +        other_membership = ProjectMembership.objects.create(project=other_project, user=other_user)
    +
    +        response = self.client.post(
    +            f"/projects/{self.project.id}/members/",
    +            {"action": f"remove:{other_user.id}"},
    +        )
    +
    +        self.assertEqual(response.status_code, 200)
    +        self.assertTrue(ProjectMembership.objects.filter(id=other_membership.id).exists())
    +
    +    def test_alert_service_remove_scopes_to_project(self):
    +        other_project = Project.objects.create(name="other")
    +        other_service = MessagingServiceConfig.objects.create(project=other_project)
    +
    +        response = self.client.post(
    +            f"/projects/{self.project.id}/alerts/",
    +            {"action": f"remove:{other_service.id}"},
    +        )
    +
    +        self.assertEqual(response.status_code, 200)
    +        self.assertTrue(MessagingServiceConfig.objects.filter(id=other_service.id).exists())
    
  • teams/tests.py+30 1 modified
    @@ -1 +1,30 @@
    -# Create your tests here.
    +from django.contrib.auth import get_user_model
    +
    +from bugsink.test_utils import TransactionTestCase25251 as TransactionTestCase
    +
    +from .models import Team, TeamMembership, TeamRole
    +
    +User = get_user_model()
    +
    +
    +class TeamScopedActionTestCase(TransactionTestCase):
    +
    +    def setUp(self):
    +        super().setUp()
    +        self.user = User.objects.create_user(username="team-admin", password="test")
    +        self.team = Team.objects.create(name="owned")
    +        TeamMembership.objects.create(team=self.team, user=self.user, role=TeamRole.ADMIN, accepted=True)
    +        self.client.force_login(self.user)
    +
    +    def test_member_remove_scopes_to_team(self):
    +        other_user = User.objects.create_user(username="other", password="test")
    +        other_team = Team.objects.create(name="other")
    +        other_membership = TeamMembership.objects.create(team=other_team, user=other_user)
    +
    +        response = self.client.post(
    +            f"/teams/{self.team.id}/members/",
    +            {"action": f"remove:{other_user.id}"},
    +        )
    +
    +        self.assertEqual(response.status_code, 200)
    +        self.assertTrue(TeamMembership.objects.filter(id=other_membership.id).exists())
    

Vulnerability mechanics

Root cause

"Missing project-scope filter on the issue queryset in the bulk-action view allows cross-project state changes."

Attack vector

An authenticated attacker who is a member of Project A can craft a POST request to `/issues/{project_a_id}/` with `issue_ids[]` containing issue UUIDs from Project B, which they are not authorized to modify. The view authorizes access based on the project in the URL but then applies the bulk action (e.g., "resolve") to the submitted issue IDs without verifying they belong to that project [patch_id=2565633]. Because the attacker only needs low privileges (PR:L) and network access, and the attack requires knowing another project's issue UUIDs (AC:H), the impact is limited to low-integrity changes like toggling issue resolution state.

Affected code

The vulnerable code is in `issues/views.py` in the `_issue_list_pt_1` function, where the bulk-action handler constructs the issue queryset as `Issue.objects.filter(pk__in=issue_ids)` without scoping it to the authorized project [patch_id=2565633]. The fix adds `project=project` and `is_deleted=False` to that filter.

What the fix does

The patch adds a `project=project` filter to the `Issue.objects.filter(pk__in=issue_ids)` queryset in `issues/views.py`, ensuring that only issues belonging to the authorized project are affected by the bulk action [patch_id=2565633]. It also adds `is_deleted=False` to the filter for defense-in-depth. A regression test was added that posts a resolve action with an issue ID from a different project and asserts the issue's state remains unchanged [patch_id=2565633].

Preconditions

  • authAttacker must be an authenticated user who is a member of at least one project (Project A) in the Bugsink instance.
  • inputAttacker must know the UUID of an issue belonging to another project (Project B) that they are not authorized to modify.
  • networkAttacker must have network access to the Bugsink web application.

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.