GHSA-99jh-6h7p-pp36
Echo: Reverse Proxy Forwards Attacker-Supplied X-Real-IP to Upstream by Default
Packages
| Ecosystem | Package | Affected | Fixed in |
|---|---|---|---|
| Go | github.com/labstack/echo/v5 | < 5.4.0 | 5.4.0 |
| Go | github.com/labstack/echo/v4 | >= 4.0.0, <= 4.16.2 | no fix listed |
Details
As written in the advisory (Markdown source, shown as plain text).
## Summary
The Echo proxy middleware (`middleware/proxy.go`) preserves an incoming `X-Real-IP` header verbatim and forwards it to the upstream service when `Echo#IPExtractor` is not configured — which is the default. Because any HTTP client can supply an arbitrary `X-Real-IP` value, an attacker can cause Echo to relay a spoofed client IP address to every upstream service behind the proxy, undermining upstream IP-based access controls, rate limiting, audit logging, and geo-restriction.
## Affected Component
- `middleware/proxy.go` — `ProxyConfig.ToMiddleware()`, header-rewriting block (lines 351–355)
## Description
### The header-preservation condition passes spoofed values to upstream
`ProxyConfig.ToMiddleware()` (`proxy.go:338-405`) prepares forwarding headers before dispatching each request. The `X-Real-IP` rewrite logic at lines 351-355 reads:
```go
// proxy.go:351-355
// Basically it's not good practice to unconditionally pass incoming x-real-ip header to upstream.
// However, for backward compatibility, legacy behavior is preserved unless you configure Echo#IPExtractor.
if req.Header.Get(echo.HeaderXRealIP) == "" || c.Echo().IPExtractor != nil {
req.Header.Set(echo.HeaderXRealIP, c.RealIP())
}
```
The condition is `TRUE` (and therefore overwrites the header with a derived value) only when:
- The header is absent — `req.Header.Get(echo.HeaderXRealIP) == ""`; **or**
- `IPExtractor` is explicitly configured — `c.Echo().IPExtractor != nil`
The condition is `FALSE` — and the header is left **unchanged** — when the request arrives with an `X-Real-IP` header already set **and** `IPExtractor` is `nil`. Because `Echo.New()` leaves `IPExtractor` as `nil`, this is the out-of-the-box behaviour for every application that uses `Proxy()` or `ProxyWithConfig()` without extra configuration.
An attacker who sets `X-Real-IP` to any value causes Echo to forward exactly that value to the upstream, with no modification or validation.
### The comment in the source code confirms intent and the security gap
The inline comment at line 351-352 explicitly states:
> "Basically it's not good practice to unconditionally pass incoming x-real-ip header to upstream. However, for backward compatibility, legacy behavior is preserved unless you configure Echo#IPExtractor."
The framework is aware that the default is insecure and retains it for backward compatibility. No warning appears in the `Proxy()` or `ProxyWithConfig()` documentation, so operators who follow the standard setup example receive a silently insecure proxy configuration.
This is structurally identical to the documented insecurity in `ip.go:27`:
> "Note: if you don't set `Echo#IPExtractor` explicitly, Echo fallback to legacy behavior, which is not a good choice."
### Full execution chain
1. Attacker sends `GET /api/admin` to the Echo proxy with header `X-Real-IP: 203.0.113.1` (a trusted corporate IP)
2. `ToMiddleware()` evaluates the guard at line 353: header is present (`!= ""`), `IPExtractor` is `nil` → condition is `FALSE`
3. `req.Header.Set` is **not** called; the upstream request carries the original, attacker-supplied `X-Real-IP: 203.0.113.1`
4. Upstream service checks `X-Real-IP` against its IP allowlist → finds `203.0.113.1` → grants access
5. Attacker reaches an endpoint that should be restricted to the corporate IP range
Secondary impact path (audit poisoning):
- All upstream log entries, rate-limit counters, and geo-restriction checks are evaluated against `203.0.113.1` rather than the real client IP, rendering audit trails and security analytics unreliable.
## Proof of Concept
```go
package main
import (
"fmt"
"log"
"net/http"
echo "github.com/labstack/echo/v5"
"github.com/labstack/echo/v5/middleware"
)
func main() {
// Upstream server: echoes back the X-Real-IP it received
upstream := http.NewServeMux()
upstream.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "upstream sees X-Real-IP: %s\n", r.Header.Get("X-Real-IP"))
})
go http.ListenAndServe(":9000", upstream)
// Echo proxy — IPExtractor deliberately NOT set (the default)
e := echo.New()
targets := []*middleware.ProxyTarget{{URL: mustParse("http://127.0.0.1:9000")}}
e.Use(middleware.Proxy(middleware.NewRoundRobinBalancer(targets)))
log.Fatal(e.Start(":1323"))
}
```
```bash
# Without spoofed header — upstream sees actual peer address
curl http://127.0.0.1:1323/
# upstream sees X-Real-IP: 127.0.0.1
# With spoofed header — upstream sees attacker-supplied value
curl -H 'X-Real-IP: 198.51.100.77' http://127.0.0.1:1323/
# upstream sees X-Real-IP: 198.51.100.77
```
The attacker-supplied IP `198.51.100.77` arrives at the upstream unchanged.
## Impact
- Upstream IP-based access controls (allowlists, blocklists, geo-restrictions) can be bypassed by supplying the expected IP in `X-Real-IP`
- Upstream rate limiters that key on `X-Real-IP` are bypassable (same class as the Echo rate-limiter finding, now on every upstream service)
- Audit logs, intrusion detection systems, and SIEM tools that rely on `X-Real-IP` record the attacker-chosen IP, not the real source — forensic trails are poisoned
- Any upstream admin endpoint restricted by IP allowlist is reachable by any attacker who knows or guesses a trusted IP address
## Recommended Remediation
### Option 1: Always overwrite `X-Real-IP` from an authoritative source (preferred)
Remove the conditional that preserves the incoming header. Echo, as the edge proxy, is the only authoritative source for the real client IP:
```go
// proxy.go — always set X-Real-IP from an authoritative source
req.Header.Set(echo.HeaderXRealIP, c.RealIP())
```
For this to be safe, operators using `Proxy()` should also configure `IPExtractor`:
```go
e.IPExtractor = echo.ExtractIPDirect() // no upstream proxy
// or
e.IPExtractor = echo.ExtractIPFromXFFHeader() // behind a trusted upstream LB
```
### Option 2: Document and warn in `Proxy()` godoc
If backward compatibility must be preserved, add a prominent security warning to `Proxy()` and `ProxyWithConfig()`:
```go
// Proxy returns a Proxy middleware.
//
// SECURITY: By default, an incoming X-Real-IP header supplied by the client
// is forwarded to the upstream unchanged. This allows any client to spoof its
// IP identity to upstream services. Configure Echo#IPExtractor before using
// this middleware to ensure X-Real-IP is derived from a trusted source:
//
// e.IPExtractor = echo.ExtractIPDirect()
//
// See https://echo.labstack.com/guide/ip-address for details.
func Proxy(balancer ProxyBalancer) echo.MiddlewareFunc {
```
## Credit
This vulnerability was discovered and reported by [bugbunny.ai](https://bugbunny.ai).
## Patches
Fixed in `github.com/labstack/echo/v5` v5.4.0: the Proxy middleware always sets `X-Real-IP` from `Context.RealIP()`, which is the address of the direct peer unless `Echo#IPExtractor` is configured.
`github.com/labstack/echo/v4` is not changed, because v4's `Context.RealIP()` without `Echo#IPExtractor` trusts `X-Forwarded-For` and `X-Real-IP` by design, and changing that default would merge all clients behind a proxy into one rate-limit bucket.
## Workarounds
Configure `Echo#IPExtractor` for your deployment, for example `e.IPExtractor = echo.ExtractIPDirect()` when there is no proxy in front of the application, or `echo.ExtractIPFromXFFHeader()` / `echo.ExtractIPFromRealIPHeader()` (with `TrustIPRange` for proxies on public addresses). With an extractor set, the Proxy middleware overwrites `X-Real-IP` in v4 too.
References
External links from the advisory. They lead to third-party sites.