From bf04724173bdf05fcb0858a391322ecd7c2ed331 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 16 Oct 2026 04:06:44 +0000 Subject: [PATCH] chore(deps): update dependency: bump github.com/go-chi/chi/v5 Bumps [github.com/go-chi/chi/v5](https://github.com/go-chi/chi) from 5.3.0 to 5.4.0. - [Release notes](https://github.com/go-chi/chi/releases) - [Changelog](https://github.com/go-chi/chi/blob/master/CHANGELOG.md) - [Commits](https://github.com/go-chi/chi/compare/v5.3.0...v5.4.0) --- updated-dependencies: - dependency-name: github.com/go-chi/chi/v5 dependency-version: 5.4.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- go.mod | 2 +- go.sum | 4 +- vendor/github.com/go-chi/chi/v5/README.md | 92 +++++---- vendor/github.com/go-chi/chi/v5/chi.go | 17 +- vendor/github.com/go-chi/chi/v5/context.go | 38 +++- .../go-chi/chi/v5/middleware/client_ip.go | 186 +++++++++++++----- .../go-chi/chi/v5/middleware/compress.go | 32 ++- .../chi/v5/middleware/content_charset.go | 6 + .../go-chi/chi/v5/middleware/logger.go | 12 +- .../go-chi/chi/v5/middleware/profiler.go | 1 - .../go-chi/chi/v5/middleware/recoverer.go | 14 +- .../go-chi/chi/v5/middleware/terminal.go | 2 +- .../go-chi/chi/v5/middleware/value.go | 2 +- .../go-chi/chi/v5/middleware/wrap_writer.go | 34 ++-- vendor/github.com/go-chi/chi/v5/mux.go | 16 +- vendor/github.com/go-chi/chi/v5/tree.go | 66 ++++++- vendor/modules.txt | 4 +- 17 files changed, 367 insertions(+), 161 deletions(-) diff --git a/go.mod b/go.mod index 3495c2e..aab8cd9 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.26.0 require ( github.com/bwmarrin/discordgo v0.29.0 github.com/distrobyte/multielo v0.1.3 - github.com/go-chi/chi/v5 v5.3.0 + github.com/go-chi/chi/v5 v5.4.0 github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 github.com/google/uuid v1.6.0 github.com/rs/zerolog v1.35.1 diff --git a/go.sum b/go.sum index c6b312d..a9d672e 100644 --- a/go.sum +++ b/go.sum @@ -25,8 +25,8 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/dchote/go-openal v0.0.0-20171116030048-f4a9a141d372/go.mod h1:74z+CYu2/mx4N+mcIS/rsvfAxBPBV9uv8zRAnwyFkdI= github.com/distrobyte/multielo v0.1.3 h1:rqKrnTn7w4uS1MnQNtQNqC0vebU6OkHukjmu/3YnDLI= github.com/distrobyte/multielo v0.1.3/go.mod h1:svv+AlrisaP7pUZDaOAhEfUp16LlrJIelr7CueKrr8M= -github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM= -github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= +github.com/go-chi/chi/v5 v5.4.0 h1:+ufzvFTGKyin26Or5DLrlIDq4ygIDBVcj7oVNeeaYFE= +github.com/go-chi/chi/v5 v5.4.0/go.mod h1:d1IG7GtKPSjPyBP365PH8sAGVHk3qfB/IhjFNRH1wWM= github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= diff --git a/vendor/github.com/go-chi/chi/v5/README.md b/vendor/github.com/go-chi/chi/v5/README.md index a116596..5f9f443 100644 --- a/vendor/github.com/go-chi/chi/v5/README.md +++ b/vendor/github.com/go-chi/chi/v5/README.md @@ -221,6 +221,7 @@ type Router interface { Patch(pattern string, h http.HandlerFunc) Post(pattern string, h http.HandlerFunc) Put(pattern string, h http.HandlerFunc) + Query(pattern string, h http.HandlerFunc) Trace(pattern string, h http.HandlerFunc) // NotFound defines a handler to respond whenever a route could @@ -367,50 +368,50 @@ with `net/http` can be used with chi's mux. | [WithValue] | Short-hand middleware to set a key/value on the request context | ---------------------------------------------------------------------------------------------------- -[AllowContentEncoding]: https://pkg.go.dev/github.com/go-chi/chi/middleware#AllowContentEncoding -[AllowContentType]: https://pkg.go.dev/github.com/go-chi/chi/middleware#AllowContentType -[BasicAuth]: https://pkg.go.dev/github.com/go-chi/chi/middleware#BasicAuth -[Compress]: https://pkg.go.dev/github.com/go-chi/chi/middleware#Compress -[ContentCharset]: https://pkg.go.dev/github.com/go-chi/chi/middleware#ContentCharset -[CleanPath]: https://pkg.go.dev/github.com/go-chi/chi/middleware#CleanPath -[GetHead]: https://pkg.go.dev/github.com/go-chi/chi/middleware#GetHead -[GetReqID]: https://pkg.go.dev/github.com/go-chi/chi/middleware#GetReqID -[Heartbeat]: https://pkg.go.dev/github.com/go-chi/chi/middleware#Heartbeat -[Logger]: https://pkg.go.dev/github.com/go-chi/chi/middleware#Logger -[NoCache]: https://pkg.go.dev/github.com/go-chi/chi/middleware#NoCache -[Profiler]: https://pkg.go.dev/github.com/go-chi/chi/middleware#Profiler -[ClientIPFromHeader]: https://pkg.go.dev/github.com/go-chi/chi/middleware#ClientIPFromHeader -[ClientIPFromXFF]: https://pkg.go.dev/github.com/go-chi/chi/middleware#ClientIPFromXFF -[ClientIPFromXFFTrustedProxies]: https://pkg.go.dev/github.com/go-chi/chi/middleware#ClientIPFromXFFTrustedProxies -[ClientIPFromRemoteAddr]: https://pkg.go.dev/github.com/go-chi/chi/middleware#ClientIPFromRemoteAddr -[GetClientIP]: https://pkg.go.dev/github.com/go-chi/chi/middleware#GetClientIP -[GetClientIPAddr]: https://pkg.go.dev/github.com/go-chi/chi/middleware#GetClientIPAddr -[RealIP]: https://pkg.go.dev/github.com/go-chi/chi/middleware#RealIP -[Recoverer]: https://pkg.go.dev/github.com/go-chi/chi/middleware#Recoverer -[RedirectSlashes]: https://pkg.go.dev/github.com/go-chi/chi/middleware#RedirectSlashes -[RequestLogger]: https://pkg.go.dev/github.com/go-chi/chi/middleware#RequestLogger -[RequestID]: https://pkg.go.dev/github.com/go-chi/chi/middleware#RequestID -[RouteHeaders]: https://pkg.go.dev/github.com/go-chi/chi/middleware#RouteHeaders -[SetHeader]: https://pkg.go.dev/github.com/go-chi/chi/middleware#SetHeader -[StripSlashes]: https://pkg.go.dev/github.com/go-chi/chi/middleware#StripSlashes +[AllowContentEncoding]: https://pkg.go.dev/github.com/go-chi/chi/v5/middleware#AllowContentEncoding +[AllowContentType]: https://pkg.go.dev/github.com/go-chi/chi/v5/middleware#AllowContentType +[BasicAuth]: https://pkg.go.dev/github.com/go-chi/chi/v5/middleware#BasicAuth +[Compress]: https://pkg.go.dev/github.com/go-chi/chi/v5/middleware#Compress +[ContentCharset]: https://pkg.go.dev/github.com/go-chi/chi/v5/middleware#ContentCharset +[CleanPath]: https://pkg.go.dev/github.com/go-chi/chi/v5/middleware#CleanPath +[GetHead]: https://pkg.go.dev/github.com/go-chi/chi/v5/middleware#GetHead +[GetReqID]: https://pkg.go.dev/github.com/go-chi/chi/v5/middleware#GetReqID +[Heartbeat]: https://pkg.go.dev/github.com/go-chi/chi/v5/middleware#Heartbeat +[Logger]: https://pkg.go.dev/github.com/go-chi/chi/v5/middleware#Logger +[NoCache]: https://pkg.go.dev/github.com/go-chi/chi/v5/middleware#NoCache +[Profiler]: https://pkg.go.dev/github.com/go-chi/chi/v5/middleware#Profiler +[ClientIPFromHeader]: https://pkg.go.dev/github.com/go-chi/chi/v5/middleware#ClientIPFromHeader +[ClientIPFromXFF]: https://pkg.go.dev/github.com/go-chi/chi/v5/middleware#ClientIPFromXFF +[ClientIPFromXFFTrustedProxies]: https://pkg.go.dev/github.com/go-chi/chi/v5/middleware#ClientIPFromXFFTrustedProxies +[ClientIPFromRemoteAddr]: https://pkg.go.dev/github.com/go-chi/chi/v5/middleware#ClientIPFromRemoteAddr +[GetClientIP]: https://pkg.go.dev/github.com/go-chi/chi/v5/middleware#GetClientIP +[GetClientIPAddr]: https://pkg.go.dev/github.com/go-chi/chi/v5/middleware#GetClientIPAddr +[RealIP]: https://pkg.go.dev/github.com/go-chi/chi/v5/middleware#RealIP +[Recoverer]: https://pkg.go.dev/github.com/go-chi/chi/v5/middleware#Recoverer +[RedirectSlashes]: https://pkg.go.dev/github.com/go-chi/chi/v5/middleware#RedirectSlashes +[RequestLogger]: https://pkg.go.dev/github.com/go-chi/chi/v5/middleware#RequestLogger +[RequestID]: https://pkg.go.dev/github.com/go-chi/chi/v5/middleware#RequestID +[RouteHeaders]: https://pkg.go.dev/github.com/go-chi/chi/v5/middleware#RouteHeaders +[SetHeader]: https://pkg.go.dev/github.com/go-chi/chi/v5/middleware#SetHeader +[StripSlashes]: https://pkg.go.dev/github.com/go-chi/chi/v5/middleware#StripSlashes [Sunset]: https://pkg.go.dev/github.com/go-chi/chi/v5/middleware#Sunset -[Throttle]: https://pkg.go.dev/github.com/go-chi/chi/middleware#Throttle -[ThrottleBacklog]: https://pkg.go.dev/github.com/go-chi/chi/middleware#ThrottleBacklog -[ThrottleWithOpts]: https://pkg.go.dev/github.com/go-chi/chi/middleware#ThrottleWithOpts -[Timeout]: https://pkg.go.dev/github.com/go-chi/chi/middleware#Timeout -[URLFormat]: https://pkg.go.dev/github.com/go-chi/chi/middleware#URLFormat -[WithLogEntry]: https://pkg.go.dev/github.com/go-chi/chi/middleware#WithLogEntry -[WithValue]: https://pkg.go.dev/github.com/go-chi/chi/middleware#WithValue -[Compressor]: https://pkg.go.dev/github.com/go-chi/chi/middleware#Compressor -[DefaultLogFormatter]: https://pkg.go.dev/github.com/go-chi/chi/middleware#DefaultLogFormatter -[EncoderFunc]: https://pkg.go.dev/github.com/go-chi/chi/middleware#EncoderFunc -[HeaderRoute]: https://pkg.go.dev/github.com/go-chi/chi/middleware#HeaderRoute -[HeaderRouter]: https://pkg.go.dev/github.com/go-chi/chi/middleware#HeaderRouter -[LogEntry]: https://pkg.go.dev/github.com/go-chi/chi/middleware#LogEntry -[LogFormatter]: https://pkg.go.dev/github.com/go-chi/chi/middleware#LogFormatter -[LoggerInterface]: https://pkg.go.dev/github.com/go-chi/chi/middleware#LoggerInterface -[ThrottleOpts]: https://pkg.go.dev/github.com/go-chi/chi/middleware#ThrottleOpts -[WrapResponseWriter]: https://pkg.go.dev/github.com/go-chi/chi/middleware#WrapResponseWriter +[Throttle]: https://pkg.go.dev/github.com/go-chi/chi/v5/middleware#Throttle +[ThrottleBacklog]: https://pkg.go.dev/github.com/go-chi/chi/v5/middleware#ThrottleBacklog +[ThrottleWithOpts]: https://pkg.go.dev/github.com/go-chi/chi/v5/middleware#ThrottleWithOpts +[Timeout]: https://pkg.go.dev/github.com/go-chi/chi/v5/middleware#Timeout +[URLFormat]: https://pkg.go.dev/github.com/go-chi/chi/v5/middleware#URLFormat +[WithLogEntry]: https://pkg.go.dev/github.com/go-chi/chi/v5/middleware#WithLogEntry +[WithValue]: https://pkg.go.dev/github.com/go-chi/chi/v5/middleware#WithValue +[Compressor]: https://pkg.go.dev/github.com/go-chi/chi/v5/middleware#Compressor +[DefaultLogFormatter]: https://pkg.go.dev/github.com/go-chi/chi/v5/middleware#DefaultLogFormatter +[EncoderFunc]: https://pkg.go.dev/github.com/go-chi/chi/v5/middleware#EncoderFunc +[HeaderRoute]: https://pkg.go.dev/github.com/go-chi/chi/v5/middleware#HeaderRoute +[HeaderRouter]: https://pkg.go.dev/github.com/go-chi/chi/v5/middleware#HeaderRouter +[LogEntry]: https://pkg.go.dev/github.com/go-chi/chi/v5/middleware#LogEntry +[LogFormatter]: https://pkg.go.dev/github.com/go-chi/chi/v5/middleware#LogFormatter +[LoggerInterface]: https://pkg.go.dev/github.com/go-chi/chi/v5/middleware#LoggerInterface +[ThrottleOpts]: https://pkg.go.dev/github.com/go-chi/chi/v5/middleware#ThrottleOpts +[WrapResponseWriter]: https://pkg.go.dev/github.com/go-chi/chi/v5/middleware#WrapResponseWriter ### Choosing a ClientIP middleware @@ -464,6 +465,11 @@ is folded to plain IPv4, and IPv6 zone identifiers carried in headers are stripped, so one logical client maps to a single canonical key for logs, rate limits, and ACLs. +For `ClientIPFromXFFTrustedProxies`, `numTrustedProxies` is the total proxy +hops between client and server. Prefer `ClientIPFromXFF` with explicit CIDRs +when you can — CIDR-based trust cannot off-by-one. See the godoc for a +deployment recipe and a verify checklist. + See the per-function godoc for the full semantics of each middleware, and [adam-p's "The perils of the 'real' client IP"](https://adam-p.ca/blog/2022/03/x-forwarded-for/) for the underlying threat model. diff --git a/vendor/github.com/go-chi/chi/v5/chi.go b/vendor/github.com/go-chi/chi/v5/chi.go index ad0ca74..6911bb5 100644 --- a/vendor/github.com/go-chi/chi/v5/chi.go +++ b/vendor/github.com/go-chi/chi/v5/chi.go @@ -2,7 +2,7 @@ // // chi supports the four most recent major versions of Go. // -// Example: +// # Example // // package main // @@ -25,11 +25,13 @@ // http.ListenAndServe(":3333", r) // } // -// See github.com/go-chi/chi/_examples/ for more in-depth examples. +// See [github.com/go-chi/chi/v5/_examples] for more in-depth examples. +// +// # URL patterns // // URL patterns allow for easy matching of path components in HTTP // requests. The matching components can then be accessed using -// chi.URLParam(). All patterns must begin with a slash. +// [chi.URLParam](). All patterns must begin with a slash. // // A simple named placeholder {name} matches any sequence of characters // up to the next / or the end of the URL. Trailing slashes on paths must @@ -37,11 +39,11 @@ // // A placeholder with a name followed by a colon allows a regular // expression match, for example {number:\\d+}. The regular expression -// syntax is Go's normal regexp RE2 syntax, except that / will never be -// matched. An anonymous regexp pattern is allowed, using an empty string -// before the colon in the placeholder, such as {:\\d+} +// syntax is Go's normal [regexp] RE2 syntax (see [regexp/syntax]), except +// that / will never be matched. An anonymous regexp pattern is allowed, using +// an empty string before the colon in the placeholder, such as {:\\d+}. // -// The special placeholder of asterisk matches the rest of the requested +// The special placeholder of asterisk (*) matches the rest of the requested // URL. Any trailing characters in the pattern are ignored. This is the only // placeholder which will match / characters. // @@ -102,6 +104,7 @@ type Router interface { Patch(pattern string, h http.HandlerFunc) Post(pattern string, h http.HandlerFunc) Put(pattern string, h http.HandlerFunc) + Query(pattern string, h http.HandlerFunc) Trace(pattern string, h http.HandlerFunc) // NotFound defines a handler to respond whenever a route could diff --git a/vendor/github.com/go-chi/chi/v5/context.go b/vendor/github.com/go-chi/chi/v5/context.go index 8222073..c5c9a74 100644 --- a/vendor/github.com/go-chi/chi/v5/context.go +++ b/vendor/github.com/go-chi/chi/v5/context.go @@ -3,15 +3,13 @@ package chi import ( "context" "net/http" + "slices" "strings" ) // URLParam returns the url parameter from a http.Request object. func URLParam(r *http.Request, key string) string { - if rctx := RouteContext(r.Context()); rctx != nil { - return rctx.URLParam(key) - } - return "" + return URLParamFromCtx(r.Context(), key) } // URLParamFromCtx returns the url parameter from a http.Request Context. @@ -42,6 +40,8 @@ var ( // Context is the default routing context set on the root node of a // request context to track route patterns, URL parameters and // an optional routing path. +// +// NOTE: New reference fields (slices, maps, pointers) must be deep-copied in Clone. type Context struct { Routes Routes @@ -95,12 +95,36 @@ func (x *Context) Reset() { x.parentCtx = nil } +// Clone a routing context so that it may be used outside of the request/response +// lifecycle, e.g. in a goroutine that outlives the request handler. +// +// Clone must be called before the request handler returns; after that, the +// original context is reset and put back into the pool for reuse by another +// request, racing with the copy. +func (x *Context) Clone() *Context { + clone := *x + + // Detach from the request's context, which is canceled once the request finishes. + clone.parentCtx = nil + + clone.URLParams.Keys = slices.Clone(x.URLParams.Keys) + clone.URLParams.Values = slices.Clone(x.URLParams.Values) + + clone.routeParams.Keys = slices.Clone(x.routeParams.Keys) + clone.routeParams.Values = slices.Clone(x.routeParams.Values) + + clone.RoutePatterns = slices.Clone(x.RoutePatterns) + clone.methodsAllowed = slices.Clone(x.methodsAllowed) + + return &clone +} + // URLParam returns the corresponding URL parameter value from the request // routing context. func (x *Context) URLParam(key string) string { - for k := len(x.URLParams.Keys) - 1; k >= 0; k-- { - if x.URLParams.Keys[k] == key { - return x.URLParams.Values[k] + for i, k := range slices.Backward(x.URLParams.Keys) { + if k == key { + return x.URLParams.Values[i] } } return "" diff --git a/vendor/github.com/go-chi/chi/v5/middleware/client_ip.go b/vendor/github.com/go-chi/chi/v5/middleware/client_ip.go index 1495a86..55e2dc2 100644 --- a/vendor/github.com/go-chi/chi/v5/middleware/client_ip.go +++ b/vendor/github.com/go-chi/chi/v5/middleware/client_ip.go @@ -5,6 +5,7 @@ import ( "net" "net/http" "net/netip" + "slices" "strings" ) @@ -38,15 +39,37 @@ const xForwardedForHeader = "X-Forwarded-For" // // v4-mapped IPv6 (::ffff:a.b.c.d) folds to plain v4 and IPv6 zones are // stripped before storage. -func ClientIPFromHeader(trustedHeader string) func(http.Handler) http.Handler { +// +// Overwriting only protects requests that pass through your proxy. A client +// that reaches this server directly can set the header to anything. Pass +// your proxy's CIDR prefixes as trustedIPPrefixes to close that gap: the +// header is then read only when r.RemoteAddr falls within one of them; +// otherwise r.RemoteAddr itself is the client IP. This mirrors nginx +// (set_real_ip_from), Apache (RemoteIPTrustedProxy), and Caddy +// (trusted_proxies), and Cloudflare's advice to accept origin traffic only +// from its IP ranges. Without trustedIPPrefixes, guarantee at the network +// layer (security group / firewall) that only your proxy can reach this +// server. +// +// r.Use(middleware.ClientIPFromHeader("CF-Connecting-IP", +// "173.245.48.0/20", // Cloudflare IPv4, see https://www.cloudflare.com/ips/ +// "2400:cb00::/32", // Cloudflare IPv6 +// )) +// +// Panics at startup if any prefix is invalid. +func ClientIPFromHeader(trustedHeader string, trustedIPPrefixes ...string) func(http.Handler) http.Handler { header := http.CanonicalHeaderKey(trustedHeader) + prefixes := mustParsePrefixes(trustedIPPrefixes) return func(h http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - values := r.Header.Values(header) - if len(values) > 0 { - if ip, ok := parseHeaderAddr(values[len(values)-1]); ok { - r = r.WithContext(context.WithValue(r.Context(), clientIPCtxKey, ip)) - } + var found netip.Addr + if peer, untrusted := untrustedPeer(r, prefixes); untrusted { + found = peer + } else if values := r.Header.Values(header); len(values) > 0 { + found, _ = parseHeaderAddr(values[len(values)-1]) + } + if found.IsValid() { + r = r.WithContext(context.WithValue(r.Context(), clientIPCtxKey, found)) } h.ServeHTTP(w, r) }) @@ -62,7 +85,14 @@ func ClientIPFromHeader(trustedHeader string) func(http.Handler) http.Handler { // set (fail-closed) — we can't safely trust anything left of garbage. // // Use this when you sit behind one or more reverse proxies whose IP ranges -// you can enumerate as CIDRs: +// you can enumerate as CIDRs. Most CDNs publish their IPs: +// +// Cloudflare: https://www.cloudflare.com/ips/ +// AWS: https://ip-ranges.amazonaws.com/ip-ranges.json +// Fastly: https://api.fastly.com/public-ip-list +// Google Cloud: https://www.gstatic.com/ipranges/cloud.json +// +// Example (CloudFront): // // r.Use(middleware.ClientIPFromXFF( // "13.32.0.0/15", // CloudFront IPv4 @@ -70,9 +100,15 @@ func ClientIPFromHeader(trustedHeader string) func(http.Handler) http.Handler { // "2600:9000::/28", // CloudFront IPv6 // )) // -// Calling with no arguments returns the rightmost XFF entry, or no IP if -// that entry doesn't parse (fail-closed) — safe only if you have exactly -// one trusted hop directly in front of this server (e.g., nginx on localhost). +// The TCP peer in r.RemoteAddr is treated as the rightmost hop: if it is +// not within a trusted prefix, it is the client IP and X-Forwarded-For is +// ignored. A client reaching this server directly therefore cannot spoof +// its IP. If r.RemoteAddr doesn't parse, no client IP is set (fail-closed). +// +// At least one prefix is required. For a single trusted hop directly in +// front of this server (e.g., nginx on localhost), pass its address: +// +// r.Use(middleware.ClientIPFromXFF("127.0.0.1/32", "::1/128")) // // v4-mapped IPv6 (::ffff:a.b.c.d) folds to plain v4 and IPv6 zones are // stripped before the prefix check and storage; otherwise an attacker @@ -81,26 +117,30 @@ func ClientIPFromHeader(trustedHeader string) func(http.Handler) http.Handler { // If you know the number of trusted proxies but not their IPs, use // [ClientIPFromXFFTrustedProxies] instead. // -// Panics at startup if any prefix is invalid. +// Panics at startup if no prefix is given or any prefix is invalid. func ClientIPFromXFF(trustedIPPrefixes ...string) func(http.Handler) http.Handler { - prefixes := make([]netip.Prefix, len(trustedIPPrefixes)) - for i, p := range trustedIPPrefixes { - prefixes[i] = netip.MustParsePrefix(p) + if len(trustedIPPrefixes) == 0 { + panic("middleware.ClientIPFromXFF: at least one trusted IP prefix is required; use ClientIPFromXFFTrustedProxies(1) to trust any peer") } + prefixes := mustParsePrefixes(trustedIPPrefixes) return func(h http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var found netip.Addr - walkXFF(r.Header[xForwardedForHeader], func(v string) bool { - ip, ok := parseHeaderAddr(v) - if !ok { - return true // fail-closed; leave found unset - } - if inAnyPrefix(ip, prefixes) { - return false // trusted hop; keep walking left - } - found = ip - return true - }) + if peer, untrusted := untrustedPeer(r, prefixes); untrusted { + found = peer + } else { + walkXFF(r.Header[xForwardedForHeader], func(v string) bool { + ip, ok := parseHeaderAddr(v) + if !ok { + return true // fail-closed; leave found unset + } + if inAnyPrefix(ip, prefixes) { + return false // trusted hop; keep walking left + } + found = ip + return true + }) + } if found.IsValid() { r = r.WithContext(context.WithValue(r.Context(), clientIPCtxKey, found)) } @@ -109,30 +149,34 @@ func ClientIPFromXFF(trustedIPPrefixes ...string) func(http.Handler) http.Handle } } -// ClientIPFromXFFTrustedProxies stores the client IP read from the -// X-Forwarded-For header, given the exact number of trusted reverse proxies -// between this server and the public internet. It returns the IP at position -// len(xff) - numTrustedProxies in the merged X-Forwarded-For list — the IP -// added by the outermost of your trusted proxies, the only IP in the chain -// that none of your proxies have allowed an attacker to forge. Read it with -// [GetClientIP]. +// ClientIPFromXFFTrustedProxies stores the client IP read from +// X-Forwarded-For, given the exact number of trusted reverse proxies +// between this server and the public internet. Read it with [GetClientIP]. +// +// PREFER [ClientIPFromXFF] with explicit CIDRs whenever you can — it +// cannot off-by-one and is robust to architecture changes. Most CDNs +// publish their IP ranges (Cloudflare, AWS, Fastly, Google Cloud). Use +// this counting variant only when proxy IPs are dynamic and unpublishable. // -// Use this when: -// - You know exactly how many proxies you sit behind, AND -// - Their IP addresses are dynamic (autoscaling proxy pools, ephemeral -// containers, dynamic CDN edges) so listing CIDRs with [ClientIPFromXFF] -// is impractical. +// numTrustedProxies = total proxy hops between the client and this server. +// Count every hop in the request path: // -// WARNING: This variant is brittle to network architecture changes. If you -// add or remove a proxy level, numTrustedProxies silently becomes wrong and -// you may start trusting an attacker-supplied IP. Prefer [ClientIPFromXFF] -// with explicit trusted CIDRs whenever you can. +// Single proxy (one LB / nginx / Heroku / Fly.io / Render) ....... 1 +// Two proxies (Cloudflare → ALB, CloudFront → ALB) .............. 2 +// Three proxies (CDN → API gateway → LB) ......................... 3 // -// If the XFF chain has fewer than numTrustedProxies entries (header missing -// or architecture changed), no client IP is set and [GetClientIP] returns "". +// VERIFY BEFORE GOING LIVE: send a request from a known IP and confirm +// [GetClientIP] returns that IP. If it returns a proxy IP, your count is +// too LOW — a client can spoof their IP, fix immediately. If it returns +// "", your count is too HIGH — no leak, but no client IP either. // -// Like [ClientIPFromXFF], v4-mapped IPv6 folds to plain v4 and IPv6 zones -// are stripped before storage. +// This middleware reads ONLY X-Forwarded-For; it does not inspect +// r.RemoteAddr. Guarantee at the network layer (security group / firewall) +// that only your proxies can reach this server. +// +// If the XFF chain has fewer than numTrustedProxies entries, no client IP +// is set (fail-closed). Like [ClientIPFromXFF], v4-mapped IPv6 folds to v4 +// and IPv6 zones are stripped before storage. // // Panics at startup if numTrustedProxies < 1. func ClientIPFromXFFTrustedProxies(numTrustedProxies int) func(http.Handler) http.Handler { @@ -175,12 +219,8 @@ func ClientIPFromXFFTrustedProxies(numTrustedProxies int) func(http.Handler) htt // IPv6 zones are preserved (link-local connections may legitimately have one). func ClientIPFromRemoteAddr(h http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - host, _, err := net.SplitHostPort(r.RemoteAddr) - if err != nil { - host = r.RemoteAddr // RemoteAddr may already be a bare IP (e.g. in tests). - } - if ip, err := netip.ParseAddr(host); err == nil { - r = r.WithContext(context.WithValue(r.Context(), clientIPCtxKey, ip.Unmap())) + if ip, ok := parseRemoteAddr(r.RemoteAddr); ok { + r = r.WithContext(context.WithValue(r.Context(), clientIPCtxKey, ip)) } h.ServeHTTP(w, r) }) @@ -215,8 +255,7 @@ func GetClientIPAddr(ctx context.Context) netip.Addr { // comma-separated entries in order received — so an attacker cannot pick // which value security logic sees by sending a duplicate header. func walkXFF(headers []string, visit func(entry string) bool) { - for hi := len(headers) - 1; hi >= 0; hi-- { - h := headers[hi] + for _, h := range slices.Backward(headers) { for h != "" { var v string if i := strings.LastIndexByte(h, ','); i >= 0 { @@ -235,6 +274,45 @@ func walkXFF(headers []string, visit func(entry string) bool) { } } +// mustParsePrefixes parses CIDR prefixes, panicking on the first invalid one. +func mustParsePrefixes(cidrs []string) []netip.Prefix { + prefixes := make([]netip.Prefix, len(cidrs)) + for i, p := range cidrs { + prefixes[i] = netip.MustParsePrefix(p) + } + return prefixes +} + +// untrustedPeer reports whether the TCP peer in r.RemoteAddr is outside +// prefixes. If so, peer is the client IP, or invalid if RemoteAddr doesn't +// parse. With no prefixes, every peer is trusted. +func untrustedPeer(r *http.Request, prefixes []netip.Prefix) (peer netip.Addr, untrusted bool) { + if len(prefixes) == 0 { + return netip.Addr{}, false + } + peer, ok := parseRemoteAddr(r.RemoteAddr) + if !ok { + return netip.Addr{}, true + } + // Zoned addresses never match a prefix, so check a link-local peer without its zone. + return peer, !inAnyPrefix(peer.WithZone(""), prefixes) +} + +// parseRemoteAddr parses the host of r.RemoteAddr. v4-mapped IPv6 folds to +// plain v4; the IPv6 zone is preserved (link-local connections may +// legitimately have one). +func parseRemoteAddr(remoteAddr string) (netip.Addr, bool) { + host, _, err := net.SplitHostPort(remoteAddr) + if err != nil { + host = remoteAddr // RemoteAddr may already be a bare IP (e.g. in tests). + } + ip, err := netip.ParseAddr(host) + if err != nil { + return netip.Addr{}, false + } + return ip.Unmap(), true +} + // inAnyPrefix reports whether ip falls within any of the given prefixes. func inAnyPrefix(ip netip.Addr, prefixes []netip.Prefix) bool { for _, p := range prefixes { @@ -252,7 +330,7 @@ func inAnyPrefix(ip netip.Addr, prefixes []netip.Prefix) bool { // address, so without folding/stripping an attacker could escape an // otherwise valid trust list. // -// Header-sourced IPs only. [ClientIPFromRemoteAddr] normalizes inline +// Header-sourced IPs only. r.RemoteAddr goes through parseRemoteAddr // (Unmap, but zone preserved for legitimate link-local connections). func parseHeaderAddr(s string) (netip.Addr, bool) { ip, err := netip.ParseAddr(s) diff --git a/vendor/github.com/go-chi/chi/v5/middleware/compress.go b/vendor/github.com/go-chi/chi/v5/middleware/compress.go index 4e46f70..2b3653f 100644 --- a/vendor/github.com/go-chi/chi/v5/middleware/compress.go +++ b/vendor/github.com/go-chi/chi/v5/middleware/compress.go @@ -18,11 +18,16 @@ var defaultCompressibleContentTypes = []string{ "text/css", "text/plain", "text/javascript", + "text/markdown", + "text/csv", + "text/vtt", "application/javascript", "application/x-javascript", "application/json", "application/atom+xml", "application/rss+xml", + "application/xml", + "text/xml", "image/svg+xml", } @@ -60,6 +65,10 @@ type Compressor struct { // // The level should be one of the ones defined in the flate package. // The types are the content types that are allowed to be compressed. +// +// Catch-all wildcards ("*/*", "/*") are rejected: compressing every response +// wastes CPU on already-compressed types like zip, jpeg or png. Pass explicit +// types instead, e.g. "text/html" or "application/*". func NewCompressor(level int, types ...string) *Compressor { // If types are provided, set those as the allowed types. If none are // provided, use the default list. @@ -68,9 +77,12 @@ func NewCompressor(level int, types ...string) *Compressor { if len(types) > 0 { for _, t := range types { if strings.Contains(strings.TrimSuffix(t, "/*"), "*") { - panic(fmt.Sprintf("middleware/compress: Unsupported content-type wildcard pattern '%s'. Only '/*' supported", t)) + panic(fmt.Sprintf("middleware/compress: Unsupported content-type wildcard pattern '%s'. Only '/*' supported", t)) } if before, ok := strings.CutSuffix(t, "/*"); ok { + if before == "" { + panic(fmt.Sprintf("middleware/compress: Unsupported content-type wildcard pattern '%s'. Only '/*' supported", t)) + } allowedWildcards[before] = struct{}{} } else { allowedTypes[t] = struct{}{} @@ -136,13 +148,21 @@ func NewCompressor(level int, types ...string) *Compressor { // // For example, add the Brotli algorithm: // -// import brotli_enc "gopkg.in/kothar/brotli-go.v0/enc" +// import "github.com/google/brotli/go/cbrotli" // // compressor := middleware.NewCompressor(5, "text/html") // compressor.SetEncoder("br", func(w io.Writer, level int) io.Writer { -// params := brotli_enc.NewBrotliParams() -// params.SetQuality(level) -// return brotli_enc.NewBrotliWriter(params, w) +// return cbrotli.NewWriter(w, cbrotli.WriterOptions{Quality: level}) +// }) +// +// Alternatively, this Cgo-free Brotli module can be used: +// +// import "github.com/andybalholm/brotli" +// +// compressor := middleware.NewCompressor(5, "text/html") +// +// compressor.SetEncoder("br", func(w io.Writer, level int) io.Writer { +// return brotli.NewWriterV2(w, level) // }) func (c *Compressor) SetEncoder(encoding string, fn EncoderFunc) { encoding = strings.ToLower(encoding) @@ -162,7 +182,7 @@ func (c *Compressor) SetEncoder(encoding string, fn EncoderFunc) { encoder := fn(io.Discard, c.level) if _, ok := encoder.(ioResetterWriter); ok { pool := &sync.Pool{ - New: func() interface{} { + New: func() any { return fn(io.Discard, c.level) }, } diff --git a/vendor/github.com/go-chi/chi/v5/middleware/content_charset.go b/vendor/github.com/go-chi/chi/v5/middleware/content_charset.go index 8e75fe8..5577351 100644 --- a/vendor/github.com/go-chi/chi/v5/middleware/content_charset.go +++ b/vendor/github.com/go-chi/chi/v5/middleware/content_charset.go @@ -8,6 +8,7 @@ import ( // ContentCharset generates a handler that writes a 415 Unsupported Media Type response if none of the charsets match. // An empty charset will allow requests with no Content-Type header or no specified charset. +// Requests without a body (ContentLength == 0) are always allowed. func ContentCharset(charsets ...string) func(next http.Handler) http.Handler { for i, c := range charsets { charsets[i] = strings.ToLower(c) @@ -15,6 +16,11 @@ func ContentCharset(charsets ...string) func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.ContentLength == 0 { + next.ServeHTTP(w, r) + return + } + if !contentEncoding(r.Header.Get("Content-Type"), charsets...) { w.WriteHeader(http.StatusUnsupportedMediaType) return diff --git a/vendor/github.com/go-chi/chi/v5/middleware/logger.go b/vendor/github.com/go-chi/chi/v5/middleware/logger.go index 4d30a9a..72e735f 100644 --- a/vendor/github.com/go-chi/chi/v5/middleware/logger.go +++ b/vendor/github.com/go-chi/chi/v5/middleware/logger.go @@ -67,8 +67,8 @@ type LogFormatter interface { // LogEntry records the final log when a request completes. // See defaultLogEntry for an example implementation. type LogEntry interface { - Write(status, bytes int, header http.Header, elapsed time.Duration, extra interface{}) - Panic(v interface{}, stack []byte) + Write(status, bytes int, header http.Header, elapsed time.Duration, extra any) + Panic(v any, stack []byte) } // GetLogEntry returns the in-context LogEntry for a request. @@ -85,7 +85,7 @@ func WithLogEntry(r *http.Request, entry LogEntry) *http.Request { // LoggerInterface accepts printing to stdlib logger or compatible logger. type LoggerInterface interface { - Print(v ...interface{}) + Print(v ...any) } // DefaultLogFormatter is a simple logger that implements a LogFormatter. @@ -137,7 +137,7 @@ type defaultLogEntry struct { useColor bool } -func (l *defaultLogEntry) Write(status, bytes int, header http.Header, elapsed time.Duration, extra interface{}) { +func (l *defaultLogEntry) Write(status, bytes int, header http.Header, elapsed time.Duration, extra any) { switch { case status < 200: cW(l.buf, l.useColor, bBlue, "%03d", status) @@ -165,8 +165,8 @@ func (l *defaultLogEntry) Write(status, bytes int, header http.Header, elapsed t l.Logger.Print(l.buf.String()) } -func (l *defaultLogEntry) Panic(v interface{}, stack []byte) { - PrintPrettyStack(v) +func (l *defaultLogEntry) Panic(v any, stack []byte) { + printPrettyStack(v, l.useColor) } func init() { diff --git a/vendor/github.com/go-chi/chi/v5/middleware/profiler.go b/vendor/github.com/go-chi/chi/v5/middleware/profiler.go index 0ad6a99..5784ff6 100644 --- a/vendor/github.com/go-chi/chi/v5/middleware/profiler.go +++ b/vendor/github.com/go-chi/chi/v5/middleware/profiler.go @@ -1,5 +1,4 @@ //go:build !tinygo -// +build !tinygo package middleware diff --git a/vendor/github.com/go-chi/chi/v5/middleware/recoverer.go b/vendor/github.com/go-chi/chi/v5/middleware/recoverer.go index 81342df..e82de6b 100644 --- a/vendor/github.com/go-chi/chi/v5/middleware/recoverer.go +++ b/vendor/github.com/go-chi/chi/v5/middleware/recoverer.go @@ -51,10 +51,17 @@ func Recoverer(next http.Handler) http.Handler { // for ability to test the PrintPrettyStack function var recovererErrorWriter io.Writer = os.Stderr -func PrintPrettyStack(rvr interface{}) { +func PrintPrettyStack(rvr any) { + printPrettyStack(rvr, true) +} + +// printPrettyStack prints a formatted stack trace to stderr. When useColor is +// false, ANSI colour codes are suppressed, which is useful for terminals that +// do not support them (e.g. on Windows) or when output is being captured. +func printPrettyStack(rvr any, useColor bool) { debugStack := debug.Stack() s := prettyStack{} - out, err := s.parse(debugStack, rvr) + out, err := s.parse(debugStack, rvr, useColor) if err == nil { recovererErrorWriter.Write(out) } else { @@ -66,9 +73,8 @@ func PrintPrettyStack(rvr interface{}) { type prettyStack struct { } -func (s prettyStack) parse(debugStack []byte, rvr interface{}) ([]byte, error) { +func (s prettyStack) parse(debugStack []byte, rvr any, useColor bool) ([]byte, error) { var err error - useColor := true buf := &bytes.Buffer{} cW(buf, false, bRed, "\n") diff --git a/vendor/github.com/go-chi/chi/v5/middleware/terminal.go b/vendor/github.com/go-chi/chi/v5/middleware/terminal.go index 5ead7b9..5613318 100644 --- a/vendor/github.com/go-chi/chi/v5/middleware/terminal.go +++ b/vendor/github.com/go-chi/chi/v5/middleware/terminal.go @@ -52,7 +52,7 @@ func init() { } // colorWrite -func cW(w io.Writer, useColor bool, color []byte, s string, args ...interface{}) { +func cW(w io.Writer, useColor bool, color []byte, s string, args ...any) { if IsTTY && useColor { w.Write(color) } diff --git a/vendor/github.com/go-chi/chi/v5/middleware/value.go b/vendor/github.com/go-chi/chi/v5/middleware/value.go index a9dfd43..fbb9573 100644 --- a/vendor/github.com/go-chi/chi/v5/middleware/value.go +++ b/vendor/github.com/go-chi/chi/v5/middleware/value.go @@ -6,7 +6,7 @@ import ( ) // WithValue is a middleware that sets a given key/value in a context chain. -func WithValue(key, val interface{}) func(next http.Handler) http.Handler { +func WithValue(key, val any) func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler { fn := func(w http.ResponseWriter, r *http.Request) { r = r.WithContext(context.WithValue(r.Context(), key, val)) diff --git a/vendor/github.com/go-chi/chi/v5/middleware/wrap_writer.go b/vendor/github.com/go-chi/chi/v5/middleware/wrap_writer.go index b2de875..2fdd9c4 100644 --- a/vendor/github.com/go-chi/chi/v5/middleware/wrap_writer.go +++ b/vendor/github.com/go-chi/chi/v5/middleware/wrap_writer.go @@ -63,6 +63,7 @@ type WrapResponseWriter interface { Unwrap() http.ResponseWriter // Discard causes all writes to the original ResponseWriter be discarded, // instead writing only to the tee'd writer if it's set. + // Flush calls also do not reach the original ResponseWriter. // The caller is responsible for calling WriteHeader and Write on the // original ResponseWriter once the processing is done. Discard() @@ -119,6 +120,13 @@ func (b *basicWriter) maybeWriteHeader() { } } +func (b *basicWriter) flush() { + b.maybeWriteHeader() + if !b.discard { + b.ResponseWriter.(http.Flusher).Flush() + } +} + func (b *basicWriter) Status() int { return b.code } @@ -145,9 +153,7 @@ type flushWriter struct { } func (f *flushWriter) Flush() { - f.wroteHeader = true - fl := f.basicWriter.ResponseWriter.(http.Flusher) - fl.Flush() + f.basicWriter.flush() } var _ http.Flusher = &flushWriter{} @@ -170,9 +176,7 @@ type flushHijackWriter struct { } func (f *flushHijackWriter) Flush() { - f.wroteHeader = true - fl := f.basicWriter.ResponseWriter.(http.Flusher) - fl.Flush() + f.basicWriter.flush() } func (f *flushHijackWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { @@ -192,9 +196,7 @@ type httpFancyWriter struct { } func (f *httpFancyWriter) Flush() { - f.wroteHeader = true - fl := f.basicWriter.ResponseWriter.(http.Flusher) - fl.Flush() + f.basicWriter.flush() } func (f *httpFancyWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { @@ -207,10 +209,12 @@ func (f *http2FancyWriter) Push(target string, opts *http.PushOptions) error { } func (f *httpFancyWriter) ReadFrom(r io.Reader) (int64, error) { - if f.basicWriter.tee != nil { - // Route through basicWriter.Write so that data is also written to the - // tee writer. basicWriter.Write already increments basicWriter.bytes, - // so we must NOT add n again here (that would double-count). + if f.basicWriter.tee != nil || f.basicWriter.discard { + // Route through basicWriter.Write so that the tee and discard semantics + // are honored (the fast ReaderFrom path below would bypass both, writing + // straight to the original ResponseWriter). basicWriter.Write already + // increments basicWriter.bytes, so we must NOT add n again here (that + // would double-count). n, err := io.Copy(&f.basicWriter, r) return n, err } @@ -235,9 +239,7 @@ type http2FancyWriter struct { } func (f *http2FancyWriter) Flush() { - f.wroteHeader = true - fl := f.basicWriter.ResponseWriter.(http.Flusher) - fl.Flush() + f.basicWriter.flush() } var _ http.Flusher = &http2FancyWriter{} diff --git a/vendor/github.com/go-chi/chi/v5/mux.go b/vendor/github.com/go-chi/chi/v5/mux.go index 3da7f3f..7cfc614 100644 --- a/vendor/github.com/go-chi/chi/v5/mux.go +++ b/vendor/github.com/go-chi/chi/v5/mux.go @@ -51,7 +51,7 @@ type Mux struct { // interface. func NewMux() *Mux { mux := &Mux{tree: &node{}, pool: &sync.Pool{}} - mux.pool.New = func() interface{} { + mux.pool.New = func() any { return NewRouteContext() } return mux @@ -186,6 +186,12 @@ func (mx *Mux) Put(pattern string, handlerFn http.HandlerFunc) { mx.handle(mPUT, pattern, handlerFn) } +// Query adds the route `pattern` that matches a QUERY http method to +// execute the `handlerFn` http.HandlerFunc. +func (mx *Mux) Query(pattern string, handlerFn http.HandlerFunc) { + mx.handle(mQUERY, pattern, handlerFn) +} + // Trace adds the route `pattern` that matches a TRACE http method to // execute the `handlerFn` http.HandlerFunc. func (mx *Mux) Trace(pattern string, handlerFn http.HandlerFunc) { @@ -299,6 +305,14 @@ func (mx *Mux) Mount(pattern string, handler http.Handler) { // Assign sub-Router's with the parent not found & method not allowed handler if not specified. subr, ok := handler.(*Mux) + + // Provide runtime safety for ensuring a router isn't mounted onto itself, + // which includes an inline mux derived from it via With()/Group() since + // that shares the same underlying tree. See issue #843. + if ok && subr.tree == mx.tree { + panic(fmt.Sprintf("chi: attempting to Mount() a router onto itself on '%s'", pattern)) + } + if ok && subr.notFoundHandler == nil && mx.notFoundHandler != nil { subr.NotFound(mx.notFoundHandler) } diff --git a/vendor/github.com/go-chi/chi/v5/tree.go b/vendor/github.com/go-chi/chi/v5/tree.go index 95f31d4..af4052c 100644 --- a/vendor/github.com/go-chi/chi/v5/tree.go +++ b/vendor/github.com/go-chi/chi/v5/tree.go @@ -7,6 +7,7 @@ package chi import ( "fmt" "net/http" + "reflect" "regexp" "slices" "sort" @@ -26,11 +27,17 @@ const ( mPATCH mPOST mPUT + mQUERY mTRACE ) var mALL = mCONNECT | mDELETE | mGET | mHEAD | - mOPTIONS | mPATCH | mPOST | mPUT | mTRACE + mOPTIONS | mPATCH | mPOST | mPUT | mQUERY | mTRACE + +// methodQuery is the HTTP QUERY method (RFC 10008), a safe, idempotent +// method that conveys a request body. It is defined here until net/http +// provides an equivalent constant, at which point this is a 1-1 swap. +const methodQuery = "QUERY" var methodMap = map[string]methodTyp{ http.MethodConnect: mCONNECT, @@ -41,6 +48,7 @@ var methodMap = map[string]methodTyp{ http.MethodPatch: mPATCH, http.MethodPost: mPOST, http.MethodPut: mPUT, + methodQuery: mQUERY, http.MethodTrace: mTRACE, } @@ -53,6 +61,7 @@ var reverseMethodMap = map[methodTyp]string{ mPATCH: http.MethodPatch, mPOST: http.MethodPost, mPUT: http.MethodPut, + mQUERY: methodQuery, mTRACE: http.MethodTrace, } @@ -470,7 +479,9 @@ func (n *node) findRoute(rctx *Context, method methodTyp, path string) *node { if endpoints == mALL || endpoints == mSTUB { continue } - rctx.methodsAllowed = append(rctx.methodsAllowed, endpoints) + if !slices.Contains(rctx.methodsAllowed, endpoints) { + rctx.methodsAllowed = append(rctx.methodsAllowed, endpoints) + } } // flag that the routing context found a route, but not a corresponding @@ -516,7 +527,9 @@ func (n *node) findRoute(rctx *Context, method methodTyp, path string) *node { if endpoints == mALL || endpoints == mSTUB { continue } - rctx.methodsAllowed = append(rctx.methodsAllowed, endpoints) + if !slices.Contains(rctx.methodsAllowed, endpoints) { + rctx.methodsAllowed = append(rctx.methodsAllowed, endpoints) + } } // flag that the routing context found a route, but not a corresponding @@ -621,8 +634,10 @@ func (n *node) routes() []Route { rts := []Route{} n.walk(func(eps endpoints, subroutes Routes) bool { - if eps[mSTUB] != nil && eps[mSTUB].handler != nil && subroutes == nil { - return false + // Hide Mount()'s stub handler, but not a real handler sharing its pattern. + var stubHandler http.Handler + if eps[mSTUB] != nil { + stubHandler = eps[mSTUB].handler } // Group methodHandlers by unique patterns @@ -642,12 +657,17 @@ func (n *node) routes() []Route { for p, mh := range pats { hs := make(map[string]http.Handler) + + // Walk() reads Handlers["*"] for With() middleware when recursing + // into a subroute, so keep it there even if it's also the stub. if mh[mALL] != nil && mh[mALL].handler != nil { - hs["*"] = mh[mALL].handler + if subroutes != nil || !equalHandlers(mh[mALL].handler, stubHandler) { + hs["*"] = mh[mALL].handler + } } for mt, h := range mh { - if h.handler == nil { + if h.handler == nil || equalHandlers(h.handler, stubHandler) { continue } if m, ok := reverseMethodMap[mt]; ok { @@ -655,6 +675,11 @@ func (n *node) routes() []Route { } } + // Keep subroute nodes so Walk() can recurse; a stub-only leaf has nothing to report. + if len(hs) == 0 && subroutes == nil { + continue + } + rt := Route{subroutes, hs, p} rts = append(rts, rt) } @@ -665,6 +690,29 @@ func (n *node) routes() []Route { return rts } +// equalHandlers reports whether a and b are the same handler value. Handlers +// are commonly funcs (e.g. http.HandlerFunc), and a direct == on those +// panics at runtime, so funcs are compared by pointer instead. +func equalHandlers(a, b http.Handler) bool { + if a == nil || b == nil { + return a == b + } + + av := reflect.ValueOf(a) + bv := reflect.ValueOf(b) + if av.Type() != bv.Type() { + return false + } + + if av.Kind() == reflect.Func { + return av.Pointer() == bv.Pointer() + } + if av.Type().Comparable() { + return a == b + } + return false +} + func (n *node) walk(fn func(eps endpoints, subroutes Routes) bool) bool { // Visit the leaf values if any if (n.endpoints != nil || n.subroutes != nil) && fn(n.endpoints, n.subroutes) { @@ -791,8 +839,8 @@ func (ns nodes) Less(i, j int) bool { return ns[i].label < ns[j].label } // tailSort pushes nodes with '/' as the tail to the end of the list for param nodes. // The list order determines the traversal order. func (ns nodes) tailSort() { - for i := len(ns) - 1; i >= 0; i-- { - if ns[i].typ > ntStatic && ns[i].tail == '/' { + for i, n := range slices.Backward(ns) { + if n.typ > ntStatic && n.tail == '/' { ns.Swap(i, len(ns)-1) return } diff --git a/vendor/modules.txt b/vendor/modules.txt index 756caf2..b30ce86 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -41,8 +41,8 @@ github.com/bwmarrin/discordgo github.com/distrobyte/multielo github.com/distrobyte/multielo/domain github.com/distrobyte/multielo/infrastructure -# github.com/go-chi/chi/v5 v5.3.0 -## explicit; go 1.23 +# github.com/go-chi/chi/v5 v5.4.0 +## explicit; go 1.24 github.com/go-chi/chi/v5 github.com/go-chi/chi/v5/middleware # github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0