Files
gh-christianlouis-docuelevate/tests/test_webhooks.py
T
Christian Krakau-Louis e2fa96318f 🛡️ Sentinel: [HIGH] Fix SSRF in webhook delivery (#846)
* fix: validate webhook targets before delivery

* test: cover webhook SSRF validation
2026-05-17 16:19:41 +02:00

505 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Tests for the webhook subsystem (utility, API, and task)."""
import hashlib
import hmac
import json
import time
from unittest.mock import MagicMock, patch
import pytest
from app.models import WebhookConfig
from app.utils.webhook import (
build_payload,
compute_signature,
deliver_webhook,
dispatch_webhook_event,
get_active_webhooks_for_event,
)
# ---------------------------------------------------------------------------
# Unit tests compute_signature
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestComputeSignature:
"""Tests for HMAC-SHA256 signature generation."""
def test_signature_format(self):
"""Signature starts with 'sha256=' and is a hex digest."""
sig = compute_signature(b'{"event":"test"}', "mysecret")
assert sig.startswith("sha256=")
# hex digest should be 64 chars
assert len(sig.split("=")[1]) == 64
def test_signature_matches_manual_hmac(self):
"""Signature matches a manually computed HMAC-SHA256."""
payload = b'{"key":"value"}'
secret = "s3cret"
expected = "sha256=" + hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest()
assert compute_signature(payload, secret) == expected
def test_different_secrets_produce_different_signatures(self):
"""Different secrets must produce different signatures."""
payload = b"same"
assert compute_signature(payload, "secret1") != compute_signature(payload, "secret2")
# ---------------------------------------------------------------------------
# Unit tests build_payload
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestBuildPayload:
"""Tests for the webhook payload builder."""
def test_contains_required_keys(self):
"""Payload contains event, timestamp, and data."""
payload = build_payload("document.uploaded", {"file_id": 1})
assert payload["event"] == "document.uploaded"
assert "timestamp" in payload
assert payload["data"] == {"file_id": 1}
def test_timestamp_is_recent(self):
"""Timestamp should be close to current time."""
before = time.time()
payload = build_payload("document.processed", {})
after = time.time()
assert before <= payload["timestamp"] <= after
# ---------------------------------------------------------------------------
# Unit tests deliver_webhook
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestDeliverWebhook:
"""Tests for the HTTP delivery function."""
def test_success_returns_true(self, mocker):
"""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.return_value = MagicMock(ok=True, status_code=200)
result = deliver_webhook("https://example.com/hook", {"event": "test"})
assert result is True
mock_post.assert_called_once()
def test_non_2xx_returns_false(self, mocker):
"""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.return_value = MagicMock(ok=False, status_code=500)
result = deliver_webhook("https://example.com/hook", {"event": "test"})
assert result is False
def test_request_exception_returns_false(self, mocker):
"""A network error returns False."""
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"))
result = deliver_webhook("https://example.com/hook", {"event": "test"})
assert result is False
def test_signature_header_included_when_secret(self, mocker):
"""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.return_value = MagicMock(ok=True, status_code=200)
deliver_webhook("https://example.com/hook", {"event": "test"}, secret="abc")
call_kwargs = mock_post.call_args
headers = call_kwargs.kwargs.get("headers") or call_kwargs[1].get("headers")
assert "X-Webhook-Signature" in headers
assert headers["X-Webhook-Signature"].startswith("sha256=")
def test_no_signature_header_without_secret(self, mocker):
"""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.return_value = MagicMock(ok=True, status_code=200)
deliver_webhook("https://example.com/hook", {"event": "test"})
call_kwargs = mock_post.call_args
headers = call_kwargs.kwargs.get("headers") or call_kwargs[1].get("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)
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestGetActiveWebhooks:
"""Tests that active webhook configs are correctly filtered by event."""
def test_returns_matching_webhooks(self, db_session):
"""Only webhooks subscribed to the given event are returned."""
cfg1 = WebhookConfig(
url="https://a.com/hook",
secret="s1",
events=json.dumps(["document.uploaded", "document.processed"]),
is_active=True,
)
cfg2 = WebhookConfig(
url="https://b.com/hook",
events=json.dumps(["document.failed"]),
is_active=True,
)
db_session.add_all([cfg1, cfg2])
db_session.commit()
with patch("app.utils.webhook.SessionLocal", return_value=db_session):
results = get_active_webhooks_for_event("document.uploaded")
assert len(results) == 1
assert results[0]["url"] == "https://a.com/hook"
def test_inactive_webhooks_excluded(self, db_session):
"""Inactive webhooks are not returned."""
cfg = WebhookConfig(
url="https://inactive.com/hook",
events=json.dumps(["document.uploaded"]),
is_active=False,
)
db_session.add(cfg)
db_session.commit()
with patch("app.utils.webhook.SessionLocal", return_value=db_session):
results = get_active_webhooks_for_event("document.uploaded")
assert results == []
def test_returns_empty_when_no_match(self, db_session):
"""No webhooks returned when none match the event."""
cfg = WebhookConfig(
url="https://c.com/hook",
events=json.dumps(["document.failed"]),
is_active=True,
)
db_session.add(cfg)
db_session.commit()
with patch("app.utils.webhook.SessionLocal", return_value=db_session):
results = get_active_webhooks_for_event("document.uploaded")
assert results == []
# ---------------------------------------------------------------------------
# Unit tests dispatch_webhook_event
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestDispatchWebhookEvent:
"""Tests for the high-level dispatch function."""
def test_unknown_event_is_ignored(self, mocker):
"""Unknown events are silently ignored."""
mock_get = mocker.patch("app.utils.webhook.get_active_webhooks_for_event")
dispatch_webhook_event("unknown.event", {})
mock_get.assert_not_called()
def test_no_webhooks_does_not_fail(self, mocker):
"""No error when there are no matching webhooks."""
mocker.patch("app.utils.webhook.get_active_webhooks_for_event", return_value=[])
dispatch_webhook_event("document.uploaded", {"file_id": 1})
def test_queues_celery_task_for_each_webhook(self, mocker):
"""A Celery task is queued for each matching webhook."""
mocker.patch(
"app.utils.webhook.get_active_webhooks_for_event",
return_value=[
{"id": 1, "url": "https://a.com", "secret": "s", "events": ["document.uploaded"]},
{"id": 2, "url": "https://b.com", "secret": None, "events": ["document.uploaded"]},
],
)
mock_task = mocker.patch("app.tasks.webhook_tasks.deliver_webhook_task.delay")
dispatch_webhook_event("document.uploaded", {"file_id": 42})
assert mock_task.call_count == 2
# ---------------------------------------------------------------------------
# Integration tests API endpoints
# ---------------------------------------------------------------------------
@pytest.mark.integration
class TestWebhookAPI:
"""Tests for the /api/webhooks/ endpoints."""
def _with_admin(self, client):
"""Return headers / approach to authenticate as admin for test client."""
# FastAPI TestClient + SessionMiddleware: we can set session data by
# using the app's dependency override or the session directly.
# Simplest: override the _require_admin dependency.
from app.api.webhooks import _require_admin
client.app.dependency_overrides[_require_admin] = lambda: {"is_admin": True, "name": "test-admin"}
return client
def test_create_webhook(self, client):
"""POST /api/webhooks/ creates a new webhook."""
self._with_admin(client)
resp = client.post(
"/api/webhooks/",
json={
"url": "https://example.com/webhook",
"secret": "my-secret",
"events": ["document.uploaded"],
"description": "Test hook",
},
)
assert resp.status_code == 201
data = resp.json()
assert data["url"] == "https://example.com/webhook"
assert data["events"] == ["document.uploaded"]
assert data["is_active"] is True
assert data["has_secret"] is True
assert "secret" not in data # secret must not be exposed
def test_list_webhooks(self, client, db_session):
"""GET /api/webhooks/ returns all webhooks."""
self._with_admin(client)
# seed one
cfg = WebhookConfig(
url="https://list.example.com/hook",
events=json.dumps(["document.processed"]),
is_active=True,
)
db_session.add(cfg)
db_session.commit()
resp = client.get("/api/webhooks/")
assert resp.status_code == 200
items = resp.json()
assert any(w["url"] == "https://list.example.com/hook" for w in items)
def test_get_webhook(self, client, db_session):
"""GET /api/webhooks/{id} returns a single webhook."""
self._with_admin(client)
cfg = WebhookConfig(
url="https://get.example.com/hook",
events=json.dumps(["document.failed"]),
is_active=True,
)
db_session.add(cfg)
db_session.commit()
resp = client.get(f"/api/webhooks/{cfg.id}")
assert resp.status_code == 200
assert resp.json()["url"] == "https://get.example.com/hook"
def test_get_webhook_not_found(self, client):
"""GET /api/webhooks/9999 returns 404."""
self._with_admin(client)
resp = client.get("/api/webhooks/9999")
assert resp.status_code == 404
def test_update_webhook(self, client, db_session):
"""PUT /api/webhooks/{id} updates the webhook."""
self._with_admin(client)
cfg = WebhookConfig(
url="https://old.example.com/hook",
events=json.dumps(["document.uploaded"]),
is_active=True,
)
db_session.add(cfg)
db_session.commit()
resp = client.put(
f"/api/webhooks/{cfg.id}",
json={"url": "https://new.example.com/hook", "is_active": False},
)
assert resp.status_code == 200
data = resp.json()
assert data["url"] == "https://new.example.com/hook"
assert data["is_active"] is False
def test_update_webhook_not_found(self, client):
"""PUT /api/webhooks/9999 returns 404."""
self._with_admin(client)
resp = client.put("/api/webhooks/9999", json={"url": "https://x.com"})
assert resp.status_code == 404
def test_delete_webhook(self, client, db_session):
"""DELETE /api/webhooks/{id} removes the webhook."""
self._with_admin(client)
cfg = WebhookConfig(
url="https://del.example.com/hook",
events=json.dumps(["document.uploaded"]),
is_active=True,
)
db_session.add(cfg)
db_session.commit()
wid = cfg.id
resp = client.delete(f"/api/webhooks/{wid}")
assert resp.status_code == 204
# confirm it's gone
resp2 = client.get(f"/api/webhooks/{wid}")
assert resp2.status_code == 404
def test_delete_webhook_not_found(self, client):
"""DELETE /api/webhooks/9999 returns 404."""
self._with_admin(client)
resp = client.delete("/api/webhooks/9999")
assert resp.status_code == 404
def test_create_webhook_invalid_event(self, client):
"""POST /api/webhooks/ rejects invalid events."""
self._with_admin(client)
resp = client.post(
"/api/webhooks/",
json={"url": "https://x.com/hook", "events": ["bad.event"]},
)
assert resp.status_code == 422
def test_list_events(self, client):
"""GET /api/webhooks/events/ returns valid events."""
self._with_admin(client)
resp = client.get("/api/webhooks/events/")
assert resp.status_code == 200
events = resp.json()
assert "document.uploaded" in events
assert "document.processed" in events
assert "document.failed" in events
def test_requires_admin(self, client):
"""Endpoints return 403 without admin session."""
# Remove the admin override
from app.api.webhooks import _require_admin
client.app.dependency_overrides.pop(_require_admin, None)
resp = client.get("/api/webhooks/")
assert resp.status_code == 403
def test_create_webhook_without_secret(self, client):
"""POST /api/webhooks/ works without a secret."""
self._with_admin(client)
resp = client.post(
"/api/webhooks/",
json={"url": "https://nosecret.com/hook", "events": ["document.failed"]},
)
assert resp.status_code == 201
data = resp.json()
assert data["has_secret"] is False
def test_update_webhook_events(self, client, db_session):
"""PUT /api/webhooks/{id} can update events."""
self._with_admin(client)
cfg = WebhookConfig(
url="https://evup.example.com/hook",
events=json.dumps(["document.uploaded"]),
is_active=True,
)
db_session.add(cfg)
db_session.commit()
resp = client.put(
f"/api/webhooks/{cfg.id}",
json={"events": ["document.processed", "document.failed"]},
)
assert resp.status_code == 200
assert set(resp.json()["events"]) == {"document.processed", "document.failed"}
def test_update_webhook_invalid_event(self, client, db_session):
"""PUT /api/webhooks/{id} rejects invalid events."""
self._with_admin(client)
cfg = WebhookConfig(
url="https://bdev.example.com/hook",
events=json.dumps(["document.uploaded"]),
is_active=True,
)
db_session.add(cfg)
db_session.commit()
resp = client.put(
f"/api/webhooks/{cfg.id}",
json={"events": ["invalid.event"]},
)
assert resp.status_code == 422
# ---------------------------------------------------------------------------
# Unit tests Celery task
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestDeliverWebhookTask:
"""Tests for the Celery webhook delivery task."""
def test_success_returns_status_dict(self, mocker):
"""Task returns a dict on successful delivery."""
mocker.patch("app.tasks.webhook_tasks.deliver_webhook", return_value=True)
from app.tasks.webhook_tasks import deliver_webhook_task
# Mock the task's request context for the log line
deliver_webhook_task.request.retries = 0
result = deliver_webhook_task.__wrapped__("https://example.com/hook", {"event": "test"}, None)
assert result["status"] == "delivered"
assert result["url"] == "https://example.com/hook"
def test_failure_raises_for_retry(self, mocker):
"""Task raises RuntimeError on delivery failure to trigger retry."""
mocker.patch("app.tasks.webhook_tasks.deliver_webhook", return_value=False)
from app.tasks.webhook_tasks import deliver_webhook_task
deliver_webhook_task.request.retries = 0
with pytest.raises(RuntimeError, match="Webhook delivery.*failed"):
deliver_webhook_task.__wrapped__("https://example.com/hook", {"event": "test"}, None)