Skip to content

Commit 5de0300

Browse files
billyvgclaude
andcommitted
fix(integrations): Validate webhook header count and name characters
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>
1 parent 6d66a27 commit 5de0300

2 files changed

Lines changed: 30 additions & 0 deletions

File tree

src/sentry/sentry_apps/api/parsers/sentry_app.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import logging
2+
import re
23

34
from drf_spectacular.types import OpenApiTypes
45
from drf_spectacular.utils import extend_schema_field, extend_schema_serializer
@@ -19,6 +20,10 @@
1920
# and common custom-header names. Names are compared case-insensitively.
2021
ALLOWED_WEBHOOK_HEADERS = frozenset({"authorization", "user-agent", "accept", "date", "prefer"})
2122

23+
# RFC 7230 §3.2.6 — header field names are "tokens": letters, digits, and
24+
# the limited punctuation set below. Excludes separators and control chars.
25+
_HTTP_TOKEN_RE = re.compile(r"^[!#$%&'*+\-.^_`|~A-Za-z0-9]+$")
26+
2227
# Headers Sentry owns, or transport/proxy identity headers that must not be
2328
# user-controlled. The "sentry-hook" prefix is also blocked.
2429
RESERVED_WEBHOOK_HEADERS = frozenset(
@@ -226,6 +231,8 @@ def validate_allowedOrigins(self, value):
226231
return value
227232

228233
def validate_webhookHeaders(self, value):
234+
if len(value) > 20:
235+
raise ValidationError("Cannot configure more than 20 custom webhook headers.")
229236
seen_names = set()
230237
for header in value:
231238
# Reject CR/LF to prevent header injection / request splitting.
@@ -237,6 +244,11 @@ def validate_webhookHeaders(self, value):
237244
raise ValidationError(
238245
f"Invalid webhook header '{header}'. Use the format 'Header-Name: value'."
239246
)
247+
if not _HTTP_TOKEN_RE.match(name):
248+
raise ValidationError(
249+
f"'{name}' contains invalid characters. Header names must only use "
250+
"letters, digits, and the punctuation characters !#$%&'*+-.^_`|~"
251+
)
240252
normalized = name.lower()
241253
if normalized in RESERVED_WEBHOOK_HEADERS or normalized.startswith(
242254
RESERVED_WEBHOOK_HEADER_PREFIXES

tests/sentry/sentry_apps/api/endpoints/test_sentry_apps.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -824,6 +824,24 @@ def test_create_integration_with_duplicate_webhook_header(self) -> None:
824824
)
825825
assert "webhookHeaders" in response.data
826826

827+
def test_create_integration_with_too_many_webhook_headers(self) -> None:
828+
headers = [f"X-Header-{i}: value" for i in range(21)]
829+
response = self.get_error_response(**self.get_data(webhookHeaders=headers), status_code=400)
830+
assert "webhookHeaders" in response.data
831+
832+
def test_create_integration_with_invalid_header_name_chars(self) -> None:
833+
# Control characters in header names are not valid RFC 7230 tokens.
834+
response = self.get_error_response(
835+
**self.get_data(webhookHeaders=["X-Evil\x01Header: value"]), status_code=400
836+
)
837+
assert "webhookHeaders" in response.data
838+
839+
def test_create_integration_with_space_in_header_name(self) -> None:
840+
response = self.get_error_response(
841+
**self.get_data(webhookHeaders=["X Bad Name: value"]), status_code=400
842+
)
843+
assert "webhookHeaders" in response.data
844+
827845
def test_members_cant_create(self) -> None:
828846
# create extra owner because we are demoting one
829847
self.create_member(organization=self.organization, user=self.create_user(), role="owner")

0 commit comments

Comments
 (0)