Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
711038b
feat(developer-settings): add webhook_headers model field, API, and w…
sentry-junior[bot] Jun 4, 2026
0eae918
feat(developer-settings): add webhook_headers model field, API, and w…
sentry-junior[bot] Jun 4, 2026
608bc18
feat(developer-settings): add webhook_headers model field, API, and w…
sentry-junior[bot] Jun 4, 2026
47ab6ba
feat(developer-settings): add webhook_headers model field, API, and w…
sentry-junior[bot] Jun 4, 2026
0a0f138
feat(developer-settings): add webhook_headers model field, API, and w…
sentry-junior[bot] Jun 4, 2026
c3e8acf
feat(developer-settings): add webhook_headers model field, API, and w…
sentry-junior[bot] Jun 4, 2026
a900d7e
feat(developer-settings): add webhook_headers model field, API, and w…
sentry-junior[bot] Jun 4, 2026
13c35fb
feat(developer-settings): add webhook_headers model field, API, and w…
sentry-junior[bot] Jun 4, 2026
e7a8b85
feat(developer-settings): add webhook_headers model field, API, and w…
sentry-junior[bot] Jun 4, 2026
c323e4f
feat(developer-settings): add webhook_headers model field, API, and w…
sentry-junior[bot] Jun 4, 2026
c92d7fe
feat(developer-settings): add webhook_headers model field, API, and w…
sentry-junior[bot] Jun 4, 2026
3071914
feat(developer-settings): add webhook_headers model field, API, and w…
sentry-junior[bot] Jun 4, 2026
b8728cc
feat(developer-settings): add webhook_headers model field, API, and w…
sentry-junior[bot] Jun 4, 2026
57657d5
feat(developer-settings): add webhook_headers model field, API, and w…
sentry-junior[bot] Jun 4, 2026
d89c04f
feat(developer-settings): add webhook_headers backend support
sentry-junior[bot] Jun 4, 2026
72146d3
feat(developer-settings): add webhook_headers backend support
sentry-junior[bot] Jun 4, 2026
da59aa5
feat(developer-settings): add webhook_headers backend support
sentry-junior[bot] Jun 4, 2026
c053272
feat(developer-settings): add webhook_headers backend support
sentry-junior[bot] Jun 4, 2026
48d6ad5
feat(developer-settings): add webhook_headers backend support
sentry-junior[bot] Jun 4, 2026
68783a5
feat(developer-settings): add webhook_headers backend support
sentry-junior[bot] Jun 4, 2026
211f5f0
feat(developer-settings): add webhook_headers backend support
sentry-junior[bot] Jun 4, 2026
a05a6ad
feat(developer-settings): add webhook_headers backend support
sentry-junior[bot] Jun 4, 2026
7a63919
feat(developer-settings): add webhook_headers backend support
sentry-junior[bot] Jun 4, 2026
503ea33
feat(developer-settings): add webhook_headers backend support
sentry-junior[bot] Jun 4, 2026
9497c43
feat(developer-settings): add webhook_headers backend support
sentry-junior[bot] Jun 4, 2026
3b11c0b
feat(developer-settings): add webhook_headers backend support
sentry-junior[bot] Jun 4, 2026
1d8cd1e
feat(developer-settings): add webhook_headers backend support
sentry-junior[bot] Jun 4, 2026
ddb61af
feat(developer-settings): add webhook_headers backend support
sentry-junior[bot] Jun 4, 2026
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
4 changes: 4 additions & 0 deletions src/sentry/apidocs/examples/sentry_app_examples.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"Retrieve a custom integration",
value={
"allowedOrigins": [],
"webhookHeaders": [],
"author": "ACME Corp",
"avatars": [
{
Expand Down Expand Up @@ -47,6 +48,7 @@
"Update a custom integration",
value={
"allowedOrigins": [],
"webhookHeaders": [],
"author": "ACME Corp",
"avatars": [
{
Expand Down Expand Up @@ -103,40 +105,42 @@
OpenApiExample(
"Retrieve the custom integrations created by the given organization",
value=[
{

Check failure on line 108 in src/sentry/apidocs/examples/sentry_app_examples.py

View check run for this annotation

@sentry/warden / warden: wrdn-authz

[2SX-LWT] webhookHeaders (potentially containing auth secrets) returned unconditionally to any authenticated user (additional location)

Move `webhookHeaders` inside the owner-gated block so it is only returned to members of the owning org or elevated users, matching the same protection applied to `clientId` and `clientSecret`.
"allowedOrigins": [],
"webhookHeaders": [],
"author": "ACME Corp",
"avatars": [
{
"avatarType": "avatar",
"avatarUuid": "6c25b771-a576-4c18-a1c3-ab059c1d42ba",
"avatarUrl": "https://example.com/avatar.png",
"color": False,
"photoType": "icon",
}
],
"events": ["issue"],
"isAlertable": False,
"isDisabled": False,
"metadata": "",
"name": "ACME Corp Integration",
"overview": None,
"popularity": 27,
"redirectUrl": None,
"featureData": [],
"schema": "",
"scopes": ["event:read", "org:read"],
"slug": "acme-corp-integration",
"status": "unpublished",
"uuid": "77cebea3-019e-484d-8673-6c3969698827",
"verifyInstall": True,
"webhookUrl": "https://example.com/webhook",
"clientId": "ed06141686bb60102d878c607eff449fa9907fa7a8cb70f0d337a8fb0b6566c3",
"clientSecret": "**********",
"owner": {"id": 42, "slug": "acme-corp"},
},
{

Check failure on line 141 in src/sentry/apidocs/examples/sentry_app_examples.py

View check run for this annotation

@sentry/warden / warden: wrdn-data-exfil

webhookHeaders exposed without masking in API responses, leaking potential auth tokens to all authenticated users

Custom webhook headers (which may contain `Authorization: Bearer <token>` or similar credentials) are returned unconditionally in `SentryAppSerializer.serialize()` at `api/serializers/sentry_app.py:150` without the owner-membership gate that protects `clientId`/`clientSecret`; any authenticated user can retrieve them via `GET /api/0/apps/?status=published`.
"allowedOrigins": [],
"webhookHeaders": [],
"author": "ACME Corp",
"avatars": [],
"events": ["issue", "event"],
Expand Down
34 changes: 34 additions & 0 deletions src/sentry/migrations/1109_sentryapp_webhook_headers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import django.contrib.postgres.fields
from django.db import migrations, models

from sentry.new_migrations.migrations import CheckedMigration


class Migration(CheckedMigration):
# This flag is used to mark that a migration shouldn't be automatically run in production.
# This should only be used for operations where it's safe to run the migration after your
# code has deployed. So this should not be used for most operations that alter the schema
# of a table.
# Here are some things that make sense to mark as post deployment:
# - Large data migrations. Typically we want these to be run manually so that they can be
# monitored and not block the deploy for a long period of time while they run.
# - Adding indexes to large tables. Since this can take a long time, we'd generally prefer to
# run this outside deployments so that we don't block them. Note that while adding an index
# is a schema change, it's completely safe to run the operation after the code has deployed.
# Once deployed, run these manually via: https://develop.sentry.dev/database-migrations/#migration-deployment

is_post_deployment = False

dependencies = [
("sentry", "1108_drop_organizationmapping_codecov_access_pending"),
]

operations = [
migrations.AddField(
model_name="sentryapp",
name="webhook_headers",
field=django.contrib.postgres.fields.ArrayField(
base_field=models.TextField(), default=list, size=None
),
),
]
1 change: 1 addition & 0 deletions src/sentry/sentry_apps/api/endpoints/sentry_app_details.py
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ def put(self, request: Request, sentry_app) -> Response:
schema=result.get("schema"),
overview=result.get("overview"),
allowed_origins=result.get("allowedOrigins"),
webhook_headers=result.get("webhookHeaders"),
popularity=result.get("popularity"),
is_disabled=result.get("isDisabled"),
).run(user=request.user)
Expand Down
2 changes: 2 additions & 0 deletions src/sentry/sentry_apps/api/endpoints/sentry_apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@
"schema": request.data.get("schema", {}),
"overview": request.data.get("overview"),
"allowedOrigins": request.data.get("allowedOrigins", []),
"webhookHeaders": request.data.get("webhookHeaders", []),
"popularity": (
request.data.get("popularity") if is_active_superuser(request) else None
),
Expand Down Expand Up @@ -133,6 +134,7 @@
schema=data["schema"],
overview=data["overview"],
allowed_origins=data["allowedOrigins"],
webhook_headers=data["webhookHeaders"],

Check warning on line 137 in src/sentry/sentry_apps/api/endpoints/sentry_apps.py

View check run for this annotation

@sentry/warden / warden: security-review

Custom webhook headers exposed unmasked in GET responses for published Sentry Apps

`SentryAppSerializer` returns `webhookHeaders` verbatim in the GET response for any Sentry App, and `SentryAppPermission.has_object_permission` lets any authenticated user GET a published app's details. Since `webhook_headers` is designed to carry per-app credentials (e.g. `Authorization: Bearer ...`), any Sentry user can read another org's webhook secrets via `GET /api/0/sentry-apps/{slug}/`. Mask the values like `clientSecret` does, or restrict exposure to org members with `org:write`.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Custom webhook headers exposed unmasked in GET responses for published Sentry Apps

SentryAppSerializer returns webhookHeaders verbatim in the GET response for any Sentry App, and SentryAppPermission.has_object_permission lets any authenticated user GET a published app's details. Since webhook_headers is designed to carry per-app credentials (e.g. Authorization: Bearer ...), any Sentry user can read another org's webhook secrets via GET /api/0/sentry-apps/{slug}/. Mask the values like clientSecret does, or restrict exposure to org members with org:write.

Evidence
  • src/sentry/sentry_apps/api/serializers/sentry_app.py:150 sets webhookHeaders=obj.webhook_headers unconditionally in SentryAppSerializerResponse, with no MASKED_VALUE branch (contrast with clientSecret later in the same serialize).
  • src/sentry/sentry_apps/api/bases/sentryapps.py:272 returns True from SentryAppPermission.has_object_permission whenever sentry_app.is_published and request.method == 'GET', so any authenticated user passes the object permission check on the details endpoint.
  • SentryAppDetailsEndpoint (endpoints/sentry_app_details.py:57-65) uses SentryAppDetailsEndpointPermission (a subclass of SentryAppAndStaffPermissionSentryAppPermission) and calls serialize(sentry_app, ..., ResponseSentryAppSerializer()) for GET, so the unmasked headers reach the response body.
  • Parser docs/tests in this PR describe values like Authorization: Bearer ..., confirming the field is intended to carry secret credentials and is not just metadata.
Also found at 6 additional locations
  • src/sentry/sentry_apps/api/serializers/sentry_app.py:48
  • src/sentry/sentry_apps/api/serializers/sentry_app.py:150
  • src/sentry/sentry_apps/logic.py:117
  • src/sentry/sentry_apps/logic.py:352
  • src/sentry/sentry_apps/services/app/model.py:88
  • tests/sentry/sentry_apps/api/endpoints/test_sentry_apps.py:762-766

Identified by Warden security-review · XPX-475

popularity=data["popularity"],
).run(user=request.user, request=request, skip_default_auth_token=True)
# We want to stop creating the default auth token for new apps and installations through the API
Expand Down
30 changes: 30 additions & 0 deletions src/sentry/sentry_apps/api/parsers/sentry_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,11 @@ class SentryAppParser(Serializer):
required=False,
help_text="The list of allowed origins for CORS.",
)
webhookHeaders = serializers.ListField(
child=serializers.CharField(max_length=1024),
required=False,
help_text="Custom headers sent with every webhook request. Each entry must be in 'Name: value' format.",
)
# Bounds chosen to match PositiveSmallIntegerField (https://docs.djangoproject.com/en/3.2/ref/models/fields/#positivesmallintegerfield)
popularity = serializers.IntegerField(
min_value=0,
Expand Down Expand Up @@ -199,6 +204,31 @@ def validate_allowedOrigins(self, value):
raise ValidationError("'*' not allowed in origin")
return value

# Headers that Sentry sets on every webhook request and must not be overridden.
_RESERVED_HEADER_PREFIXES = (
"sentry-hook-",
"content-type",
"request-id",
)

def validate_webhookHeaders(self, value):
for header in value:
if ":" not in header:
raise ValidationError(
f"Invalid header format '{header}'. Each header must be in 'Name: value' format."
)
name, _, _ = header.partition(":")
name = name.strip().lower()
if not name:
raise ValidationError(
f"Invalid header format '{header}'. Header name cannot be empty."
)
if any(name.startswith(prefix) for prefix in self._RESERVED_HEADER_PREFIXES):
raise ValidationError(
f"Header '{name}' is reserved and cannot be overridden."
)
return value

def validate_scopes(self, value):
if not value:
return value
Expand Down
10 changes: 10 additions & 0 deletions src/sentry/sentry_apps/api/serializers/app_platform_event.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,17 @@ def body(self) -> str:
def headers(self) -> dict[str, str]:
request_uuid = uuid4().hex

# Start with any custom headers configured on the sentry app. Sentry's
# own headers are merged in last so they always take precedence and
# cannot be spoofed by the app owner.
custom_headers: dict[str, str] = {}
for header_line in self.install.sentry_app.webhook_headers:
if ":" in header_line:
name, _, value = header_line.partition(":")
custom_headers[name.strip()] = value.strip()

return {
**custom_headers,
"Content-Type": "application/json",
"Request-ID": request_uuid,
"Sentry-Hook-Resource": self.resource,
Expand Down
3 changes: 3 additions & 0 deletions src/sentry/sentry_apps/api/serializers/sentry_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,10 @@
slug: str
status: str
uuid: str
verifyInstall: bool

Check failure on line 47 in src/sentry/sentry_apps/api/serializers/sentry_app.py

View check run for this annotation

@sentry/warden / warden: wrdn-data-exfil

[285-X8D] webhookHeaders exposed without masking in API responses, leaking potential auth tokens to all authenticated users (additional location)

Custom webhook headers (which may contain `Authorization: Bearer <token>` or similar credentials) are returned unconditionally in `SentryAppSerializer.serialize()` at `api/serializers/sentry_app.py:150` without the owner-membership gate that protects `clientId`/`clientSecret`; any authenticated user can retrieve them via `GET /api/0/apps/?status=published`.

Check warning on line 48 in src/sentry/sentry_apps/api/serializers/sentry_app.py

View check run for this annotation

@sentry/warden / warden: security-review

[XPX-475] Custom webhook headers exposed unmasked in GET responses for published Sentry Apps (additional location)

`SentryAppSerializer` returns `webhookHeaders` verbatim in the GET response for any Sentry App, and `SentryAppPermission.has_object_permission` lets any authenticated user GET a published app's details. Since `webhook_headers` is designed to carry per-app credentials (e.g. `Authorization: Bearer ...`), any Sentry user can read another org's webhook secrets via `GET /api/0/sentry-apps/{slug}/`. Mask the values like `clientSecret` does, or restrict exposure to org members with `org:write`.
webhookHeaders: list[str]

Check failure on line 49 in src/sentry/sentry_apps/api/serializers/sentry_app.py

View check run for this annotation

@sentry/warden / warden: wrdn-authz

[2SX-LWT] webhookHeaders (potentially containing auth secrets) returned unconditionally to any authenticated user (additional location)

Move `webhookHeaders` inside the owner-gated block so it is only returned to members of the owning org or elevated users, matching the same protection applied to `clientId` and `clientSecret`.

# Optional fields
isDisabled: NotRequired[bool]
author: NotRequired[str | None]
Expand Down Expand Up @@ -145,6 +147,7 @@
uuid=obj.uuid,
verifyInstall=obj.verify_install,
webhookUrl=obj.webhook_url,
webhookHeaders=obj.webhook_headers,

Check failure on line 150 in src/sentry/sentry_apps/api/serializers/sentry_app.py

View check run for this annotation

@sentry/warden / warden: wrdn-authz

webhookHeaders (potentially containing auth secrets) returned unconditionally to any authenticated user

Move `webhookHeaders` inside the owner-gated block so it is only returned to members of the owning org or elevated users, matching the same protection applied to `clientId` and `clientSecret`.

Check warning on line 150 in src/sentry/sentry_apps/api/serializers/sentry_app.py

View check run for this annotation

@sentry/warden / warden: security-review

[XPX-475] Custom webhook headers exposed unmasked in GET responses for published Sentry Apps (additional location)

`SentryAppSerializer` returns `webhookHeaders` verbatim in the GET response for any Sentry App, and `SentryAppPermission.has_object_permission` lets any authenticated user GET a published app's details. Since `webhook_headers` is designed to carry per-app credentials (e.g. `Authorization: Bearer ...`), any Sentry user can read another org's webhook secrets via `GET /api/0/sentry-apps/{slug}/`. Mask the values like `clientSecret` does, or restrict exposure to org members with `org:write`.

Check failure on line 150 in src/sentry/sentry_apps/api/serializers/sentry_app.py

View check run for this annotation

@sentry/warden / warden: wrdn-data-exfil

[285-X8D] webhookHeaders exposed without masking in API responses, leaking potential auth tokens to all authenticated users (additional location)

Custom webhook headers (which may contain `Authorization: Bearer <token>` or similar credentials) are returned unconditionally in `SentryAppSerializer.serialize()` at `api/serializers/sentry_app.py:150` without the owner-membership gate that protects `clientId`/`clientSecret`; any authenticated user can retrieve them via `GET /api/0/apps/?status=published`.
)

if obj.status != SentryAppStatus.INTERNAL:
Expand Down
8 changes: 8 additions & 0 deletions src/sentry/sentry_apps/logic.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@
schema: Schema | None = None
overview: str | None = None
allowed_origins: list[str] | None = None
webhook_headers: list[str] | None = None

Check failure on line 117 in src/sentry/sentry_apps/logic.py

View check run for this annotation

@sentry/warden / warden: wrdn-authz

[2SX-LWT] webhookHeaders (potentially containing auth secrets) returned unconditionally to any authenticated user (additional location)

Move `webhookHeaders` inside the owner-gated block so it is only returned to members of the owning org or elevated users, matching the same protection applied to `clientId` and `clientSecret`.

Check warning on line 117 in src/sentry/sentry_apps/logic.py

View check run for this annotation

@sentry/warden / warden: security-review

[XPX-475] Custom webhook headers exposed unmasked in GET responses for published Sentry Apps (additional location)

`SentryAppSerializer` returns `webhookHeaders` verbatim in the GET response for any Sentry App, and `SentryAppPermission.has_object_permission` lets any authenticated user GET a published app's details. Since `webhook_headers` is designed to carry per-app credentials (e.g. `Authorization: Bearer ...`), any Sentry user can read another org's webhook secrets via `GET /api/0/sentry-apps/{slug}/`. Mask the values like `clientSecret` does, or restrict exposure to org members with `org:write`.
popularity: int | None = None
features: list[int] | None = None
is_disabled: bool | None = None
Expand All @@ -137,6 +138,7 @@
self._update_verify_install()
self._update_overview()
self._update_allowed_origins()
self._update_webhook_headers()

Check failure on line 141 in src/sentry/sentry_apps/logic.py

View check run for this annotation

@sentry/warden / warden: wrdn-data-exfil

[285-X8D] webhookHeaders exposed without masking in API responses, leaking potential auth tokens to all authenticated users (additional location)

Custom webhook headers (which may contain `Authorization: Bearer <token>` or similar credentials) are returned unconditionally in `SentryAppSerializer.serialize()` at `api/serializers/sentry_app.py:150` without the owner-membership gate that protects `clientId`/`clientSecret`; any authenticated user can retrieve them via `GET /api/0/apps/?status=published`.
new_schema_elements = self._update_schema()
self._update_popularity(user=user)
self.sentry_app.save()
Expand Down Expand Up @@ -286,6 +288,10 @@
self.sentry_app.application.allowed_origins = "\n".join(self.allowed_origins)
self.sentry_app.application.save()

def _update_webhook_headers(self) -> None:
if self.webhook_headers is not None:
self.sentry_app.webhook_headers = self.webhook_headers

def _update_popularity(self, user: User | RpcUser) -> None:
if self.popularity is not None:
if _is_elevated_user(user):
Expand Down Expand Up @@ -343,6 +349,7 @@
schema: Schema = dataclasses.field(default_factory=dict)
overview: str | None = None
allowed_origins: list[str] = dataclasses.field(default_factory=list)
webhook_headers: list[str] = dataclasses.field(default_factory=list)

Check warning on line 352 in src/sentry/sentry_apps/logic.py

View check run for this annotation

@sentry/warden / warden: security-review

[XPX-475] Custom webhook headers exposed unmasked in GET responses for published Sentry Apps (additional location)

`SentryAppSerializer` returns `webhookHeaders` verbatim in the GET response for any Sentry App, and `SentryAppPermission.has_object_permission` lets any authenticated user GET a published app's details. Since `webhook_headers` is designed to carry per-app credentials (e.g. `Authorization: Bearer ...`), any Sentry user can read another org's webhook secrets via `GET /api/0/sentry-apps/{slug}/`. Mask the values like `clientSecret` does, or restrict exposure to org members with `org:write`.

Check failure on line 352 in src/sentry/sentry_apps/logic.py

View check run for this annotation

@sentry/warden / warden: wrdn-data-exfil

[285-X8D] webhookHeaders exposed without masking in API responses, leaking potential auth tokens to all authenticated users (additional location)

Custom webhook headers (which may contain `Authorization: Bearer <token>` or similar credentials) are returned unconditionally in `SentryAppSerializer.serialize()` at `api/serializers/sentry_app.py:150` without the owner-membership gate that protects `clientId`/`clientSecret`; any authenticated user can retrieve them via `GET /api/0/apps/?status=published`.
popularity: int | None = None
metadata: dict | None = field(default_factory=dict)

Expand Down Expand Up @@ -426,6 +433,7 @@
"events": expand_events(self.events),
"schema": self.schema or {},
"webhook_url": self.webhook_url,
"webhook_headers": self.webhook_headers,

Check failure on line 436 in src/sentry/sentry_apps/logic.py

View check run for this annotation

@sentry/warden / warden: wrdn-data-exfil

[285-X8D] webhookHeaders exposed without masking in API responses, leaking potential auth tokens to all authenticated users (additional location)

Custom webhook headers (which may contain `Authorization: Bearer <token>` or similar credentials) are returned unconditionally in `SentryAppSerializer.serialize()` at `api/serializers/sentry_app.py:150` without the owner-membership gate that protects `clientId`/`clientSecret`; any authenticated user can retrieve them via `GET /api/0/apps/?status=published`.
"redirect_url": self.redirect_url,
"is_alertable": self.is_alertable,
"verify_install": self.verify_install,
Expand Down
1 change: 1 addition & 0 deletions src/sentry/sentry_apps/models/sentry_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@
verify_install = models.BooleanField(default=True)

events = ArrayField(models.TextField(), default=list)
webhook_headers = ArrayField(models.TextField(), default=list)

Check failure on line 129 in src/sentry/sentry_apps/models/sentry_app.py

View check run for this annotation

@sentry/warden / warden: wrdn-data-exfil

[285-X8D] webhookHeaders exposed without masking in API responses, leaking potential auth tokens to all authenticated users (additional location)

Custom webhook headers (which may contain `Authorization: Bearer <token>` or similar credentials) are returned unconditionally in `SentryAppSerializer.serialize()` at `api/serializers/sentry_app.py:150` without the owner-membership gate that protects `clientId`/`clientSecret`; any authenticated user can retrieve them via `GET /api/0/apps/?status=published`.

overview = models.TextField(null=True)
schema = models.JSONField(default=dict)
Expand Down
1 change: 1 addition & 0 deletions src/sentry/sentry_apps/services/app/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@
uuid: str = ""
events: list[str] = Field(default_factory=list)
webhook_url: str | None = None
webhook_headers: list[str] = Field(default_factory=list)

Check warning on line 88 in src/sentry/sentry_apps/services/app/model.py

View check run for this annotation

@sentry/warden / warden: security-review

[XPX-475] Custom webhook headers exposed unmasked in GET responses for published Sentry Apps (additional location)

`SentryAppSerializer` returns `webhookHeaders` verbatim in the GET response for any Sentry App, and `SentryAppPermission.has_object_permission` lets any authenticated user GET a published app's details. Since `webhook_headers` is designed to carry per-app credentials (e.g. `Authorization: Bearer ...`), any Sentry user can read another org's webhook secrets via `GET /api/0/sentry-apps/{slug}/`. Mask the values like `clientSecret` does, or restrict exposure to org members with `org:write`.

Check failure on line 88 in src/sentry/sentry_apps/services/app/model.py

View check run for this annotation

@sentry/warden / warden: wrdn-data-exfil

[285-X8D] webhookHeaders exposed without masking in API responses, leaking potential auth tokens to all authenticated users (additional location)

Custom webhook headers (which may contain `Authorization: Bearer <token>` or similar credentials) are returned unconditionally in `SentryAppSerializer.serialize()` at `api/serializers/sentry_app.py:150` without the owner-membership gate that protects `clientId`/`clientSecret`; any authenticated user can retrieve them via `GET /api/0/apps/?status=published`.
is_alertable: bool = False
is_disabled: bool = False
is_published: bool = False
Expand Down
1 change: 1 addition & 0 deletions src/sentry/sentry_apps/services/app/serial.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
uuid=app.uuid,
events=app.events,
webhook_url=app.webhook_url,
webhook_headers=app.webhook_headers,

Check failure on line 44 in src/sentry/sentry_apps/services/app/serial.py

View check run for this annotation

@sentry/warden / warden: wrdn-data-exfil

[285-X8D] webhookHeaders exposed without masking in API responses, leaking potential auth tokens to all authenticated users (additional location)

Custom webhook headers (which may contain `Authorization: Bearer <token>` or similar credentials) are returned unconditionally in `SentryAppSerializer.serialize()` at `api/serializers/sentry_app.py:150` without the owner-membership gate that protects `clientId`/`clientSecret`; any authenticated user can retrieve them via `GET /api/0/apps/?status=published`.
is_alertable=app.is_alertable,
is_disabled=app.is_disabled,
is_published=app.status == SentryAppStatus.PUBLISHED,
Expand Down
32 changes: 32 additions & 0 deletions tests/sentry/sentry_apps/api/endpoints/test_sentry_app_details.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ def _validate_updated_published_app(self, response: Response) -> None:
"clientSecret": self.published_app.application.client_secret,
"overview": self.published_app.overview,
"allowedOrigins": [],
"webhookHeaders": [],
"schema": {},
"owner": {"id": self.organization.id, "slug": self.organization.slug},
"featureData": [
Expand Down Expand Up @@ -744,6 +745,37 @@ def test_allowed_origins_with_star(self) -> None:
)
assert response.data == {"allowedOrigins": ["'*' not allowed in origin"]}

@override_options({"staff.ga-rollout": True})
def test_set_webhook_headers(self) -> None:
self.get_success_response(
self.published_app.slug,
webhookHeaders=["X-Custom-Header: value1", "Authorization: Bearer token"],
status_code=200,
)
self.published_app.refresh_from_db()
assert self.published_app.webhook_headers == [
"X-Custom-Header: value1",
"Authorization: Bearer token",
]

@override_options({"staff.ga-rollout": True})
def test_webhook_headers_invalid_format(self) -> None:
response = self.get_error_response(
self.published_app.slug,
webhookHeaders=["InvalidHeaderWithoutColon"],
status_code=400,
)
assert "webhookHeaders" in response.data

@override_options({"staff.ga-rollout": True})
def test_webhook_headers_reserved_header(self) -> None:
response = self.get_error_response(
self.published_app.slug,
webhookHeaders=["Sentry-Hook-Signature: fake"],
status_code=400,
)
assert "webhookHeaders" in response.data

@override_options({"staff.ga-rollout": True})
def test_members_cant_update(self) -> None:
with assume_test_silo_mode(SiloMode.CELL):
Expand Down
24 changes: 24 additions & 0 deletions tests/sentry/sentry_apps/api/endpoints/test_sentry_apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@
assert sentry_app.application is not None
data = {
"allowedOrigins": [],
"webhookHeaders": [],
"author": sentry_app.author,
"avatars": [],
"clientId": sentry_app.application.client_id,
Expand Down Expand Up @@ -758,7 +759,30 @@
sentry_app = SentryApp.objects.get(slug=response.data["slug"])
assert sentry_app.application is not None
assert sentry_app.application.get_allowed_origins() == ["google.com", "example.com"]

def test_create_integration_with_webhook_headers(self) -> None:
headers = ["X-Custom-Header: value", "Authorization: Bearer token123"]
response = self.get_success_response(
**self.get_data(webhookHeaders=headers), status_code=201

Check warning on line 766 in tests/sentry/sentry_apps/api/endpoints/test_sentry_apps.py

View check run for this annotation

@sentry/warden / warden: security-review

[XPX-475] Custom webhook headers exposed unmasked in GET responses for published Sentry Apps (additional location)

`SentryAppSerializer` returns `webhookHeaders` verbatim in the GET response for any Sentry App, and `SentryAppPermission.has_object_permission` lets any authenticated user GET a published app's details. Since `webhook_headers` is designed to carry per-app credentials (e.g. `Authorization: Bearer ...`), any Sentry user can read another org's webhook secrets via `GET /api/0/sentry-apps/{slug}/`. Mask the values like `clientSecret` does, or restrict exposure to org members with `org:write`.
)
sentry_app = SentryApp.objects.get(slug=response.data["slug"])
assert sentry_app.webhook_headers == headers
assert response.data["webhookHeaders"] == headers

def test_create_integration_with_invalid_webhook_header_format(self) -> None:
response = self.get_error_response(
**self.get_data(webhookHeaders=["BadHeader"]),
status_code=400,
)
assert "webhookHeaders" in response.data

def test_create_integration_with_reserved_webhook_header(self) -> None:
response = self.get_error_response(
**self.get_data(webhookHeaders=["Sentry-Hook-Resource: spoofed"]),
status_code=400,
)
assert "webhookHeaders" in response.data

def test_create_internal_integration_with_allowed_origins_and_test_route(self) -> None:
self.create_project(organization=self.organization)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,3 +91,41 @@ def test_user_actor(self) -> None:
assert result.headers["Content-Type"] == "application/json"
assert result.headers["Sentry-Hook-Resource"] == "installation"
assert result.headers["Sentry-Hook-Signature"] == signature

def test_custom_webhook_headers_are_sent(self) -> None:
"""Custom webhook_headers from the SentryApp are included in outgoing headers."""
self.sentry_app.webhook_headers = [
"X-Api-Version: 2023-06-01",
"X-Custom-Token: secret",
]
self.sentry_app.save()
# Reload install so sentry_app reflects the updated record
self.install.refresh_from_db()

result = AppPlatformEvent[dict[str, Any]](
resource=SentryAppResourceType.ISSUE,
action=IssueActionType.ASSIGNED,
install=self.install,
data={},
)

assert result.headers["X-Api-Version"] == "2023-06-01"
assert result.headers["X-Custom-Token"] == "secret"
# Sentry's own headers must still take precedence
assert result.headers["Content-Type"] == "application/json"

def test_reserved_headers_cannot_be_overridden_by_webhook_headers(self) -> None:
"""Sentry system headers always take precedence over custom webhook_headers."""
self.sentry_app.webhook_headers = ["Content-Type: text/plain"]
self.sentry_app.save()
self.install.refresh_from_db()

result = AppPlatformEvent[dict[str, Any]](
resource=SentryAppResourceType.ISSUE,
action=IssueActionType.ASSIGNED,
install=self.install,
data={},
)

# Sentry's Content-Type must win
assert result.headers["Content-Type"] == "application/json"
Loading