Skip to content

feat(developer-settings): add webhook_headers backend support#116901

Closed
sentry-junior[bot] wants to merge 28 commits into
masterfrom
feat/webhook-headers-backend
Closed

feat(developer-settings): add webhook_headers backend support#116901
sentry-junior[bot] wants to merge 28 commits into
masterfrom
feat/webhook-headers-backend

Conversation

@sentry-junior

@sentry-junior sentry-junior Bot commented Jun 4, 2026

Copy link
Copy Markdown
Contributor

Summary

Backend implementation of custom webhook headers for Sentry App integrations. This is the follow-up to the frontend-only PR #116732.

What changed

Model (SentryApp): adds webhook_headers = ArrayField(TextField, default=list) — stores header lines as a plain text[] column, same shape as events.

Migration (1109_sentryapp_webhook_headers): AddField on sentry_sentryapp.

RPC layer (RpcSentryApp, serialize_sentry_app): exposes webhook_headers across the hybrid-cloud boundary.

Creator / Updater (SentryAppCreator, SentryAppUpdater): accept and persist webhook_headers (Updater method: _update_webhook_headers).

Parser (SentryAppParser): new webhookHeaders field (list of strings). Validated: each item must be Name: value format; reserved Sentry prefixes (sentry-hook-*, content-type, request-id) are rejected.

Response serializer (SentryAppSerializer): includes webhookHeaders in all GET / POST / PUT responses.

Endpoints: SentryAppsEndpoint (POST) and SentryAppDetailsEndpoint (PUT) wire the new field through.

Webhook delivery (AppPlatformEvent.headers): custom headers are merged into the outgoing request dict before Sentry's own headers, so Sentry system headers always take precedence and cannot be spoofed.

Notes

Action taken on behalf of Billy.


View Session in Sentry

sentry-junior Bot added 28 commits June 4, 2026 20:51
@github-actions github-actions Bot added the Scope: Backend Automatically applied to PRs that change backend components label Jun 4, 2026
@github-actions

github-actions Bot commented Jun 4, 2026

Copy link
Copy Markdown
Contributor

Backend Test Failures

Failures on ebf9587 in this run:

tests/sentry/sentry_apps/api/serializers/test_app_platform_event.py::AppPlatformEventSerializerTest::test_custom_webhook_headers_are_sentlog
[gw0] linux -- Python 3.13.1 /home/runner/work/sentry/sentry/.venv/bin/python3
tests/sentry/sentry_apps/api/serializers/test_app_platform_event.py:101: in test_custom_webhook_headers_are_sent
    self.sentry_app.save()
src/sentry/silo/base.py:165: in override
    return handler(*args, **kwargs)
           ^^^^^^^^^^^^^^^^^^^^^^^^
src/sentry/db/models/base.py:467: in handle
    raise self.AvailabilityError(message)
E   sentry.silo.base.SiloLimit.AvailabilityError: Called `SentryApp.save` on server in REGION mode. SentryApp is available only in: CONTROL, MONOLITH
tests/sentry/sentry_apps/api/serializers/test_app_platform_event.py::AppPlatformEventSerializerTest::test_reserved_headers_cannot_be_overridden_by_webhook_headerslog
[gw0] linux -- Python 3.13.1 /home/runner/work/sentry/sentry/.venv/bin/python3
tests/sentry/sentry_apps/api/serializers/test_app_platform_event.py:120: in test_reserved_headers_cannot_be_overridden_by_webhook_headers
    self.sentry_app.save()
src/sentry/silo/base.py:165: in override
    return handler(*args, **kwargs)
           ^^^^^^^^^^^^^^^^^^^^^^^^
src/sentry/db/models/base.py:467: in handle
    raise self.AvailabilityError(message)
E   sentry.silo.base.SiloLimit.AvailabilityError: Called `SentryApp.save` on server in REGION mode. SentryApp is available only in: CONTROL, MONOLITH
tests/sentry/sentry_apps/api/endpoints/test_organization_sentry_apps.py::GetOrganizationSentryAppsTest::test_gets_all_apps_in_own_orglog
[gw1] linux -- Python 3.13.1 /home/runner/work/sentry/sentry/.venv/bin/python3
tests/sentry/sentry_apps/api/endpoints/test_organization_sentry_apps.py:40: in test_gets_all_apps_in_own_org
    assert_response_json(
tests/sentry/sentry_apps/api/endpoints/test_organization_sentry_apps.py:16: in assert_response_json
    assert orjson.loads(response.content) == orjson.loads(orjson.dumps(data))
E   AssertionError: assert [{'allowedOri...538601', ...}] == [{'allowedOri...538601', ...}]
E     
E     At index 0 diff: �[0m{�[33m'�[39;49;00m�[33mallowedOrigins�[39;49;00m�[33m'�[39;49;00m: [], �[33m'�[39;49;00m�[33mauthor�[39;49;00m�[33m'�[39;49;00m: �[33m'�[39;49;00m�[33mA Company�[39;49;00m�[33m'�[39;49;00m, �[33m'�[39;49;00m�[33mavatars�[39;49;00m�[33m'�[39;49;00m: [], �[33m'�[39;49;00m�[33mevents�[39;49;00m�[33m'�[39;49;00m: [], �[33m'�[39;49;00m�[33mfeatureData�[39;49;00m�[33m'�[39;49;00m: [{�[33m'�[39;49;00m�[33mfeatureId�[39;49;00m�[33m'�[39;49;00m: �[94m0�[39;49;00m, �[33m'�[39;49;00m�[33mfeatureGate�[39;49;00m�[33m'�[39;49;00m: �[33m'�[39;49;00m�[33mintegrations-api�[39;49;00m�[33m'�[39;49;00m, �[33m'�[39;49;00m�[33mdescription�[39;49;00m�[33m'�[39;49;00m: �[33m'�[39;49;00m�[33mTestin can **utilize the Sentry API** to pull data or update resources in Sentry (with permissions granted, of course).�[39;49;00m�[33m'�[39;49;00m}], �[33m'�[39;49;00m�[33misAlertable�[39;49;00m�[33m'�[39;49;00m: �[94mFalse�[39;49;00m, �[33m'�[39;49;00m�[33misDisabled�[39;49;00m�[33m'�[39;49;00m: �[94mFalse�[39;49;00m, �[33m'�[39;49;00m�[33mmetadata�[39;49;00m�[33m'�[39;49;00m: {}, �[33m'�[39;49;00m�[33mname�[39;49;00m�[33m'�[39;49;00m: �[33m'�[39;49;00m�[33mTestin�[39;49;00m�[33m'�[39;49;00m, �[33m'�[39;49;00m�[33moverview�[39;49;00m�[33m'�[39;49;00m: �[94mNone�[39;49;00m, �[33m'�[39;49;00m�[33mpopularity�[39;49;00m�[33m'�[39;49;00m: �[94m1�[39;49;00m, �[33m'�[39;49;00m�[33mredirectUrl�[39;49;00m�[33m'�[39;49;00m: �[94mNone�[39;49;00m, �[33m'�[39;49;00m�[33mschema�[39;49;00m�[33m'�[39;49;00m: {}, �[33m'�[39;49;00m�[33mscopes�[39;49;00m�[33m'�[39;49;00m: [], �[33m'�[39;49;00m�[33mslug�[39;49;00m�[33m'�[39;49;00m: �[33m'�[39;49;00m�[33mtestin�[39;49;00m�[33m'�[39;49;00m, �[33m'�[39;49;00m�[33mstatus�[39;49;00m�[33m'�[39;49;00m: �[33m'�[39;49;00m�[33munpublished�[39;49;00m�[33m'�[39;49;00m, �[33m'�[39;49;00m�[33muuid�[39;49;00m�[33m'�[39;49;00m: �[33m'�[39;49;00m�[33m3ec08f13-4839-4384-9b13-ddc3eaedfe76�[39;49;00m�[33m'�[39;49;00m, �[33m'�[39;49;00m�[33mverifyInstall�[39;49;00m�[33m'�[39;49;00m: �[94mTrue�[39;49;00m, �[33m'�[39;49;00m�[33mwebhookUrl�[39;49;00m�[33m'�[39;49;00m: �[33m'�[39;49;00m�[33mhttps://example.com/webhook�[39;49;00m�[33m'�[39;49;00m, �[33m'�[39;49;00m�[33mwebhookHeaders�[39;49;00m�[33m'�[39;49;00m: [], �[33m'�[39;49;00m�[33mclientId�[39;49;00m�[33m'�[39;49;00m: �[33m'�[39;49;00m�[33m7aa669caa5b4bed889d303b95413e1a6ba425b60433ce79b2127340cc3538601�[39;49;00m�[33m'�[39;49;00m, �[33m'�[39;49;00m�[33mclientSecret�[39;49;00m�[33m'�[39;49;00m: �[33m'�[39;49;00m�[33m9c1797abc1274f39be31cbb22e824031c04a42e38d7a7c5c5308f12685ae7797�[39;49;00m�[33m'�[39;49;00m, �[33m'�[39;49;00m�[33mowner�[39;49;00m�[33m'�[39;49;00m: {�[33m'�[39;49;00m�[33mid�[39;49;00m�[33m'�[39;49;00m: �[94m4558257215373344�[39;49;00m, �[33m'�[39;49;00m�[33mslug�[39;49;00m�[33m'�[39;49;00m: �[33m'�[39;49;00m�[33mpossible-dodo�[39;49;00m�[33m'�[39;49;00m}}�[90m�[39;49;00m != �[0m{�[33m'�[39;49;00m�[33mname�[39;49;00m�[33m'�[39;49;00m: �[33m'�[39;49;00m�[33mTestin�[39;49;00m�[33m'�[39;49;00m, �[33m'�[39;49;00m�[33mauthor�[39;49;00m�[33m'�[39;49;00m: �[33m'�[39;49;00m�[33mA Company�[39;49;00m�[33m'�[39;49;00m, �[33m'�[39;49;00m�[33mslug�[39;49;00m�[33m'�[39;49;00m: �[33m'�[39;49;00m�[33mtestin�[39;49;00m�[33m'�[39;49;00m, �[33m'�[39;49;00m�[33mscopes�[39;49;00m�[33m'�[39;49;00m: [], �[33m'�[39;49;00m�[33mevents�[39;49;00m�[33m'�[39;49;00m: [], �[33m'�[39;49;00m�[33muuid�[39;49;00m�[33m'�[39;49;00m: �[33m'�[39;49;00m�[33m3ec08f13-4839-4384-9b13-ddc3eaedfe76�[39;49;00m�[33m'�[39;49;00m, �[33m'�[39;49;00m�[33mstatus�[39;49;00m�[33m'�[39;49;00m: �[33m'�[39;49;00m�[33munpublished�[39;49;00m�[33m'�[39;49;00m, �[33m'�[39;49;00m�[33mwebhookUrl�[39;49;00m�[33m'�[39;49;00m: �[33m'�[39;49;00m�[33mhttps://example.com/webhook�[39;49;00m�[33m'�[39;49;00m, �[33m'�[39;49;00m�[33mredirectUrl�[39;49;00m�[33m'�[39;49;00m: �[94mNone�[39;49;00m, �[33m'�[39;49;00m�[33misAlertable�[39;49;00m�[33m'�[39;49;00m: �[94mFalse�[39;49;00m, �[33m'�[39;49;00m�[33misDisabled�[39;49;00m�[33m'�[39;49;00m: �[94mFalse�[39;49;00m, �[33m'�[39;49;00m�[33mverifyInstall�[39;49;00m�[33m'�[39;49;00m: �[94mTrue�[39;49;00m, �[33m'�[39;49;00m�[33mclientId�[39;49;00m�[33m'�[39;49;00m: �[33m'�[39;49;00m�[33m7aa669caa5b4bed889d303b95413e1a6ba425b60433ce79b2127340cc3538601�[39;49;00m�[33m'�[39;49;00m, �[33m'�[39;49;00m�[33mclientSecret�[39;49;00m�[33m'�[39;49;00m: �[33m'�[39;49;00m�[33m9c1797abc1274f39be31cbb22e824031c04a42e38d7a7c5c5308f12685ae7797�[39;49;00m�[33m'�[39;49;00m, �[33m'�[39;49;00m�[33moverview�[39;49;00m�[33m'�[39;49;00m: �[94mNone�[39;49;00m, �[33m'�[39;49;00m�[33mallowedOrigins�[39;49;00m�[33m'�[39;49;00m: [], �[33m'�[39;49;00m�[33mschema�[39;49;00m�[33m'�[39;49;00m: {}, �[33m'�[39;49;00m�[33mowner�[39;49;00m�[33m'�[39;49;00m: {�[33m'�[39;49;00m�[33mid�[39;49;00m�[33m'�[39;49;00m: �[94m4558257215373344�[39;49;00m, �[33m'�[39;49;00m�[33mslug�[39;49;00m�[33m'�[39;49;00m: �[33m'�[39;49;00m�[33mpossible-dodo�[39;49;00m�[33m'�[39;49;00m}, �[33m'�[39;49;00m�[33mfeatureData�[39;49;00m�[33m'�[39;49;00m: [{�[33m'�[39;49;00m�[33mfeatureId�[39;49;00m�[33m'�[39;49;00m: �[94m0�[39;49;00m, �[33m'�[39;49;00m�[33mfeatureGate�[39;49;00m�[33m'�[39;49;00m: �[33m'�[39;49;00m�[33mintegrations-api�[39;49;00m�[33m'�[39;49;00m, �[33m'�[39;49;00m�[33mdescription�[39;49;00m�[33m'�[39;49;00m: �[33m'�[39;49;00m�[33mTestin can **utilize the Sentry API** to pull data or update resources in Sentry (with permissions granted, of course).�[39;49;00m�[33m'�[39;49;00m}], �[33m'�[39;49;00m�[33mpopularity�[39;49;00m�[33m'�[39;49;00m: �[94m1�[39;49;00m, �[33m'�[39;49;00m�[33mavatars�[39;49;00m�[33m'�[39;49;00m: [], �[33m'�[39;49;00m�[33mmetadata�[39;49;00m�[33m'�[39;49;00m: {}}�[90m�[39;49;00m
E     
E     Full diff:
E     �[0m�[90m �[39;49;00m [�[90m�[39;49;00m
E     �[90m �[39;49;00m     {�[90m�[39;49;00m
E     �[90m �[39;49;00m         'allowedOrigins': [],�[90m�[39;49;00m
E     �[90m �[39;49;00m         'author': 'A Company',�[90m�[39;49;00m
E     �[90m �[39;49;00m         'avatars': [],�[90m�[39;49;00m
E     �[90m �[39;49;00m         'clientId': '7aa669caa5b4bed889d303b95413e1a6ba425b60433ce79b2127340cc3538601',�[90m�[39;49;00m
E     �[90m �[39;49;00m         'clientSecret': '9c1797abc1274f39be31cbb22e824031c04a42e38d7a7c5c5308f12685ae7797',�[90m�[39;49;00m
E     �[90m �[39;49;00m         'events': [],�[90m�[39;49;00m
E     �[90m �[39;49;00m         'featureData': [�[90m�[39;49;00m
E     �[90m �[39;49;00m             {�[90m�[39;49;00m
E     �[90m �[39;49;00m                 'description': 'Testin can **utilize the Sentry API** to pull data or update '�[90m�[39;49;00m
E     �[90m �[39;49;00m                 'resources in Sentry (with permissions granted, of course).',�[90m�[39;49;00m
E     �[90m �[39;49;00m                 'featureGate': 'integrations-api',�[90m�[39;49;00m
E     �[90m �[39;49;00m                 'featureId': 0,�[90m�[39;49;00m
E     �[90m �[39;49;00m             },�[90m�[39;49;00m
E     �[90m �[39;49;00m         ],�[90m�[39;49;00m
E     �[90m �[39;49;00m         'isAlertable': False,�[90m�[39;49;00m
E     �[90m �[39;49;00m         'isDisabled': False,�[90m�[39;49;00m
E     �[90m �[39;49;00m         'metadata': {},�[90m�[39;49;00m
E     �[90m �[39;49;00m         'name': 'Testin',�[90m�[39;49;00m
E     �[90m �[39;49;00m         'overview': None,�[90m�[39;49;00m
E     �[90m �[39;49;00m         'owner': {�[90m�[39;49;00m
E     �[90m �[39;49;00m             'id': 4558257215373344,�[90m�[39;49;00m
E     �[90m �[39;49;00m             'slug': 'possible-dodo',�[90m�[39;49;00m
E     �[90m �[39;49;00m         },�[90m�[39;49;00m
E     �[90m �[39;49;00m         'popularity': 1,�[90m�[39;49;00m
E     �[90m �[39;49;00m         'redirectUrl': None,�[90m�[39;49;00m
E     �[90m �[39;49;00m         'schema': {},�[90m�[39;49;00m
E     �[90m �[39;49;00m         'scopes': [],�[90m�[39;49;00m
E     �[90m �[39;49;00m         'slug': 'testin',�[90m�[39;49;00m
E     �[90m �[39;49;00m         'status': 'unpublished',�[90m�[39;49;00m
E     �[90m �[39;49;00m         'uuid': '3ec08f13-4839-4384-9b13-ddc3eaedfe76',�[90m�[39;49;00m
E     �[90m �[39;49;00m         'verifyInstall': True,�[90m�[39;49;00m
E     �[92m+         'webhookHeaders': [],�[39;49;00m�[90m�[39;49;00m
E     �[90m �[39;49;00m         'webhookUrl': 'https://example.com/webhook',�[90m�[39;49;00m
E     �[90m �[39;49;00m     },�[90m�[39;49;00m
E     �[90m �[39;49;00m ]�[90m�[39;49;00m

schema=data["schema"],
overview=data["overview"],
allowed_origins=data["allowedOrigins"],
webhook_headers=data["webhookHeaders"],

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

@billyvg billyvg closed this Jun 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Scope: Backend Automatically applied to PRs that change backend components

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant