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):
GHSA-cxf3-9h37-7rr8(CVSS 9.1 Critical in GHSA, rated Medium by maintainer) andGHSA-9969-6676-49h5(CVSS 8.2 High in GHSA): Whenever a client sentConnection: UpgradeandUpgrade: websocketto a route using Echo’smiddleware.Proxy, Echo bypassedhttputil.ReverseProxyand calledproxyRaw(), which immediately hijacked the client TCP socket and dialed a raw bidirectionalio.Copypipe to the upstream target before checking whether the upstream returned101 Switching Protocols. If the upstream rejected the WebSocket upgrade with an ordinary HTTP response (such as200 OK,400 Bad Request, or404 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 bothio.Copydirections to return before closing either socket (GHSA-9969-6676-49h5) and forwarded client-suppliedForwardedheaders untouched.GHSA-h53f-rm2w-w7rg(Medium, Echov5.0.0tov5.4.0):ExtractIPFromRealIPHeadercheckedchecker.trust(ip)on the IP address inside the client-suppliedX-Real-IPheader instead of checking the direct TCP peer’s IP (req.RemoteAddr). Any direct external caller could sendX-Real-IP: 127.0.0.1or10.0.0.1and spoof a trusted internal IP inc.RealIP().GHSA-h7wr-284f-5qvcandGHSA-f48p-c9v4-h7xv(CVSS 8.2 High in GHSA, Echov4 <= 4.16.0), plusGHSA-x9ff-22gv-65f4(Echov5.0.0tov5.4.0): In Echo v4,middleware.Decompressexposed a raw*gzip.Readerwith no decompressed byte limit, andmiddleware.BodyDumpranio.ReadAll(c.Request().Body)and buffered the full response body in memory with no cap. In Echo v5,BodyDumpcapped its logging buffer atMaxRequestBytes(default 5 MiB) but then drained the rest of the request with_, _ = io.Copy(io.Discard, c.Request().Body)and replacedc.Request().Bodywith the truncated 5 MiB slice, swallowing outerBodyLimiterrors on chunked requests and handing the handler a silently truncated payload.
Affected packages and projects
| Ecosystem | Go module | GHSA | GHSA CVSS | Affected versions | Fixed version | Vulnerable component |
|---|---|---|---|---|---|---|
| Go | github.com/labstack/echo/v4, github.com/labstack/echo/v5 | GHSA-cxf3-9h37-7rr8 | 9.1 Critical | v4 < 4.16.1, v5 < 5.4.1 | v4.16.1, v5.4.1 (v5.4.2) | middleware/proxy.go (proxyRaw unvalidated WebSocket tunnel) |
| Go | github.com/labstack/echo/v4, github.com/labstack/echo/v5 | GHSA-9969-6676-49h5 | 8.2 High | >= 4.15.0, < 4.16.1, >= 5.0.0, < 5.4.1 | v4.16.1, v5.4.1 | middleware/proxy.go (proxyRaw half-close goroutine hang) |
| Go | github.com/labstack/echo/v4 | GHSA-h7wr-284f-5qvc | 8.2 High | < 4.16.1 | v4.16.1 | middleware/decompress.go (unbounded gzip stream expansion) |
| Go | github.com/labstack/echo/v4 | GHSA-f48p-c9v4-h7xv | 8.2 High | < 4.16.1 | v4.16.1 | middleware/body_dump.go (unbounded io.ReadAll request/response capture) |
| Go | github.com/labstack/echo/v5 | GHSA-x9ff-22gv-65f4 | 6.3 Medium | >= 5.0.0, < 5.4.1 | v5.4.1 | middleware/body_dump.go (io.Copy(io.Discard, ...) truncates body and drops BodyLimit error) |
| Go | github.com/labstack/echo/v5 | GHSA-h53f-rm2w-w7rg (GHSA-c3r7-8j36-7mx8) | 5.3 Medium | >= 5.0.0, < 5.4.1 | v5.4.1 | ip.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:
- 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/wsor/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/wswithConnection: UpgradeandUpgrade: websocket,proxyRaw()wiresindirectly toout. When the upstream backend rejects the WebSocket upgrade with a non-101 HTTP response (for example400 Bad Requestor200 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. - Goroutine and socket leak after client disconnect (
GHSA-9969-6676-49h5): BecauseproxyRaw()waited for both<-errChreceives (err1anderr2) before runningdefer in.Close()anddefer out.Close(), a client that disconnected (io.Copy(out, in)returningnilon EOF) did not closeout. 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) andBodyDump(GHSA-f48p-c9v4-h7xv): Echo v4’sDecompressWithConfigassignedc.Request().Body = grwithout wrapping the*gzip.Readerin a byte limiter, so a tiny gzip bomb bypassed compressedContent-Lengthchecks and expanded until OOM. Similarly, v4’sBodyDumpWithConfigcalledio.ReadAll(c.Request().Body)and buffered the full response in memory without limits. Echov4.16.1addsDecompressConfig.MaxDecompressedSize(defaulting to100 MiB, returningecho.ErrStatusRequestEntityTooLarge/ HTTP413vialimitedGzipReader) andBodyDumpConfig.MaxRequestBytes/MaxResponseBytes(defaulting to5 MiBeach). - Echo v5
BodyDumptruncation andBodyLimitbypass (GHSA-x9ff-22gv-65f4): In Echo v5 (v5.0.0tov5.4.0),BodyDumpread up toMaxRequestBytes(5 MiB), discarded the rest of the stream while ignoring errors (_, _ = io.Copy(io.Discard, c.Request().Body)), and replacedc.Request().Bodywithio.NopCloser(bytes.NewReader(reqBody)). When an outerBodyLimitmiddleware wrapped a chunked request (for example with a10 MiBlimit) andBodyDumpused its5 MiBdefault,io.Copy(io.Discard, ...)triggered and silently ignored theBodyLimiterror, and the downstream handler processed the truncated 5 MiB prefix as if it were the complete request. Bothv4.16.1andv5.4.1now reconstructc.Request().Bodyusingio.MultiReader(bytes.NewReader(reqBody), c.Request().Body)while preserving the originalCloserand 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
- 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 - Review custom
MaxDecompressedSizeandMaxRequestBytesthresholds: After upgrading Echo v4 tov4.16.1,Decompressdefaults to a100 MiBdecompressed cap andBodyDumpdefaults to a5 MiBcapture cap (passing the full stream through to handlers viaio.MultiReader). AdjustDecompressConfig.MaxDecompressedSizeif 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) and1855061e57(v5), tagsv4.16.1andv5.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, andGHSA-h53f-rm2w-w7rg). - 2026-10-23 (01:54 UTC): LabStack tags
v5.4.2with empty-CSRF-token rejection (#3135) andBasicauthentication 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
- Echo advisory GHSA-cxf3-9h37-7rr8 (rejected WebSocket upgrades bypass route-level access controls)
- Echo advisory GHSA-9969-6676-49h5 (WebSocket proxy retains connections and handlers after disconnect)
- Echo advisory GHSA-h7wr-284f-5qvc (Decompress permits unbounded gzip expansion in v4)
- Echo advisory GHSA-f48p-c9v4-h7xv (BodyDump captures request and response bodies without size limits in v4)
- Echo advisory GHSA-x9ff-22gv-65f4 (v5 BodyDump discards BodyLimit overflow errors on chunked requests)
- Echo advisory GHSA-h53f-rm2w-w7rg (v5 ExtractIPFromRealIPHeader trusts header value instead of peer)
- labstack/echo commit 3fb0ba7e17 (v4.16.1 security fixes)
- labstack/echo commit 1855061e57 (v5.4.1 security fixes)