Skip to content

Commit 457372d

Browse files
billyvgclaude
andcommitted
feat(integrations): Log masked custom webhook headers to request buffer
The webhook request buffer (surfaced in the custom integration debug API) only recorded Sentry's own headers, so custom webhook_headers left no trace there. Users debugging a failed delivery saw no sign their configured headers were sent, which is confusing. Buffer a masked version instead: custom header names are kept so the debug view shows which headers were sent, but their values are replaced with MASKED_VALUE so secrets are never persisted to the buffer. Sentry's own headers stay in the clear and win name collisions, mirroring what is actually sent on the wire. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent bfda0a6 commit 457372d

4 files changed

Lines changed: 96 additions & 11 deletions

File tree

src/sentry/sentry_apps/api/serializers/app_platform_event.py

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from typing import Any, TypedDict
66
from uuid import uuid4
77

8+
from sentry.sentry_apps.models.sentry_app import MASKED_VALUE
89
from sentry.sentry_apps.models.sentry_app_installation import SentryAppInstallation
910
from sentry.sentry_apps.services.app.model import RpcSentryAppInstallation
1011
from sentry.sentry_apps.utils.webhooks import SentryAppActionType, SentryAppResourceType
@@ -94,9 +95,6 @@ def body(self) -> str:
9495
def sentry_headers(self) -> dict[str, str]:
9596
"""Headers Sentry sets on every webhook request.
9697
97-
These are the only headers recorded in the request buffer / debug UI:
98-
custom webhook headers may carry secrets and must never be logged there.
99-
10098
Cached so the Request-ID, timestamp, and signature are computed once and
10199
stay consistent between the sent request and the logged buffer entry.
102100
"""
@@ -125,3 +123,23 @@ def headers(self) -> dict[str, str]:
125123
# Sentry's headers are merged last so they always win: a custom header
126124
# can never override the signature and spoof payload integrity.
127125
return {**self.custom_headers, **self.sentry_headers}
126+
127+
@property
128+
def masked_custom_headers(self) -> dict[str, str]:
129+
"""Custom header names with their values replaced by MASKED_VALUE.
130+
131+
Custom header values may carry secrets (e.g. bearer tokens), so they are
132+
never persisted to the request buffer. The names are kept so the debug UI
133+
can show which custom headers were sent without leaking the values.
134+
"""
135+
return {name: MASKED_VALUE for name in self.custom_headers}
136+
137+
@property
138+
def loggable_headers(self) -> dict[str, str]:
139+
"""Headers safe to record in the request buffer / debug UI.
140+
141+
Sentry's own headers in the clear, plus custom headers with masked values.
142+
Sentry's headers are merged last so the buffer mirrors the precedence of
143+
what was actually sent (see ``headers``).
144+
"""
145+
return {**self.masked_custom_headers, **self.sentry_headers}

src/sentry/utils/sentry_apps/webhooks.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -298,8 +298,9 @@ def send_and_save_webhook_request(
298298
org_id=org_id,
299299
event=event,
300300
url=url,
301-
# Only log Sentry's own headers; custom headers may contain secrets.
302-
headers=app_platform_event.sentry_headers,
301+
# Log Sentry's headers plus masked custom headers: custom header
302+
# values may contain secrets and must never be persisted.
303+
headers=app_platform_event.loggable_headers,
303304
)
304305
lifecycle.record_halt(e)
305306
# Re-raise the exception because some of these tasks might retry on the exception
@@ -330,8 +331,9 @@ def send_and_save_webhook_request(
330331
error_id=response.headers.get("Sentry-Hook-Error"),
331332
project_id=project_id,
332333
response=response,
333-
# Only log Sentry's own headers; custom headers may contain secrets.
334-
headers=app_platform_event.sentry_headers,
334+
# Log Sentry's headers plus masked custom headers: custom header
335+
# values may contain secrets and must never be persisted.
336+
headers=app_platform_event.loggable_headers,
335337
)
336338

337339
debug_logging_enabled = (

tests/sentry/sentry_apps/api/serializers/test_app_platform_event.py

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import orjson
55

66
from sentry.sentry_apps.api.serializers.app_platform_event import AppPlatformEvent
7-
from sentry.sentry_apps.models.sentry_app import SentryApp
7+
from sentry.sentry_apps.models.sentry_app import MASKED_VALUE, SentryApp
88
from sentry.sentry_apps.utils.webhooks import (
99
InstallationActionType,
1010
IssueActionType,
@@ -128,13 +128,38 @@ def test_custom_headers_cannot_override_sentry_headers(self) -> None:
128128
assert result.headers["Content-Type"] == "application/json"
129129

130130
def test_sentry_headers_exclude_custom_headers(self) -> None:
131-
# sentry_headers is what gets logged to the request buffer / debug UI, so
132-
# custom headers (which may contain secrets) must never appear there.
131+
# sentry_headers carries only Sentry's own headers in the clear; custom
132+
# headers (which may contain secrets) must never appear there unmasked.
133133
_, result = self._event_for_app_with_headers(["Authorization: Bearer secret"])
134134

135135
assert "Authorization" not in result.sentry_headers
136136
assert result.headers["Authorization"] == "Bearer secret"
137137

138+
def test_loggable_headers_mask_custom_header_values(self) -> None:
139+
# loggable_headers is what gets recorded in the request buffer / debug UI:
140+
# custom header names are kept so users can confirm they were sent, but the
141+
# values are masked so secrets never get persisted.
142+
_, result = self._event_for_app_with_headers(
143+
["X-Custom: value", "Authorization: Bearer secret"]
144+
)
145+
146+
loggable = result.loggable_headers
147+
# Names are visible so the debug UI shows which custom headers were sent.
148+
assert loggable["X-Custom"] == MASKED_VALUE
149+
assert loggable["Authorization"] == MASKED_VALUE
150+
# The real secret value is never recorded in the buffer.
151+
assert "Bearer secret" not in loggable.values()
152+
# Sentry's own headers stay in the clear so they remain useful for debugging.
153+
assert loggable["Content-Type"] == "application/json"
154+
assert loggable["Sentry-Hook-Signature"] == result.sentry_headers["Sentry-Hook-Signature"]
155+
156+
def test_loggable_headers_sentry_headers_win_collisions(self) -> None:
157+
# A custom header that collides with a Sentry header must not shadow the real
158+
# value in the buffer, mirroring the precedence of what was actually sent.
159+
_, result = self._event_for_app_with_headers(["Content-Type: text/plain"])
160+
161+
assert result.loggable_headers["Content-Type"] == "application/json"
162+
138163
def test_sentry_headers_are_stable_across_calls(self) -> None:
139164
# The Request-ID/timestamp logged to the buffer must match what was sent,
140165
# so sentry_headers is computed once per event rather than per access.

tests/sentry/utils/sentry_apps/test_webhooks.py

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,17 @@
88

99
from sentry.notifications.platform.service import NotificationService
1010
from sentry.sentry_apps.api.serializers.app_platform_event import AppPlatformEvent
11+
from sentry.sentry_apps.models.sentry_app import MASKED_VALUE
1112
from sentry.sentry_apps.utils.webhooks import IssueActionType, SentryAppResourceType
12-
from sentry.shared_integrations.exceptions import ApiHostError
13+
from sentry.shared_integrations.exceptions import ApiHostError, ClientError
1314
from sentry.testutils.asserts import assert_failure_metric
1415
from sentry.testutils.cases import TestCase
1516
from sentry.testutils.helpers.features import with_feature
1617
from sentry.testutils.helpers.options import override_options
1718
from sentry.testutils.silo import cell_silo_test
1819
from sentry.utils import redis
1920
from sentry.utils.circuit_breaker2 import CircuitBreaker
21+
from sentry.utils.sentry_apps import SentryAppWebhookRequestsBuffer
2022
from sentry.utils.sentry_apps.webhooks import WebhookTimeoutError, send_and_save_webhook_request
2123

2224

@@ -145,6 +147,44 @@ def test_http_error_response_records_success_and_raises(
145147

146148
mock_record_success.assert_called_once()
147149

150+
@override_options(CIRCUIT_BREAKER_OPTIONS)
151+
@patch("sentry.utils.sentry_apps.webhooks.safe_urlopen")
152+
def test_error_response_buffers_masked_custom_headers(self, mock_safe_urlopen):
153+
"""A failed delivery records masked custom headers in the request buffer, so the
154+
debug UI shows which custom headers were sent without persisting their secrets."""
155+
sentry_app = self.create_sentry_app(
156+
name="HeaderApp",
157+
organization=self.organization,
158+
webhook_url="https://example.com/webhook",
159+
published=True,
160+
webhook_headers=["Authorization: Bearer super-secret"],
161+
)
162+
install = self.create_sentry_app_installation(
163+
organization=self.organization, slug=sentry_app.slug
164+
)
165+
event = AppPlatformEvent(
166+
resource=SentryAppResourceType.ISSUE,
167+
action=IssueActionType.CREATED,
168+
install=install,
169+
data={"test": "data"},
170+
)
171+
mock_safe_urlopen.return_value = _MockResponse(
172+
{}, "{}", "", False, 401, _raise_status_false, None
173+
)
174+
175+
with pytest.raises(ClientError):
176+
send_and_save_webhook_request(sentry_app, event)
177+
178+
requests = SentryAppWebhookRequestsBuffer(sentry_app).get_requests(errors_only=True)
179+
assert len(requests) == 1
180+
headers = requests[0].get("request_headers")
181+
assert headers is not None
182+
# The custom header name is recorded but its value is masked.
183+
assert headers["Authorization"] == MASKED_VALUE
184+
assert "Bearer super-secret" not in headers.values()
185+
# Sentry's own headers are still recorded in the clear.
186+
assert headers["Content-Type"] == "application/json"
187+
148188

149189
@cell_silo_test
150190
class WebhookCircuitBreakerNotifyTest(TestCase):

0 commit comments

Comments
 (0)