diff --git a/httpbin/handlers.go b/httpbin/handlers.go index 9673d9e..a4285d2 100644 --- a/httpbin/handlers.go +++ b/httpbin/handlers.go @@ -122,6 +122,56 @@ func (h *HTTPBin) RequestWithBodyDiscard(w http.ResponseWriter, r *http.Request) writeJSON(http.StatusOK, w, resp) } +// Echo returns the request body. If the incoming request uses gzip or deflate +// 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() + + var reader io.Reader = r.Body + encoding := strings.ToLower(r.Header.Get("Content-Encoding")) + contentType := r.Header.Get("Content-Type") + if contentType == "" { + contentType = textContentType + } + + 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 + } + defer zr.Close() + 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)) + 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, contentType, 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..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) { @@ -593,6 +605,202 @@ 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("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() + + 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 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() + + 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("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() + + 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() + + 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("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() + + 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) + }) + + 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) { 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..71de4c0 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 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.