diff --git a/go.mod b/go.mod
index b2d77df8..5c0a73da 100644
--- a/go.mod
+++ b/go.mod
@@ -16,7 +16,7 @@ require (
github.com/fsnotify/fsnotify v1.10.1
github.com/goccy/go-json v0.11.2
github.com/hashicorp/golang-lru/v2 v2.0.7
- github.com/labstack/echo/v4 v4.16.0
+ github.com/labstack/echo/v4 v4.16.2
github.com/matrix-org/gomatrixserverlib v0.0.0-20260716140101-4fe595dc7f58
github.com/mattevans/postmark-go v1.0.0
github.com/microcosm-cc/bluemonday v1.0.27
diff --git a/go.sum b/go.sum
index ea465369..74d14e4b 100644
--- a/go.sum
+++ b/go.sum
@@ -124,6 +124,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/labstack/echo/v4 v4.16.0 h1:cFqqpqVNmSVyn4nvsXHp5rU4aVLYG3hx4fGWc3FngBk=
github.com/labstack/echo/v4 v4.16.0/go.mod h1:VHAohjgM63iiTVI6EahEDjtRhQNXCMXFp0TMeIsFuW0=
+github.com/labstack/echo/v4 v4.16.2 h1:WFPVqfogOZGH4dTr1UCIB1sqjFIQ/k7vIk5ByL6oWjY=
+github.com/labstack/echo/v4 v4.16.2/go.mod h1:V/EQaTVHYJH92QhbQe7kFQ4mjgcjxLQQUQtAN87zZdw=
github.com/labstack/gommon v0.5.0 h1:6VSQ2NOzsnEJ5W6+84E0RbcaDDmgB6NIAzWCczTEe6c=
github.com/labstack/gommon v0.5.0/go.mod h1:Rzlg7HHy1maLfzBYGg9NZcVuz1sA68HHhLjhcEllYE0=
github.com/matrix-org/gomatrix v0.0.0-20220926102614-ceba4d9f7530 h1:kHKxCOLcHH8r4Fzarl4+Y3K5hjothkVW5z7T1dUM11U=
diff --git a/vendor/github.com/labstack/echo/v4/README.md b/vendor/github.com/labstack/echo/v4/README.md
index 5e52d1d4..49040fd7 100644
--- a/vendor/github.com/labstack/echo/v4/README.md
+++ b/vendor/github.com/labstack/echo/v4/README.md
@@ -36,6 +36,13 @@ Help and questions: [Github Discussions](https://github.com/labstack/echo/discus
## Sponsors
+
+
@@ -54,7 +61,15 @@ Click [here](https://github.com/sponsors/labstack) for more information on spons
// go get github.com/labstack/echo/{version}
go get github.com/labstack/echo/v4
```
-Latest version of Echo supports last four Go major [releases](https://go.dev/doc/devel/release) and might work with older versions.
+Echo v4.16.1 requires Go 1.26 or later so it can use patched `golang.org/x/` dependencies. Use a supported, patched Go toolchain when building applications.
+
+### Security defaults in v4.16.1
+
+`BodyDump` captures at most 5 MiB of the request and 5 MiB of the response by default. The application and client still receive the complete bodies. Configure `BodyDumpConfig.MaxRequestBytes` and `MaxResponseBytes` to change the capture limits; `-1` disables a limit.
+
+`Decompress` limits decompressed request bodies to 100 MiB by default. Reading beyond the limit returns HTTP 413, including through JSON and XML binding. Configure `DecompressConfig.MaxDecompressedSize` to change the limit; `-1` disables it. Apply any smaller application body limit after decompression.
+
+The proxy now validates WebSocket upgrade responses, closes both WebSocket connections when either side disconnects, and removes incoming `Forwarded` headers. Backends should use the proxy's validated forwarding headers and enforce their own authorization.
### Example
@@ -99,6 +114,7 @@ Following list of middleware is maintained by Echo team.
|------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| [github.com/labstack/echo-jwt](https://github.com/labstack/echo-jwt) | [JWT](https://github.com/golang-jwt/jwt) middleware |
| [github.com/labstack/echo-contrib](https://github.com/labstack/echo-contrib) | [casbin](https://github.com/casbin/casbin), [gorilla/sessions](https://github.com/gorilla/sessions), [jaegertracing](https://github.com/uber/jaeger-client-go), [prometheus](https://github.com/prometheus/client_golang/), [pprof](https://pkg.go.dev/net/http/pprof), [zipkin](https://github.com/openzipkin/zipkin-go) middlewares |
+| [github.com/labstack/echo-otel](https://github.com/labstack/echo-otel/tree/v4) | [OpenTelemetry](https://opentelemetry.io/) middleware for tracing and metrics (`github.com/labstack/echo-otel/v4`) |
# Third-party middleware repositories
diff --git a/vendor/github.com/labstack/echo/v4/bind.go b/vendor/github.com/labstack/echo/v4/bind.go
index 1d4fe6f0..b9cffab0 100644
--- a/vendor/github.com/labstack/echo/v4/bind.go
+++ b/vendor/github.com/labstack/echo/v4/bind.go
@@ -22,6 +22,13 @@ type Binder interface {
}
// DefaultBinder is the default implementation of the Binder interface.
+// For path, query, header, and form binding, time.Time and *time.Time fields (not slices)
+// may use a format tag: "date-time" uses the standard RFC3339 decoding, "date-time-local"
+// accepts "2006-01-02T15:04:05" with optional fractional seconds, "date" accepts
+// "2006-01-02", and other values specify Go time layouts. "date", "date-time-local", and
+// layouts without a timezone give UTC times. JSON and XML decoding do not use this tag.
+// HTML datetime-local inputs omit seconds by default, so bind them with a Go layout
+// such as "2006-01-02T15:04" instead of "date-time-local".
type DefaultBinder struct{}
// BindUnmarshaler is the interface used to wrap the UnmarshalParam method.
@@ -42,10 +49,11 @@ type bindMultipleUnmarshaler interface {
// BindPathParams binds path params to bindable object
//
// Time format support: time.Time fields can use `format` tags to specify custom parsing layouts.
-// Example: `param:"created" format:"2006-01-02T15:04"` for datetime-local format
+// Example: `form:"created" format:"2006-01-02T15:04"` for HTML datetime-local input
// Example: `param:"date" format:"2006-01-02"` for date format
// Uses Go's standard time format reference time: Mon Jan 2 15:04:05 MST 2006
-// Works with form data, query parameters, and path parameters (not JSON body)
+// OpenAPI format names "date-time", "date-time-local" and "date" are also supported (see DefaultBinder)
+// Works with form data, query parameters, headers, and path parameters (not JSON body)
// Falls back to default time.Time parsing if no format tag is specified
func (b *DefaultBinder) BindPathParams(c Context, i interface{}) error {
names := c.ParamNames()
@@ -95,6 +103,10 @@ func (b *DefaultBinder) BindBody(c Context, i interface{}) (err error) {
}
case MIMEApplicationXML, MIMETextXML:
if err = xml.NewDecoder(req.Body).Decode(i); err != nil {
+ var httpError *HTTPError
+ if errors.As(err, &httpError) {
+ return httpError
+ }
if ute, ok := err.(*xml.UnsupportedTypeError); ok {
return NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Unsupported type error: type=%v, error=%v", ute.Type, ute.Error())).SetInternal(err)
} else if se, ok := err.(*xml.SyntaxError); ok {
@@ -105,6 +117,10 @@ func (b *DefaultBinder) BindBody(c Context, i interface{}) (err error) {
case MIMEApplicationForm:
params, err := c.FormParams()
if err != nil {
+ var httpError *HTTPError
+ if errors.As(err, &httpError) {
+ return httpError
+ }
return NewHTTPError(http.StatusBadRequest, err.Error()).SetInternal(err)
}
if err = b.bindData(i, params, "form", nil); err != nil {
@@ -113,6 +129,10 @@ func (b *DefaultBinder) BindBody(c Context, i interface{}) (err error) {
case MIMEMultipartForm:
params, err := c.MultipartForm()
if err != nil {
+ var httpError *HTTPError
+ if errors.As(err, &httpError) {
+ return httpError
+ }
return NewHTTPError(http.StatusBadRequest, err.Error()).SetInternal(err)
}
if err = b.bindData(i, params.Value, "form", params.File); err != nil {
@@ -375,10 +395,19 @@ func unmarshalInputToField(valueKind reflect.Kind, val string, field reflect.Val
fieldIValue := field.Addr().Interface()
- // Handle time.Time with custom format tag
- if formatTag != "" {
+ // date-time uses the same TextUnmarshaler as an untagged time.Time.
+ if formatTag != "" && formatTag != "date-time" {
if _, isTime := fieldIValue.(*time.Time); isTime {
- t, err := time.Parse(formatTag, val)
+ // OpenAPI dates and local date-times have no timezone. time.Parse assigns UTC,
+ // as it does for custom layouts without timezone information.
+ layout := formatTag
+ switch formatTag {
+ case "date":
+ layout = "2006-01-02"
+ case "date-time-local":
+ layout = "2006-01-02T15:04:05"
+ }
+ t, err := time.Parse(layout, val)
if err != nil {
return true, err
}
diff --git a/vendor/github.com/labstack/echo/v4/echo.go b/vendor/github.com/labstack/echo/v4/echo.go
index 85a467e7..99debe7a 100644
--- a/vendor/github.com/labstack/echo/v4/echo.go
+++ b/vendor/github.com/labstack/echo/v4/echo.go
@@ -285,7 +285,7 @@ const (
const (
// Version of Echo
- Version = "4.16.0"
+ Version = "4.16.2"
website = "https://echo.labstack.com"
// http://patorjk.com/software/taag/#p=display&f=Small%20Slant&t=Echo
banner = `
@@ -860,6 +860,8 @@ func (e *Echo) TLSListenerAddr() net.Addr {
}
// StartH2CServer starts a custom http/2 server with h2c (HTTP/2 Cleartext).
+//
+//lint:ignore SA1019 Preserve the v4 public API through its supported lifetime; x/net remains security patched.
func (e *Echo) StartH2CServer(address string, h2s *http2.Server) error {
e.startupMutex.Lock()
// Setup
diff --git a/vendor/github.com/labstack/echo/v4/echo_fs.go b/vendor/github.com/labstack/echo/v4/echo_fs.go
index cd96a547..e0905194 100644
--- a/vendor/github.com/labstack/echo/v4/echo_fs.go
+++ b/vendor/github.com/labstack/echo/v4/echo_fs.go
@@ -211,8 +211,8 @@ func escapeControlChars(s string) string {
return b.String()
}
-// hasDotOrEmptySegment reports whether URL path p has a ".", ".." or empty segment. A single leading and a single
-// trailing slash are allowed.
+// hasDotOrEmptySegment reports whether URL path p has a ".", ".." or empty segment, or a segment with a "." or ".."
+// part between backslashes (e.g. `..\x`). A single leading and a single trailing slash are allowed.
// Keep in sync with the copy in middleware/static.go.
func hasDotOrEmptySegment(p string) bool {
p = strings.TrimPrefix(p, "/")
@@ -224,6 +224,15 @@ func hasDotOrEmptySegment(p string) bool {
if segment == "" || segment == "." || segment == ".." {
return true
}
+ // A backslash is a literal character in fs.FS names, but a filesystem that wrongly treats it as a separator
+ // (for example one built on filepath.Join on Windows) would resolve `..\` outside its root.
+ if strings.Contains(segment, `\`) {
+ for part := range strings.SplitSeq(segment, `\`) {
+ if part == "." || part == ".." {
+ return true
+ }
+ }
+ }
}
return false
}
diff --git a/vendor/github.com/labstack/echo/v4/middleware/basic_auth.go b/vendor/github.com/labstack/echo/v4/middleware/basic_auth.go
index 4a46098e..f92a318e 100644
--- a/vendor/github.com/labstack/echo/v4/middleware/basic_auth.go
+++ b/vendor/github.com/labstack/echo/v4/middleware/basic_auth.go
@@ -78,7 +78,7 @@ func BasicAuthWithConfig(config BasicAuthConfig) echo.MiddlewareFunc {
auth := c.Request().Header.Get(echo.HeaderAuthorization)
l := len(basic)
- if len(auth) > l+1 && strings.EqualFold(auth[:l], basic) {
+ if len(auth) > l+1 && auth[l] == ' ' && strings.EqualFold(auth[:l], basic) {
// Invalid base64 shouldn't be treated as error
// instead should be treated as invalid client input
b, err := base64.StdEncoding.DecodeString(auth[l+1:])
diff --git a/vendor/github.com/labstack/echo/v4/middleware/body_dump.go b/vendor/github.com/labstack/echo/v4/middleware/body_dump.go
index add778d6..96350319 100644
--- a/vendor/github.com/labstack/echo/v4/middleware/body_dump.go
+++ b/vendor/github.com/labstack/echo/v4/middleware/body_dump.go
@@ -22,6 +22,14 @@ type BodyDumpConfig struct {
// Handler receives request and response payload.
// Required.
Handler BodyDumpHandler
+
+ // MaxRequestBytes limits captured request bytes, without truncating the body
+ // delivered to the next handler. Defaults to 5 MiB; -1 disables the limit.
+ MaxRequestBytes int64
+
+ // MaxResponseBytes limits captured response bytes, without truncating the
+ // response delivered to the client. Defaults to 5 MiB; -1 disables the limit.
+ MaxResponseBytes int64
}
// BodyDumpHandler receives the request and response payload.
@@ -57,6 +65,12 @@ func BodyDumpWithConfig(config BodyDumpConfig) echo.MiddlewareFunc {
if config.Skipper == nil {
config.Skipper = DefaultBodyDumpConfig.Skipper
}
+ if config.MaxRequestBytes == 0 {
+ config.MaxRequestBytes = 5 * 1024 * 1024
+ }
+ if config.MaxResponseBytes == 0 {
+ config.MaxResponseBytes = 5 * 1024 * 1024
+ }
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) (err error) {
@@ -67,17 +81,30 @@ func BodyDumpWithConfig(config BodyDumpConfig) echo.MiddlewareFunc {
// Request
reqBody := []byte{}
if c.Request().Body != nil {
+ originalBody := c.Request().Body
+ var reader io.Reader = originalBody
+ if config.MaxRequestBytes > 0 {
+ reader = io.LimitReader(reader, config.MaxRequestBytes)
+ }
var readErr error
- reqBody, readErr = io.ReadAll(c.Request().Body)
+ reqBody, readErr = io.ReadAll(reader)
if readErr != nil {
return readErr
}
+ c.Request().Body = struct {
+ io.Reader
+ io.Closer
+ }{Reader: io.MultiReader(bytes.NewReader(reqBody), originalBody), Closer: originalBody}
+ } else {
+ c.Request().Body = http.NoBody
}
- c.Request().Body = io.NopCloser(bytes.NewBuffer(reqBody)) // Reset
// Response
resBody := new(bytes.Buffer)
- mw := io.MultiWriter(c.Response().Writer, resBody)
+ var mw io.Writer = io.MultiWriter(c.Response().Writer, resBody)
+ if config.MaxResponseBytes > 0 {
+ mw = &bodyDumpLimitedWriter{writer: c.Response().Writer, buffer: resBody, remaining: config.MaxResponseBytes}
+ }
writer := &bodyDumpResponseWriter{Writer: mw, ResponseWriter: c.Response().Writer}
c.Response().Writer = writer
@@ -93,6 +120,22 @@ func BodyDumpWithConfig(config BodyDumpConfig) echo.MiddlewareFunc {
}
}
+type bodyDumpLimitedWriter struct {
+ writer io.Writer
+ buffer *bytes.Buffer
+ remaining int64
+}
+
+func (w *bodyDumpLimitedWriter) Write(p []byte) (int, error) {
+ n, err := w.writer.Write(p)
+ if w.remaining > 0 {
+ captured := min(int64(n), w.remaining)
+ _, _ = w.buffer.Write(p[:captured])
+ w.remaining -= captured
+ }
+ return n, err
+}
+
func (w *bodyDumpResponseWriter) WriteHeader(code int) {
w.ResponseWriter.WriteHeader(code)
}
diff --git a/vendor/github.com/labstack/echo/v4/middleware/body_limit.go b/vendor/github.com/labstack/echo/v4/middleware/body_limit.go
index e48aff61..35a6a182 100644
--- a/vendor/github.com/labstack/echo/v4/middleware/body_limit.go
+++ b/vendor/github.com/labstack/echo/v4/middleware/body_limit.go
@@ -6,8 +6,6 @@ package middleware
import (
"fmt"
"io"
- "net/http"
- "sync"
"github.com/labstack/echo/v4"
"github.com/labstack/gommon/bytes"
@@ -28,6 +26,7 @@ type limitedReader struct {
BodyLimitConfig
reader io.ReadCloser
read int64
+ err error
}
// DefaultBodyLimitConfig is the default BodyLimit middleware config.
@@ -58,11 +57,10 @@ func BodyLimitWithConfig(config BodyLimitConfig) echo.MiddlewareFunc {
}
limit, err := bytes.Parse(config.Limit)
- if err != nil {
+ if err != nil || limit < 0 {
panic(fmt.Errorf("echo: invalid body-limit=%s", config.Limit))
}
config.limit = limit
- pool := limitedReaderPool(config)
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
@@ -78,13 +76,7 @@ func BodyLimitWithConfig(config BodyLimitConfig) echo.MiddlewareFunc {
}
// Based on content read
- r, ok := pool.Get().(*limitedReader)
- if !ok {
- return echo.NewHTTPError(http.StatusInternalServerError, "invalid pool object")
- }
- r.Reset(req.Body)
- defer pool.Put(r)
- req.Body = r
+ req.Body = &limitedReader{BodyLimitConfig: config, reader: req.Body}
return next(c)
}
@@ -92,33 +84,35 @@ func BodyLimitWithConfig(config BodyLimitConfig) echo.MiddlewareFunc {
}
func (r *limitedReader) Read(b []byte) (n int, err error) {
- if r.limit > 0 && r.read > r.limit {
- return 0, echo.ErrStatusRequestEntityTooLarge
+ // A zero limit has historically disabled the read limit in v4.
+ if r.limit == 0 {
+ return r.reader.Read(b)
+ }
+ if r.err != nil {
+ return 0, r.err
+ }
+ if len(b) == 0 {
+ return 0, nil
}
+ remaining := r.limit - r.read
+ // Read at most one byte beyond the limit to distinguish an exact-size body
+ // from an oversized one without exposing the extra byte to the caller.
+ // Unlike http.MaxBytesReader, this reader does not signal net/http to close
+ // the connection after the limit is exceeded.
+ if int64(len(b)) > remaining {
+ b = b[:remaining+1]
+ }
n, err = r.reader.Read(b)
- r.read += int64(n)
-
- if r.limit > 0 && r.read > r.limit {
- return n, echo.ErrStatusRequestEntityTooLarge
+ if int64(n) > remaining {
+ r.read = r.limit
+ r.err = echo.ErrStatusRequestEntityTooLarge
+ return int(remaining), r.err
}
-
+ r.read += int64(n)
return n, err
}
func (r *limitedReader) Close() error {
return r.reader.Close()
}
-
-func (r *limitedReader) Reset(reader io.ReadCloser) {
- r.reader = reader
- r.read = 0
-}
-
-func limitedReaderPool(c BodyLimitConfig) sync.Pool {
- return sync.Pool{
- New: func() interface{} {
- return &limitedReader{BodyLimitConfig: c}
- },
- }
-}
diff --git a/vendor/github.com/labstack/echo/v4/middleware/csrf.go b/vendor/github.com/labstack/echo/v4/middleware/csrf.go
index 1a35da63..60d31682 100644
--- a/vendor/github.com/labstack/echo/v4/middleware/csrf.go
+++ b/vendor/github.com/labstack/echo/v4/middleware/csrf.go
@@ -5,6 +5,7 @@ package middleware
import (
"crypto/subtle"
+ "errors"
"net/http"
"slices"
"strings"
@@ -165,6 +166,9 @@ func (config CSRFConfig) ToMiddleware() (echo.MiddlewareFunc, error) {
if cErr != nil {
return nil, cErr
}
+ if len(extractors) == 0 {
+ return nil, errors.New("echo csrf middleware could not create extractors from TokenLookup string")
+ }
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
@@ -268,6 +272,9 @@ func (config CSRFConfig) ToMiddleware() (echo.MiddlewareFunc, error) {
}
func validateCSRFToken(token, clientToken string) bool {
+ if token == "" || clientToken == "" {
+ return false
+ }
return subtle.ConstantTimeCompare([]byte(token), []byte(clientToken)) == 1
}
diff --git a/vendor/github.com/labstack/echo/v4/middleware/decompress.go b/vendor/github.com/labstack/echo/v4/middleware/decompress.go
index 0c56176e..0920f1df 100644
--- a/vendor/github.com/labstack/echo/v4/middleware/decompress.go
+++ b/vendor/github.com/labstack/echo/v4/middleware/decompress.go
@@ -19,6 +19,10 @@ type DecompressConfig struct {
// GzipDecompressPool defines an interface to provide the sync.Pool used to create/store Gzip readers
GzipDecompressPool Decompressor
+
+ // MaxDecompressedSize limits the decompressed body in bytes. Reading beyond
+ // the limit returns HTTP 413. Defaults to 100 MiB; -1 disables the limit.
+ MaxDecompressedSize int64
}
// GZIPEncoding content-encoding header if set to "gzip", decompress body contents.
@@ -57,6 +61,9 @@ func DecompressWithConfig(config DecompressConfig) echo.MiddlewareFunc {
if config.GzipDecompressPool == nil {
config.GzipDecompressPool = DefaultDecompressConfig.GzipDecompressPool
}
+ if config.MaxDecompressedSize == 0 {
+ config.MaxDecompressedSize = 100 * 1024 * 1024
+ }
return func(next echo.HandlerFunc) echo.HandlerFunc {
pool := config.GzipDecompressPool.gzipDecompressPool()
@@ -90,9 +97,43 @@ func DecompressWithConfig(config DecompressConfig) echo.MiddlewareFunc {
// only Close gzip reader if it was set to a proper gzip source otherwise it will panic on close.
defer gr.Close()
- c.Request().Body = gr
+ if config.MaxDecompressedSize > 0 {
+ c.Request().Body = &limitedGzipReader{Reader: gr, remaining: config.MaxDecompressedSize}
+ } else {
+ c.Request().Body = gr
+ }
+ c.Request().ContentLength = -1
return next(c)
}
}
}
+
+type limitedGzipReader struct {
+ *gzip.Reader
+ remaining int64
+}
+
+func (r *limitedGzipReader) Read(p []byte) (int, error) {
+ if len(p) == 0 {
+ return 0, nil
+ }
+ if r.remaining < 0 {
+ return 0, echo.ErrStatusRequestEntityTooLarge
+ }
+ if r.remaining == 0 {
+ var probe [1]byte
+ n, err := r.Reader.Read(probe[:])
+ if n > 0 {
+ r.remaining = -1
+ return 0, echo.ErrStatusRequestEntityTooLarge
+ }
+ return 0, err
+ }
+ if int64(len(p)) > r.remaining {
+ p = p[:r.remaining]
+ }
+ n, err := r.Reader.Read(p)
+ r.remaining -= int64(n)
+ return n, err
+}
diff --git a/vendor/github.com/labstack/echo/v4/middleware/proxy.go b/vendor/github.com/labstack/echo/v4/middleware/proxy.go
index 2c7c333e..a619e9e2 100644
--- a/vendor/github.com/labstack/echo/v4/middleware/proxy.go
+++ b/vendor/github.com/labstack/echo/v4/middleware/proxy.go
@@ -4,8 +4,8 @@
package middleware
import (
+ "bufio"
"context"
- "crypto/tls"
"fmt"
"io"
"math/rand"
@@ -21,8 +21,6 @@ import (
"github.com/labstack/echo/v4"
)
-// TODO: Handle TLS proxy
-
// ProxyConfig defines the config for Proxy middleware.
type ProxyConfig struct {
// Skipper defines a function to skip middleware.
@@ -131,63 +129,6 @@ var DefaultProxyConfig = ProxyConfig{
ContextKey: "target",
}
-func proxyRaw(t *ProxyTarget, c echo.Context, config ProxyConfig) http.Handler {
- var dialFunc func(ctx context.Context, network, addr string) (net.Conn, error)
- if transport, ok := config.Transport.(*http.Transport); ok {
- if transport.TLSClientConfig != nil {
- d := tls.Dialer{
- Config: transport.TLSClientConfig,
- }
- dialFunc = d.DialContext
- }
- }
- if dialFunc == nil {
- var d net.Dialer
- dialFunc = d.DialContext
- }
-
- return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- in, _, err := c.Response().Hijack()
- if err != nil {
- c.Set("_error", fmt.Errorf("proxy raw, hijack error=%w, url=%s", err, t.URL))
- return
- }
- defer in.Close()
- out, err := dialFunc(c.Request().Context(), "tcp", t.URL.Host)
- if err != nil {
- c.Set("_error", echo.NewHTTPError(http.StatusBadGateway, fmt.Sprintf("proxy raw, dial error=%v, url=%s", err, t.URL)))
- return
- }
- defer out.Close()
-
- // Write header
- err = r.Write(out)
- if err != nil {
- c.Set("_error", echo.NewHTTPError(http.StatusBadGateway, fmt.Sprintf("proxy raw, request header copy error=%v, url=%s", err, t.URL)))
- return
- }
-
- errCh := make(chan error, 2)
- cp := func(dst io.Writer, src io.Reader) {
- _, copyErr := io.Copy(dst, src)
- errCh <- copyErr
- }
-
- go cp(out, in)
- go cp(in, out)
-
- // Wait for BOTH goroutines to complete
- err1 := <-errCh
- err2 := <-errCh
-
- if err1 != nil && err1 != io.EOF {
- c.Set("_error", fmt.Errorf("proxy raw, copy body error=%w, url=%s", err1, t.URL))
- } else if err2 != nil && err2 != io.EOF {
- c.Set("_error", fmt.Errorf("proxy raw, copy body error=%w, url=%s", err2, t.URL))
- }
- })
-}
-
// NewRandomBalancer returns a random proxy balancer.
func NewRandomBalancer(targets []*ProxyTarget) ProxyBalancer {
b := randomBalancer{}
@@ -291,6 +232,8 @@ func (b *roundRobinBalancer) Next(c echo.Context) *ProxyTarget {
// Proxy returns a Proxy middleware.
//
// Proxy middleware forwards the request to upstream server using a configured load balancing technique.
+// The unvalidated Forwarded header is removed. Configure Echo#IPExtractor to
+// validate proxy-supplied client identities before trusting X-Real-IP upstream.
func Proxy(balancer ProxyBalancer) echo.MiddlewareFunc {
c := DefaultProxyConfig
c.Balancer = balancer
@@ -357,9 +300,8 @@ func ProxyWithConfig(config ProxyConfig) echo.MiddlewareFunc {
req.Header.Del(echo.HeaderXForwardedSsl)
req.Header.Del(echo.HeaderXForwardedProtocol)
req.Header.Del(echo.HeaderXUrlScheme)
- if c.IsWebSocket() && req.Header.Get(echo.HeaderXForwardedFor) == "" { // For HTTP, it is automatically set by Go HTTP reverse proxy.
- req.Header.Set(echo.HeaderXForwardedFor, c.RealIP())
- }
+ // Do not forward an unvalidated parallel source of client identity and scheme.
+ req.Header.Del("Forwarded")
retries := config.RetryCount
for {
@@ -387,13 +329,8 @@ func ProxyWithConfig(config ProxyConfig) echo.MiddlewareFunc {
// that Balancer may have replaced with c.SetRequest.
req = c.Request()
- // Proxy
- switch {
- case c.IsWebSocket():
- proxyRaw(tgt, c, config).ServeHTTP(res, req)
- default: // even SSE requests
- proxyHTTP(tgt, c, config).ServeHTTP(res, req)
- }
+ // ReverseProxy validates upgrades and closes both sides when a tunnel ends.
+ proxyHTTP(tgt, c, config).ServeHTTP(res, req)
err, hasError := c.Get("_error").(error)
if !hasError {
@@ -419,7 +356,17 @@ func ProxyWithConfig(config ProxyConfig) echo.MiddlewareFunc {
const StatusCodeContextCanceled = 499
func proxyHTTP(tgt *ProxyTarget, c echo.Context, config ProxyConfig) http.Handler {
- proxy := httputil.NewSingleHostReverseProxy(tgt.URL)
+ // The HTTP transport also handles WebSocket upgrades. Normalize WebSocket
+ // target schemes on a copy so shared balancer targets remain unchanged.
+ targetURL := *tgt.URL
+ switch targetURL.Scheme {
+ case "ws":
+ targetURL.Scheme = "http"
+ case "wss":
+ targetURL.Scheme = "https"
+ }
+ proxy := httputil.NewSingleHostReverseProxy(&targetURL)
+ isWebSocket := c.IsWebSocket()
proxy.ErrorHandler = func(resp http.ResponseWriter, req *http.Request, err error) {
desc := tgt.URL.String()
if tgt.Name != "" {
@@ -441,6 +388,44 @@ func proxyHTTP(tgt *ProxyTarget, c echo.Context, config ProxyConfig) http.Handle
}
}
proxy.Transport = config.Transport
- proxy.ModifyResponse = config.ModifyResponse
- return proxy
+ proxy.ModifyResponse = func(response *http.Response) error {
+ if config.ModifyResponse != nil {
+ if err := config.ModifyResponse(response); err != nil {
+ return err
+ }
+ }
+ if isWebSocket && response.StatusCode == http.StatusSwitchingProtocols {
+ if body, ok := response.Body.(io.ReadWriteCloser); ok {
+ // Hide backend CloseWrite after the user's hook, so a client EOF
+ // closes the tunnel even when the upstream ignores half-close.
+ response.Body = struct{ io.ReadWriteCloser }{body}
+ }
+ }
+ return nil
+ }
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if isWebSocket {
+ w = proxyUpgradeResponseWriter{w}
+ }
+ proxy.ServeHTTP(w, r)
+ })
+}
+
+type proxyUpgradeResponseWriter struct {
+ http.ResponseWriter
+}
+
+func (w proxyUpgradeResponseWriter) Unwrap() http.ResponseWriter {
+ return w.ResponseWriter
+}
+
+func (w proxyUpgradeResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
+ conn, rw, err := http.NewResponseController(w.ResponseWriter).Hijack()
+ if err != nil {
+ return nil, nil, err
+ }
+ // WebSocket does not support TCP half-close. Hide CloseWrite so ReverseProxy
+ // closes both connections when either direction reaches EOF, rather than
+ // waiting indefinitely for an idle peer to close its other half.
+ return struct{ net.Conn }{conn}, rw, nil
}
diff --git a/vendor/github.com/labstack/echo/v4/middleware/static.go b/vendor/github.com/labstack/echo/v4/middleware/static.go
index ac36bee6..834067ac 100644
--- a/vendor/github.com/labstack/echo/v4/middleware/static.go
+++ b/vendor/github.com/labstack/echo/v4/middleware/static.go
@@ -218,7 +218,8 @@ func StaticWithConfig(config StaticConfig) echo.MiddlewareFunc {
// 1. HTTP URLs always use forward slashes, regardless of server OS
// 2. path.Clean() provides platform-independent behavior for URL paths
// 3. The "/" prefix forces absolute path interpretation, removing ".." components
- // 4. Backslashes are treated as literal characters (not path separators), preventing traversal
+ // 4. path.Clean() treats backslashes as literal characters; "."/".." parts between backslashes are rejected above
+ // for filesystems that wrongly treat a backslash as a separator
// See static_windows.go for Go 1.20+ filepath.Clean compatibility notes
name := path.Join(config.Root, path.Clean("/"+p)) // "/"+ for security
@@ -320,8 +321,8 @@ func listDir(t *template.Template, name string, dir http.File, res *echo.Respons
return t.Execute(res, data)
}
-// hasDotOrEmptySegment reports whether URL path p has a ".", ".." or empty segment. A single leading and a single
-// trailing slash are allowed.
+// hasDotOrEmptySegment reports whether URL path p has a ".", ".." or empty segment, or a segment with a "." or ".."
+// part between backslashes (e.g. `..\x`). A single leading and a single trailing slash are allowed.
// Keep in sync with the copy in echo_fs.go.
func hasDotOrEmptySegment(p string) bool {
p = strings.TrimPrefix(p, "/")
@@ -333,6 +334,15 @@ func hasDotOrEmptySegment(p string) bool {
if segment == "" || segment == "." || segment == ".." {
return true
}
+ // A backslash is a literal character in fs.FS names, but a filesystem that wrongly treats it as a separator
+ // (for example one built on filepath.Join on Windows) would resolve `..\` outside its root.
+ if strings.Contains(segment, `\`) {
+ for part := range strings.SplitSeq(segment, `\`) {
+ if part == "." || part == ".." {
+ return true
+ }
+ }
+ }
}
return false
}
diff --git a/vendor/github.com/labstack/echo/v4/route_path.go b/vendor/github.com/labstack/echo/v4/route_path.go
new file mode 100644
index 00000000..7d4aacec
--- /dev/null
+++ b/vendor/github.com/labstack/echo/v4/route_path.go
@@ -0,0 +1,104 @@
+// SPDX-License-Identifier: MIT
+// SPDX-FileCopyrightText: © 2015 LabStack LLC and Echo contributors
+
+package echo
+
+import "strings"
+
+// routePathPart is one parsed piece of a route pattern. A backslash before a
+// colon makes the colon static. After a parameter name it starts an inline verb
+// (`/:name\:cancel`) when the rest of that path segment is static.
+type routePathPart struct {
+ kind kind
+ value string
+}
+
+func parseRoutePath(path string) []routePathPart {
+ var parts []routePathPart
+ walkRoutePath(path, func(part routePathPart) { parts = append(parts, part) })
+ return parts
+}
+
+// walkRoutePath is the common syntax scanner. Reverse uses it directly to
+// avoid allocating a parts slice for each URL it builds.
+func walkRoutePath(path string, emit func(routePathPart)) {
+ for i := 0; i < len(path); {
+ if isEscapedColon(path, i) {
+ emit(routePathPart{kind: staticKind, value: ":"})
+ i += 2
+ } else if path[i] == ':' {
+ start := i + 1
+ i = start
+ plainName := true // an escaped colon only starts an inline verb after a name without ':' or '*'
+ for i < len(path) && path[i] != '/' {
+ if isEscapedColon(path, i) {
+ if plainName && isInlineVerb(path[i+2:]) {
+ break
+ }
+ // not an inline verb: the rest of the segment is the param name, as before inline verbs
+ for i < len(path) && path[i] != '/' {
+ i++
+ }
+ break
+ }
+ if path[i] == ':' || path[i] == '*' {
+ plainName = false
+ }
+ i++
+ }
+ emit(routePathPart{kind: paramKind, value: path[start:i]})
+ } else if path[i] == '*' {
+ start := i
+ for i < len(path) && path[i] != '/' {
+ i++
+ }
+ emit(routePathPart{kind: anyKind, value: path[start:i]})
+ } else {
+ start := i
+ for i < len(path) && path[i] != ':' && path[i] != '*' && !isEscapedColon(path, i) {
+ i++
+ }
+ emit(routePathPart{kind: staticKind, value: path[start:i]})
+ }
+ }
+}
+
+func isEscapedColon(path string, i int) bool {
+ return path[i] == '\\' && i+1 < len(path) && path[i+1] == ':'
+}
+
+// isInlineVerb reports whether the route text after an escaped colon stays
+// static up to the end of its path segment. Only then can the router find where
+// the parameter value ends by trying the colons in the requested segment. Other
+// escaped colons keep the older meaning and remain part of the parameter name.
+func isInlineVerb(rest string) bool {
+ for i := 0; i < len(rest) && rest[i] != '/'; i++ {
+ switch rest[i] {
+ case '*':
+ return false
+ case ':':
+ if i == 0 || rest[i-1] != '\\' {
+ return false
+ }
+ }
+ }
+ return true
+}
+
+func routeTreePath(parts []routePathPart) (string, []int) {
+ var path strings.Builder
+ var paramMarkers []int
+ for _, part := range parts {
+ switch part.kind {
+ case staticKind:
+ path.WriteString(part.value)
+ case paramKind:
+ paramMarkers = append(paramMarkers, path.Len())
+ path.WriteByte(':')
+ case anyKind:
+ path.WriteByte(anyLabel)
+ return path.String(), paramMarkers
+ }
+ }
+ return path.String(), paramMarkers
+}
diff --git a/vendor/github.com/labstack/echo/v4/router.go b/vendor/github.com/labstack/echo/v4/router.go
index 912cfeac..0c8eadb3 100644
--- a/vendor/github.com/labstack/echo/v4/router.go
+++ b/vendor/github.com/labstack/echo/v4/router.go
@@ -7,6 +7,8 @@ import (
"bytes"
"fmt"
"net/http"
+ "slices"
+ "strings"
)
// Router is the registry of all registered routes for an `Echo` instance for
@@ -33,7 +35,8 @@ type node struct {
// isLeaf indicates that node does not have child routes
isLeaf bool
// isHandler indicates that node has at least one handler registered to it
- isHandler bool
+ isHandler bool
+ hasColonChild bool
}
type kind uint8
@@ -66,8 +69,7 @@ const (
paramKind
anyKind
- paramLabel = byte(':')
- anyLabel = byte('*')
+ anyLabel = byte('*')
)
func (m *routeMethods) isHandler() bool {
@@ -158,26 +160,23 @@ func (r *Router) Routes() []*Route {
// Reverse generates a URL from route name and provided parameters.
func (r *Router) Reverse(name string, params ...interface{}) string {
uri := new(bytes.Buffer)
- ln := len(params)
- n := 0
for _, route := range r.routes {
if route.Name == name {
- for i, l := 0, len(route.Path); i < l; i++ {
- hasBackslash := route.Path[i] == '\\'
- if hasBackslash && i+1 < l && route.Path[i+1] == ':' {
- i++ // backslash before colon escapes that colon. in that case skip backslash
- }
- if n < ln && (route.Path[i] == '*' || (!hasBackslash && route.Path[i] == ':')) {
- // in case of `*` wildcard or `:` (unescaped colon) param we replace everything till next slash or end of path
- for ; i < l && route.Path[i] != '/'; i++ {
- }
- uri.WriteString(fmt.Sprintf("%v", params[n]))
+ n := 0
+ walkRoutePath(route.Path, func(part routePathPart) {
+ if part.kind == staticKind {
+ uri.WriteString(part.value)
+ } else if n < len(params) {
+ fmt.Fprint(uri, params[n])
n++
+ } else {
+ // placeholder for a missing value. An escaped colon in a param name is written without its backslash.
+ if part.kind == paramKind {
+ uri.WriteByte(':')
+ }
+ uri.WriteString(strings.ReplaceAll(part.value, `\:`, ":"))
}
- if i < l {
- uri.WriteByte(route.Path[i])
- }
- }
+ })
break
}
}
@@ -213,49 +212,48 @@ func (r *Router) Add(method, path string, h HandlerFunc) {
func (r *Router) insert(method, path string, h HandlerFunc) {
path = normalizePathSlash(path)
- pnames := []string{} // Param names
- ppath := path // Pristine path
-
if h == nil && r.echo.Logger != nil {
// FIXME: in future we should return error
r.echo.Logger.Errorf("Adding route without handler function: %v:%v", method, path)
}
-
- for i, lcpIndex := 0, len(path); i < lcpIndex; i++ {
- if path[i] == ':' {
- if i > 0 && path[i-1] == '\\' {
- path = path[:i-1] + path[i:]
- i--
- lcpIndex--
- continue
- }
- j := i + 1
-
- r.insertNode(method, path[:i], staticKind, routeMethod{})
- for ; i < lcpIndex && path[i] != '/'; i++ {
+ parts := parseRoutePath(path)
+ pnames := []string{}
+ for _, part := range parts {
+ if part.kind == paramKind {
+ pnames = append(pnames, part.value)
+ } else if part.kind == anyKind {
+ pnames = append(pnames, "*")
+ break
+ }
+ }
+ rm := routeMethod{ppath: path, pnames: pnames, handler: h}
+ treePath, paramMarkers := routeTreePath(parts)
+ pathEnd := 0
+ for i, part := range parts {
+ switch part.kind {
+ case staticKind:
+ pathEnd += len(part.value)
+ if i == len(parts)-1 {
+ r.insertNode(method, treePath[:pathEnd], staticKind, rm, paramMarkers)
}
-
- pnames = append(pnames, path[j:i])
- path = path[:j] + path[i:]
- i, lcpIndex = j, len(path)
-
- if i == lcpIndex {
- // path node is last fragment of route path. ie. `/users/:id`
- r.insertNode(method, path[:i], paramKind, routeMethod{ppath: ppath, pnames: pnames, handler: h})
+ case paramKind:
+ r.insertNode(method, treePath[:pathEnd], staticKind, routeMethod{}, paramMarkers)
+ pathEnd++
+ if i == len(parts)-1 {
+ r.insertNode(method, treePath[:pathEnd], paramKind, rm, paramMarkers)
} else {
- r.insertNode(method, path[:i], paramKind, routeMethod{})
+ r.insertNode(method, treePath[:pathEnd], paramKind, routeMethod{}, paramMarkers)
}
- } else if path[i] == '*' {
- r.insertNode(method, path[:i], staticKind, routeMethod{})
- pnames = append(pnames, "*")
- r.insertNode(method, path[:i+1], anyKind, routeMethod{ppath: ppath, pnames: pnames, handler: h})
+ case anyKind:
+ r.insertNode(method, treePath[:pathEnd], staticKind, routeMethod{}, paramMarkers)
+ pathEnd++
+ r.insertNode(method, treePath[:pathEnd], anyKind, rm, paramMarkers)
+ return
}
}
-
- r.insertNode(method, path, staticKind, routeMethod{ppath: ppath, pnames: pnames, handler: h})
}
-func (r *Router) insertNode(method, path string, t kind, rm routeMethod) {
+func (r *Router) insertNode(method, path string, t kind, rm routeMethod, paramMarkers []int) {
// Adjust max param
paramLen := len(rm.pnames)
if *r.echo.maxParam < paramLen {
@@ -267,6 +265,7 @@ func (r *Router) insertNode(method, path string, t kind, rm routeMethod) {
panic("echo: invalid method")
}
search := path
+ searchOffset := 0
for {
searchLen := len(search)
@@ -360,8 +359,10 @@ func (r *Router) insertNode(method, path string, t kind, rm routeMethod) {
}
currentNode.isLeaf = currentNode.staticChildren == nil && currentNode.paramChild == nil && currentNode.anyChild == nil
} else if lcpLen < searchLen {
+ searchOffset += lcpLen
search = search[lcpLen:]
- c := currentNode.findChildWithLabel(search[0])
+ isParamMarker := slices.Contains(paramMarkers, searchOffset)
+ c := currentNode.findChildWithLabel(search[0], isParamMarker)
if c != nil {
// Go deeper
currentNode = c
@@ -426,6 +427,56 @@ func newNode(
func (n *node) addStaticChild(c *node) {
n.staticChildren = append(n.staticChildren, c)
+ // param nodes are never split (their prefix is a single byte), so this is where their inline verb child is set
+ if n.kind == paramKind && c.label == ':' {
+ n.hasColonChild = true
+ }
+}
+
+// hasPendingInlineVerbSplit reports whether a param node from n up to the root has a value in paramValues that ended at
+// an inline verb split and so can still be retried. searchIndex and paramIndex are the routing state at n. It does
+// not change that state, so a request that ends here keeps its param values.
+func hasPendingInlineVerbSplit(n *node, path string, searchIndex, paramIndex int, paramValues []string) bool {
+ for ; n != nil; n = n.parent {
+ if n.hasColonChild && searchIndex < len(path) && path[searchIndex] == ':' {
+ return true
+ }
+ if n.kind == staticKind {
+ searchIndex -= len(n.prefix)
+ } else {
+ paramIndex--
+ searchIndex -= len(paramValues[paramIndex])
+ }
+ }
+ return false
+}
+
+// inlineVerbSplit returns where a param value in search ends: at the first literal colon at or after from where this
+// node's inline verb child could match, otherwise at the end of the path segment (or of the path when that child is the
+// node's only child). A split value is never empty. The scan stops at the next slash, so trying every split of a
+// segment in turn is linear in its length.
+//
+// A split is only chosen when the whole prefix of the inline verb child matches. Routing therefore never backtracks
+// into the param node from a prefix mismatch of that child, and the split only needs to be retried when backtracking
+// from within the child's subtree.
+func (n *node) inlineVerbSplit(search string, from int) int {
+ verbs := n.findStaticChild(':')
+ for i := from; i < len(search); i++ {
+ switch search[i] {
+ case '/':
+ if len(n.staticChildren) == 1 {
+ // the inline verb child is the only child (a param node never has a param or any child): without a
+ // split the param takes the rest of the path, as a leaf param does
+ return len(search)
+ }
+ return i
+ case ':':
+ if i > 0 && verbs != nil && strings.HasPrefix(search[i:], verbs.prefix) {
+ return i
+ }
+ }
+ }
+ return len(search)
}
func (n *node) findStaticChild(l byte) *node {
@@ -437,13 +488,13 @@ func (n *node) findStaticChild(l byte) *node {
return nil
}
-func (n *node) findChildWithLabel(l byte) *node {
+func (n *node) findChildWithLabel(l byte, isParamMarker bool) *node {
+ if isParamMarker {
+ return n.paramChild
+ }
if c := n.findStaticChild(l); c != nil {
return c
}
- if l == paramLabel {
- return n.paramChild
- }
if l == anyLabel {
return n.anyChild
}
@@ -531,6 +582,16 @@ func optionsMethodHandler(allowMethods string) func(c Context) error {
}
}
+// growParamValues replaces the context's pvalues when the context was created (and pooled) before a route with more
+// path params was added, so Find can set values by index. Values already set during this Find are kept and the
+// context keeps the new slice for later requests.
+func (r *Router) growParamValues(ctx *context, paramValues []string) []string {
+ grown := make([]string, *r.echo.maxParam)
+ copy(grown, paramValues)
+ ctx.pvalues = grown
+ return grown
+}
+
// Find lookup a handler registered for method and path. It also parses URL for path
// parameters and load them into context.
//
@@ -618,7 +679,7 @@ func (r *Router) Find(method, path string, c Context) {
// No matching prefix, let's backtrack to the first possible alternative node of the decision path
nk, ok := backtrackToNextNodeKind(staticKind)
if !ok {
- return // No other possibilities on the decision path, handler will be whatever context is reset to.
+ break // No other possibilities on the decision path.
} else if nk == paramKind {
goto Param
// NOTE: this case (backtracking from static node to previous any node) can not happen by current any matching logic. Any node is end of search currently
@@ -671,11 +732,17 @@ func (r *Router) Find(method, path string, c Context) {
// when param node does not have any children (path param is last piece of route path) then param node should
// act similarly to any node - consider all remaining search as match
i = l
- } else {
- for ; i < l && search[i] != '/'; i++ {
- }
+ } else if currentNode.hasColonChild {
+ // an inline verb (`/:name\:verb`) can end the param value at a literal colon. Start with the first
+ // possible split, the param node is retried with the next one before backtracking (see below).
+ i = currentNode.inlineVerbSplit(search, 0)
+ } else if i = strings.IndexByte(search, '/'); i < 0 {
+ i = l
}
+ if paramIndex >= len(paramValues) {
+ paramValues = r.growParamValues(ctx, paramValues)
+ }
paramValues[paramIndex] = search[:i]
paramIndex++
search = search[i:]
@@ -688,6 +755,9 @@ func (r *Router) Find(method, path string, c Context) {
if child := currentNode.anyChild; child != nil {
// If any node is found, use remaining path for paramValues
currentNode = child
+ if currentNode.paramsCount > len(paramValues) {
+ paramValues = r.growParamValues(ctx, paramValues)
+ }
paramValues[currentNode.paramsCount-1] = search
// update indexes/search in case we need to backtrack when no handler match is found
@@ -711,16 +781,35 @@ func (r *Router) Find(method, path string, c Context) {
// Let's backtrack to the first possible alternative node of the decision path
nk, ok := backtrackToNextNodeKind(anyKind)
+ Backtracked:
if !ok {
break // No other possibilities on the decision path
} else if nk == paramKind {
+ if currentNode.hasColonChild && search != "" && search[0] == ':' {
+ goto InlineVerbSplit
+ }
goto Param
} else if nk == anyKind {
goto Any
+ } else if hasPendingInlineVerbSplit(currentNode, path, searchIndex, paramIndex, paramValues) {
+ // A wildcard ends the search, except below a param value that ended at an inline verb split: keep
+ // backtracking, so the other routes below that split and then the next split are still tried.
+ nk, ok = backtrackToNextNodeKind(anyKind)
+ goto Backtracked
} else {
// Not found
break
}
+
+ InlineVerbSplit:
+ // A param value that ended at an inline verb split is a decision point of the param node itself. When its
+ // inline verb child fails, retry the node with the next split, and finally with the whole path segment,
+ // before backtracking to its parent.
+ start := searchIndex - len(paramValues[paramIndex-1])
+ searchIndex = start + currentNode.inlineVerbSplit(path[start:], len(paramValues[paramIndex-1])+1)
+ paramValues[paramIndex-1] = path[start:searchIndex]
+ search = path[searchIndex:]
+ continue
}
if currentNode == nil && previousBestMatchNode == nil {
diff --git a/vendor/github.com/rs/zerolog/.gitignore b/vendor/github.com/rs/zerolog/.gitignore
index c3622a0a..1a2fc214 100644
--- a/vendor/github.com/rs/zerolog/.gitignore
+++ b/vendor/github.com/rs/zerolog/.gitignore
@@ -23,5 +23,5 @@ _testmain.go
*.exe
*.test
*.prof
-
-coverage.out
+
+coverage.out
diff --git a/vendor/modules.txt b/vendor/modules.txt
index 6d96f60b..45e29eba 100644
--- a/vendor/modules.txt
+++ b/vendor/modules.txt
@@ -293,8 +293,8 @@ github.com/hashicorp/golang-lru/v2/internal
# github.com/json-iterator/go v1.1.12
## explicit; go 1.12
github.com/json-iterator/go
-# github.com/labstack/echo/v4 v4.16.0
-## explicit; go 1.25.0
+# github.com/labstack/echo/v4 v4.16.2
+## explicit; go 1.26.0
github.com/labstack/echo/v4
github.com/labstack/echo/v4/middleware
# github.com/labstack/gommon v0.5.0