🛡️ Sentinel: [HIGH] Fix SSRF in webhook delivery (#846)

* fix: validate webhook targets before delivery

* test: cover webhook SSRF validation
This commit is contained in:
Christian Krakau-Louis
2026-05-17 16:19:41 +02:00
committed by GitHub
parent 416c3c4758
commit e2fa96318f
4 changed files with 165 additions and 6 deletions
+21
View File
@@ -12,11 +12,13 @@ import smtplib
from email.mime.multipart import MIMEMultipart from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText from email.mime.text import MIMEText
from typing import Any from typing import Any
from urllib.parse import urlparse
import httpx import httpx
from app.database import SessionLocal from app.database import SessionLocal
from app.models import InAppNotification, UserNotificationPreference, UserNotificationTarget from app.models import InAppNotification, UserNotificationPreference, UserNotificationTarget
from app.utils.network import is_private_ip
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -28,6 +30,11 @@ USER_EVENT_LABELS: dict[str, str] = {
EVENT_DOCUMENT_PROCESSED: "Document Processed", EVENT_DOCUMENT_PROCESSED: "Document Processed",
EVENT_DOCUMENT_FAILED: "Document Processing Failed", EVENT_DOCUMENT_FAILED: "Document Processing Failed",
} }
METADATA_ENDPOINTS = {
"169.254.169.254",
"169.254.169.253",
"metadata.google.internal",
}
def create_in_app_notification( def create_in_app_notification(
@@ -128,6 +135,20 @@ def _send_webhook_notification(target_config: dict[str, Any], event_type: str, t
logger.warning("Webhook notification target missing url") logger.warning("Webhook notification target missing url")
return False return False
parsed_url = urlparse(url)
if parsed_url.scheme not in {"http", "https"}:
logger.warning("Webhook notification to %s blocked: invalid scheme %s", url, parsed_url.scheme)
return False
hostname = parsed_url.hostname
if not hostname:
logger.warning("Webhook notification to %s blocked: missing hostname", url)
return False
if hostname in METADATA_ENDPOINTS or is_private_ip(hostname):
logger.warning("Webhook notification to %s blocked: private or metadata endpoint", url)
return False
payload = { payload = {
"event": event_type, "event": event_type,
"title": title, "title": title,
+21
View File
@@ -18,11 +18,13 @@ import json
import logging import logging
import time import time
from typing import Any from typing import Any
from urllib.parse import urlparse
import requests import requests
from app.database import SessionLocal from app.database import SessionLocal
from app.models import WebhookConfig from app.models import WebhookConfig
from app.utils.network import is_private_ip
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -40,6 +42,11 @@ VALID_EVENTS: frozenset[str] = frozenset(
#: Timeout (seconds) for outgoing webhook HTTP requests. #: Timeout (seconds) for outgoing webhook HTTP requests.
WEBHOOK_TIMEOUT = 10 WEBHOOK_TIMEOUT = 10
METADATA_ENDPOINTS = {
"169.254.169.254",
"169.254.169.253",
"metadata.google.internal",
}
def compute_signature(payload_bytes: bytes, secret: str) -> str: def compute_signature(payload_bytes: bytes, secret: str) -> str:
@@ -67,6 +74,20 @@ def deliver_webhook(url: str, payload: dict[str, Any], secret: str | None = None
Returns: Returns:
``True`` when the remote server responds with a 2xx status. ``True`` when the remote server responds with a 2xx status.
""" """
parsed_url = urlparse(url)
if parsed_url.scheme not in {"http", "https"}:
logger.warning("Webhook to %s blocked: invalid scheme %s", url, parsed_url.scheme)
return False
hostname = parsed_url.hostname
if not hostname:
logger.warning("Webhook to %s blocked: missing hostname", url)
return False
if hostname in METADATA_ENDPOINTS or is_private_ip(hostname):
logger.warning("Webhook to %s blocked: private or metadata endpoint", url)
return False
body = json.dumps(payload, default=str, sort_keys=True) body = json.dumps(payload, default=str, sort_keys=True)
body_bytes = body.encode("utf-8") body_bytes = body.encode("utf-8")
+81 -6
View File
@@ -235,7 +235,10 @@ class TestSendWebhookNotification:
mock_response.status_code = 200 mock_response.status_code = 200
mock_response.raise_for_status = MagicMock() mock_response.raise_for_status = MagicMock()
with patch("app.utils.user_notification.httpx.post", return_value=mock_response) as mock_post: with (
patch("app.utils.user_notification.is_private_ip", return_value=False),
patch("app.utils.user_notification.httpx.post", return_value=mock_response) as mock_post,
):
result = _send_webhook_notification( result = _send_webhook_notification(
{"url": "https://hook.example.com/test", "secret": "mysecret"}, {"url": "https://hook.example.com/test", "secret": "mysecret"},
"document.processed", "document.processed",
@@ -256,7 +259,10 @@ class TestSendWebhookNotification:
mock_response.status_code = 200 mock_response.status_code = 200
mock_response.raise_for_status = MagicMock() mock_response.raise_for_status = MagicMock()
with patch("app.utils.user_notification.httpx.post", return_value=mock_response) as mock_post: with (
patch("app.utils.user_notification.is_private_ip", return_value=False),
patch("app.utils.user_notification.httpx.post", return_value=mock_response) as mock_post,
):
result = _send_webhook_notification( result = _send_webhook_notification(
{"url": "https://hook.example.com/test"}, {"url": "https://hook.example.com/test"},
"document.failed", "document.failed",
@@ -272,9 +278,12 @@ class TestSendWebhookNotification:
"""_send_webhook_notification returns False when httpx raises.""" """_send_webhook_notification returns False when httpx raises."""
from app.utils.user_notification import _send_webhook_notification from app.utils.user_notification import _send_webhook_notification
with patch( with (
"app.utils.user_notification.httpx.post", patch("app.utils.user_notification.is_private_ip", return_value=False),
side_effect=Exception("connection error"), patch(
"app.utils.user_notification.httpx.post",
side_effect=Exception("connection error"),
),
): ):
result = _send_webhook_notification( result = _send_webhook_notification(
{"url": "https://hook.example.com/test"}, {"url": "https://hook.example.com/test"},
@@ -300,7 +309,10 @@ class TestSendWebhookNotification:
) )
) )
with patch("app.utils.user_notification.httpx.post", return_value=mock_response): with (
patch("app.utils.user_notification.is_private_ip", return_value=False),
patch("app.utils.user_notification.httpx.post", return_value=mock_response),
):
result = _send_webhook_notification( result = _send_webhook_notification(
{"url": "https://hook.example.com/test"}, {"url": "https://hook.example.com/test"},
"document.processed", "document.processed",
@@ -310,6 +322,69 @@ class TestSendWebhookNotification:
assert result is False assert result is False
def test_blocks_private_webhook_target(self):
"""Webhook delivery is skipped for private network targets."""
from app.utils.user_notification import _send_webhook_notification
with (
patch("app.utils.user_notification.is_private_ip", return_value=True),
patch("app.utils.user_notification.httpx.post") as mock_post,
):
result = _send_webhook_notification(
{"url": "https://10.0.0.5/test"},
"document.processed",
"T",
"M",
)
assert result is False
mock_post.assert_not_called()
def test_blocks_metadata_webhook_target(self):
"""Webhook delivery is skipped for cloud metadata endpoints."""
from app.utils.user_notification import _send_webhook_notification
with patch("app.utils.user_notification.httpx.post") as mock_post:
result = _send_webhook_notification(
{"url": "http://169.254.169.254/latest/meta-data"},
"document.processed",
"T",
"M",
)
assert result is False
mock_post.assert_not_called()
def test_blocks_invalid_webhook_scheme(self):
"""Webhook delivery is skipped for unsupported URL schemes."""
from app.utils.user_notification import _send_webhook_notification
with patch("app.utils.user_notification.httpx.post") as mock_post:
result = _send_webhook_notification(
{"url": "file:///etc/passwd"},
"document.processed",
"T",
"M",
)
assert result is False
mock_post.assert_not_called()
def test_blocks_webhook_without_hostname(self):
"""Webhook delivery is skipped when the URL has no hostname."""
from app.utils.user_notification import _send_webhook_notification
with patch("app.utils.user_notification.httpx.post") as mock_post:
result = _send_webhook_notification(
{"url": "https:///missing-host"},
"document.processed",
"T",
"M",
)
assert result is False
mock_post.assert_not_called()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# dispatch_user_notification preference loop # dispatch_user_notification preference loop
+42
View File
@@ -81,6 +81,7 @@ class TestDeliverWebhook:
def test_success_returns_true(self, mocker): def test_success_returns_true(self, mocker):
"""A 200 response returns True.""" """A 200 response returns True."""
mocker.patch("app.utils.webhook.is_private_ip", return_value=False)
mock_post = mocker.patch("app.utils.webhook.requests.post") mock_post = mocker.patch("app.utils.webhook.requests.post")
mock_post.return_value = MagicMock(ok=True, status_code=200) mock_post.return_value = MagicMock(ok=True, status_code=200)
@@ -90,6 +91,7 @@ class TestDeliverWebhook:
def test_non_2xx_returns_false(self, mocker): def test_non_2xx_returns_false(self, mocker):
"""A non-2xx response returns False.""" """A non-2xx response returns False."""
mocker.patch("app.utils.webhook.is_private_ip", return_value=False)
mock_post = mocker.patch("app.utils.webhook.requests.post") mock_post = mocker.patch("app.utils.webhook.requests.post")
mock_post.return_value = MagicMock(ok=False, status_code=500) mock_post.return_value = MagicMock(ok=False, status_code=500)
@@ -100,6 +102,7 @@ class TestDeliverWebhook:
"""A network error returns False.""" """A network error returns False."""
import requests import requests
mocker.patch("app.utils.webhook.is_private_ip", return_value=False)
mocker.patch("app.utils.webhook.requests.post", side_effect=requests.ConnectionError("fail")) mocker.patch("app.utils.webhook.requests.post", side_effect=requests.ConnectionError("fail"))
result = deliver_webhook("https://example.com/hook", {"event": "test"}) result = deliver_webhook("https://example.com/hook", {"event": "test"})
@@ -107,6 +110,7 @@ class TestDeliverWebhook:
def test_signature_header_included_when_secret(self, mocker): def test_signature_header_included_when_secret(self, mocker):
"""X-Webhook-Signature header is present when a secret is supplied.""" """X-Webhook-Signature header is present when a secret is supplied."""
mocker.patch("app.utils.webhook.is_private_ip", return_value=False)
mock_post = mocker.patch("app.utils.webhook.requests.post") mock_post = mocker.patch("app.utils.webhook.requests.post")
mock_post.return_value = MagicMock(ok=True, status_code=200) mock_post.return_value = MagicMock(ok=True, status_code=200)
@@ -118,6 +122,7 @@ class TestDeliverWebhook:
def test_no_signature_header_without_secret(self, mocker): def test_no_signature_header_without_secret(self, mocker):
"""X-Webhook-Signature header is absent when no secret is supplied.""" """X-Webhook-Signature header is absent when no secret is supplied."""
mocker.patch("app.utils.webhook.is_private_ip", return_value=False)
mock_post = mocker.patch("app.utils.webhook.requests.post") mock_post = mocker.patch("app.utils.webhook.requests.post")
mock_post.return_value = MagicMock(ok=True, status_code=200) mock_post.return_value = MagicMock(ok=True, status_code=200)
@@ -126,6 +131,43 @@ class TestDeliverWebhook:
headers = call_kwargs.kwargs.get("headers") or call_kwargs[1].get("headers") headers = call_kwargs.kwargs.get("headers") or call_kwargs[1].get("headers")
assert "X-Webhook-Signature" not in headers assert "X-Webhook-Signature" not in headers
def test_private_target_is_blocked(self, mocker):
"""Private network webhook targets are not called."""
mocker.patch("app.utils.webhook.is_private_ip", return_value=True)
mock_post = mocker.patch("app.utils.webhook.requests.post")
result = deliver_webhook("https://10.0.0.5/hook", {"event": "test"})
assert result is False
mock_post.assert_not_called()
def test_metadata_target_is_blocked(self, mocker):
"""Cloud metadata webhook targets are not called."""
mock_post = mocker.patch("app.utils.webhook.requests.post")
result = deliver_webhook("http://169.254.169.254/latest/meta-data", {"event": "test"})
assert result is False
mock_post.assert_not_called()
def test_invalid_scheme_is_blocked(self, mocker):
"""Unsupported webhook URL schemes are not called."""
mock_post = mocker.patch("app.utils.webhook.requests.post")
result = deliver_webhook("file:///etc/passwd", {"event": "test"})
assert result is False
mock_post.assert_not_called()
def test_missing_hostname_is_blocked(self, mocker):
"""Webhook URLs without a hostname are not called."""
mock_post = mocker.patch("app.utils.webhook.requests.post")
result = deliver_webhook("https:///missing-host", {"event": "test"})
assert result is False
mock_post.assert_not_called()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Unit tests get_active_webhooks_for_event (DB) # Unit tests get_active_webhooks_for_event (DB)