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 +
+ + Blacksmith logo + Blacksmith – faster, drop-in GitHub Actions runners + +
+
encore icon @@ -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