Which HTTP libraries actually fetch a gopher:// URL? An empirically-tested SSRF matrix

Only HTTP clients built on libcurl will actually open a gopher:// socket, and that single fact decides whether a server-side request forgery can be escalated from "make the server fetch a URL" to "send arbitrary bytes to any internal TCP service." We tested the default HTTP client of ten languages against a live gopher listener. Six client libraries opened the socket, and every one of them is a thin wrapper around libcurl: the curl command line, PHP's ext-curl, Python's pycurl, Guzzle's curl handler, the Rust curl crate, and Faraday on the typhoeus adapter. Every native-language HTTP stack we tried refused gopher before a socket ever opened: Go net/http, Java HttpURLConnection and HttpClient, Python requests, httpx, aiohttp and urllib3, Node http and fetch, Ruby Net::HTTP, .NET HttpClient, Rust reqwest, and wget. The one-sentence law: gopher-over-SSRF is a libcurl story.
ext-curl is exposed and a PHP app on stream wrappers is not; a Ruby app on Net::HTTP is safe and the same app on a typhoeus adapter is not.
Why gopher is the scheme that matters for SSRF
Server-side request forgery is usually described as tricking an application into fetching an attacker-chosen URL. On its own that reaches internal HTTP services, which is bad but bounded. gopher is what removes the bound. libcurl treats a gopher URL as gopher://host:port/<type><selector>, where the first path character is the gopher item type and everything after it is sent verbatim as the selector, after URL-decoding, followed by CRLF. Because the bytes are URL-decoded first, a percent-encoded newline in the path becomes a real newline on the wire. That is the entire gadget. It turns "fetch a URL" into "write any byte sequence to any TCP port the server can reach," which is why gopher plus an internal Redis or php-fpm is the canonical path from a blind SSRF to code execution. It is one of the sharper edges of an external attack surface, and it hides entirely in the choice of HTTP client.
The test: a real socket, not a claim
Most references assert that "many libraries support gopher" and move straight to payloads. We wanted the pass or fail to be observed, not asserted, so the method is deliberately dumb. For each client we start a one-shot TCP listener on localhost, ask the client to fetch gopher://127.0.0.1:PORT/_<payload>, and record whether the listener ever accepts a connection and what bytes arrive. A client that refuses the scheme never connects, so the listener reports nothing. A client that opens the socket hands us the exact bytes on the wire. This is an active experiment against our own throwaway containers and a localhost listener, touching no third-party host. Here is the core of it against curl, pycurl, and PHP ext-curl, across three different libcurl versions:
curl gopher:// (libcurl 7.81.0) -> CONNECTED b'AAAA\r\n'
curl gophers:// (libcurl 7.81.0) -> CONNECTED (TLS ClientHello on the wire)
curl dict:// (libcurl 7.81.0) -> CONNECTED b'CLIENT libcurl 7.81.0\r\nAAAA\r\nQUIT\r\n'
pycurl gopher:// (libcurl 8.21.0) -> CONNECTED b'PYCURL\r\nLINE2\r\n'
php ext-curl gopher:// (libcurl 8.14.1) -> CONNECTED b'PHPCURL\r\nLINE2\r\n'
wget gopher:// (wget 1.21.2) -> NO-CONNECT
python requests -> NO-CONNECT (InvalidSchema: no connection adapters)
python urllib -> NO-CONNECT (unknown url type: gopher)
node fetch / http.get -> NO-CONNECT (scheme rejected before connect)
Two things are worth reading off that output directly. First, the libcurl clients open the socket on every version we tried, from an old 7.81.0 to a current 8.21.0, so this is not a quirk of one build. Second, the CRLF gadget is real and exact. The pycurl and PHP rows were fetched with the URL selector _PYCURL%0d%0aLINE2 and _PHPCURL%0d%0aLINE2, and the bytes that arrived contained a genuine carriage-return line-feed between the two tokens. We confirmed it in isolation as well: the URL gopher://host:port/_TESTPAYLOAD%0d%0aSECONDLINE lands on the socket as b'TESTPAYLOAD\r\nSECONDLINE\r\n'. That is a two-line protocol message written straight into a TCP service by nothing more than a URL.
The matrix
Rows marked live were re-tested against a socket for this post. The rest are verified from source, protocol registries, and each client's documented scheme handling. The pattern does not wander: the "opens a socket" column is exactly the set of libcurl bindings, plus one modular outlier in Perl.
| Client / library | Language | gopher:// socket | Why |
|---|---|---|---|
| curl CLI / libcurl (live) | C | YES | gopher, gophers, dict, ftp, file all compiled in; default transfer allowlist enables them |
| wget / wget2 (live) | C | no | only http/https/ftp built in; wget2 is https-only |
PHP ext-curl (curl_exec) (live) | PHP | YES | thin libcurl binding; the classic SSRF-to-Redis/FastCGI sink |
PHP streams (file_get_contents) | PHP | no | only registered wrappers; no gopher/dict wrapper (file and ftp yes) |
| Guzzle (curl handler, default) | PHP | YES | delegates to ext-curl; stream handler does not |
| pycurl (live) | Python | YES | direct libcurl binding; same footgun as PHP curl |
| requests (live) | Python | no | InvalidSchema: no connection adapters |
| httpx / aiohttp / urllib3 | Python | no | UnsupportedProtocol / NonHttpUrlClientError / URLSchemeUnknown |
| urllib (stdlib) (live) | Python | no | no gopher opener in Python 3 (file and ftp yes) |
| net/http | Go | no | unsupported protocol scheme "gopher"; only http/https round-trippers |
| HttpURLConnection / HttpClient | Java | no | handlers are http/https/ftp/file/jar/mailto; unknown protocol: gopher |
| Apache HttpClient 4/5 / OkHttp | Java | no | scheme registry is http/https only by design |
| Net::HTTP | Ruby | no | http/https specific; OpenURI sends gopher to File.open (local-read, not a TCP pivot) |
| Faraday | Ruby | no* | on Net::HTTP; the typhoeus (libcurl) adapter is the exception that reaches gopher |
| http / https / undici / fetch (live) | Node.js | no | protocol-specific; WHATWG fetch is http/https/file/data/blob only |
| HttpClient / HttpWebRequest | .NET | no | http/https only; the legacy GopherWebRequest was removed in .NET Core |
| reqwest / ureq | Rust | no | hyper and pure-Rust stacks, http/https only |
| curl crate | Rust | YES | direct libcurl binding, same caveat as every other binding |
| LWP::UserAgent | Perl | YES* | only if LWP::Protocol::gopher is installed; LWP is modular per scheme |
Count the YES rows and the story is unmissable. Six client libraries open a gopher socket, and all six are libcurl: curl, PHP ext-curl, pycurl, Guzzle-curl, the Rust curl crate, and Faraday-on-typhoeus. The seventh and only non-curl positive is Perl's LWP::UserAgent, and even that needs an operator to have installed the optional LWP::Protocol::gopher plugin, so it is off by default. Everything else, every mainstream native HTTP stack in Go, Java, Python, Node, Ruby, .NET, and Rust, rejects the scheme before it opens a connection. There is no clever payload that changes that, because the refusal happens at the URL parser or the adapter registry, upstream of any socket.
Nuance one: redirect into gopher is off by default
"The app uses curl, so it is exploitable" is only half true on a modern build, and the missing half is where people waste an engagement. There are two separate allowlists in libcurl. The transfer allowlist governs a URL you hand it directly, and it still enables gopher. The redirect allowlist governs what schemes a Location: header may switch to, and since libcurl 7.65.2 in May 2019 it is narrowed to http, https, ftp, and ftps only. So an open-redirect that points at gopher:// is refused by default. We watched it happen on libcurl 7.81.0:
# default: an HTTP 302 to gopher:// is blocked
$ curl -sL http://listener/ -> Location: gopher://127.0.0.1:PORT/_REDIRPWN
* Protocol "gopher" not supported or disabled in libcurl
listener: (never reached)
# widened: the same redirect now opens the gopher socket
$ curl -sL --proto-redir all http://listener/
listener: GOPHER SOCKET OPENED b'REDIRPWN\r\n'
The redirect only reaches gopher if the application re-widened the redirect protocols itself, by setting CURLOPT_REDIR_PROTOCOLS_STR to include gopher or by handing the CLI --proto-redir all. That misconfiguration is not hypothetical. CVE-2026-33752 is exactly this class of bug: the curl_cffi library followed redirects into arbitrary protocols because it did not constrain the redirect allowlist, turning a plain http SSRF back into a full gopher pivot. The practical read for an attacker is that on a current libcurl target you need the SSRF to let you place the whole scheme, a direct gopher:// URL, rather than relying on an open-redirect. The practical read for a defender is that widening redirect protocols is a loaded gun, and the default that disarms it has been correct since 2019.
Nuance two: the payload is CRLF, and it is trivial
The reason gopher matters more than dict:// or file:// is the byte-level control. libcurl sends the selector portion of the URL as raw bytes after URL-decoding, so the attacker composes the wire traffic in the URL path. A percent-encoded %0d%0a becomes a real carriage-return line-feed, and any line-oriented protocol will parse the result as separate commands:
- Redis. A single gopher URL can send
SET, thenCONFIG SET dirandCONFIG SET dbfilename, thenSAVE, writing a webshell, a cron entry, or an authorized_keys file to disk. This is the chain the public payload generators produce. - FastCGI (php-fpm on 9000). A crafted FastCGI record with
PHP_VALUEorSCRIPT_FILENAMEreaches code execution against a php-fpm bound only to localhost. - memcached, SMTP, Postgres, Zabbix. Any line-oriented or length-prefixed protocol reachable from the SSRF origin is a candidate, because you fully control the bytes.
None of this needs a memory bug or a race. It is a URL that decodes into a protocol conversation, which is why a gopher-capable client turns a modest SSRF into a serious one. The same instinct, an agent or a service fetching a URL you influence, is what makes machine clients that connect to arbitrary endpoints worth auditing for the same class of pivot.
What to do about it
The defense is not "stop using curl." libcurl is fine when its protocol scope is pinned. The failure is relying on defaults and letting user input choose the scheme.
- Pin the scheme allowlist explicitly. For libcurl, ext-curl, and pycurl set
CURLOPT_PROTOCOLS_STR = "http,https"and never widenCURLOPT_REDIR_PROTOCOLS_STRbeyondhttp,https. On the CLI:--proto -all,http,https --proto-redir -all,http,https. - Never let user input control the URL scheme. Accept a host and path, hardcode
https://, and reject anything else. Most SSRF-to-gopher chains die here. - Treat a redirect into a non-http scheme as a hard failure, and re-validate the resolved scheme after every redirect hop, server-side, not just on the input URL.
- Prefer an http-only client for fetches that do not need multiple protocols. Go
net/http, JavaHttpClient, and Pythonrequestsorhttpxgive you gopher, dict, and file immunity for free. - Strip raw and encoded CRLF (
%0d,%0a) from any user-influenced outbound URL path or query. - Egress-filter at the network layer so even a successful pivot cannot reach an internal service: block 6379, 11211, 9000, 25, and 5432 from application egress, and bind internal services to localhost with authentication.
- Audit the indirect libcurl users your stack hides: Guzzle's curl handler, Faraday on typhoeus, the Rust curl crate, and Perl LWP with the gopher plugin have the same exposure as PHP curl, and they are easy to miss in a dependency graph.
Method and limits, so the numbers are trustworthy
The dataset is our own, built by observing sockets, and it is reproducible.
- How we tested. The live rows ran in disposable containers (php:8.3-cli, python:3.12-slim) and on a standard Linux host, against a localhost
python3TCP listener that reports the connection and the first bytes received. Three libcurl versions were exercised: 7.81.0, 8.14.1, and 8.21.0. - What "no" means. A "no" is a client that never opens the socket, because it rejects the scheme at the URL parser, the adapter registry, or the protocol handler. That is stronger than a connection that is opened and then closed, because the attacker's bytes never leave the process.
- The starred rows. Faraday and Perl LWP depend on the configured adapter or installed plugin, which is why they carry an asterisk: the default is safe, a specific configuration is not.
- Scope. This is about whether a socket opens for gopher, which decides the SSRF pivot. It says nothing about http-based SSRF, which every client here performs, nor about
file://exposure, which Java'sURLand a few others still carry.
No third-party host was contacted at any point. Every connection in this research was to a listener we started on loopback, which is the difference between measuring a behavior and probing someone else's system.
Frequently asked questions
Does Python's requests library support the gopher protocol?
No. In a live test, requests raises InvalidSchema: No connection adapters were found for a gopher URL and never opens a socket. The same holds for httpx, aiohttp, urllib3, and the standard-library urllib. The one Python exception is pycurl, a thin binding over libcurl, which does open a gopher socket. If your Python code uses requests or httpx, a gopher SSRF payload cannot reach an internal service through it.
Why does curl support gopher but wget does not?
Different protocol sets. libcurl compiles in gopher, gophers, dict, ftp, and file by default, and its transfer allowlist enables them. wget was only built for http, https, and ftp, and wget2 is https-only. It is a build-time and design difference, not a runtime switch, which is why curl is the classic SSRF pivot and wget is not.
Which protocols does libcurl support for SSRF?
On a standard build, libcurl can open gopher, gophers, dict, ftp, ftps, file, tftp, ldap, and the mail protocols, on top of http and https. For SSRF the important ones are gopher and dict, because both put attacker-controlled bytes on a raw socket. gopher is the strongest, because the selector after the item-type character is sent verbatim, so an encoded CRLF becomes a real line break.
How does the gopher protocol escalate an SSRF to RCE?
An ordinary SSRF makes the server fetch an http URL. gopher turns that into sending arbitrary bytes to any reachable TCP port, because libcurl URL-decodes the selector and writes it raw. A single URL can send a Redis SET plus CONFIG SET dir and SAVE to drop a webshell, or a FastCGI record to php-fpm. The SSRF is the delivery, gopher is the payload, and a line-oriented internal service is the target.
Does Java's HttpURLConnection support gopher?
No. java.net.URL and HttpURLConnection register handlers for http, https, ftp, file, jar, and mailto only, and a gopher URL throws unknown protocol: gopher. The modern HttpClient, Apache HttpClient, and OkHttp reject any non-http scheme by design. Java is effectively immune to gopher SSRF, though java.net.URL still supports file://, which is a separate concern.
Why does libcurl not follow a redirect to a gopher:// URL?
Since libcurl 7.65.2 in May 2019, the default redirect allowlist is http, https, ftp, and ftps only, so a 302 into gopher:// is refused and no socket opens. Direct submission of a full gopher URL still works, because the transfer allowlist is separate. An application can re-open the redirect path by widening CURLOPT_REDIR_PROTOCOLS_STR, which is the class of bug behind CVE-2026-33752 in curl_cffi. On a modern target, place the whole gopher scheme rather than relying on an open-redirect.
How do you send CRLF to Redis through a gopher SSRF payload?
Encode the newlines. libcurl URL-decodes the selector, so gopher://127.0.0.1:6379/_SET%20k%20v%0d%0aSAVE%0d%0aQUIT sends SET k v, a real CRLF, SAVE, a real CRLF, then QUIT. In our test, _TESTPAYLOAD%0d%0aSECONDLINE arrived as the bytes TESTPAYLOAD\r\nSECONDLINE\r\n. Redis speaks a newline-delimited protocol, so that is a valid command sequence, which is why gopher plus Redis is the textbook SSRF-to-RCE chain.
Related reading
- What is an external attack surface? Where an SSRF sits in the map an attacker builds of what your organization exposes.
- Remote MCP server security: half of the registry is someone else's server. The same "a client fetches a URL you influence" pattern, at the machine-agent layer.
- Check versus scan versus pentest. Why a chained finding like SSRF-to-gopher-to-RCE is what a real test surfaces and a scanner does not.
Would a gopher pivot work against you?
Our $100 check maps your real external attack surface the way an attacker does, on scope you have verified you own and authorized in writing, with a senior operator on the readout. Whether an internal Redis or php-fpm is one SSRF away from code execution is exactly the kind of chain we look for.
Book a $100 check