From 683ef7f8cabd7aa42dceb6b18ca242a93704b2f9 Mon Sep 17 00:00:00 2001 From: Anuraag Agrawal <anuraaga@gmail.com> Date: Fri, 11 Nov 2022 09:15:34 +0900 Subject: [PATCH] Compatibility immprovements (#89) - Handle bodies for GET requests to the /anything endpoint - Do not encode HTML tags in serialized JSON --- httpbin/handlers.go | 49 ++++++++++++++++++++++++---------------- httpbin/handlers_test.go | 26 +++++++++++++++++---- httpbin/helpers.go | 7 +++--- 3 files changed, 55 insertions(+), 27 deletions(-) diff --git a/httpbin/handlers.go b/httpbin/handlers.go index 4d8c7cf..aef9ee4 100644 --- a/httpbin/handlers.go +++ b/httpbin/handlers.go @@ -54,14 +54,14 @@ func (h *HTTPBin) Get(w http.ResponseWriter, r *http.Request) { Origin: getClientIP(r), URL: getURL(r).String(), } - body, _ := json.Marshal(resp) + body, _ := jsonMarshalNoEscape(resp) writeJSON(w, body, http.StatusOK) } // Anything returns anything that is passed to request. func (h *HTTPBin) Anything(w http.ResponseWriter, r *http.Request) { switch r.Method { - case "GET", "HEAD": + case "HEAD": h.Get(w, r) default: h.RequestWithBody(w, r) @@ -83,7 +83,7 @@ func (h *HTTPBin) RequestWithBody(w http.ResponseWriter, r *http.Request) { return } - body, _ := json.Marshal(resp) + body, _ := jsonMarshalNoEscape(resp) writeJSON(w, body, http.StatusOK) } @@ -94,7 +94,7 @@ func (h *HTTPBin) Gzip(w http.ResponseWriter, r *http.Request) { Origin: getClientIP(r), Gzipped: true, } - body, _ := json.Marshal(resp) + body, _ := jsonMarshalNoEscape(resp) buf := &bytes.Buffer{} gzw := gzip.NewWriter(buf) @@ -114,7 +114,7 @@ func (h *HTTPBin) Deflate(w http.ResponseWriter, r *http.Request) { Origin: getClientIP(r), Deflated: true, } - body, _ := json.Marshal(resp) + body, _ := jsonMarshalNoEscape(resp) buf := &bytes.Buffer{} w2 := zlib.NewWriter(buf) @@ -129,7 +129,7 @@ func (h *HTTPBin) Deflate(w http.ResponseWriter, r *http.Request) { // IP echoes the IP address of the incoming request func (h *HTTPBin) IP(w http.ResponseWriter, r *http.Request) { - body, _ := json.Marshal(&ipResponse{ + body, _ := jsonMarshalNoEscape(&ipResponse{ Origin: getClientIP(r), }) writeJSON(w, body, http.StatusOK) @@ -137,7 +137,7 @@ func (h *HTTPBin) IP(w http.ResponseWriter, r *http.Request) { // UserAgent echoes the incoming User-Agent header func (h *HTTPBin) UserAgent(w http.ResponseWriter, r *http.Request) { - body, _ := json.Marshal(&userAgentResponse{ + body, _ := jsonMarshalNoEscape(&userAgentResponse{ UserAgent: r.Header.Get("User-Agent"), }) writeJSON(w, body, http.StatusOK) @@ -145,7 +145,7 @@ func (h *HTTPBin) UserAgent(w http.ResponseWriter, r *http.Request) { // Headers echoes the incoming request headers func (h *HTTPBin) Headers(w http.ResponseWriter, r *http.Request) { - body, _ := json.Marshal(&headersResponse{ + body, _ := jsonMarshalNoEscape(&headersResponse{ Headers: getRequestHeaders(r), }) writeJSON(w, body, http.StatusOK) @@ -175,7 +175,7 @@ func (h *HTTPBin) Status(w http.ResponseWriter, r *http.Request) { "Location": "/redirect/1", }, } - notAcceptableBody, _ := json.Marshal(map[string]interface{}{ + notAcceptableBody, _ := jsonMarshalNoEscape(map[string]interface{}{ "message": "Client did not request a supported media type", "accept": acceptedMediaTypes, }) @@ -302,7 +302,7 @@ func (h *HTTPBin) ResponseHeaders(w http.ResponseWriter, r *http.Request) { w.Header().Add(k, v) } } - body, _ := json.Marshal(args) + body, _ := jsonMarshalNoEscape(args) if contentType := w.Header().Get("Content-Type"); contentType == "" { w.Header().Set("Content-Type", jsonContentType) } @@ -400,7 +400,7 @@ func (h *HTTPBin) Cookies(w http.ResponseWriter, r *http.Request) { for _, c := range r.Cookies() { resp[c.Name] = c.Value } - body, _ := json.Marshal(resp) + body, _ := jsonMarshalNoEscape(resp) writeJSON(w, body, http.StatusOK) } @@ -455,7 +455,7 @@ func (h *HTTPBin) BasicAuth(w http.ResponseWriter, r *http.Request) { w.Header().Set("WWW-Authenticate", `Basic realm="Fake Realm"`) } - body, _ := json.Marshal(&authResponse{ + body, _ := jsonMarshalNoEscape(&authResponse{ Authorized: authorized, User: givenUser, }) @@ -481,7 +481,7 @@ func (h *HTTPBin) HiddenBasicAuth(w http.ResponseWriter, r *http.Request) { return } - body, _ := json.Marshal(&authResponse{ + body, _ := jsonMarshalNoEscape(&authResponse{ Authorized: authorized, User: givenUser, }) @@ -517,9 +517,8 @@ func (h *HTTPBin) Stream(w http.ResponseWriter, r *http.Request) { f := w.(http.Flusher) for i := 0; i < n; i++ { resp.ID = i - line, _ := json.Marshal(resp) + line, _ := jsonMarshalNoEscape(resp) w.Write(line) - w.Write([]byte("\n")) f.Flush() } } @@ -728,7 +727,7 @@ func (h *HTTPBin) ETag(w http.ResponseWriter, r *http.Request) { Origin: getClientIP(r), URL: getURL(r).String(), } - body, _ := json.Marshal(resp) + body, _ := jsonMarshalNoEscape(resp) // Let http.ServeContent deal with If-None-Match and If-Match headers: // https://golang.org/pkg/net/http/#ServeContent @@ -954,7 +953,7 @@ func (h *HTTPBin) DigestAuth(w http.ResponseWriter, r *http.Request) { return } - resp, _ := json.Marshal(&authResponse{ + resp, _ := jsonMarshalNoEscape(&authResponse{ Authorized: true, User: user, }) @@ -963,7 +962,7 @@ func (h *HTTPBin) DigestAuth(w http.ResponseWriter, r *http.Request) { // UUID - responds with a generated UUID func (h *HTTPBin) UUID(w http.ResponseWriter, r *http.Request) { - resp, _ := json.Marshal(&uuidResponse{ + resp, _ := jsonMarshalNoEscape(&uuidResponse{ UUID: uuidv4(), }) writeJSON(w, resp, http.StatusOK) @@ -1007,7 +1006,7 @@ func (h *HTTPBin) Bearer(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusUnauthorized) return } - body, _ := json.Marshal(&bearerResponse{ + body, _ := jsonMarshalNoEscape(&bearerResponse{ Authenticated: true, Token: tokenFields[1], }) @@ -1016,8 +1015,18 @@ func (h *HTTPBin) Bearer(w http.ResponseWriter, r *http.Request) { // Hostname - returns the hostname. func (h *HTTPBin) Hostname(w http.ResponseWriter, r *http.Request) { - body, _ := json.Marshal(hostnameResponse{ + body, _ := jsonMarshalNoEscape(hostnameResponse{ Hostname: h.hostname, }) writeJSON(w, body, http.StatusOK) } + +// json.Marshal escapes HTML in strings while httpbin does not, so +// we need to set up the encoder manually to reproduce that behavior. +func jsonMarshalNoEscape(value interface{}) ([]byte, error) { + buffer := &bytes.Buffer{} + encoder := json.NewEncoder(buffer) + encoder.SetEscapeHTML(false) + err := encoder.Encode(value) + return buffer.Bytes(), err +} diff --git a/httpbin/handlers_test.go b/httpbin/handlers_test.go index 89503c9..366bccd 100644 --- a/httpbin/handlers_test.go +++ b/httpbin/handlers_test.go @@ -480,6 +480,7 @@ func TestAnything(t *testing.T) { t.Parallel() var ( verbsWithReqBodies = []string{ + "GET", "DELETE", "PATCH", "POST", @@ -494,10 +495,6 @@ func TestAnything(t *testing.T) { for _, verb := range verbsWithReqBodies { testRequestWithBody(t, verb, path) } - // also test GET requests for each path - t.Run("GET "+path, func(t *testing.T) { - testRequestWithoutBody(t, path, nil, nil, http.StatusOK) - }) } } @@ -527,6 +524,7 @@ func testRequestWithBody(t *testing.T, verb, path string) { testRequestWithBodyInvalidFormEncodedBody, testRequestWithBodyInvalidJSON, testRequestWithBodyInvalidMultiPartBody, + testRequestWithBodyHTML, testRequestWithBodyJSON, testRequestWithBodyMultiPartBody, testRequestWithBodyQueryParams, @@ -623,6 +621,26 @@ func testRequestWithBodyFormEncodedBody(t *testing.T, verb, path string) { } } +func testRequestWithBodyHTML(t *testing.T, verb, path string) { + data := "<html><body><h1>hello world</h1></body></html>" + + r, _ := http.NewRequest(verb, path, strings.NewReader(data)) + r.Header.Set("Content-Type", htmlContentType) + w := httptest.NewRecorder() + handler.ServeHTTP(w, r) + + assertStatusCode(t, w, http.StatusOK) + assertContentType(t, w, jsonContentType) + + // We do not use json.Unmarshal here which would unescape any escaped characters. + // For httpbin compatibility, we need to verify the data is returned as-is without + // escaping. + respBody := w.Body.String() + if !strings.Contains(respBody, data) { + t.Fatalf("response data mismatch, %#v != %#v", respBody, data) + } +} + func testRequestWithBodyFormEncodedBodyNoContentType(t *testing.T, verb, path string) { params := url.Values{} params.Set("foo", "foo") diff --git a/httpbin/helpers.go b/httpbin/helpers.go index ef48f60..48c18c3 100644 --- a/httpbin/helpers.go +++ b/httpbin/helpers.go @@ -123,12 +123,13 @@ func parseBody(w http.ResponseWriter, r *http.Request, resp *bodyResponse) error ct := r.Header.Get("Content-Type") switch { case strings.HasPrefix(ct, "application/x-www-form-urlencoded"): - // r.ParseForm() does not populate r.PostForm for DELETE requests, but + // r.ParseForm() does not populate r.PostForm for DELETE or GET requests, but // we need it to for compatibility with the httpbin implementation, so // we trick it with this ugly hack. - if r.Method == http.MethodDelete { + if r.Method == http.MethodDelete || r.Method == http.MethodGet { + originalMethod := r.Method r.Method = http.MethodPost - defer func() { r.Method = http.MethodDelete }() + defer func() { r.Method = originalMethod }() } if err := r.ParseForm(); err != nil { return err -- GitLab