critical

CVE

Not assigned

CWE

CWE-287, CWE-400, CWE-409, CWE-290, CWE-770

Affected Surface

  • Go module `github.com/labstack/echo/v4` versions `>= 4.0.0, < 4.16.1` (fixed in `v4.16.1`)
  • Go module `github.com/labstack/echo/v5` versions `>= 5.0.0, < 5.4.1` (fixed in `v5.4.1`, with additional CSRF and Basic Auth hardening in `v5.4.2`)
  • Go services using Echo's `middleware.Proxy` (`ProxyWithConfig`), `middleware.Decompress`, `middleware.BodyDump`, or `echo.ExtractIPFromRealIPHeader`

TL;DR

On 23 October 2026 (20:48 to 20:50 UTC), the GitHub Advisory Database published six reviewed advisories for Echo (github.com/labstack/echo/v4 and github.com/labstack/echo/v5), one of the most widely deployed HTTP frameworks in Go. All six issues are fixed in Echo v4.16.1 and Echo v5.4.1 (with v5.4.2 shipping hours later to reject empty CSRF tokens and tighten Basic auth scheme prefix parsing):

  1. GHSA-cxf3-9h37-7rr8 (CVSS 9.1 Critical in GHSA, rated Medium by maintainer) and GHSA-9969-6676-49h5 (CVSS 8.2 High in GHSA): Whenever a client sent Connection: Upgrade and Upgrade: websocket to a route using Echo’s middleware.Proxy, Echo bypassed httputil.ReverseProxy and called proxyRaw(), which immediately hijacked the client TCP socket and dialed a raw bidirectional io.Copy pipe to the upstream target before checking whether the upstream returned 101 Switching Protocols. If the upstream rejected the WebSocket upgrade with an ordinary HTTP response (such as 200 OK, 400 Bad Request, or 404 Not Found) on a keep-alive connection, the raw TCP pipe stayed open: any second HTTP request sent over that same socket (GET /admin/internal HTTP/1.1) went straight to the upstream server without passing through Echo’s router or route-level auth middleware. In addition, proxyRaw() waited for both io.Copy directions to return before closing either socket (GHSA-9969-6676-49h5) and forwarded client-supplied Forwarded headers untouched.
  2. GHSA-h53f-rm2w-w7rg (Medium, Echo v5.0.0 to v5.4.0): ExtractIPFromRealIPHeader checked checker.trust(ip) on the IP address inside the client-supplied X-Real-IP header instead of checking the direct TCP peer’s IP (req.RemoteAddr). Any direct external caller could send X-Real-IP: 127.0.0.1 or 10.0.0.1 and spoof a trusted internal IP in c.RealIP().
  3. GHSA-h7wr-284f-5qvc and GHSA-f48p-c9v4-h7xv (CVSS 8.2 High in GHSA, Echo v4 <= 4.16.0), plus GHSA-x9ff-22gv-65f4 (Echo v5.0.0 to v5.4.0): In Echo v4, middleware.Decompress exposed a raw *gzip.Reader with no decompressed byte limit, and middleware.BodyDump ran io.ReadAll(c.Request().Body) and buffered the full response body in memory with no cap. In Echo v5, BodyDump capped its logging buffer at MaxRequestBytes (default 5 MiB) but then drained the rest of the request with _, _ = io.Copy(io.Discard, c.Request().Body) and replaced c.Request().Body with the truncated 5 MiB slice, swallowing outer BodyLimit errors on chunked requests and handing the handler a silently truncated payload.

Affected packages and projects

EcosystemGo moduleGHSAGHSA CVSSAffected versionsFixed versionVulnerable component
Gogithub.com/labstack/echo/v4, github.com/labstack/echo/v5GHSA-cxf3-9h37-7rr89.1 Criticalv4 < 4.16.1, v5 < 5.4.1v4.16.1, v5.4.1 (v5.4.2)middleware/proxy.go (proxyRaw unvalidated WebSocket tunnel)
Gogithub.com/labstack/echo/v4, github.com/labstack/echo/v5GHSA-9969-6676-49h58.2 High>= 4.15.0, < 4.16.1, >= 5.0.0, < 5.4.1v4.16.1, v5.4.1middleware/proxy.go (proxyRaw half-close goroutine hang)
Gogithub.com/labstack/echo/v4GHSA-h7wr-284f-5qvc8.2 High< 4.16.1v4.16.1middleware/decompress.go (unbounded gzip stream expansion)
Gogithub.com/labstack/echo/v4GHSA-f48p-c9v4-h7xv8.2 High< 4.16.1v4.16.1middleware/body_dump.go (unbounded io.ReadAll request/response capture)
Gogithub.com/labstack/echo/v5GHSA-x9ff-22gv-65f46.3 Medium>= 5.0.0, < 5.4.1v5.4.1middleware/body_dump.go (io.Copy(io.Discard, ...) truncates body and drops BodyLimit error)
Gogithub.com/labstack/echo/v5GHSA-h53f-rm2w-w7rg (GHSA-c3r7-8j36-7mx8)5.3 Medium>= 5.0.0, < 5.4.1v5.4.1ip.go (ExtractIPFromRealIPHeader checks header IP instead of peer IP)

Technical analysis

1. GHSA-cxf3-9h37-7rr8 and GHSA-9969-6676-49h5: proxyRaw() hijacks WebSocket upgrades before the upstream responds

In both Echo v4 (<= 4.16.0) and Echo v5 (<= 5.4.0), ProxyConfig.ToMiddleware() branched on c.IsWebSocket(), which simply checks whether the incoming request headers contain Upgrade: websocket and Connection: upgrade:

// middleware/proxy.go in Echo v4 <= 4.16.0 and v5 <= 5.4.0
switch {
case c.IsWebSocket():
	proxyRaw(c, tgt, config).ServeHTTP(res, req)
default: // even SSE requests
	proxyHTTP(c, tgt, config).ServeHTTP(res, req)
}

Instead of letting net/http/httputil.ReverseProxy forward the HTTP request and inspect the upstream’s status code, proxyRaw() immediately hijacked the client’s http.ResponseWriter, opened a raw TCP connection to t.URL.Host, wrote the serialized HTTP request onto the wire, and spawned two io.Copy goroutines:

// middleware/proxy.go (vulnerable proxyRaw)
in, _, err := http.NewResponseController(w).Hijack()
if err != nil {
	c.Set("_error", fmt.Errorf("proxy raw, hijack error=%w, url=%s", err, t.URL))
	return
}
defer in.Close()

out, err := dialFunc(c.Request().Context(), "tcp", t.URL.Host)
...
err = r.Write(out)
...
errCh := make(chan error, 2)
cp := func(dst io.Writer, src io.Reader) {
	_, copyErr := io.Copy(dst, src)
	errCh <- copyErr
}

go cp(out, in)
go cp(in, out)

// Wait for BOTH goroutines to complete
err1 := <-errCh
err2 := <-errCh

That design created two distinct vulnerabilities:

  1. Route-level access-control bypass on rejected upgrades (GHSA-cxf3-9h37-7rr8): Suppose an Echo gateway exposes a public or low-privilege proxied route (such as /public/ws or /api/v1/stream) that forwards to an internal backend service, while sensitive backend paths (/admin/* or /internal/*) are blocked or require administrator JWT middleware on Echo’s router. If a client sends an HTTP/1.1 request to /public/ws with Connection: Upgrade and Upgrade: websocket, proxyRaw() wires in directly to out. When the upstream backend rejects the WebSocket upgrade with a non-101 HTTP response (for example 400 Bad Request or 200 OK) and keeps the HTTP/1.1 connection alive, io.Copy(out, in) keeps reading bytes from the client socket and writing them straight to the upstream TCP socket. A second HTTP request sent down the same TCP connection (GET /internal/users HTTP/1.1) bypasses Echo’s router and middleware completely.
  2. Goroutine and socket leak after client disconnect (GHSA-9969-6676-49h5): Because proxyRaw() waited for both <-errCh receives (err1 and err2) before running defer in.Close() and defer out.Close(), a client that disconnected (io.Copy(out, in) returning nil on EOF) did not close out. If the upstream kept its side of the TCP socket open, the second goroutine (io.Copy(in, out)) blocked indefinitely, leaking the handler goroutine and both file descriptors.

Commit 3fb0ba7e17 (v4.16.1) and commit 1855061e57 (v5.4.1) delete proxyRaw() entirely, strip unvalidated incoming Forwarded headers (req.Header.Del("Forwarded")), and route all requests through Go’s httputil.NewSingleHostReverseProxy. To prevent ReverseProxy from hanging on half-closed WebSocket connections, proxyHTTP wraps both the 101 Switching Protocols backend body and the hijacked client net.Conn in anonymous structs (struct{ io.ReadWriteCloser }{body} and struct{ net.Conn }{conn}) that hide CloseWrite(), forcing ReverseProxy to close both sockets as soon as either side reaches EOF.

2. GHSA-h53f-rm2w-w7rg: ExtractIPFromRealIPHeader checked the spoofed header instead of the peer

In Echo v5 (v5.0.0 through v5.4.0), ExtractIPFromRealIPHeader in ip.go validated checker.trust(ip) against the IP parsed from the X-Real-IP request header rather than the direct peer’s IP (req.RemoteAddr):

--- a/ip.go
+++ b/ip.go
@@ -224,15 +224,21 @@ func extractIP(req *http.Request) string {
 func ExtractIPFromRealIPHeader(options ...TrustOption) IPExtractor {
 	checker := newIPChecker(options)
 	return func(req *http.Request) string {
+		directIP := extractIP(req)
+		peerIP := net.ParseIP(directIP)
+		if peerIP == nil || !checker.trust(peerIP) {
+			return directIP
+		}
+
 		realIP := req.Header.Get(HeaderXRealIP)
 		if realIP != "" {
 			realIP = strings.TrimPrefix(realIP, "[")
 			realIP = strings.TrimSuffix(realIP, "]")
-			if ip := net.ParseIP(realIP); ip != nil && checker.trust(ip) {
+			if ip := net.ParseIP(realIP); ip != nil {
 				return realIP
 			}
 		}
-		return extractIP(req)
+		return directIP
 	}
 }

Because newIPChecker() trusts loopback (127.0.0.0/8, ::1/128), link-local (169.254.0.0/16), and RFC 1918 private ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16) by default, checking checker.trust(ip) on the X-Real-IP value itself meant two things were backwards: an untrusted internet client could send X-Real-IP: 127.0.0.1 and have c.RealIP() return 127.0.0.1 (bypassing IP allowlists or rate limiters), while a legitimate trusted reverse proxy forwarding a real public client IP in X-Real-IP had that public IP rejected unless TrustRange was widened! v5.4.1 checks checker.trust(peerIP) on extractIP(req) first, then returns any syntactically valid IP from X-Real-IP.

3. GHSA-h7wr-284f-5qvc, GHSA-f48p-c9v4-h7xv, and GHSA-x9ff-22gv-65f4: Decompress and BodyDump bounds and truncation

  • Echo v4 Decompress (GHSA-h7wr-284f-5qvc) and BodyDump (GHSA-f48p-c9v4-h7xv): Echo v4’s DecompressWithConfig assigned c.Request().Body = gr without wrapping the *gzip.Reader in a byte limiter, so a tiny gzip bomb bypassed compressed Content-Length checks and expanded until OOM. Similarly, v4’s BodyDumpWithConfig called io.ReadAll(c.Request().Body) and buffered the full response in memory without limits. Echo v4.16.1 adds DecompressConfig.MaxDecompressedSize (defaulting to 100 MiB, returning echo.ErrStatusRequestEntityTooLarge / HTTP 413 via limitedGzipReader) and BodyDumpConfig.MaxRequestBytes / MaxResponseBytes (defaulting to 5 MiB each).
  • Echo v5 BodyDump truncation and BodyLimit bypass (GHSA-x9ff-22gv-65f4): In Echo v5 (v5.0.0 to v5.4.0), BodyDump read up to MaxRequestBytes (5 MiB), discarded the rest of the stream while ignoring errors (_, _ = io.Copy(io.Discard, c.Request().Body)), and replaced c.Request().Body with io.NopCloser(bytes.NewReader(reqBody)). When an outer BodyLimit middleware wrapped a chunked request (for example with a 10 MiB limit) and BodyDump used its 5 MiB default, io.Copy(io.Discard, ...) triggered and silently ignored the BodyLimit error, and the downstream handler processed the truncated 5 MiB prefix as if it were the complete request. Both v4.16.1 and v5.4.1 now reconstruct c.Request().Body using io.MultiReader(bytes.NewReader(reqBody), c.Request().Body) while preserving the original Closer and read errors.

Detection and remediation

1. Audit go.mod and search for affected Echo middlewares

Run the following commands across your Go repositories:

# Check resolved Echo v4 or v5 version in go.mod
rg -n 'github\.com/labstack/echo/v[45]' go.mod go.sum 2>/dev/null

# Search for Proxy, Decompress, BodyDump, or ExtractIPFromRealIPHeader usage
rg -n 'middleware\.(Proxy|ProxyWithConfig|Decompress|DecompressWithConfig|BodyDump|BodyDumpWithConfig)|ExtractIPFromRealIPHeader' . --glob '*.go' 2>/dev/null

2. Upgrade to Echo v4.16.1 or v5.4.2

  1. Upgrade Echo and rebuild Go binaries:
    # For Echo v4 projects
    go get github.com/labstack/echo/v4@v4.16.1 && go mod tidy
    
    # For Echo v5 projects (v5.4.2 includes v5.4.1 plus CSRF and BasicAuth fixes)
    go get github.com/labstack/echo/v5@v5.4.2 && go mod tidy
  2. Review custom MaxDecompressedSize and MaxRequestBytes thresholds: After upgrading Echo v4 to v4.16.1, Decompress defaults to a 100 MiB decompressed cap and BodyDump defaults to a 5 MiB capture cap (passing the full stream through to handlers via io.MultiReader). Adjust DecompressConfig.MaxDecompressedSize if your service legitimately accepts decompressed payloads larger than 100 MiB.

Timeline

  • 2026-10-22 (16:18 to 16:33 UTC): LabStack merges security commits 3fb0ba7e17 (v4) and 1855061e57 (v5), tags v4.16.1 and v5.4.1, and publishes six upstream GitHub security advisories (GHSA-cxf3-9h37-7rr8, GHSA-9969-6676-49h5, GHSA-h7wr-284f-5qvc, GHSA-f48p-c9v4-h7xv, GHSA-x9ff-22gv-65f4, and GHSA-h53f-rm2w-w7rg).
  • 2026-10-23 (01:54 UTC): LabStack tags v5.4.2 with empty-CSRF-token rejection (#3135) and Basic authentication scheme boundary checks (#3138).
  • 2026-10-23 (20:48 to 20:50 UTC): The six Echo advisories are published to the global GitHub Advisory Database.

From research to remediation

Check whether this pattern exists in your codebase

Turn this research into a remediation workflow. Use AI-native static analysis to find similar application vulnerabilities and generate review-ready fixes.

References