How it arises in Django
URLField feels like a control. A webhook, avatar or feed URL goes through forms.URLField or models.URLField, which use URLValidator. Django documents what that validator accepts: a value that looks like a URL, with the default schemes http, https, ftp and ftps, and it states that loopback addresses and reserved IP spaces are considered valid. So http://127.0.0.1:6379/ and http://169.254.169.254/latest/meta-data/ are valid form input. That is the documented, correct behaviour of a syntax check.
Stored URLs, fetched later. The URL is saved on a model: Integration.webhook_url, Feed.source. A Celery beat task fetches it hours later. The request that planted it is long gone, the fetch runs with the worker's network position, and whoever can edit that row controls an internal HTTP client.
Redirects. Even a URL that points at a public host can answer with a 302 to an internal one. Requests performs location redirection by default for all verbs except HEAD, so a check made on the submitted URL says nothing about where the connection finally goes.
Server-side fetch features. Link previews, "import from URL", PDF rendering of a user-supplied page, image fetching for thumbnails, and health-check dashboards that let staff enter a host.
Vulnerable and fixed
import requests
from django import forms
from django.http import JsonResponse
class ImportForm(forms.Form):
url = forms.URLField()
def import_feed(request):
form = ImportForm(request.POST)
if not form.is_valid():
return JsonResponse({"errors": form.errors}, status=400)
body = requests.get(form.cleaned_data["url"], timeout=5).text
return JsonResponse({"bytes": len(body)})
Add the check Django leaves to you: resolve the host, refuse anything that is not a public address, restrict the scheme, and do not follow redirects automatically.
import ipaddress
import socket
from urllib.parse import urlsplit
import requests
from django import forms
from django.core.exceptions import ValidationError
from django.http import JsonResponse
def public_http_url(value):
parts = urlsplit(value)
if parts.scheme not in ("http", "https") or not parts.hostname:
raise ValidationError("http or https URLs only")
for info in socket.getaddrinfo(parts.hostname, parts.port or 443):
if not ipaddress.ip_address(info[4][0]).is_global:
raise ValidationError("this host is not allowed")
class ImportForm(forms.Form):
url = forms.URLField(validators=[public_http_url])
def import_feed(request):
form = ImportForm(request.POST)
if not form.is_valid():
return JsonResponse({"errors": form.errors}, status=400)
resp = requests.get(form.cleaned_data["url"], timeout=5, allow_redirects=False)
return JsonResponse({"status": resp.status_code, "bytes": len(resp.content)})
A validator cannot close the gap between the lookup it makes and the lookup the HTTP client makes a moment later. For that, send outbound fetches through an egress proxy or a network policy that cannot reach internal ranges.
What Django already does for you
URLValidator rejects malformed values and unexpected schemes, and schemes can be narrowed to ["https"]. Custom validators plug into forms and model fields in one line, as above, so the destination rule lives beside the field. ALLOWED_HOSTS protects the inbound side. Outbound destination policy is outside the framework's scope, and its documentation does not claim otherwise.
What es-python adds
es-python reports at the point where the connection is actually made, which is the one place the submitted URL, the DNS answer and any redirect all converge. The base events are socket getaddrinfo and socket connect, the calls that requests and urllib funnel through. When the destination steered by untrusted data is a loopback, link-local or private-range address, an ssrf event fires beside them with the ssrf impact tag.
Because the decision is taken on the address being dialled, it does not depend on how the URL was written. A public-looking hostname that resolves internally through an explicit Python lookup, and a redirect that the HTTP client followed inside the application, both end in a connect to an internal address and both fire.
For the stored-URL case, turn on the db_rows source with socket. The webhook URL read back from the model is then followed into the worker's HTTP call.
The limits are worth stating. The address test recognises standard dotted-quad and canonical IPv6 forms, not alternate encodings of the same address, and there is no DNS-rebinding coverage. With the Block backend network attacks template, or any Raise rule on ssrf, the connection raises a RuntimeError before it is made.
Review checklist
- List every
URLFieldwhose value the server later fetches. - Attach a destination validator and narrow
schemes. - Set
allow_redirects=False, or re-validate every hop. - Re-validate stored URLs at fetch time, not only when they are saved.
- Route outbound fetches through an egress-restricted network path.
References
- Reference: Django documentation: Validators, URLValidator (checked 2026-10-12)
- Reference: Django documentation: Settings, ALLOWED_HOSTS (checked 2026-10-12)
- Reference: Requests documentation: Quickstart, Redirection and History (checked 2026-10-12)