Skip to content

Commit 3fb0ba7

Browse files
vishrjakezwangShubham-Padkonde
authored
Merge commit from fork
* fix(middleware): bound v4 body dumps without truncating requests Default request and response capture to 5 MiB with configurable opt-outs. Replay captured request bytes before the remaining original stream and preserve read errors and Close ownership. Cover bounded captures, full payload delivery, and BodyLimit composition. Addresses GHSA-f48p-c9v4-h7xv. Request replay follows the v5 contribution in PR 3105. Co-authored-by: Jake Wang <jake.wang@argonlabs.tech> * fix(middleware): limit v4 decompressed request sizes Apply a configurable 100 MiB decompression cap and return HTTP 413 for excess data. Accept exact-limit bodies, retain overflow errors on repeated reads, and clear compressed ContentLength metadata. Cover defaults, opt-outs, boundaries, and streaming compressed expansion. Addresses GHSA-h7wr-284f-5qvc. Boundary handling follows the v5 contribution in PR 3136. Co-authored-by: Shubham Padkonde <shubhampadkonde12@gmail.com> * fix(middleware): validate v4 proxy upgrades and close disconnected tunnels Backport the v5 standard reverse-proxy handling, WebSocket disconnect cleanup, ws/wss target normalization, and Forwarded-header removal. Cover rejected and accepted upgrades, both disconnect directions, and protocol mismatch. Addresses GHSA-cxf3-9h37-7rr8, GHSA-9969-6676-49h5, and hardens GHSA-6gpx-gjwr-mhp8. * fix(deps): update v4 HTTP/2 dependencies and require Go 1.26 Upgrade golang.org/x/net to v0.60.0 and its required crypto, sys, and text versions to remediate reachable HTTP/2 advisories. Match the supported CI matrix to Go 1.26 and 1.27. Preserve the existing StartH2CServer API with narrow deprecation annotations. * test(middleware): verify v4 WebSocket transport and response hooks * fix(middleware): close v4 WebSocket backends that ignore client EOF Disable backend half-close after the response hook, matching frontend handling, for WebSocket upgrades. Add the review regression with an upstream that stays open after reading client EOF; the proxy must finish independently. * test(middleware): verify v4 decompression limits through JSON and XML binding Confirm that Bind preserves ErrStatusRequestEntityTooLarge and the default error handler returns HTTP 413 for both formats. This disproves the review claim that these paths turn the limit into HTTP 400. --------- Co-authored-by: Jake Wang <jake.wang@argonlabs.tech> Co-authored-by: Shubham Padkonde <shubhampadkonde12@gmail.com>
1 parent be08005 commit 3fb0ba7

13 files changed

Lines changed: 708 additions & 93 deletions

‎.github/workflows/checks.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ permissions:
1616

1717
env:
1818
# run static analysis only with the latest Go version
19-
LATEST_GO_VERSION: "1.26"
19+
LATEST_GO_VERSION: "1.27"
2020

2121
jobs:
2222
check:

‎.github/workflows/echo.yml‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ permissions:
1616

1717
env:
1818
# run coverage and benchmarks only with the latest Go version
19-
LATEST_GO_VERSION: "1.26"
19+
LATEST_GO_VERSION: "1.27"
2020

2121
jobs:
2222
test:
@@ -27,7 +27,7 @@ jobs:
2727
# Echo tests with last four major releases (unless there are pressing vulnerabilities)
2828
# As we depend on `golang.org/x/` libraries which only support the last 2 Go releases, we could have situations when
2929
# we derive from the last four major releases promise.
30-
go: ["1.25", "1.26"]
30+
go: ["1.26", "1.27"]
3131
name: ${{ matrix.os }} @ Go ${{ matrix.go }}
3232
runs-on: ${{ matrix.os }}
3333
steps:

‎echo.go‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -860,6 +860,8 @@ func (e *Echo) TLSListenerAddr() net.Addr {
860860
}
861861

862862
// StartH2CServer starts a custom http/2 server with h2c (HTTP/2 Cleartext).
863+
//
864+
//lint:ignore SA1019 Preserve the v4 public API through its supported lifetime; x/net remains security patched.
863865
func (e *Echo) StartH2CServer(address string, h2s *http2.Server) error {
864866
e.startupMutex.Lock()
865867
// Setup

‎echo_test.go‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1195,6 +1195,7 @@ func TestEcho_StartH2CServer(t *testing.T) {
11951195
t.Run(tc.name, func(t *testing.T) {
11961196
e := New()
11971197
e.Debug = true
1198+
//lint:ignore SA1019 Exercise the supported v4 StartH2CServer API.
11981199
h2s := &http2.Server{}
11991200

12001201
errChan := make(chan error)

‎go.mod‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
module github.com/labstack/echo/v4
22

3-
go 1.25.0
3+
go 1.26.0
44

55
require (
66
github.com/labstack/gommon v0.5.0
77
github.com/stretchr/testify v1.11.1
88
github.com/valyala/fasttemplate v1.2.2
9-
golang.org/x/crypto v0.53.0
10-
golang.org/x/net v0.56.0
9+
golang.org/x/crypto v0.57.0
10+
golang.org/x/net v0.60.0
1111
golang.org/x/time v0.15.0
1212
)
1313

@@ -17,7 +17,7 @@ require (
1717
github.com/mattn/go-isatty v0.0.22 // indirect
1818
github.com/pmezard/go-difflib v1.0.0 // indirect
1919
github.com/valyala/bytebufferpool v1.0.0 // indirect
20-
golang.org/x/sys v0.46.0 // indirect
21-
golang.org/x/text v0.40.0 // indirect
20+
golang.org/x/sys v0.48.0 // indirect
21+
golang.org/x/text v0.42.0 // indirect
2222
gopkg.in/yaml.v3 v3.0.1 // indirect
2323
)

‎go.sum‎

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,14 @@ github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6Kllzaw
1414
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
1515
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
1616
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
17-
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
18-
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
19-
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
20-
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
21-
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
22-
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
23-
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
24-
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
17+
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
18+
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
19+
golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU=
20+
golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
21+
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
22+
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
23+
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
24+
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
2525
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
2626
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
2727
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=

‎middleware/body_dump.go‎

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,14 @@ type BodyDumpConfig struct {
2222
// Handler receives request and response payload.
2323
// Required.
2424
Handler BodyDumpHandler
25+
26+
// MaxRequestBytes limits captured request bytes, without truncating the body
27+
// delivered to the next handler. Defaults to 5 MiB; -1 disables the limit.
28+
MaxRequestBytes int64
29+
30+
// MaxResponseBytes limits captured response bytes, without truncating the
31+
// response delivered to the client. Defaults to 5 MiB; -1 disables the limit.
32+
MaxResponseBytes int64
2533
}
2634

2735
// BodyDumpHandler receives the request and response payload.
@@ -57,6 +65,12 @@ func BodyDumpWithConfig(config BodyDumpConfig) echo.MiddlewareFunc {
5765
if config.Skipper == nil {
5866
config.Skipper = DefaultBodyDumpConfig.Skipper
5967
}
68+
if config.MaxRequestBytes == 0 {
69+
config.MaxRequestBytes = 5 * 1024 * 1024
70+
}
71+
if config.MaxResponseBytes == 0 {
72+
config.MaxResponseBytes = 5 * 1024 * 1024
73+
}
6074

6175
return func(next echo.HandlerFunc) echo.HandlerFunc {
6276
return func(c echo.Context) (err error) {
@@ -67,17 +81,30 @@ func BodyDumpWithConfig(config BodyDumpConfig) echo.MiddlewareFunc {
6781
// Request
6882
reqBody := []byte{}
6983
if c.Request().Body != nil {
84+
originalBody := c.Request().Body
85+
var reader io.Reader = originalBody
86+
if config.MaxRequestBytes > 0 {
87+
reader = io.LimitReader(reader, config.MaxRequestBytes)
88+
}
7089
var readErr error
71-
reqBody, readErr = io.ReadAll(c.Request().Body)
90+
reqBody, readErr = io.ReadAll(reader)
7291
if readErr != nil {
7392
return readErr
7493
}
94+
c.Request().Body = struct {
95+
io.Reader
96+
io.Closer
97+
}{Reader: io.MultiReader(bytes.NewReader(reqBody), originalBody), Closer: originalBody}
98+
} else {
99+
c.Request().Body = http.NoBody
75100
}
76-
c.Request().Body = io.NopCloser(bytes.NewBuffer(reqBody)) // Reset
77101

78102
// Response
79103
resBody := new(bytes.Buffer)
80-
mw := io.MultiWriter(c.Response().Writer, resBody)
104+
var mw io.Writer = io.MultiWriter(c.Response().Writer, resBody)
105+
if config.MaxResponseBytes > 0 {
106+
mw = &bodyDumpLimitedWriter{writer: c.Response().Writer, buffer: resBody, remaining: config.MaxResponseBytes}
107+
}
81108
writer := &bodyDumpResponseWriter{Writer: mw, ResponseWriter: c.Response().Writer}
82109
c.Response().Writer = writer
83110

@@ -93,6 +120,22 @@ func BodyDumpWithConfig(config BodyDumpConfig) echo.MiddlewareFunc {
93120
}
94121
}
95122

123+
type bodyDumpLimitedWriter struct {
124+
writer io.Writer
125+
buffer *bytes.Buffer
126+
remaining int64
127+
}
128+
129+
func (w *bodyDumpLimitedWriter) Write(p []byte) (int, error) {
130+
n, err := w.writer.Write(p)
131+
if w.remaining > 0 {
132+
captured := min(int64(n), w.remaining)
133+
_, _ = w.buffer.Write(p[:captured])
134+
w.remaining -= captured
135+
}
136+
return n, err
137+
}
138+
96139
func (w *bodyDumpResponseWriter) WriteHeader(code int) {
97140
w.ResponseWriter.WriteHeader(code)
98141
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
// SPDX-License-Identifier: MIT
2+
// SPDX-FileCopyrightText: © 2026 LabStack LLC and Echo contributors
3+
4+
package middleware
5+
6+
import (
7+
"errors"
8+
"io"
9+
"net/http"
10+
"net/http/httptest"
11+
"strings"
12+
"testing"
13+
"testing/iotest"
14+
15+
"github.com/labstack/echo/v4"
16+
"github.com/stretchr/testify/require"
17+
)
18+
19+
func TestBodyDumpCaptureLimits(t *testing.T) {
20+
for _, tc := range []struct {
21+
name string
22+
reqLimit, resLimit int64
23+
size, reqWant, resWant int
24+
}{
25+
{"configured", 3, 4, 8, 3, 4},
26+
{"exact", 8, 8, 8, 8, 8},
27+
{"unlimited", -1, -1, 8, 8, 8},
28+
{"default", 0, 0, 6 << 20, 5 << 20, 5 << 20},
29+
} {
30+
t.Run(tc.name, func(t *testing.T) {
31+
payload := strings.Repeat("x", tc.size)
32+
var requestDump, responseDump []byte
33+
e := echo.New()
34+
e.Use(BodyDumpWithConfig(BodyDumpConfig{MaxRequestBytes: tc.reqLimit, MaxResponseBytes: tc.resLimit,
35+
Handler: func(_ echo.Context, req, res []byte) { requestDump, responseDump = req, res }}))
36+
e.POST("/", func(c echo.Context) error {
37+
body, err := io.ReadAll(c.Request().Body)
38+
if err != nil {
39+
return err
40+
}
41+
require.Equal(t, payload, string(body))
42+
return c.String(http.StatusOK, string(body))
43+
})
44+
rec := httptest.NewRecorder()
45+
e.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/", strings.NewReader(payload)))
46+
require.Equal(t, http.StatusOK, rec.Code)
47+
require.Equal(t, payload, rec.Body.String())
48+
require.Equal(t, payload[:tc.reqWant], string(requestDump))
49+
require.Equal(t, payload[:tc.resWant], string(responseDump))
50+
})
51+
}
52+
}
53+
54+
type bodyDumpCloseTracker struct {
55+
io.Reader
56+
closed bool
57+
}
58+
59+
func (b *bodyDumpCloseTracker) Close() error { b.closed = true; return nil }
60+
61+
func TestBodyDumpPreservesRemainderErrorAndClose(t *testing.T) {
62+
wantErr := errors.New("remainder failed")
63+
body := &bodyDumpCloseTracker{Reader: io.MultiReader(strings.NewReader("abcdefgh"), iotest.ErrReader(wantErr))}
64+
req := httptest.NewRequest(http.MethodPost, "/", nil)
65+
req.Body = body
66+
e := echo.New()
67+
c := e.NewContext(req, httptest.NewRecorder())
68+
var dumped []byte
69+
err := BodyDumpWithConfig(BodyDumpConfig{MaxRequestBytes: 3, Handler: func(_ echo.Context, req, res []byte) { dumped = req }})(func(c echo.Context) error {
70+
require.False(t, body.closed)
71+
data, err := io.ReadAll(c.Request().Body)
72+
require.Equal(t, "abcdefgh", string(data))
73+
require.ErrorIs(t, err, wantErr)
74+
require.NoError(t, c.Request().Body.Close())
75+
require.True(t, body.closed)
76+
return err
77+
})(c)
78+
require.ErrorIs(t, err, wantErr)
79+
require.Equal(t, "abc", string(dumped))
80+
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
// SPDX-License-Identifier: MIT
2+
// SPDX-FileCopyrightText: © 2026 LabStack LLC and Echo contributors
3+
4+
package middleware
5+
6+
import (
7+
"fmt"
8+
"io"
9+
"net/http"
10+
"net/http/httptest"
11+
"strings"
12+
"testing"
13+
14+
"github.com/labstack/echo/v4"
15+
"github.com/stretchr/testify/require"
16+
)
17+
18+
func TestBodyDumpPreservesBodyLimit(t *testing.T) {
19+
for _, dumpFirst := range []bool{false, true} {
20+
for _, knownLength := range []bool{false, true} {
21+
t.Run(fmt.Sprintf("dumpFirst=%t/knownLength=%t", dumpFirst, knownLength), func(t *testing.T) {
22+
e := echo.New()
23+
dump := BodyDumpWithConfig(BodyDumpConfig{MaxRequestBytes: 4, Handler: func(echo.Context, []byte, []byte) {}})
24+
limit := BodyLimit("5")
25+
if dumpFirst {
26+
e.Use(dump, limit)
27+
} else {
28+
e.Use(limit, dump)
29+
}
30+
accepted := false
31+
e.POST("/", func(c echo.Context) error {
32+
if _, err := io.ReadAll(c.Request().Body); err != nil {
33+
return err
34+
}
35+
accepted = true
36+
return c.NoContent(http.StatusOK)
37+
})
38+
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("ABCDEFGH"))
39+
if !knownLength {
40+
req.ContentLength = -1
41+
req.TransferEncoding = []string{"chunked"}
42+
}
43+
rec := httptest.NewRecorder()
44+
e.ServeHTTP(rec, req)
45+
require.Equal(t, http.StatusRequestEntityTooLarge, rec.Code)
46+
require.False(t, accepted)
47+
})
48+
}
49+
}
50+
}
51+
52+
func TestBodyDumpPreservesBodyLimitChunkedHTTP(t *testing.T) {
53+
e := echo.New()
54+
e.Use(BodyLimit("5"), BodyDumpWithConfig(BodyDumpConfig{MaxRequestBytes: 4, Handler: func(echo.Context, []byte, []byte) {}}))
55+
e.POST("/", func(c echo.Context) error {
56+
body, err := io.ReadAll(c.Request().Body)
57+
if err != nil {
58+
return err
59+
}
60+
return c.String(http.StatusOK, string(body))
61+
})
62+
server := httptest.NewServer(e)
63+
defer server.Close()
64+
req, err := http.NewRequest(http.MethodPost, server.URL, io.NopCloser(strings.NewReader("ABCDEFGH")))
65+
require.NoError(t, err)
66+
req.TransferEncoding = []string{"chunked"}
67+
response, err := server.Client().Do(req)
68+
require.NoError(t, err)
69+
defer response.Body.Close()
70+
require.Equal(t, http.StatusRequestEntityTooLarge, response.StatusCode)
71+
}

‎middleware/decompress.go‎

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ type DecompressConfig struct {
1919

2020
// GzipDecompressPool defines an interface to provide the sync.Pool used to create/store Gzip readers
2121
GzipDecompressPool Decompressor
22+
23+
// MaxDecompressedSize limits the decompressed body in bytes. Reading beyond
24+
// the limit returns HTTP 413. Defaults to 100 MiB; -1 disables the limit.
25+
MaxDecompressedSize int64
2226
}
2327

2428
// GZIPEncoding content-encoding header if set to "gzip", decompress body contents.
@@ -57,6 +61,9 @@ func DecompressWithConfig(config DecompressConfig) echo.MiddlewareFunc {
5761
if config.GzipDecompressPool == nil {
5862
config.GzipDecompressPool = DefaultDecompressConfig.GzipDecompressPool
5963
}
64+
if config.MaxDecompressedSize == 0 {
65+
config.MaxDecompressedSize = 100 * 1024 * 1024
66+
}
6067

6168
return func(next echo.HandlerFunc) echo.HandlerFunc {
6269
pool := config.GzipDecompressPool.gzipDecompressPool()
@@ -90,9 +97,43 @@ func DecompressWithConfig(config DecompressConfig) echo.MiddlewareFunc {
9097
// only Close gzip reader if it was set to a proper gzip source otherwise it will panic on close.
9198
defer gr.Close()
9299

93-
c.Request().Body = gr
100+
if config.MaxDecompressedSize > 0 {
101+
c.Request().Body = &limitedGzipReader{Reader: gr, remaining: config.MaxDecompressedSize}
102+
} else {
103+
c.Request().Body = gr
104+
}
105+
c.Request().ContentLength = -1
94106

95107
return next(c)
96108
}
97109
}
98110
}
111+
112+
type limitedGzipReader struct {
113+
*gzip.Reader
114+
remaining int64
115+
}
116+
117+
func (r *limitedGzipReader) Read(p []byte) (int, error) {
118+
if len(p) == 0 {
119+
return 0, nil
120+
}
121+
if r.remaining < 0 {
122+
return 0, echo.ErrStatusRequestEntityTooLarge
123+
}
124+
if r.remaining == 0 {
125+
var probe [1]byte
126+
n, err := r.Reader.Read(probe[:])
127+
if n > 0 {
128+
r.remaining = -1
129+
return 0, echo.ErrStatusRequestEntityTooLarge
130+
}
131+
return 0, err
132+
}
133+
if int64(len(p)) > r.remaining {
134+
p = p[:r.remaining]
135+
}
136+
n, err := r.Reader.Read(p)
137+
r.remaining -= int64(n)
138+
return n, err
139+
}

0 commit comments

Comments
 (0)