Skip to content

Commit 604407d

Browse files
billyvgclaude
authored andcommitted
feat(integrations): Send custom webhook_headers on SentryApp webhooks (#117090)
Attach a custom integration's `webhook_headers` to every outgoing webhook request. For errored requests, we always mask custom header values, but display the headers that were sent for debuggability. - Add `webhook_headers` to RpcSentryApp so delivery works across the silo boundary (the install's sentry_app may be an RPC model). - AppPlatformEvent merges custom headers under Sentry's own headers, so Sentry's headers always win and a custom Sentry-Hook-Signature can never spoof the signed payload. - The webhook request buffer (surfaced in the debug UI) now logs only Sentry's own headers via a new sentry_headers property; custom headers may carry secrets and must never be persisted to Redis or shown there. The outgoing request still uses the merged headers, and safe_urlopen continues to guard against SSRF to internal addresses. Co-Authored-By: Claude <noreply@anthropic.com> --- <sub>Stack created with <a href="https://github.com/github/gh-stack">GitHub Stacks CLI</a> • <a href="https://gh.io/stacks-feedback">Give Feedback 💬</a></sub> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 43aa94f commit 604407d

6 files changed

Lines changed: 235 additions & 9 deletions

File tree

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

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
from collections.abc import Mapping
22
from enum import StrEnum
3+
from functools import cached_property
34
from time import time
45
from typing import Any, TypedDict
56
from uuid import uuid4
67

8+
from sentry.sentry_apps.models.sentry_app import MASKED_VALUE
79
from sentry.sentry_apps.models.sentry_app_installation import SentryAppInstallation
810
from sentry.sentry_apps.services.app.model import RpcSentryAppInstallation
911
from sentry.sentry_apps.utils.webhooks import SentryAppActionType, SentryAppResourceType
@@ -89,8 +91,13 @@ def body(self) -> str:
8991
)
9092
)
9193

92-
@property
93-
def headers(self) -> dict[str, str]:
94+
@cached_property
95+
def sentry_headers(self) -> dict[str, str]:
96+
"""Headers Sentry sets on every webhook request.
97+
98+
Cached so the Request-ID, timestamp, and signature are computed once and
99+
stay consistent between the sent request and the logged buffer entry.
100+
"""
94101
request_uuid = uuid4().hex
95102

96103
return {
@@ -100,3 +107,39 @@ def headers(self) -> dict[str, str]:
100107
"Sentry-Hook-Timestamp": str(int(time())),
101108
"Sentry-Hook-Signature": self.install.sentry_app.build_signature(self.body),
102109
}
110+
111+
@property
112+
def custom_headers(self) -> dict[str, str]:
113+
"""User-configured headers parsed from the SentryApp's webhook_headers."""
114+
headers: dict[str, str] = {}
115+
for header in self.install.sentry_app.webhook_headers or []:
116+
name, separator, value = header.partition(":")
117+
if separator:
118+
headers[name.strip()] = value.strip()
119+
return headers
120+
121+
@property
122+
def headers(self) -> dict[str, str]:
123+
# Sentry's headers are merged last so they always win: a custom header
124+
# can never override the signature and spoof payload integrity.
125+
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/sentry_apps/services/app/model.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ class RpcSentryApp(RpcModel):
8585
uuid: str = ""
8686
events: list[str] = Field(default_factory=list)
8787
webhook_url: str | None = None
88+
webhook_headers: list[str] = Field(repr=False, default_factory=list)
8889
is_alertable: bool = False
8990
is_disabled: bool = False
9091
is_published: bool = False
@@ -125,7 +126,7 @@ class RpcSentryAppInstallation(RpcModel):
125126
sentry_app: RpcSentryApp = Field(default_factory=lambda: RpcSentryApp())
126127
date_deleted: datetime.datetime | None = None
127128
uuid: str = ""
128-
api_token: str | None = None
129+
api_token: str | None = Field(repr=False, default=None)
129130

130131

131132
class RpcSentryAppComponent(RpcModel):

src/sentry/sentry_apps/services/app/serial.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ def serialize_sentry_app(
4141
uuid=app.uuid,
4242
events=app.events,
4343
webhook_url=app.webhook_url,
44+
webhook_headers=app.webhook_headers,
4445
is_alertable=app.is_alertable,
4546
is_disabled=app.is_disabled,
4647
is_published=app.status == SentryAppStatus.PUBLISHED,

src/sentry/utils/sentry_apps/webhooks.py

Lines changed: 19 additions & 5 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,7 +317,9 @@ def send_and_save_webhook_request(
307317
org_id=org_id,
308318
event=event,
309319
url=url,
310-
headers=app_platform_event.headers,
320+
headers=app_platform_event.loggable_headers
321+
if custom_headers_enabled
322+
else app_platform_event.sentry_headers,
311323
)
312324
lifecycle.record_halt(e)
313325
# Re-raise the exception because some of these tasks might retry on the exception
@@ -338,7 +350,9 @@ def send_and_save_webhook_request(
338350
error_id=response.headers.get("Sentry-Hook-Error"),
339351
project_id=project_id,
340352
response=response,
341-
headers=app_platform_event.headers,
353+
headers=app_platform_event.loggable_headers
354+
if custom_headers_enabled
355+
else app_platform_event.sentry_headers,
342356
)
343357

344358
debug_logging_enabled = (

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

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +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 MASKED_VALUE, SentryApp
78
from sentry.sentry_apps.utils.webhooks import (
89
InstallationActionType,
910
IssueActionType,
@@ -91,3 +92,88 @@ def test_user_actor(self) -> None:
9192
assert result.headers["Content-Type"] == "application/json"
9293
assert result.headers["Sentry-Hook-Resource"] == "installation"
9394
assert result.headers["Sentry-Hook-Signature"] == signature
95+
96+
def _event_for_app_with_headers(
97+
self, headers: list[str]
98+
) -> tuple[SentryApp, AppPlatformEvent[dict[str, Any]]]:
99+
sentry_app = self.create_sentry_app(organization=self.organization, webhook_headers=headers)
100+
install = self.create_sentry_app_installation(
101+
organization=self.organization, slug=sentry_app.slug
102+
)
103+
event = AppPlatformEvent[dict[str, Any]](
104+
resource=SentryAppResourceType.ISSUE,
105+
action=IssueActionType.ASSIGNED,
106+
install=install,
107+
data={},
108+
)
109+
return sentry_app, event
110+
111+
def test_custom_webhook_headers_are_merged(self) -> None:
112+
_, result = self._event_for_app_with_headers(
113+
["X-Custom: value", "Authorization: Bearer token"]
114+
)
115+
116+
assert result.headers["X-Custom"] == "value"
117+
assert result.headers["Authorization"] == "Bearer token"
118+
# Sentry's own headers are still present.
119+
assert result.headers["Content-Type"] == "application/json"
120+
121+
def test_custom_headers_cannot_override_sentry_headers(self) -> None:
122+
sentry_app, result = self._event_for_app_with_headers(
123+
["Sentry-Hook-Signature: spoofed", "Content-Type: text/plain"]
124+
)
125+
126+
# Sentry's headers win the merge so the signature can't be spoofed.
127+
assert result.headers["Sentry-Hook-Signature"] == sentry_app.build_signature(result.body)
128+
assert result.headers["Content-Type"] == "application/json"
129+
130+
def test_sentry_headers_exclude_custom_headers(self) -> None:
131+
# sentry_headers carries only Sentry's own headers in the clear; custom
132+
# headers (which may contain secrets) must never appear there unmasked.
133+
_, result = self._event_for_app_with_headers(["Authorization: Bearer secret"])
134+
135+
assert "Authorization" not in result.sentry_headers
136+
assert result.headers["Authorization"] == "Bearer secret"
137+
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+
163+
def test_sentry_headers_are_stable_across_calls(self) -> None:
164+
# The Request-ID/timestamp logged to the buffer must match what was sent,
165+
# so sentry_headers is computed once per event rather than per access.
166+
result = AppPlatformEvent[dict[str, Any]](
167+
resource=SentryAppResourceType.ISSUE,
168+
action=IssueActionType.ASSIGNED,
169+
install=self.install,
170+
data={},
171+
)
172+
173+
first = result.sentry_headers
174+
second = result.sentry_headers
175+
assert first["Request-ID"] == second["Request-ID"]
176+
assert first["Sentry-Hook-Timestamp"] == second["Sentry-Hook-Timestamp"]
177+
# The headers actually sent carry the same values that get logged.
178+
assert result.headers["Request-ID"] == first["Request-ID"]
179+
assert result.headers["Sentry-Hook-Timestamp"] == first["Sentry-Hook-Timestamp"]

tests/sentry/utils/sentry_apps/test_webhooks.py

Lines changed: 82 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,85 @@ def test_http_error_response_records_success_and_raises(
145147

146148
mock_record_success.assert_called_once()
147149

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

149230
@cell_silo_test
150231
class WebhookCircuitBreakerNotifyTest(TestCase):

0 commit comments

Comments
 (0)