Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions httpbin/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why h.MaxBodySize+1 here?

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
}
Comment on lines +162 to +170

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the second check here necessary? My epxectation is that io.LimitReader will take effect after incoming bytes are decoded from gzip/deflate and should effectively enforce the limit itself.


writeResponse(w, http.StatusOK, contentType, body)
}

// Gzip returns a gzipped response
func (h *HTTPBin) Gzip(w http.ResponseWriter, r *http.Request) {
var (
Expand Down
208 changes: 208 additions & 0 deletions httpbin/handlers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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()
Expand Down
1 change: 1 addition & 0 deletions httpbin/httpbin.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions httpbin/static/index.html.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@
<li><a href="{{.Prefix}}/digest-auth/auth/user/password/SHA-256"><code>{{.Prefix}}/digest-auth/:qop/:user/:password/:algorithm</code></a> Challenges HTTP Digest Auth using specified algorithm (MD5 or SHA-256)</li>
<li><a href="{{.Prefix}}/drip?code=200&amp;numbytes=5&amp;duration=5"><code>{{.Prefix}}/drip?numbytes=n&amp;duration=s&amp;delay=s&amp;code=code</code></a> Drips data over the given duration after an optional initial delay, simulating a slow HTTP server.</li>
<li><a href="{{.Prefix}}/dump/request"><code>{{.Prefix}}/dump/request</code></a> Returns the given request in its HTTP/1.x wire approximate representation.</li>
<li><a href="{{.Prefix}}/echo"><code>{{.Prefix}}/echo</code></a> Echoes the request body as plain text, decompressing gzip or deflate request bodies unless <code>?raw</code> is set.</li>
<li><a href="{{.Prefix}}/encoding/utf8"><code>{{.Prefix}}/encoding/utf8</code></a> Returns page containing UTF-8 data.</li>
<li><a href="{{.Prefix}}/env"><code>{{.Prefix}}/env</code></a> Returns all environment variables named with <code>HTTPBIN_ENV_</code> prefix.</li>
<li><a href="{{.Prefix}}/etag/etag"><code>{{.Prefix}}/etag/:etag</code></a> 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.</li>
Expand Down