Skip to content

feat(integrations): Add webhook_headers field to SentryApp API#117089

Draft
billyvg wants to merge 14 commits into
feat/webhook-headers/flagfrom
feat/webhook-headers/api
Draft

feat(integrations): Add webhook_headers field to SentryApp API#117089
billyvg wants to merge 14 commits into
feat/webhook-headers/flagfrom
feat/webhook-headers/api

Conversation

@billyvg

@billyvg billyvg commented Jun 8, 2026

Copy link
Copy Markdown
Member

This updates the SentryApp API endpoint to accept a webhookHeaders field, which is a newline separated string of http headers that will be sent with the integration's webhooks.

Since a use-case can be attaching an Authorization header, we want to mask the header values unless you have sufficient access (e.g. integration owner or org:write access). [i've changed this so it's always masked] I believe we have sufficient test cases testing updates when we have masked values and are adding more.

You are only allowed to define the following headers:

  • authorization
  • x-* (with some additional restrictions)
  • user-agent
  • accept
  • date
  • prefer

Slop

Wire webhook_headers through the SentryApp create/update API so custom
integrations can manage the headers stored in the prior commit.

  • Parser validates each entry is a single 'Header-Name: value' pair, rejects
    CR/LF (header injection), and rejects reserved headers (Content-Type, Host,
    Request-ID, and the Sentry-Hook-* family) so they cannot be overridden.
  • Serializer masks header values on read, revealing real values only to viewers
    allowed to see secrets (same gate as clientSecret, minus its time window since
    headers must stay editable).
  • Creator and updater persist the field. The updater preserves stored values for
    entries resubmitted with the mask sentinel, so a form prefill+resave does not
    clobber real secrets; an empty list clears all headers.

Co-Authored-By: Claude noreply@anthropic.com


Stack created with GitHub Stacks CLIGive Feedback 💬

@github-actions github-actions Bot added the Scope: Backend Automatically applied to PRs that change backend components label Jun 8, 2026
Comment thread src/sentry/sentry_apps/api/serializers/sentry_app.py
@billyvg billyvg marked this pull request as draft June 8, 2026 16:30
@billyvg billyvg force-pushed the feat/webhook-headers/model branch from d2afcd7 to 7e5a624 Compare June 8, 2026 16:57
@billyvg billyvg force-pushed the feat/webhook-headers/api branch from b78eff1 to 61e2022 Compare June 8, 2026 16:57
@billyvg billyvg changed the title feat(integrations): Add webhook_headers to SentryApp API feat(integrations): Add webhook_headers field to SentryApp API Jun 9, 2026
@billyvg billyvg force-pushed the feat/webhook-headers/model branch from 7e5a624 to f8b9a90 Compare June 9, 2026 22:07
@billyvg billyvg force-pushed the feat/webhook-headers/api branch from b99a4b3 to f034cce Compare June 9, 2026 22:07
Comment on lines +307 to +311
existing_by_name = {}
for header in self.sentry_app.webhook_headers:
name, separator, _value = header.partition(":")
if separator:
existing_by_name[name.strip().lower()] = header

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

create a map of existing header names to the header so that we can check if the user has updated an existing header's value

if separator and value.strip() == MASKED_VALUE:
stored = existing_by_name.get(name.strip().lower())
if stored is not None:
resolved.append(stored)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

since the read api only sends masked values, the user will be sending us a mix of masked and unmasked. assume that they are not modifying a header if its value is the masked value, also SOL if you want to change to the masked value.

@billyvg billyvg force-pushed the feat/webhook-headers/model branch from 78f3715 to 395266b Compare June 10, 2026 14:02
@billyvg billyvg force-pushed the feat/webhook-headers/api branch from f034cce to bfcfdcc Compare June 10, 2026 14:02
@billyvg billyvg force-pushed the feat/webhook-headers/model branch from 395266b to aafdc08 Compare June 11, 2026 19:43
@billyvg billyvg force-pushed the feat/webhook-headers/api branch from fd01108 to 85b577d Compare June 11, 2026 19:43
@billyvg

billyvg commented Jun 16, 2026

Copy link
Copy Markdown
Member Author

@sentry review

@billyvg billyvg force-pushed the feat/webhook-headers/model branch from aafdc08 to 0903390 Compare June 17, 2026 14:19
@billyvg billyvg force-pushed the feat/webhook-headers/api branch 2 times, most recently from 7820a8e to f15bf3a Compare June 17, 2026 14:33
@billyvg billyvg force-pushed the feat/webhook-headers/model branch 2 times, most recently from 081133b to 22619f5 Compare June 17, 2026 14:58
@billyvg billyvg force-pushed the feat/webhook-headers/api branch from f15bf3a to 2a5161c Compare June 17, 2026 14:58
@billyvg billyvg changed the base branch from feat/webhook-headers/model to feat/webhook-headers/flag June 17, 2026 15:04
@billyvg billyvg force-pushed the feat/webhook-headers/api branch from 2a5161c to 5de0300 Compare June 17, 2026 15:04
Comment thread src/sentry/sentry_apps/api/serializers/sentry_app.py
@billyvg billyvg force-pushed the feat/webhook-headers/flag branch from a171cda to d134a00 Compare June 17, 2026 15:47
@billyvg billyvg force-pushed the feat/webhook-headers/api branch from 5de0300 to 069446e Compare June 17, 2026 15:47
@billyvg billyvg force-pushed the feat/webhook-headers/flag branch from d134a00 to 8000015 Compare June 18, 2026 15:49
@billyvg billyvg force-pushed the feat/webhook-headers/api branch from 75192ba to 2da7b51 Compare June 18, 2026 15:49
billyvg and others added 2 commits June 18, 2026 11:57
Add a webhook_headers ArrayField to SentryApp so custom integrations can
store HTTP headers (one 'Header-Name: value' per entry) to send with every
outgoing webhook request. This is the storage layer for the Webhook Headers UI.

The field is stored on SentryApp alongside webhook_url (not on ApiApplication
where allowed_origins lives) since headers are a property of the webhook. Values
may contain secrets (e.g. bearer tokens), so the relocation sanitizer scrubs
webhook_headers from exports, matching how the events array is handled.

Uses db_default=[] for a safe zero-downtime additive migration.

Co-Authored-By: Claude <noreply@anthropic.com>
Wire webhook_headers through the SentryApp create/update API so custom
integrations can manage the headers stored in the prior commit.

- Parser validates each entry is a single 'Header-Name: value' pair, rejects
  CR/LF (header injection), and rejects reserved headers (Content-Type, Host,
  Request-ID, and the Sentry-Hook-* family) so they cannot be overridden.
- Serializer masks header values on read, revealing real values only to viewers
  allowed to see secrets (same gate as clientSecret, minus its time window since
  headers must stay editable).
- Creator and updater persist the field. The updater preserves stored values for
  entries resubmitted with the mask sentinel, so a form prefill+resave does not
  clobber real secrets; an empty list clears all headers.

Co-Authored-By: Claude <noreply@anthropic.com>
billyvg and others added 9 commits June 18, 2026 11:57
…eaders

Regression test confirming the serializer unmasks webhook_headers for internal
integration owners, not just public apps. The unmasking lives in the owner
visibility block (not the non-internal featureData block), so internal apps are
covered.

Co-Authored-By: Claude <noreply@anthropic.com>
The webhookHeaders response field is required, so the OpenAPI example
validator (run with --no-additional-properties) failed for every documented
SentryApp response example. Add webhookHeaders to the retrieve, update, and
organization-list examples so the api docs test passes.

Co-Authored-By: Claude <noreply@anthropic.com>
Webhook header values are masked on read, and the updater re-pairs masked
entries to stored values by header name on save. Reject duplicate names
(case-insensitive) in the parser so that re-pairing is always unambiguous and a
prefill+resave can't mis-restore or lose a value.

Also document the remaining masked round-trip limitation: renaming a header
while leaving its value masked drops the entry (only reachable by an editor who
sees masks, i.e. org:write without scope coverage).

Co-Authored-By: Claude <noreply@anthropic.com>
…t scrubbing

Lock in the security and correctness guarantees of the webhook header
masking logic that were previously only described in comments:

- CR/LF rejection guards against header injection / request splitting
- reserved-header check is case-insensitive (relies on .lower())
- a masked entry with no stored match is dropped, never persisting the
  literal mask placeholder as a real header value
- the documented rename-while-masked drop behavior is pinned
- relocation export scrubs webhook_headers so secrets never leave

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two gaps in the custom webhook headers validator:

- No upper bound on the number of headers allowed a user to configure
  hundreds, inflating every outgoing webhook request and the stored
  ArrayField without limit. Cap at 20.
- Header names were only checked for CR/LF (header injection) but not
  for RFC 7230 token characters. urllib3 does not validate names before
  sending, so a control character embedded in an x-* name (e.g.
  X-Evil\x01Header) would be sent verbatim. Add _HTTP_TOKEN_RE to
  enforce the token character set after partitioning the name.

Three new tests cover the count limit, a control-character name, and a
space in a header name.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Narrow the custom webhook header allow list to only the Authorization
header and X-* custom headers. Previously User-Agent, Accept, Date, and
Prefer were also permitted; these are removed so callers can only set an
auth credential and their own namespaced custom headers.

The X-* allowance is unchanged (it is a separate prefix check), and the
reserved-header guards still take precedence, so headers like
X-Forwarded-* and X-Sentry-* remain blocked.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@billyvg billyvg force-pushed the feat/webhook-headers/flag branch from 8000015 to d082a93 Compare June 18, 2026 15:57
@billyvg billyvg force-pushed the feat/webhook-headers/api branch from 2da7b51 to b695797 Compare June 18, 2026 15:57
billyvg and others added 2 commits June 18, 2026 12:00
With the allow list narrowed to Authorization and X-*, the reserved
checks for non-X- headers (content-type, host, request-id, and the
sentry-hook prefix) are unreachable: anything that is not Authorization
or X-* is already rejected by the allow list before the reserved check
runs.

Trim RESERVED_WEBHOOK_HEADERS and RESERVED_WEBHOOK_HEADER_PREFIXES to
only the X-* names that must be carved out of the X-* allowance, and
retarget the case-insensitivity test at those entries so it exercises
the reserved branch. No change in which headers are accepted.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Comment thread src/sentry/sentry_apps/api/parsers/sentry_app.py
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