Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 17 additions & 5 deletions backend/notification_v2/internal_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
WebhookTestSerializer,
)
from notification_v2.models import Notification
from unstract.core.network.ssrf import is_safe_webhook_url

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -337,6 +338,15 @@ def post(self, request):
validated_data = serializer.validated_data
headers = self._build_headers(validated_data)

# Same guard as the delivery sinks. This endpoint is behind
# INTERNAL_SERVICE_API_KEY and not tenant-reachable, but it takes
# an arbitrary URL and so gets the same treatment.
if not is_safe_webhook_url(validated_data["url"]):
return Response(
{"error": "URL must resolve to a public address."},
status=status.HTTP_400_BAD_REQUEST,
)

import requests

try:
Expand All @@ -345,16 +355,18 @@ def post(self, request):
json=validated_data["payload"],
headers=headers,
timeout=validated_data["timeout"],
allow_redirects=False,
)

# Status only. The response body and headers are not the
# caller's to read, and request_headers carried back the
# Authorization value built from authorization_key.
Comment on lines +361 to +363

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[High] [Lens 4, 16] — This fix is half-applied: the error branch still returns the Authorization header

On CodeRabbit's thread at test_webhook_ssrf.py:108 you replied "Taken. The response is now success, status_code and url only", and CodeRabbit accepted it. That is true of the success branch only.

The except requests.exceptions.RequestException branch immediately below (:378-387) is untouched and still builds:

test_result = {
    "success": False,
    "error": str(e),
    "url": validated_data["url"],
    "request_headers": headers,      # <-- still here
    "request_payload": validated_data["payload"],  # <-- still here
}

And _build_headers (:396-418) is what populates headers:

if auth_type == AuthorizationType.BEARER.value and auth_key:
    headers["Authorization"] = f"Bearer {auth_key}"
elif auth_type == AuthorizationType.API_KEY.value and auth_key:
    headers["Authorization"] = auth_key

So any target that times out, fails DNS, or refuses the connection returns the credential — and the new guard at :344 does not prevent it, since a perfectly public host that simply does not answer reaches that branch. This is the most common failure path for a webhook test.

Impact is bounded — the disclosure goes back to the same internal caller that supplied authorization_key in the request body, and the endpoint is behind INTERNAL_SERVICE_API_KEY. Rated High because the comment directly above states this leak is closed, which is what will stop the next reader from checking, and because the new test can never catch it: test_response_body_and_headers_are_not_echoed only sets post.return_value.status_code = 200, so it goes green with the leak intact while asserting the property as general.

Suggested fix: reduce the error branch to {"success": False, "error": str(e), "url": ...}, and add a case where requests.post raises requests.exceptions.ConnectTimeout asserting both keys are absent. If the fields are deliberately kept for debugging, the comment needs to say so.

Lens 4 · 16

test_result = {
"success": response.status_code < 400,
# 2xx only: redirects are not followed, so a 301/302 means
# the payload never reached the final destination.
"success": 200 <= response.status_code < 300,
"status_code": response.status_code,
"response_headers": dict(response.headers),
"response_body": response.text[:1000],
"url": validated_data["url"],
"request_headers": headers,
"request_payload": validated_data["payload"],
}

logger.info(
Expand Down
27 changes: 27 additions & 0 deletions backend/notification_v2/serializers.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
from rest_framework import serializers
from utils.input_sanitizer import validate_name_field

from unstract.core.network.ssrf import is_safe_webhook_url

from .enums import AuthorizationType, NotificationType, PlatformType
from .models import Notification

Expand Down Expand Up @@ -34,8 +36,33 @@ def validate(self, data):
# General validation for the relationship between api and pipeline
self._validate_api_or_pipeline(data)
self._validate_authorization(data)
self._validate_url(data)
return data

def _validate_url(self, data):
"""Reject internal webhook targets when a URL is supplied.

URLField only checks the shape, so an internal address would be stored
and only refused later at the sink, silently and out of the user's
sight. Fail here instead; the sink guard stays as the real control.

Only checks a URL the caller actually sent. Re-resolving the stored one
would make an unrelated PATCH fail whenever DNS is briefly unavailable
or a legacy record predates this check.

resolve=False keeps DNS off the request thread — getaddrinfo honours no
timeout, so a slow resolver here would stall the worker serving the
request. Literal internal addresses are still refused; a hostname
pointing inward is caught at the sink, which is the real control.
Comment on lines +44 to +56

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[High] [Lens 3, 10, 16] — This docstring claims to prevent the exact silent failure that resolve=False guarantees

To be clear about scope: I am not reopening the resolve=False trade-off. Greptile raised it, you answered it, and your answer is right — getaddrinfo honours no timeout, the sink is the real control, and keeping DNS off the request thread is the correct call. That is settled.

What is not settled is this docstring, and what happens after the sink refuses.

The contradiction. :45-47 says an internal address "would be stored and only refused later at the sink, silently and out of the user's sight. Fail here instead." But :61 passes resolve=False, which by the guard's own contract (ssrf.py:145-152) accepts every hostname — it only refuses literals. Webhook targets are overwhelmingly hostnames, so for the majority case this check achieves precisely the outcome the paragraph above it says it prevents. hooks.internal.corp, or any DNS name whose A record is 10.x, saves with a 201 and shows as a healthy notification in the UI.

What happens next is the part worth fixing. I traced the delivery path: send_webhook_request returns the refusal dict → WebhookProvider.send (workers/notification/providers/webhook_provider.py:146-163, format_failure_result) → send_webhook_notification (workers/notification/tasks.py:300-310) raises → except Exception (:338-361) → logger.errorreturn None. Nothing is persisted and nothing reaches the user. There is no error state on the notification record, the pipeline, or any UI surface — the notification simply never arrives, forever. The clubbed path already has the machinery for this (_mark_buffer_outcomeDEAD_LETTER at tasks.py:327); the direct path does not use it.

Suggested fix: two things, neither of which requires resolving on the request thread. (1) Correct the docstring so the next reader does not believe internal hostnames are refused at save time. (2) Persist a delivery outcome on the non-clubbed path the way the clubbed one does, so a refusal is queryable rather than log-only. Optionally, do a one-shot resolving check where latency is tolerable — WebhookTestAPIView already resolves — and surface it as a warning at configuration time.

Related: :63's error string "URL must resolve to a public address" is asserted on the path that performs no resolution. internal_views.py:346 uses the identical string where it is backed by a lookup — same message, two different guarantees.

Lens 3 · 10 · 16

"""
if "url" not in data:
return
url = data["url"]
if url and not is_safe_webhook_url(url, resolve=False):
Comment thread
athul-rs marked this conversation as resolved.
Comment on lines +58 to +61

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[Medium] [Lens 3] — A create that omits url persists a webhook notification with no destination

The "url" not in data early return is correct for the PATCH case it was written for. It also lets a create through unchecked.

Notification.url is models.URLField(null=True) (backend/notification_v2/models.py:27). DRF's get_field_kwargs maps a null=True model field to required=False, allow_null=True, and Meta.fields = "__all__" with validators = [] adds nothing back. So a POST that omits url hits the early return at :59, and one that sends "url": null is short-circuited by if url and ... at :61. Either way a webhook notification is persisted with no destination and returns 201.

At dispatch, is_safe_webhook_url(None) returns False at ssrf.py:115, and the user's log says the URL "is not an allowed public destination" — for a URL that was never set. I found no upstream check in backend/notification_v2/views.py that would block it first.

Suggested fix: require url when notification_type == WEBHOOK, in the same validate() that already cross-validates api/pipeline and authorization. Keep the "url" not in data early return for the documented PATCH case, but gate it on self.partial (or on the instance already having a URL) so a create cannot use it.

Lens 3

raise serializers.ValidationError(
{"url": "URL must resolve to a public address."}
)

Comment thread
coderabbitai[bot] marked this conversation as resolved.
def _validate_api_or_pipeline(self, data):
"""Ensure either 'api' or 'pipeline' is provided, but not both."""
api = data.get("api", getattr(self.instance, "api", None))
Expand Down
Empty file.
124 changes: 124 additions & 0 deletions backend/notification_v2/tests/test_webhook_ssrf.py
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
Comment thread
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
Comment thread
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
3 changes: 2 additions & 1 deletion unstract/core/src/unstract/core/network/__init__.py
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"]
160 changes: 160 additions & 0 deletions unstract/core/src/unstract/core/network/ssrf.py
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.
Comment on lines +8 to +20

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[Medium] [Lens 16] — The docstring lists the checks in an order the code does not follow, and states step 3 unconditionally

The docstring says the checks run "in order: 1. Parser agreement. 2. Scheme and userinfo. 3. Resolved address."

Actual order in is_safe_webhook_url: scheme at :122, userinfo at :127, parser agreement at :130-140, resolution at :145+. Items 1 and 2 are swapped.

This is not merely cosmetic here, because of the logging situation: the parser-agreement check at :139 emits the module's only diagnostic log line. A reader trusting the stated order will assume a refused URL got past the scheme and credential checks and will mis-diagnose which check fired — and since no other reason is logged at all (see the :136-139 finding), that stated order is the only map they have.

Item 3 is also stated unconditionally, but the entire resolution step is skipped when resolve=False (:145-152) — which is how NotificationSerializer calls it.

Suggested fix: renumber to match the code, and qualify item 3 with "(when resolve is set)". Alternatively move the parser-agreement check first so the log line actually leads — but the docstring is the cheaper fix.

Lens 16


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")
Comment on lines +43 to +51

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[Medium] [Lens 3] — _normalize_host uses IDNA-2003 while urllib3 uses UTS-46, so legitimate IDN hosts are refused

The docstring's justification for normalizing is correct, and the two claims it makes about urllib3 are accurate (verified: [::1] vs ::1, xn--e1afmkfd.xn--p1ai vs пример.рф). The problem is which encoder is used.

str.encode("idna") is stdlib IDNA-2003 with nameprep — ßss, ςσ, NFKC folding. urllib3 2.7.0's _idna_encode uses the idna package with strict=True, std3_rules=True, i.e. UTS-46 non-transitional. Wherever the two mappings differ, the parser-agreement check at :138 fires and refuses the URL.

Measured with DNS stubbed to a public address:

host validator sees transport sees result
faß.de fass.de xn--fa-hia.de refused
σόλος.gr xn--wxaikc6b.gr xn--wxaijb9b.gr refused
пример.рф xn--e1afmkfd.xn--p1ai same allowed

The suite only covers пример.рф, which is a case where both mappings coincide — so the gap is invisible to CI.

This fails closed, so it is a false reject, not a bypass. But it also undercuts the module docstring's claim at :14-15 that comparing the two parsers "is an invariant rather than a list of characters to reject, so it holds as either parser changes" — as written, _normalize_host is coupled to stdlib's IDNA version and must track urllib3's encoder.

Suggested fix: normalize with the library the transport uses — idna.encode(host, uts46=True), already a hard requests dependency — falling back to the raw host on idna.IDNAError. That makes the comparison like-for-like and restores the invariant the docstring claims.

Lens 3 · 16

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()
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment on lines +70 to +73

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[High] [Lens 3, 8, 10] — A transient DNS failure is reported as a permanent security refusal, and the default config drops the notification on the first blip

_resolve swallows OSError and returns an empty set. OSError covers socket.gaierror — resolver down, timeout, EAI_AGAIN, NXDOMAIN — so a resolver outage in the worker's netns makes is_safe_webhook_url return False for every customer's perfectly public webhook. The sink then logs "Refusing webhook to a non-public or ambiguous URL" and returns error: "Webhook URL is not an allowed public destination".

A transient resolver failure and a genuinely internal target are indistinguishable anywhere in the logs — identical logger.error line at notification_utils.py:142, identical error string. On the postprocessor path (postprocessor.py:65) both produce the same logger.warning with no URL and no reason.

The retry budget does not save it. Notification.max_retries defaults to 0 (backend/notification_v2/models.py:39-41), so tasks.py:340-341 evaluates self.request.retries (0) < max_retries (0) as false — no retry at all. One DNS blip, one permanently lost notification, on default configuration. (The PR description's "the task retries up to its configured max_retries, which is capped at 4" is accurate only for a user who explicitly raised it above the default.)

Suggested fix: separate "could not resolve" from "resolved to a non-public address" — return a reason from _resolve/is_safe_webhook_url, or raise a distinct WebhookResolutionError — and let the DNS-failure case take the retryable path while the refused-as-internal case dead-letters immediately. That pairs with M2 on the retry loop: unresolvable should retry, refused should not.

Lens 3 · 8 · 10



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")
Comment on lines +136 to +139

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[High] [Lens 10] — Eight distinct rejection reasons collapse into a bare False; one logs, and without the host

is_safe_webhook_url returns False at :115 (empty url), :123 (scheme), :128 (credentials), :135 (LocationParseError), :143 (empty host), :152 (literal internal on the no-resolve path), :158 (unresolvable) and :160 (non-public address). Exactly one of those logs — this line — and it carries neither the URL nor the host.

Failure mode: support receives "my webhook stopped firing after the upgrade". The available evidence is "Refusing webhook to a non-public or ambiguous URL" (no URL, no reason) on the notification path and "Postprocessing webhook URL is not allowed; skipping." (no URL, no reason) at postprocessor.py:65 and answer_prompt.py:242. There is no way to tell whether the cause was http vs https, a @ in the netloc, a CGNAT address, a parser disagreement, or the resolver being down — five entirely different remediations.

This matters more than it normally would because the PR widens the rule set relative to the old check: is_global newly refuses 100.64.0.0/10, 198.18.0.0/15 and 192.0.0.0/24 where the previous five-flag test allowed them, and "@" in parsed.netloc (:127) is newly refused. Customers on those ranges break on upgrade with no diagnosable log line.

Suggested fix: return a reason alongside the boolean — tuple[bool, str] or a small enum — and have each sink log it. backend/notification_v2/helper.py:61-66 already has webhook_url_hash for logging a target without leaking query-string tokens. At minimum, log the parsed host at the refusal sites.

Lens 10

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)
Comment on lines +145 to +152

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[Medium] [Lens 16] — "a literal internal IP is still refused" is not true for non-dotted-quad encodings

The resolve arg docstring at :356-358 and the serializer's at serializers.py:55-56 both promise that literal internal addresses are still refused on this path. That holds only for what ipaddress.ip_address() can parse — and it rejects decimal, octal, hex and short-form IPv4.

Measured:

is_safe_webhook_url("http://2130706433/hook", resolve=False)   # True
is_safe_webhook_url("http://0177.0.0.1/hook", resolve=False)   # True
is_safe_webhook_url("http://127.1/hook", resolve=False)        # True
is_safe_webhook_url("http://localhost/hook", resolve=False)    # True

All four are 127.0.0.1. They fall through the except ValueError: return True at :150-151 and persist through the notification API.

Not a live SSRF — I confirmed the sink catches every one of them: both parsers agree on these hosts, and glibc getaddrinfo resolves all four to 127.0.0.1, so _is_public refuses. The finding is that the stated contract is wrong, and TestWithoutResolution::test_literal_and_syntactic_cases_still_refused uses only dotted-quad and bracketed-IPv6 forms, so nothing pins it either way.

Suggested fix: either narrow both docstrings to "dotted-quad and bracketed IPv6 literals", or canonicalize all-numeric hosts (ipaddress.IPv4Address(int(host, 0))) and reject localhost by name on this path. The first is fine — the sink is the real control — but the sentence should match what runs.

Lens 16


# 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)
Loading
Loading