-
Notifications
You must be signed in to change notification settings - Fork 688
UN-3815 [FIX] Validate webhook URLs in one place, at both sinks #2214
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
athul-rs
wants to merge
5
commits into
main
Choose a base branch
from
UN-3794-webhook-egress
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
2b23330
UN-3794 [FIX] Validate webhook URLs in one place, at both sinks
athul-rs 71c9d3b
UN-3815 [FIX] Use is_global for address checks; narrow URL revalidation
athul-rs a62fbca
UN-3815 [FIX] Let postprocessor unit tests past the new egress guard
athul-rs f7dceb4
UN-3815 [FIX] Keep DNS off request threads; stop echoing request headers
athul-rs e45f2a3
Merge branch 'main' into UN-3794-webhook-egress
athul-rs File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Empty file.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,124 @@ | ||
| """Webhook URL egress controls on the backend side. | ||
|
|
||
| The sink guard in ``unstract.core`` is the real control; these cover the two | ||
| backend surfaces that also accept a URL — the notification serializer, which | ||
| should refuse an internal target at creation rather than at delivery time, and | ||
| the internal webhook-test endpoint, which used to return the response body. | ||
| """ | ||
|
|
||
| from unittest.mock import Mock, patch | ||
|
|
||
| import pytest | ||
| from django.test import SimpleTestCase | ||
| from notification_v2.internal_views import WebhookTestAPIView | ||
| from notification_v2.serializers import NotificationSerializer | ||
| from rest_framework import status | ||
| from rest_framework.exceptions import ValidationError | ||
| from rest_framework.parsers import JSONParser | ||
| from rest_framework.request import Request | ||
| from rest_framework.test import APIRequestFactory | ||
|
|
||
| INTERNAL_URLS = [ | ||
| "http://169.254.169.254/latest/meta-data/", | ||
| "http://127.0.0.1:8000/admin/", | ||
| r"https://127.0.0.1:6666\@1.1.1.1", | ||
| ] | ||
|
|
||
| # Stub DNS so nothing here depends on the network. The serializer path does not | ||
| # resolve at all; the endpoint path does, and would otherwise make a real | ||
| # lookup for example.com and fail in an isolated runner. | ||
| _FAKE_DNS = {"example.com": "93.184.216.34"} | ||
|
|
||
|
|
||
| @pytest.fixture(autouse=True) | ||
| def stub_dns(monkeypatch): | ||
| def fake_getaddrinfo(host, *_args, **_kwargs): | ||
| if host not in _FAKE_DNS: | ||
| raise OSError(f"unresolvable in test: {host}") | ||
| return [(None, None, None, "", (_FAKE_DNS[host], 0))] | ||
|
|
||
| monkeypatch.setattr( | ||
| "unstract.core.network.ssrf.socket.getaddrinfo", fake_getaddrinfo | ||
| ) | ||
|
|
||
|
|
||
| def _notification_data(url): | ||
| """Minimum that reaches the URL check in ``NotificationSerializer.validate``.""" | ||
| return {"pipeline": Mock(), "authorization_type": "NONE", "url": url} | ||
|
|
||
|
|
||
| class NotificationSerializerUrlTest(SimpleTestCase): | ||
| """URLField only checks the shape, so an internal target would persist.""" | ||
|
|
||
| def test_internal_urls_are_rejected(self): | ||
| for url in INTERNAL_URLS: | ||
| with self.subTest(url=url): | ||
| with self.assertRaises(ValidationError) as caught: | ||
| NotificationSerializer().validate(_notification_data(url)) | ||
| assert "url" in caught.exception.detail | ||
|
|
||
| def test_public_url_is_accepted(self): | ||
| data = _notification_data("https://example.com/hook") | ||
| assert NotificationSerializer().validate(data) == data | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| def test_patch_that_omits_url_is_not_revalidated(self): | ||
| """A PATCH touching other fields must not re-resolve the stored URL. | ||
|
|
||
| Otherwise a brief DNS failure, or a record predating this check, makes | ||
| an unrelated edit fail on a field the caller never sent. | ||
| """ | ||
| # api=None so the api/pipeline check doesn't trip on Mock's truthy | ||
| # auto-attribute before the URL check is reached. | ||
| instance = Mock(api=None, url="http://127.0.0.1:8000/legacy") | ||
| serializer = NotificationSerializer(instance=instance) | ||
|
|
||
| data = {"pipeline": Mock(), "authorization_type": "NONE", "max_retries": 2} | ||
| assert serializer.validate(data) == data | ||
|
|
||
|
|
||
| class WebhookTestEndpointTest(SimpleTestCase): | ||
| """This endpoint had no URL check, and returned the response body.""" | ||
|
|
||
| def _post(self, url): | ||
| request = Request( | ||
| APIRequestFactory().post( | ||
| "/internal/webhook/test/", {"url": url, "payload": {}}, format="json" | ||
| ), | ||
| parsers=[JSONParser()], | ||
| ) | ||
| return WebhookTestAPIView().post(request) | ||
|
|
||
| def test_internal_url_is_refused_before_any_request(self): | ||
| for url in INTERNAL_URLS: | ||
| with self.subTest(url=url): | ||
| with patch("requests.post") as post: | ||
| response = self._post(url) | ||
| assert response.status_code == status.HTTP_400_BAD_REQUEST | ||
| post.assert_not_called() | ||
|
|
||
| def test_response_body_and_headers_are_not_echoed(self): | ||
| with patch("requests.post") as post: | ||
| post.return_value.status_code = 200 | ||
| post.return_value.headers = {"X-Internal-Secret": "leaked"} | ||
| post.return_value.text = "internal response body" | ||
| response = self._post("https://example.com/hook") | ||
|
|
||
| assert response.status_code == status.HTTP_200_OK | ||
| assert response.data["status_code"] == 200 | ||
| assert post.call_args.kwargs["allow_redirects"] is False | ||
|
athul-rs marked this conversation as resolved.
|
||
|
|
||
| # Nothing about the upstream response comes back, and neither do the | ||
| # request headers — those carry the Authorization value we built. | ||
| for leaked in ("response_body", "response_headers", "request_headers"): | ||
| assert leaked not in response.data, f"{leaked} is echoed to the caller" | ||
|
|
||
| def test_redirect_is_not_reported_as_success(self): | ||
| """Redirects are not followed, so a 3xx means the payload never landed.""" | ||
| with patch("requests.post") as post: | ||
| post.return_value.status_code = 302 | ||
| post.return_value.headers = {} | ||
| post.return_value.text = "" | ||
| response = self._post("https://example.com/hook") | ||
|
|
||
| assert response.data["status_code"] == 302 | ||
| assert response.data["success"] is False | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,6 @@ | ||
| from unstract.core.network.enums import HTTPMethod | ||
| from unstract.core.network.http_client import HttpClient | ||
| from unstract.core.network.retry import get_retry_session | ||
| from unstract.core.network.ssrf import is_safe_webhook_url | ||
|
|
||
| __all__ = ["HTTPMethod", "get_retry_session", "HttpClient"] | ||
| __all__ = ["HTTPMethod", "HttpClient", "get_retry_session", "is_safe_webhook_url"] |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,160 @@ | ||
| """Shared egress guard for user-supplied webhook URLs. | ||
|
|
||
| Both webhook sinks — prompt postprocessing and pipeline notifications — take a | ||
| URL from a tenant and hand it to ``requests``. This module is the single place | ||
| that decides whether such a URL may be dialled, so a new sink does not have to | ||
| carry its own copy of the rules. | ||
|
|
||
| Three things are checked, in order: | ||
|
|
||
| 1. **Parser agreement.** This module reads the URL with ``urllib.parse`` while | ||
| the transport underneath ``requests`` resolves it with ``urllib3``. The two | ||
| do not always agree on the host, and where they disagree the URL is refused, | ||
| because the host approved here is not the host the socket connects to. | ||
| Comparing the two parsers is an invariant rather than a list of characters to | ||
| reject, so it holds as either parser changes. | ||
| 2. **Scheme and userinfo.** Anything outside the caller's allowlist is refused, | ||
| as is a URL carrying credentials. | ||
| 3. **Resolved address.** Every address the host resolves to must be publicly | ||
| routable. Loopback, private, link-local (which covers the cloud metadata | ||
| endpoints), reserved and multicast ranges are all refused. | ||
|
|
||
| Note the ceiling: resolve-then-connect cannot cover a name that is re-resolved | ||
| to an internal address between this check and the socket. The control for that | ||
| is an egress policy on the worker pods, not application code. | ||
| """ | ||
|
|
||
| import ipaddress | ||
| import logging | ||
| import socket | ||
| from urllib.parse import urlparse | ||
|
|
||
| from urllib3.exceptions import LocationParseError | ||
| from urllib3.util import parse_url | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
| DEFAULT_ALLOWED_SCHEMES = ("http", "https") | ||
|
|
||
|
|
||
| def _normalize_host(host: str | None) -> str: | ||
| """Reduce a host to a form the two parsers can be compared on. | ||
|
|
||
| urllib3 keeps the brackets on an IPv6 literal and punycodes a unicode host; | ||
| ``urlparse`` does neither. Comparing the raw strings would refuse both of | ||
| those legitimate URLs. | ||
| """ | ||
| if not host: | ||
| return "" | ||
| host = host.strip().strip("[]").rstrip(".").lower() | ||
| try: | ||
| return host.encode("idna").decode("ascii") | ||
| except UnicodeError: | ||
| # Not IDNA-encodable (empty label, over-long label). Compare as-is; | ||
| # the parsers still have to agree for the URL to be accepted. | ||
| return host | ||
|
|
||
|
|
||
| def _resolve(host: str) -> set[str]: | ||
| """Return every IP the host resolves to, or an empty set on failure.""" | ||
| try: | ||
| ipaddress.ip_address(host) | ||
| return {host} | ||
| except ValueError: | ||
| pass | ||
| try: | ||
| return { | ||
| sockaddr[0] | ||
| for *_, sockaddr in socket.getaddrinfo(host, None, type=socket.SOCK_STREAM) | ||
| } | ||
| except (OSError, UnicodeError): | ||
| # UnicodeError: getaddrinfo IDNA-encodes internally and raises, not | ||
| # returns, on an over-long or empty label. Unresolvable either way. | ||
| return set() | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
|
|
||
| def _is_public(addr: str) -> bool: | ||
| """Whether an address is globally routable. | ||
|
|
||
| ``is_global`` is an allowlist maintained against the IANA special-purpose | ||
| registries, so it stays correct as ranges are added. Enumerating the | ||
| negative flags instead misses ranges that belong to none of them — RFC 6598 | ||
| shared address space (100.64.0.0/10) is private in practice but is not | ||
| ``is_private``, ``is_reserved`` or any of the rest. | ||
| """ | ||
| try: | ||
| return ipaddress.ip_address(addr).is_global | ||
| except ValueError: | ||
| return False | ||
|
|
||
|
|
||
| def is_safe_webhook_url( | ||
| url: str | None, | ||
| allowed_schemes: tuple[str, ...] = DEFAULT_ALLOWED_SCHEMES, | ||
| resolve: bool = True, | ||
| ) -> bool: | ||
| """Whether ``url`` may be dialled from inside the network. | ||
|
|
||
| Args: | ||
| url: The tenant-supplied URL. | ||
| allowed_schemes: Schemes to accept. Callers that already require TLS | ||
| should pass ``("https",)`` rather than widening to the default. | ||
| resolve: Whether to resolve the host and check every address. Leave it | ||
| on at the sinks — that is the real control. Turn it off on request | ||
| handling threads: ``socket.getaddrinfo`` honours no timeout, so a | ||
| slow or hostile resolver would stall the worker serving the | ||
| request. With it off, the syntactic checks still run and a literal | ||
| internal IP is still refused; a hostname that resolves internally | ||
| is caught at the sink instead. | ||
|
|
||
| Returns: | ||
| True only if the URL is well-formed, unambiguous to both parsers, and | ||
| (when ``resolve`` is set) maps entirely to public addresses. | ||
| """ | ||
| if not url: | ||
| return False | ||
|
|
||
| try: | ||
| parsed = urlparse(url) | ||
| except ValueError: | ||
| return False | ||
|
|
||
| if parsed.scheme not in allowed_schemes: | ||
| return False | ||
|
|
||
| # Credentials in the URL are the vehicle for the parser confusion above and | ||
| # have no legitimate use on a webhook target. | ||
| if parsed.username or parsed.password or "@" in (parsed.netloc or ""): | ||
| return False | ||
|
|
||
| try: | ||
| transport_host = parse_url(url).host | ||
| except LocationParseError: | ||
| # The transport cannot parse it, so nothing here can predict where it | ||
| # would connect. | ||
| return False | ||
|
|
||
| host = _normalize_host(parsed.hostname) | ||
| if host != _normalize_host(transport_host): | ||
| logger.warning("Refusing webhook URL: validator and transport disagree on host") | ||
| return False | ||
|
|
||
| if not host: | ||
| return False | ||
|
|
||
| if not resolve: | ||
| # No DNS on this path. A literal address is still checked, since that | ||
| # needs no lookup and is how most internal targets are written. | ||
| try: | ||
| ipaddress.ip_address(host) | ||
| except ValueError: | ||
| return True | ||
| return _is_public(host) | ||
|
|
||
| # Resolve the normalized host: that is the canonical form the transport | ||
| # ends up dialling, so the addresses checked here are the ones used. | ||
| addrs = _resolve(host) | ||
| if not addrs: | ||
| return False | ||
|
|
||
| return all(_is_public(addr) for addr in addrs) | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.