Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
185 changes: 179 additions & 6 deletions src/firetower/incidents/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -344,10 +344,11 @@
captain=other_user,
)

self.client.force_authenticate(user=self.user)
response = self.client.post(
f"/api/incidents/{incident.incident_number}/sync-participants/"
)
with patch("firetower.incidents.views.sync_incident_participants_from_slack"):
self.client.force_authenticate(user=self.user)
response = self.client.post(
f"/api/incidents/{incident.incident_number}/sync-participants/"
)

assert response.status_code == 404

Expand All @@ -359,6 +360,174 @@
assert response.status_code == 400


@pytest.mark.django_db
class TestPrivateIncidentVisibilitySync:
"""Tests that Slack channel members can access private incidents in the UI
even before their participant record has been synced to the DB."""

def setup_method(self):
self.client = APIClient()
self.user = User.objects.create_user(
username="channel_member@example.com",
email="channel_member@example.com",
password="testpass123",
)
self.captain = User.objects.create_user(
username="captain@example.com",
email="captain@example.com",
password="testpass123",
)

def _make_private_incident(self) -> Incident:
return Incident.objects.create(
title="Private Incident",
status=IncidentStatus.ACTIVE,
severity=IncidentSeverity.P1,
is_private=True,
captain=self.captain,
)

def test_detail_view_syncs_before_visibility_check(self):
"""User in Slack channel but not yet synced gets access after force-sync."""
incident = self._make_private_incident()

def sync_adds_user(inc, force=False):
inc.participants.add(self.user)
return ParticipantsSyncStats(added=1)

with patch(
"firetower.incidents.views.sync_incident_participants_from_slack",
side_effect=sync_adds_user,
):
self.client.force_authenticate(user=self.user)
response = self.client.get(f"/api/ui/incidents/{incident.incident_number}/")

assert response.status_code == 200
assert response.data["incident"]["id"] == incident.incident_number

def test_detail_view_404_when_sync_does_not_add_user(self):
"""User NOT in Slack channel still gets 404 after sync attempt."""
incident = self._make_private_incident()

with patch(
"firetower.incidents.views.sync_incident_participants_from_slack",
return_value=ParticipantsSyncStats(),
):
self.client.force_authenticate(user=self.user)
response = self.client.get(f"/api/ui/incidents/{incident.incident_number}/")

assert response.status_code == 404

Check warning on line 419 in src/firetower/incidents/tests/test_views.py

View check run for this annotation

@sentry/warden / warden: find-bugs

Fallback participant sync uses `force=True`, bypassing the throttle and enabling Slack API exhaustion

In `_get_visible_incident`, when an authenticated user cannot see a private incident, the helper unconditionally calls `sync_incident_participants_from_slack(incident, force=True)`. Passing `force=True` bypasses the `PARTICIPANT_SYNC_THROTTLE_SECONDS` guard in `services.py`, so each request issues a fresh Slack API call regardless of how recently the incident was synced. Because this path is reachable by any authenticated user against any private incident they are not a member of, an attacker can repeatedly request private incident IDs (the same one or many) to force unbounded Slack API calls and exhaust the rate limit. `SyncIncidentParticipantsView` amplifies this: a single POST triggers two `force=True` syncs (one in `_get_visible_incident`, one in `post()`).

def test_detail_view_404_when_sync_raises(self):
"""Sync failure on the fallback path results in 404, not 500."""
incident = self._make_private_incident()

with patch(
"firetower.incidents.views.sync_incident_participants_from_slack",
side_effect=Exception("Slack API error"),
):
self.client.force_authenticate(user=self.user)
response = self.client.get(f"/api/ui/incidents/{incident.incident_number}/")

assert response.status_code == 404

def test_detail_view_fast_path_for_existing_participant(self):
"""User already in participants skips the fallback sync entirely."""
incident = self._make_private_incident()
incident.participants.add(self.user)

with patch(
"firetower.incidents.views.sync_incident_participants_from_slack"
) as mock_sync:
self.client.force_authenticate(user=self.user)
response = self.client.get(f"/api/ui/incidents/{incident.incident_number}/")

assert response.status_code == 200
# Called once from the view's post-fetch sync, NOT from the helper.
mock_sync.assert_called_once_with(incident)

def test_public_incident_does_not_trigger_fallback_sync(self):
"""Public incidents never hit the fallback sync path."""
incident = Incident.objects.create(
title="Public Incident",
status=IncidentStatus.ACTIVE,
severity=IncidentSeverity.P1,
is_private=False,
)

with patch(
"firetower.incidents.views.sync_incident_participants_from_slack"
) as mock_sync:
self.client.force_authenticate(user=self.user)
response = self.client.get(f"/api/ui/incidents/{incident.incident_number}/")

assert response.status_code == 200
# Only the view's normal post-fetch sync, not a force sync.
mock_sync.assert_called_once_with(incident)

def test_action_items_view_syncs_before_visibility_check(self):
"""Action items endpoint also syncs participants for private incidents."""
incident = self._make_private_incident()
ActionItem.objects.create(
incident=incident,
linear_issue_id="id-1",
linear_identifier="ENG-1",
title="Fix the bug",
status=ActionItemStatus.TODO,
url="https://linear.app/team/issue/ENG-1",
)

def sync_adds_user(inc, force=False):
inc.participants.add(self.user)
return ParticipantsSyncStats(added=1)

with (
patch(
"firetower.incidents.views.sync_incident_participants_from_slack",
side_effect=sync_adds_user,
),
patch(
"firetower.incidents.views.sync_action_items_from_linear",
),
):
self.client.force_authenticate(user=self.user)
response = self.client.get(
f"/api/ui/incidents/{incident.incident_number}/action-items/"
)

assert response.status_code == 200
assert len(response.data) == 1
assert response.data[0]["title"] == "Fix the bug"

def test_action_items_view_404_when_not_in_channel(self):
"""Action items endpoint returns 404 when user is not a channel member."""
incident = self._make_private_incident()

with patch(
"firetower.incidents.views.sync_incident_participants_from_slack",
return_value=ParticipantsSyncStats(),
):
self.client.force_authenticate(user=self.user)
response = self.client.get(
f"/api/ui/incidents/{incident.incident_number}/action-items/"
)

assert response.status_code == 404

def test_nonexistent_incident_returns_404(self):
"""Fallback path does not reveal whether an incident exists."""
with patch(
"firetower.incidents.views.sync_incident_participants_from_slack"
) as mock_sync:
self.client.force_authenticate(user=self.user)
response = self.client.get(
f"/api/ui/incidents/{settings.PROJECT_KEY}-99999/"
)

assert response.status_code == 404
mock_sync.assert_not_called()


@pytest.mark.django_db
class TestIncidentAPIViews:
"""Tests for service API endpoints (not UI)"""
Expand Down Expand Up @@ -1721,8 +1890,12 @@
status=ActionItemStatus.TODO,
url="https://linear.app/team/issue/ENG-99",
)

Check warning on line 1893 in src/firetower/incidents/tests/test_views.py

View check run for this annotation

@sentry/warden / warden: security-review

[5H9-RY9] Unauthenticated-actor-scoped abuse: any authenticated user can trigger unlimited force Slack syncs on private incidents via SyncIncidentParticipantsView (additional location)

Any authenticated user who knows a private incident's numeric ID can POST to `/api/incidents/{id}/sync-participants/` and trigger Slack API calls unconditionally — `_get_visible_incident` force-syncs participants before confirming the caller has access, and no throttle class is configured on this view. For a newly-visible caller, two force syncs fire per request (one inside `_get_visible_incident`, one in `post()`); for an unrelated caller the Slack sync still fires before returning 404.
self.client.force_authenticate(user=other_user)
response = self.client.get(self._url(private_incident.incident_number))
with patch(
"firetower.incidents.views.sync_incident_participants_from_slack",
return_value=ParticipantsSyncStats(),
):
self.client.force_authenticate(user=other_user)
response = self.client.get(self._url(private_incident.incident_number))

assert response.status_code == 404
59 changes: 46 additions & 13 deletions src/firetower/incidents/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
from dataclasses import asdict

from django.conf import settings
from django.contrib.auth.models import User
from django.db.models import Case, Count, F, QuerySet, When
from django.http import Http404

Check warning on line 9 in src/firetower/incidents/views.py

View check run for this annotation

@sentry/warden / warden: find-bugs

[4TG-JKJ] Fallback participant sync uses `force=True`, bypassing the throttle and enabling Slack API exhaustion (additional location)

In `_get_visible_incident`, when an authenticated user cannot see a private incident, the helper unconditionally calls `sync_incident_participants_from_slack(incident, force=True)`. Passing `force=True` bypasses the `PARTICIPANT_SYNC_THROTTLE_SECONDS` guard in `services.py`, so each request issues a fresh Slack API call regardless of how recently the incident was synced. Because this path is reachable by any authenticated user against any private incident they are not a member of, an attacker can repeatedly request private incident IDs (the same one or many) to force unbounded Slack API calls and exhaust the rate limit. `SyncIncidentParticipantsView` amplifies this: a single POST triggers two `force=True` syncs (one in `_get_visible_incident`, one in `post()`).
from django.shortcuts import get_object_or_404
from django.utils import timezone
from rest_framework import generics, serializers
Expand Down Expand Up @@ -77,6 +79,42 @@
return int(match.group(1))


def _get_visible_incident(
queryset: QuerySet[Incident], numeric_id: int, user: User
) -> Incident:
"""Get an incident, syncing Slack participants first if needed for private incidents.

Slack commands let channel members interact with private incidents, but
the UI requires DB participant records for visibility. When a channel
member hasn't been synced yet, the normal visibility filter returns 404.
This helper force-syncs participants on the fallback path so that channel
members are recognised on their first UI visit.
"""
visible_qs = filter_visible_to_user(queryset, user)
incident = visible_qs.filter(id=numeric_id).first()
if incident is not None:
return incident

# Not visible yet -- if the incident exists and is private, the user
# might be a Slack channel member who hasn't been synced to the DB.
incident = queryset.filter(id=numeric_id).first()
if incident is None or not incident.is_private:
raise Http404

try:
sync_incident_participants_from_slack(incident, force=True)

Check warning on line 105 in src/firetower/incidents/views.py

View check run for this annotation

@sentry/warden / warden: find-bugs

[4TG-JKJ] Fallback participant sync uses `force=True`, bypassing the throttle and enabling Slack API exhaustion (additional location)

In `_get_visible_incident`, when an authenticated user cannot see a private incident, the helper unconditionally calls `sync_incident_participants_from_slack(incident, force=True)`. Passing `force=True` bypasses the `PARTICIPANT_SYNC_THROTTLE_SECONDS` guard in `services.py`, so each request issues a fresh Slack API call regardless of how recently the incident was synced. Because this path is reachable by any authenticated user against any private incident they are not a member of, an attacker can repeatedly request private incident IDs (the same one or many) to force unbounded Slack API calls and exhaust the rate limit. `SyncIncidentParticipantsView` amplifies this: a single POST triggers two `force=True` syncs (one in `_get_visible_incident`, one in `post()`).
except Exception:

Check warning on line 106 in src/firetower/incidents/views.py

View check run for this annotation

@sentry/warden / warden: security-review

[5H9-RY9] Unauthenticated-actor-scoped abuse: any authenticated user can trigger unlimited force Slack syncs on private incidents via SyncIncidentParticipantsView (additional location)

Any authenticated user who knows a private incident's numeric ID can POST to `/api/incidents/{id}/sync-participants/` and trigger Slack API calls unconditionally — `_get_visible_incident` force-syncs participants before confirming the caller has access, and no throttle class is configured on this view. For a newly-visible caller, two force syncs fire per request (one inside `_get_visible_incident`, one in `post()`); for an unrelated caller the Slack sync still fires before returning 404.
logger.exception(
f"Failed to sync participants for incident {incident.id} during visibility check"
)
raise Http404

if not incident.is_visible_to_user(user):
raise Http404

return incident


class IncidentListUIView(generics.ListAPIView):
"""
List all incidents from database.
Expand Down Expand Up @@ -143,12 +181,9 @@
"""
incident_id = self.kwargs["incident_id"]
numeric_id = parse_incident_id(incident_id)

# Get incident by numeric ID
queryset = self.get_queryset()
queryset = filter_visible_to_user(queryset, self.request.user)

incident = get_object_or_404(queryset, id=numeric_id)
incident = _get_visible_incident(
self.get_queryset(), numeric_id, self.request.user
)

Check warning on line 186 in src/firetower/incidents/views.py

View check run for this annotation

@sentry/warden / warden: find-bugs

[4TG-JKJ] Fallback participant sync uses `force=True`, bypassing the throttle and enabling Slack API exhaustion (additional location)

In `_get_visible_incident`, when an authenticated user cannot see a private incident, the helper unconditionally calls `sync_incident_participants_from_slack(incident, force=True)`. Passing `force=True` bypasses the `PARTICIPANT_SYNC_THROTTLE_SECONDS` guard in `services.py`, so each request issues a fresh Slack API call regardless of how recently the incident was synced. Because this path is reachable by any authenticated user against any private incident they are not a member of, an attacker can repeatedly request private incident IDs (the same one or many) to force unbounded Slack API calls and exhaust the rate limit. `SyncIncidentParticipantsView` amplifies this: a single POST triggers two `force=True` syncs (one in `_get_visible_incident`, one in `post()`).

try:
sync_incident_participants_from_slack(incident)
Expand Down Expand Up @@ -322,9 +357,7 @@
"""
incident_id = self.kwargs["incident_id"]
numeric_id = parse_incident_id(incident_id)
queryset = filter_visible_to_user(self.get_queryset(), self.request.user)

obj = get_object_or_404(queryset, id=numeric_id)
obj = _get_visible_incident(self.get_queryset(), numeric_id, self.request.user)

Check warning on line 360 in src/firetower/incidents/views.py

View check run for this annotation

@sentry/warden / warden: security-review

Unauthenticated-actor-scoped abuse: any authenticated user can trigger unlimited force Slack syncs on private incidents via SyncIncidentParticipantsView

Any authenticated user who knows a private incident's numeric ID can POST to `/api/incidents/{id}/sync-participants/` and trigger Slack API calls unconditionally — `_get_visible_incident` force-syncs participants before confirming the caller has access, and no throttle class is configured on this view. For a newly-visible caller, two force syncs fire per request (one inside `_get_visible_incident`, one in `post()`); for an unrelated caller the Slack sync still fires before returning 404.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unauthenticated-actor-scoped abuse: any authenticated user can trigger unlimited force Slack syncs on private incidents via SyncIncidentParticipantsView

Any authenticated user who knows a private incident's numeric ID can POST to /api/incidents/{id}/sync-participants/ and trigger Slack API calls unconditionally — _get_visible_incident force-syncs participants before confirming the caller has access, and no throttle class is configured on this view. For a newly-visible caller, two force syncs fire per request (one inside _get_visible_incident, one in post()); for an unrelated caller the Slack sync still fires before returning 404.

Evidence
  • _get_visible_incident (line 82) calls sync_incident_participants_from_slack(incident, force=True) for every private incident where the caller fails the initial visibility filter — before confirming the caller has any legitimate access.
  • SyncIncidentParticipantsView.post() (line 368) calls sync_incident_participants_from_slack(incident, force=True) again after get_object() returns, so a newly-synced caller triggers two force syncs in one request.
  • SyncIncidentParticipantsView declares permission_classes = [IncidentPermission] (authenticated only) but defines no throttle_classes, and the view docstring confirms it 'bypasses throttle (force=True)'.
  • A caller who does not become visible after the first sync still gets a 404, but the Slack API call already occurred — this path can be triggered by any authenticated user for any private incident ID via brute-force.
Also found at 2 additional locations
  • src/firetower/incidents/views.py:103-106
  • src/firetower/incidents/tests/test_views.py:1893

Identified by Warden security-review · 5H9-RY9

self.check_object_permissions(self.request, obj)

return obj
Expand Down Expand Up @@ -374,8 +407,9 @@
def _get_incident(self) -> Incident:
if not hasattr(self, "_incident"):
numeric_id = parse_incident_id(self.kwargs["incident_id"])
queryset = filter_visible_to_user(Incident.objects.all(), self.request.user)
self._incident = get_object_or_404(queryset, id=numeric_id)
self._incident = _get_visible_incident(
Incident.objects.all(), numeric_id, self.request.user
)
self.check_object_permissions(self.request, self._incident)
return self._incident

Expand All @@ -401,8 +435,7 @@

def get_object(self) -> Incident:
numeric_id = parse_incident_id(self.kwargs["incident_id"])
queryset = filter_visible_to_user(self.get_queryset(), self.request.user)
obj = get_object_or_404(queryset, id=numeric_id)
obj = _get_visible_incident(self.get_queryset(), numeric_id, self.request.user)
self.check_object_permissions(self.request, obj)
return obj

Expand Down
Loading