Skip to content

Commit 69aa4e1

Browse files
billyvgclaude
andcommitted
feat(sentry-apps): Gate custom webhook header delivery behind feature flag
Check organizations:sentry-apps-custom-webhook-headers on the SentryApp owner org before including custom headers in the outbound request and the request-buffer log. Without the flag, only Sentry's own headers (Content-Type, Request-ID, signature, etc.) are sent and recorded. Also adds the flag to temporary.py so tests can enable it with with_feature(), and updates the existing buffer-masking test plus adds a companion test for the flag-off path. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 4b61975 commit 69aa4e1

3 files changed

Lines changed: 62 additions & 9 deletions

File tree

src/sentry/features/temporary.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,8 @@ def register_temporary_features(manager: FeatureManager) -> None:
308308
manager.add("organizations:seer-gitlab-support", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True)
309309
# Run Seer agents inside the sandbox execution environment
310310
manager.add("organizations:seer-use-agent-sandbox", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False)
311+
# Enable delivery of custom webhook headers configured on a SentryApp
312+
manager.add("organizations:sentry-apps-custom-webhook-headers", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True)
311313
# Disable select orgs from ingesting mobile replay events.
312314
# Enable double-read from EAP for session health data validation
313315
manager.add("organizations:session-health-eap", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False)

src/sentry/utils/sentry_apps/webhooks.py

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
from requests.exceptions import ChunkedEncodingError, ConnectionError, Timeout
1414
from rest_framework import status
1515

16-
from sentry import options
16+
from sentry import features, options
1717
from sentry.exceptions import RestrictedIPAddress
1818
from sentry.http import safe_urlopen
1919
from sentry.integrations.utils.metrics import EventLifecycle
@@ -195,6 +195,7 @@ def _circuit_breaker_allows_request(
195195
def _send_webhook_request(
196196
url: str,
197197
app_platform_event: AppPlatformEvent[T],
198+
use_custom_headers: bool = False,
198199
) -> Response:
199200
# We don't want to use the alarm in CONTROL silo as it's only used for installation webhooks which are v. low volume
200201
# Also that we aren't guaranteed to be in main thread
@@ -212,7 +213,9 @@ def _send_webhook_request(
212213
return safe_urlopen(
213214
url=url,
214215
data=app_platform_event.body,
215-
headers=app_platform_event.headers,
216+
headers=app_platform_event.headers
217+
if use_custom_headers
218+
else app_platform_event.sentry_headers,
216219
timeout=options.get("sentry-apps.webhook.timeout.sec"),
217220
)
218221

@@ -262,19 +265,26 @@ def send_and_save_webhook_request(
262265
)
263266

264267
assert url is not None
268+
custom_headers_enabled = False
265269
try:
266270
owner_context = organization_service.get_organization_by_id(
267271
id=sentry_app.owner_id,
268272
include_projects=False,
269273
include_teams=False,
270274
)
271275
owner_org = owner_context.organization if owner_context is not None else None
276+
if owner_org is not None:
277+
custom_headers_enabled = features.has(
278+
"organizations:sentry-apps-custom-webhook-headers", owner_org
279+
)
272280
circuit_breaker = _create_circuit_breaker(sentry_app)
273281
if not _circuit_breaker_allows_request(circuit_breaker, sentry_app, lifecycle):
274282
return Response()
275283

276284
with circuit_breaker_tracking(circuit_breaker):
277-
response = _send_webhook_request(url, app_platform_event)
285+
response = _send_webhook_request(
286+
url, app_platform_event, use_custom_headers=custom_headers_enabled
287+
)
278288

279289
except WebhookTimeoutError:
280290
if circuit_breaker and circuit_breaker.is_open() and owner_org is not None:
@@ -307,9 +317,9 @@ def send_and_save_webhook_request(
307317
org_id=org_id,
308318
event=event,
309319
url=url,
310-
# Log Sentry's headers plus masked custom headers: custom header
311-
# values may contain secrets and must never be persisted.
312-
headers=app_platform_event.loggable_headers,
320+
headers=app_platform_event.loggable_headers
321+
if custom_headers_enabled
322+
else app_platform_event.sentry_headers,
313323
)
314324
lifecycle.record_halt(e)
315325
# Re-raise the exception because some of these tasks might retry on the exception
@@ -340,9 +350,9 @@ def send_and_save_webhook_request(
340350
error_id=response.headers.get("Sentry-Hook-Error"),
341351
project_id=project_id,
342352
response=response,
343-
# Log Sentry's headers plus masked custom headers: custom header
344-
# values may contain secrets and must never be persisted.
345-
headers=app_platform_event.loggable_headers,
353+
headers=app_platform_event.loggable_headers
354+
if custom_headers_enabled
355+
else app_platform_event.sentry_headers,
346356
)
347357

348358
debug_logging_enabled = (

tests/sentry/utils/sentry_apps/test_webhooks.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,7 @@ def test_http_error_response_records_success_and_raises(
147147

148148
mock_record_success.assert_called_once()
149149

150+
@with_feature("organizations:sentry-apps-custom-webhook-headers")
150151
@override_options(CIRCUIT_BREAKER_OPTIONS)
151152
@patch("sentry.utils.sentry_apps.webhooks.safe_urlopen")
152153
def test_error_response_buffers_masked_custom_headers(self, mock_safe_urlopen):
@@ -185,6 +186,46 @@ def test_error_response_buffers_masked_custom_headers(self, mock_safe_urlopen):
185186
# Sentry's own headers are still recorded in the clear.
186187
assert headers["Content-Type"] == "application/json"
187188

189+
@override_options(CIRCUIT_BREAKER_OPTIONS)
190+
@patch("sentry.utils.sentry_apps.webhooks.safe_urlopen")
191+
def test_custom_headers_not_sent_or_logged_without_flag(self, mock_safe_urlopen):
192+
"""Without the feature flag, custom headers are stripped from both the request
193+
and the buffer log."""
194+
sentry_app = self.create_sentry_app(
195+
name="HeaderApp",
196+
organization=self.organization,
197+
webhook_url="https://example.com/webhook",
198+
published=True,
199+
webhook_headers=["Authorization: Bearer super-secret"],
200+
)
201+
install = self.create_sentry_app_installation(
202+
organization=self.organization, slug=sentry_app.slug
203+
)
204+
event = AppPlatformEvent(
205+
resource=SentryAppResourceType.ISSUE,
206+
action=IssueActionType.CREATED,
207+
install=install,
208+
data={"test": "data"},
209+
)
210+
mock_safe_urlopen.return_value = _MockResponse(
211+
{}, "{}", "", False, 401, _raise_status_false, None
212+
)
213+
214+
with pytest.raises(ClientError):
215+
send_and_save_webhook_request(sentry_app, event)
216+
217+
# Custom header must not appear in the outbound request.
218+
call_headers = mock_safe_urlopen.call_args.kwargs["headers"]
219+
assert "Authorization" not in call_headers
220+
221+
# Custom header must not appear in the buffer log either.
222+
requests = SentryAppWebhookRequestsBuffer(sentry_app).get_requests(errors_only=True)
223+
assert len(requests) == 1
224+
headers = requests[0].get("request_headers")
225+
assert headers is not None
226+
assert "Authorization" not in headers
227+
assert headers["Content-Type"] == "application/json"
228+
188229

189230
@cell_silo_test
190231
class WebhookCircuitBreakerNotifyTest(TestCase):

0 commit comments

Comments
 (0)