From b1ec63a2166c5c910fae5262164e47e395e27cea Mon Sep 17 00:00:00 2001 From: RomeoApps <294788459+RomeoApps@users.noreply.github.com> Date: Sun, 21 Jun 2026 14:33:19 +0800 Subject: [PATCH 1/4] Add compressed body echo endpoint --- httpbin/handlers.go | 41 +++++++++++++++ httpbin/handlers_test.go | 94 ++++++++++++++++++++++++++++++++++ httpbin/httpbin.go | 1 + httpbin/static/index.html.tmpl | 1 + 4 files changed, 137 insertions(+) diff --git a/httpbin/handlers.go b/httpbin/handlers.go index 9673d9e..4ac143b 100644 --- a/httpbin/handlers.go +++ b/httpbin/handlers.go @@ -122,6 +122,47 @@ func (h *HTTPBin) RequestWithBodyDiscard(w http.ResponseWriter, r *http.Request) writeJSON(http.StatusOK, w, resp) } +// Echo returns the request body as plain text. If the incoming request uses +// gzip or deflate Content-Encoding, the body is decompressed before echoing. +func (h *HTTPBin) Echo(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + + var reader io.Reader = r.Body + encoding := strings.ToLower(r.Header.Get("Content-Encoding")) + + switch { + case strings.Contains(encoding, "gzip"): + gzr, err := gzip.NewReader(r.Body) + if err != nil { + writeError(w, http.StatusBadRequest, fmt.Errorf("error decoding gzip request body: %w", err)) + return + } + defer gzr.Close() + reader = gzr + + case strings.Contains(encoding, "deflate"): + zr, err := zlib.NewReader(r.Body) + if err != nil { + writeError(w, http.StatusBadRequest, fmt.Errorf("error decoding deflate request body: %w", err)) + return + } + defer zr.Close() + reader = zr + } + + body, err := io.ReadAll(io.LimitReader(reader, h.MaxBodySize+1)) + if err != nil { + writeError(w, http.StatusBadRequest, fmt.Errorf("error reading request body: %w", err)) + return + } + if int64(len(body)) > h.MaxBodySize { + writeError(w, http.StatusBadRequest, fmt.Errorf("request body exceeds maximum size of %d bytes", h.MaxBodySize)) + return + } + + writeResponse(w, http.StatusOK, textContentType, body) +} + // Gzip returns a gzipped response func (h *HTTPBin) Gzip(w http.ResponseWriter, r *http.Request) { var ( diff --git a/httpbin/handlers_test.go b/httpbin/handlers_test.go index 7ab4603..fb18daf 100644 --- a/httpbin/handlers_test.go +++ b/httpbin/handlers_test.go @@ -593,6 +593,100 @@ func TestAnything(t *testing.T) { }) } +func TestEcho(t *testing.T) { + t.Parallel() + + app := setupTestApp(t) + overLimitBody := strings.Repeat("x", 65) + + t.Run("plain body", func(t *testing.T) { + t.Parallel() + + req := newTestRequest(t, "POST", app.URL("/echo"), strings.NewReader("I am deflater mouse")) + resp := mustDoRequest(t, app, req) + + assert.StatusCode(t, resp, http.StatusOK) + assert.ContentType(t, resp, textContentType) + assert.BodyEquals(t, resp, "I am deflater mouse") + }) + + t.Run("gzip content encoding", func(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + gzw := gzip.NewWriter(&buf) + _, err := gzw.Write([]byte("I am gzip mouse")) + assert.NilError(t, err) + assert.NilError(t, gzw.Close()) + + req := newTestRequest(t, "POST", app.URL("/echo"), &buf) + req.Header.Set("Content-Encoding", "gzip") + resp := mustDoRequest(t, app, req) + + assert.StatusCode(t, resp, http.StatusOK) + assert.ContentType(t, resp, textContentType) + assert.BodyEquals(t, resp, "I am gzip mouse") + }) + + t.Run("gzip content encoding too big after decompression", func(t *testing.T) { + t.Parallel() + + app := setupTestApp(t, WithMaxBodySize(64)) + var buf bytes.Buffer + gzw := gzip.NewWriter(&buf) + _, err := gzw.Write([]byte(overLimitBody)) + assert.NilError(t, err) + assert.NilError(t, gzw.Close()) + if buf.Len() >= 64 { + t.Fatalf("compressed test body must stay under MaxBodySize to exercise the decompressed limit") + } + + req := newTestRequest(t, "POST", app.URL("/echo"), &buf) + req.Header.Set("Content-Encoding", "gzip") + resp := mustDoRequest(t, app, req) + + assert.StatusCode(t, resp, http.StatusBadRequest) + }) + + t.Run("deflate content encoding", func(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + zw := zlib.NewWriter(&buf) + _, err := zw.Write([]byte("I am deflate mouse")) + assert.NilError(t, err) + assert.NilError(t, zw.Close()) + + req := newTestRequest(t, "POST", app.URL("/echo"), &buf) + req.Header.Set("Content-Encoding", "deflate") + resp := mustDoRequest(t, app, req) + + assert.StatusCode(t, resp, http.StatusOK) + assert.ContentType(t, resp, textContentType) + assert.BodyEquals(t, resp, "I am deflate mouse") + }) + + t.Run("deflate content encoding too big after decompression", func(t *testing.T) { + t.Parallel() + + app := setupTestApp(t, WithMaxBodySize(64)) + var buf bytes.Buffer + zw := zlib.NewWriter(&buf) + _, err := zw.Write([]byte(overLimitBody)) + assert.NilError(t, err) + assert.NilError(t, zw.Close()) + if buf.Len() >= 64 { + t.Fatalf("compressed test body must stay under MaxBodySize to exercise the decompressed limit") + } + + req := newTestRequest(t, "POST", app.URL("/echo"), &buf) + req.Header.Set("Content-Encoding", "deflate") + resp := mustDoRequest(t, app, req) + + assert.StatusCode(t, resp, http.StatusBadRequest) + }) +} + func testRequestWithBody(t *testing.T, app *appTestInfo, verb, path string) { t.Run("BinaryBody", func(t *testing.T) { t.Parallel() diff --git a/httpbin/httpbin.go b/httpbin/httpbin.go index c16675b..e54e3fc 100644 --- a/httpbin/httpbin.go +++ b/httpbin/httpbin.go @@ -194,6 +194,7 @@ func (h *HTTPBin) Handler() http.Handler { mux.HandleFunc("/digest-auth/{qop}/{user}/{password}/{algorithm}", h.DigestAuth) mux.HandleFunc("/drip", h.Drip) mux.HandleFunc("/dump/request", h.DumpRequest) + mux.HandleFunc("/echo", h.Echo) mux.HandleFunc("/env", h.Env) mux.HandleFunc("/etag/{etag}", h.ETag) mux.HandleFunc("/gzip", h.Gzip) diff --git a/httpbin/static/index.html.tmpl b/httpbin/static/index.html.tmpl index 4e8a5fc..ae2a1bb 100644 --- a/httpbin/static/index.html.tmpl +++ b/httpbin/static/index.html.tmpl @@ -81,6 +81,7 @@
  • {{.Prefix}}/digest-auth/:qop/:user/:password/:algorithm Challenges HTTP Digest Auth using specified algorithm (MD5 or SHA-256)
  • {{.Prefix}}/drip?numbytes=n&duration=s&delay=s&code=code Drips data over the given duration after an optional initial delay, simulating a slow HTTP server.
  • {{.Prefix}}/dump/request Returns the given request in its HTTP/1.x wire approximate representation.
  • +
  • {{.Prefix}}/echo Echoes the request body as plain text, decompressing gzip or deflate request bodies.
  • {{.Prefix}}/encoding/utf8 Returns page containing UTF-8 data.
  • {{.Prefix}}/env Returns all environment variables named with HTTPBIN_ENV_ prefix.
  • {{.Prefix}}/etag/:etag Assumes the resource has the given etag and responds to If-None-Match header with a 200 or 304 and If-Match with a 200 or 412 as appropriate.
  • From 88c38b63e60d2542b6f3d76bebfd8683adc7e639 Mon Sep 17 00:00:00 2001 From: RomeoApps <294788459+RomeoApps@users.noreply.github.com> Date: Mon, 22 Jun 2026 12:07:50 +0800 Subject: [PATCH 2/4] Address echo endpoint review feedback --- httpbin/handlers.go | 12 ++++++--- httpbin/handlers_test.go | 55 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 3 deletions(-) diff --git a/httpbin/handlers.go b/httpbin/handlers.go index 4ac143b..aba81d0 100644 --- a/httpbin/handlers.go +++ b/httpbin/handlers.go @@ -122,13 +122,17 @@ func (h *HTTPBin) RequestWithBodyDiscard(w http.ResponseWriter, r *http.Request) writeJSON(http.StatusOK, w, resp) } -// Echo returns the request body as plain text. If the incoming request uses -// gzip or deflate Content-Encoding, the body is decompressed before echoing. +// Echo returns the request body. If the incoming request uses gzip or deflate +// Content-Encoding, the body is decompressed before echoing. func (h *HTTPBin) Echo(w http.ResponseWriter, r *http.Request) { defer r.Body.Close() var reader io.Reader = r.Body encoding := strings.ToLower(r.Header.Get("Content-Encoding")) + contentType := r.Header.Get("Content-Type") + if contentType == "" { + contentType = textContentType + } switch { case strings.Contains(encoding, "gzip"): @@ -150,6 +154,8 @@ func (h *HTTPBin) Echo(w http.ResponseWriter, r *http.Request) { reader = zr } + // Read one byte past the limit so over-limit decoded bodies can be + // rejected instead of silently truncated. body, err := io.ReadAll(io.LimitReader(reader, h.MaxBodySize+1)) if err != nil { writeError(w, http.StatusBadRequest, fmt.Errorf("error reading request body: %w", err)) @@ -160,7 +166,7 @@ func (h *HTTPBin) Echo(w http.ResponseWriter, r *http.Request) { return } - writeResponse(w, http.StatusOK, textContentType, body) + writeResponse(w, http.StatusOK, contentType, body) } // Gzip returns a gzipped response diff --git a/httpbin/handlers_test.go b/httpbin/handlers_test.go index fb18daf..69243d0 100644 --- a/httpbin/handlers_test.go +++ b/httpbin/handlers_test.go @@ -610,6 +610,18 @@ func TestEcho(t *testing.T) { assert.BodyEquals(t, resp, "I am deflater mouse") }) + t.Run("preserves request content type", func(t *testing.T) { + t.Parallel() + + req := newTestRequest(t, "POST", app.URL("/echo"), strings.NewReader(`{"ok":true}`)) + req.Header.Set("Content-Type", "application/json") + resp := mustDoRequest(t, app, req) + + assert.StatusCode(t, resp, http.StatusOK) + assert.ContentType(t, resp, "application/json") + assert.BodyEquals(t, resp, `{"ok":true}`) + }) + t.Run("gzip content encoding", func(t *testing.T) { t.Parallel() @@ -628,6 +640,39 @@ func TestEcho(t *testing.T) { assert.BodyEquals(t, resp, "I am gzip mouse") }) + t.Run("invalid gzip content encoding", func(t *testing.T) { + t.Parallel() + + req := newTestRequest(t, "POST", app.URL("/echo"), strings.NewReader("not gzip")) + req.Header.Set("Content-Encoding", "gzip") + resp := mustDoRequest(t, app, req) + + assert.StatusCode(t, resp, http.StatusBadRequest) + }) + + t.Run("gzip content encoding exactly at decoded limit", func(t *testing.T) { + t.Parallel() + + app := setupTestApp(t, WithMaxBodySize(64)) + decodedBody := strings.Repeat("x", 64) + + var buf bytes.Buffer + gzw := gzip.NewWriter(&buf) + _, err := gzw.Write([]byte(decodedBody)) + assert.NilError(t, err) + assert.NilError(t, gzw.Close()) + if buf.Len() >= 64 { + t.Fatalf("compressed test body must stay under MaxBodySize to exercise the decoded limit") + } + + req := newTestRequest(t, "POST", app.URL("/echo"), &buf) + req.Header.Set("Content-Encoding", "gzip") + resp := mustDoRequest(t, app, req) + + assert.StatusCode(t, resp, http.StatusOK) + assert.BodyEquals(t, resp, decodedBody) + }) + t.Run("gzip content encoding too big after decompression", func(t *testing.T) { t.Parallel() @@ -666,6 +711,16 @@ func TestEcho(t *testing.T) { assert.BodyEquals(t, resp, "I am deflate mouse") }) + t.Run("invalid deflate content encoding", func(t *testing.T) { + t.Parallel() + + req := newTestRequest(t, "POST", app.URL("/echo"), strings.NewReader("not deflate")) + req.Header.Set("Content-Encoding", "deflate") + resp := mustDoRequest(t, app, req) + + assert.StatusCode(t, resp, http.StatusBadRequest) + }) + t.Run("deflate content encoding too big after decompression", func(t *testing.T) { t.Parallel() From 0ae32687fed6de4eb9b8b667715bac17f2a8bc6b Mon Sep 17 00:00:00 2001 From: RomeoApps <294788459+RomeoApps@users.noreply.github.com> Date: Mon, 22 Jun 2026 20:58:29 +0800 Subject: [PATCH 3/4] Add raw echo mode --- httpbin/handlers.go | 37 ++++++++++++++++++---------------- httpbin/handlers_test.go | 32 +++++++++++++++++++++++++++++ httpbin/static/index.html.tmpl | 2 +- 3 files changed, 53 insertions(+), 18 deletions(-) diff --git a/httpbin/handlers.go b/httpbin/handlers.go index aba81d0..a4285d2 100644 --- a/httpbin/handlers.go +++ b/httpbin/handlers.go @@ -123,7 +123,8 @@ func (h *HTTPBin) RequestWithBodyDiscard(w http.ResponseWriter, r *http.Request) } // Echo returns the request body. If the incoming request uses gzip or deflate -// Content-Encoding, the body is decompressed before echoing. +// Content-Encoding, the body is decompressed before echoing unless the raw +// query parameter is set. func (h *HTTPBin) Echo(w http.ResponseWriter, r *http.Request) { defer r.Body.Close() @@ -134,24 +135,26 @@ func (h *HTTPBin) Echo(w http.ResponseWriter, r *http.Request) { contentType = textContentType } - switch { - case strings.Contains(encoding, "gzip"): - gzr, err := gzip.NewReader(r.Body) - if err != nil { - writeError(w, http.StatusBadRequest, fmt.Errorf("error decoding gzip request body: %w", err)) - return - } - defer gzr.Close() - reader = gzr + if !r.URL.Query().Has("raw") { + switch { + case strings.Contains(encoding, "gzip"): + gzr, err := gzip.NewReader(r.Body) + if err != nil { + writeError(w, http.StatusBadRequest, fmt.Errorf("error decoding gzip request body: %w", err)) + return + } + defer gzr.Close() + reader = gzr - case strings.Contains(encoding, "deflate"): - zr, err := zlib.NewReader(r.Body) - if err != nil { - writeError(w, http.StatusBadRequest, fmt.Errorf("error decoding deflate request body: %w", err)) - return + case strings.Contains(encoding, "deflate"): + zr, err := zlib.NewReader(r.Body) + if err != nil { + writeError(w, http.StatusBadRequest, fmt.Errorf("error decoding deflate request body: %w", err)) + return + } + defer zr.Close() + reader = zr } - defer zr.Close() - reader = zr } // Read one byte past the limit so over-limit decoded bodies can be diff --git a/httpbin/handlers_test.go b/httpbin/handlers_test.go index 69243d0..2b26737 100644 --- a/httpbin/handlers_test.go +++ b/httpbin/handlers_test.go @@ -640,6 +640,27 @@ func TestEcho(t *testing.T) { assert.BodyEquals(t, resp, "I am gzip mouse") }) + t.Run("gzip content encoding raw", func(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + gzw := gzip.NewWriter(&buf) + _, err := gzw.Write([]byte("I am raw gzip mouse")) + assert.NilError(t, err) + assert.NilError(t, gzw.Close()) + compressedBody := append([]byte(nil), buf.Bytes()...) + + req := newTestRequest(t, "POST", app.URL("/echo?raw"), bytes.NewReader(compressedBody)) + req.Header.Set("Content-Encoding", "gzip") + resp := mustDoRequest(t, app, req) + + assert.StatusCode(t, resp, http.StatusOK) + assert.ContentType(t, resp, textContentType) + body, err := io.ReadAll(resp.Body) + assert.NilError(t, err) + assert.DeepEqual(t, body, compressedBody, "raw echo body") + }) + t.Run("invalid gzip content encoding", func(t *testing.T) { t.Parallel() @@ -650,6 +671,17 @@ func TestEcho(t *testing.T) { assert.StatusCode(t, resp, http.StatusBadRequest) }) + t.Run("invalid gzip content encoding raw", func(t *testing.T) { + t.Parallel() + + req := newTestRequest(t, "POST", app.URL("/echo?raw"), strings.NewReader("not gzip")) + req.Header.Set("Content-Encoding", "gzip") + resp := mustDoRequest(t, app, req) + + assert.StatusCode(t, resp, http.StatusOK) + assert.BodyEquals(t, resp, "not gzip") + }) + t.Run("gzip content encoding exactly at decoded limit", func(t *testing.T) { t.Parallel() diff --git a/httpbin/static/index.html.tmpl b/httpbin/static/index.html.tmpl index ae2a1bb..71de4c0 100644 --- a/httpbin/static/index.html.tmpl +++ b/httpbin/static/index.html.tmpl @@ -81,7 +81,7 @@
  • {{.Prefix}}/digest-auth/:qop/:user/:password/:algorithm Challenges HTTP Digest Auth using specified algorithm (MD5 or SHA-256)
  • {{.Prefix}}/drip?numbytes=n&duration=s&delay=s&code=code Drips data over the given duration after an optional initial delay, simulating a slow HTTP server.
  • {{.Prefix}}/dump/request Returns the given request in its HTTP/1.x wire approximate representation.
  • -
  • {{.Prefix}}/echo Echoes the request body as plain text, decompressing gzip or deflate request bodies.
  • +
  • {{.Prefix}}/echo Echoes the request body as plain text, decompressing gzip or deflate request bodies unless ?raw is set.
  • {{.Prefix}}/encoding/utf8 Returns page containing UTF-8 data.
  • {{.Prefix}}/env Returns all environment variables named with HTTPBIN_ENV_ prefix.
  • {{.Prefix}}/etag/:etag Assumes the resource has the given etag and responds to If-None-Match header with a 200 or 304 and If-Match with a 200 or 412 as appropriate.
  • From cc82227e24a12ccb6a4f592b67c0e8adb92ef3d9 Mon Sep 17 00:00:00 2001 From: RomeoApps <294788459+RomeoApps@users.noreply.github.com> Date: Tue, 23 Jun 2026 07:08:50 +0800 Subject: [PATCH 4/4] Cover echo error path --- httpbin/handlers_test.go | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/httpbin/handlers_test.go b/httpbin/handlers_test.go index 2b26737..b8c56b2 100644 --- a/httpbin/handlers_test.go +++ b/httpbin/handlers_test.go @@ -56,6 +56,16 @@ type targetConfig struct { MaxJSONLCount int64 } +type errorReadCloser struct{} + +func (errorReadCloser) Read(_ []byte) (int, error) { + return 0, io.ErrUnexpectedEOF +} + +func (errorReadCloser) Close() error { + return nil +} + // configFromApp builds a [targetConfig] from an in-process [HTTPBin]. func configFromApp(app *HTTPBin) targetConfig { return targetConfig{ @@ -148,6 +158,8 @@ func TestIndex(t *testing.T) { body := must.ReadAll(t, resp.Body) assert.Contains(t, body, "go-httpbin", "body") assert.Contains(t, body, prefix+"/get", "body") + assert.Contains(t, body, prefix+"/echo", "body") + assert.Contains(t, body, "?raw", "body") }) t.Run("not found"+prefix, func(t *testing.T) { @@ -772,6 +784,21 @@ func TestEcho(t *testing.T) { assert.StatusCode(t, resp, http.StatusBadRequest) }) + + t.Run("body read error", func(t *testing.T) { + t.Parallel() + + app := createApp() + req := httptest.NewRequest("POST", "/echo", nil) + req.Body = errorReadCloser{} + w := httptest.NewRecorder() + + app.Echo(w, req) + resp := w.Result() + + assert.StatusCode(t, resp, http.StatusBadRequest) + assert.BodyContains(t, resp, "error reading request body") + }) } func testRequestWithBody(t *testing.T, app *appTestInfo, verb, path string) {