See how echo compares to other vendors in security performance
Summary
Echo's dedicated open-redirect guard, sanitizeURI, decides whether a redirect target is a local path by inspecting exactly two byte positions: uri[0] and uri[1]. If the value begins with a control character such as a tab or a newline, followed by a separator pair, the guard does not fire and the value is written to the Location header unchanged.
Browsers remove tab and newline characters from URLs before parsing them, and treat a backslash as a forward slash for HTTP and HTTPS URLs. A Location value of /\t/attacker.example is therefore resolved by the browser as an absolute URL on attacker.example. Confirmed end to end in a headless browser.
Technical background
The guard
middleware/slash.go:144 (duplicated verbatim at echo.go:958):
go func sanitizeURI(uri string) string { // double slash \\, // or even \/ is absolute uri for browsers and by redirecting request to that uri // we are vulnerable to open redirect attack. so replace all slashes from the beginning with single slash if len(uri) > 1 && (uri[0] == '\\' || uri[0] == '/') && (uri[1] == '\\' || uri[1] == '/') { uri = "/" + strings.TrimLeft(uri, /\) } return uri }
The condition is anchored at absolute offsets 0 and 1. It cannot see a separator that has been shifted by one byte.
Data flow
1. RemoveTrailingSlash (middleware/slash.go:112-140) reads url.Path, which net/http has already percent-decoded, strips the trailing slash, and appends the raw query string:
go path := url.Path ... if l > 0 && strings.HasSuffix(path, "/") { path = path[:l] uri := path if qs != "" { uri += "?" + qs } if config.RedirectCode != 0 { return c.Redirect(config.RedirectCode, sanitizeURI(uri)) // line 132 }
2. AddTrailingSlash does the same in the opposite direction at middleware/slash.go:67.
3. A request for /%09/attacker.example/x/ has url.Path equal to "/\t/attacker.example/x/" (a literal TAB byte). sanitizeURI evaluates uri[0] == '\t', the condition is false, and the value passes through untouched.
4. Context.Redirect (context.go:735) writes it to the Location header. Go's header writer removes CR and LF but leaves TAB, which is all that is required.
Conditional analysis
- Guard: uri[0] and uri[1] are separators. Trust boundary: server-side sanitization of attacker-influenced path data. - Bypass: place a single control byte before the separator pair. The separator moves to index 1 and the guard's second condition (uri[1] being a separator) is never reached because the first condition (uri[0] being a separator) already failed. - Browser-side trust boundary: the client's URL parser. It is adversary favourable: control characters are stripped and backslashes are normalized before scheme and authority parsing. The attacker does not need any server-side weakness beyond producing the malformed Location.
Note that the newline variant does not survive: net/http replaces CR and LF in header values. TAB does survive, and TAB alone is sufficient because the browser strips it.
Proof of concept
Attached PoC: ECHO-2026-002poc.zip (in this directory). Extract it and follow ECHO-2026-002/README.md for the full walkthrough. The archive contains a self-contained Go module (go.mod, go.sum, main.go, README.md).
ECHO-2026-002poc.zip
Quick start:
bash unzip ECHO-2026-002poc.zip cd ECHO-2026-002 edit go.mod: point the replace directive at your Echo checkout sed -i 's|=> .|=> /absolute/path/to/echo|' go.mod GOFLAGS=-mod=mod go run . # in-process check GOFLAGS=-mod=mod go run . -serve # real listener on 127.0.0.1:18002
Environment used for verification
- OS: Linux 6.18.33.2-microsoft-standard-WSL2, x8664 - Go: go1.27.0 linux/amd64 - Target: Echo v5.3.1 (module github.com/labstack/echo/v5) - Browser: headless Chromium drove the end-to-end confirmation
The vulnerable configuration
go package main
import ( "net/http"
"github.com/labstack/echo/v5" "github.com/labstack/echo/v5/middleware" )
func main() { e := echo.New() e.Pre(middleware.RemoveTrailingSlashWithConfig(middleware.RemoveTrailingSlashConfig{ RedirectCode: http.StatusMovedPermanently, })) e.GET("/", func(c echo.Context) error { return c.String(200, "home") }) = e.Start("127.0.0.1:18002") }
Reproduction
In-process check. The program requests five path shapes and applies the browser URL-parsing steps that matter (strip TAB, LF, CR; treat a backslash as a slash; a leading // starts an authority):
bash GOFLAGS=-mod=mod go run .
GET code Location (control chars shown) browser navigates to /normal/path/ 301 /normal/path (victim origin) control: ordinary path, sanitized correctly /%09/attacker.example/x/ 301 /^I/attacker.example/x attacker.example exploit: TAB then slash /%09%5Cattacker.example/x/ 301 /^I\attacker.example/x attacker.example exploit: TAB then backslash /..%09/attacker.example/ 301 /..^I/attacker.example (victim origin) NOT exploitable: TAB is not at index 1 /%09//attacker.example/ 301 /^I//attacker.example (victim origin) NOT exploitable: three slashes is an empty authority
RESULT: reproducible. ...
^I is a literal TAB byte. The last two rows are deliberate negatives, included so the mechanism (the guard reads only uri[0] and uri[1]) is unambiguous.
Over the wire, with the -serve variant running on 127.0.0.1:18002:
1. Show the malformed Location on the wire. No backslash is needed for this variant:
bash curl -s -i --max-time 5 'http://127.0.0.1:18002/%09/attacker.example/x/' | cat -A | head -3 HTTP/1.1 301 Moved Permanently Location: /^I/attacker.example/x
cat -A renders the tab as ^I, confirming it is a literal byte in the header.
2. Show that LF does NOT work, which proves the mechanism is the guard's fixed byte offsets rather than generic header injection:
bash curl -s -i --max-time 5 'http://127.0.0.1:18002/%0A/attacker.example/x/' | cat -A | head -3 Location: / /attacker.example/x (net/http rewrote LF to a space)
3. Confirm a browser leaves the origin. Start any HTTP server on port 18099, then open this URL in a browser:
http://127.0.0.1:18002/%09/127.0.0.1:18099/x/
Observed in headless Chromium:
finalUrl: http://127.0.0.1:18099/x
The browser left the victim origin entirely, so this is a working open redirect, not merely a suspicious header.
Exit status 0 means the finding reproduced; 1 means an expectation was violated.
Impact
An attacker can craft a link on the victim domain that redirects the victim to an arbitrary external site. Practical consequences:
- Phishing: the trusted domain name appears in the link and in the initial navigation, which defeats the usual "check the domain" advice. - OAuth and SSO token theft: any flow that permits a redirect target on the trusted origin and relies on that origin's redirects being local can be chained to leak authorization codes or access tokens. - Referrer and cache poisoning of downstream logic that treats the victim origin's redirects as trustworthy.
The client must follow the redirect (user interaction is required), which is why this is scored Medium rather than High, but open redirects on authentication-adjacent origins routinely escalate in practice.
Recommended fixes
Normalize before deciding, instead of probing two fixed offsets.
go func sanitizeURI(uri string) string { // Strip control characters that browsers remove before parsing, so they cannot // be used to shift a separator out of the checked positions. uri = strings.Map(func(r rune) rune { if r < 0x20 || r == 0x7f { return -1 } return r }, uri) // Collapse any leading separator run (both slash kinds) to a single slash. if strings.ContainsAny(uri[:min(len(uri), 1)], /\) { trimmed := strings.TrimLeft(uri, /\) if len(trimmed) != len(uri) { return "/" + trimmed } } return uri }
Additional hardening:
1. Reject the request with 400 when the decoded path contains control characters, rather than attempting to sanitize. A path containing a raw TAB or NUL is malformed under RFC 3986 and has no legitimate use. 2. Apply the same normalization to the Location value in Context.Redirect so every caller benefits, instead of relying on each call site to call sanitizeURI. 3. Add a regression test for each variant (%09/, %09%5C, %0A/) against both trailing-slash middlewares. There is currently no test that asserts sanitizeURI behavior at all.
Patches
Fixed in github.com/labstack/echo/v5 v5.4.0 and github.com/labstack/echo/v4 v4.16.0. The trailing slash middlewares and the static directory redirect percent-encode control characters in the redirect path before collapsing leading slashes, so a browser can no longer be sent to another host.
Workarounds
Use the trailing slash middlewares without RedirectCode (the default rewrites the path internally instead of redirecting), or reject request paths that contain control characters before these middlewares.
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):
go 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.
Echo is a Go web framework. From 5.0.0 until 5.4.1, Echo v5's BodyDump middleware can discard a BodyLimit overflow error for an unknown-length request when BodyDump's capture limit is below the BodyLimit threshold. BodyDump drains the remaining body while ignoring read errors and replaces it with the captured prefix, so an HTTP/1.1 chunked request can be truncated and processed successfully even though the original body exceeded the configured limit. Known-length requests rejected before BodyDump runs are unaffected, and the issue does not establish a general authentication bypass or denial of service. This issue is fixed in 5.4.1.
Echo is a Go web framework. Prior to 4.15.3 and 5.2.0, 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. This vulnerability is fixed in 4.15.3 and 5.2.0.
The Echo RSS Feed Post Generator plugin for WordPress is vulnerable to arbitrary file uploads due to missing file type validation in the echogeneratefeaturedimage() function in all versions up to, and including, 5.4.8.1. This makes it possible for unauthenticated attackers to upload arbitrary files on the affected site's server which may make remote code execution possible.
An issue in Inspect Element Ltd Echo.ac v.5.2.1.0 allows a local attacker to gain privileges via a crafted command to the echodriver.sys component. NOTE: the vendor's position is that the reported ability for user-mode applications to execute code as NT AUTHORITY\SYSTEM was "deactivated by Microsoft itself."