From 9ef6ecfa590b20390290097747c0e9bcd00bd0dc Mon Sep 17 00:00:00 2001 From: Rupayon Haldar <80724680+rupayon123@users.noreply.github.com> Date: Fri, 11 Sep 2026 09:08:24 -0400 Subject: [PATCH 1/4] fix(response): preserve raw body whitespace and line endings --- internal/response/body_test.go | 15 +++++++++++++++ internal/response/response.go | 6 ++---- 2 files changed, 17 insertions(+), 4 deletions(-) create mode 100644 internal/response/body_test.go diff --git a/internal/response/body_test.go b/internal/response/body_test.go new file mode 100644 index 0000000..33e684e --- /dev/null +++ b/internal/response/body_test.go @@ -0,0 +1,15 @@ +package response + +import "testing" + +func TestParseRawPreservesBodyBytes(t *testing.T) { + for _, body := range []string{" first\r\n\r\nlast \r\n", "\n\n", "\t ", "{\"message\":\"ok\"}\r\n"} { + for _, ending := range []string{"\r\n", "\n"} { + input := "HTTP/1.1 200 OK" + ending + "Content-Type: text/plain" + ending + ending + body + got := Parse(input) + if got.Body != body { + t.Errorf("body %q became %q with header separator %q", body, got.Body, ending) + } + } + } +} diff --git a/internal/response/response.go b/internal/response/response.go index 09f1d46..b5736ee 100644 --- a/internal/response/response.go +++ b/internal/response/response.go @@ -24,13 +24,11 @@ type Header struct { } func Parse(input string) Response { - cleaned := strings.ReplaceAll(input, "\r\n", "\n") - cleaned = strings.TrimSpace(cleaned) - if cleaned == "" { + if strings.TrimSpace(input) == "" { return Response{Mode: "empty", Warnings: []string{"input is empty"}} } - lines := strings.Split(cleaned, "\n") + lines := strings.Split(input, "\n") if looksVerbose(lines) { return parseVerbose(lines) } From fab86c488cc539eda62c69e2e178b5ccd414f997 Mon Sep 17 00:00:00 2001 From: Rupayon Haldar <80724680+rupayon123@users.noreply.github.com> Date: Fri, 11 Sep 2026 09:09:45 -0400 Subject: [PATCH 2/4] fix(response): reject invalid header names when parsing body input --- internal/response/header_name_test.go | 19 +++++++++++++++++++ internal/response/response.go | 16 +++++++++++++++- 2 files changed, 34 insertions(+), 1 deletion(-) create mode 100644 internal/response/header_name_test.go diff --git a/internal/response/header_name_test.go b/internal/response/header_name_test.go new file mode 100644 index 0000000..996ed47 --- /dev/null +++ b/internal/response/header_name_test.go @@ -0,0 +1,19 @@ +package response + +import "testing" + +func TestParseDoesNotConsumeJSONAsHeaders(t *testing.T) { + for _, input := range []string{`{"message":"ok"}`, "{\n \"message\": \"ok\"\n}\n", "not a header: body\n", ": value\n"} { + got := Parse(input) + if len(got.Headers) != 0 || got.Body != input { + t.Errorf("input %q: headers=%v body=%q", input, got.Headers, got.Body) + } + } +} + +func TestParseAcceptsTokenHeaderNames(t *testing.T) { + got := Parse("HTTP/1.1 200 OK\nX-Trace_1: yes\nX!#$%&'*+-.^_`|~: token\n\nbody") + if len(got.Headers) != 2 || got.Body != "body" { + t.Fatalf("unexpected response: %#v", got) + } +} diff --git a/internal/response/response.go b/internal/response/response.go index b5736ee..4723cc3 100644 --- a/internal/response/response.go +++ b/internal/response/response.go @@ -51,7 +51,7 @@ func Parse(input string) Response { } name, value, found := strings.Cut(line, ":") - if !found { + if !found || !validHeaderName(strings.TrimSpace(name)) { break } @@ -73,6 +73,20 @@ func Parse(input string) Response { return response } +// Header names are nonempty HTTP tokens; JSON keys and body prose are not headers. +func validHeaderName(name string) bool { + if name == "" { + return false + } + for _, r := range name { + if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || strings.ContainsRune("!#$%&'*+-.^_`|~", r) { + continue + } + return false + } + return true +} + func FromHTTPResponse(statusLine string, headers http.Header, body []byte) Response { response := Response{Mode: "http", StatusLine: statusLine} From 63101aa9d71e34c1a535115f0f8ee59d39a8e7ae Mon Sep 17 00:00:00 2001 From: Rupayon Haldar <80724680+rupayon123@users.noreply.github.com> Date: Fri, 11 Sep 2026 09:11:03 -0400 Subject: [PATCH 3/4] fix(response): retain only the final verbose HTTP exchange --- internal/response/exchanges_test.go | 30 +++++++++++++++++++++++++++++ internal/response/response.go | 6 +++++- 2 files changed, 35 insertions(+), 1 deletion(-) create mode 100644 internal/response/exchanges_test.go diff --git a/internal/response/exchanges_test.go b/internal/response/exchanges_test.go new file mode 100644 index 0000000..2cc519d --- /dev/null +++ b/internal/response/exchanges_test.go @@ -0,0 +1,30 @@ +package response + +import ( + "strings" + "testing" +) + +func TestParseVerboseKeepsFinalExchange(t *testing.T) { + input := strings.Join([]string{ + "> GET /old HTTP/1.1", "> Host: old.example", "< HTTP/1.1 302 Found", "< Location: https://new.example/", "<", "redirect body", + "> GET /new HTTP/2", "> Host: new.example", "> X-Protocol: foo HTTP/2", "< HTTP/2 200", "< Content-Type: text/plain", "<", "final body", + }, "\n") + got := Parse(input) + if got.StatusLine != "HTTP/2 200" || got.RequestLine != "GET /new HTTP/2" || got.Body != "final body" { + t.Errorf("mixed exchanges: %#v", got) + } + if len(got.Headers) != 1 || got.Headers[0].Name != "Content-Type" { + t.Errorf("stale response headers: %v", got.Headers) + } + if len(got.RequestHeaders) != 2 || got.RequestHeaders[0].Value != "new.example" { + t.Errorf("stale request headers: %v", got.RequestHeaders) + } +} + +func TestParseVerboseDiscardsInterimHeaders(t *testing.T) { + got := Parse("< HTTP/1.1 103 Early Hints\n< Link: \n<\n< HTTP/1.1 200 OK\n< Content-Type: text/plain\n<\nbody") + if got.StatusLine != "HTTP/1.1 200 OK" || len(got.Headers) != 1 || got.Headers[0].Name != "Content-Type" || got.Body != "body" { + t.Fatalf("mixed interim and final response: %#v", got) + } +} diff --git a/internal/response/response.go b/internal/response/response.go index 4723cc3..0f8ea51 100644 --- a/internal/response/response.go +++ b/internal/response/response.go @@ -224,8 +224,10 @@ func parseVerbose(lines []string) Response { if payload == "" { continue } - if response.RequestLine == "" && strings.Contains(payload, " HTTP/") { + fields := strings.Fields(payload) + if len(fields) == 3 && validHeaderName(fields[0]) && strings.HasPrefix(fields[2], "HTTP/") { response.RequestLine = payload + response.RequestHeaders = nil continue } if name, value, ok := strings.Cut(payload, ":"); ok { @@ -243,6 +245,8 @@ func parseVerbose(lines []string) Response { } if strings.HasPrefix(payload, "HTTP/") { response.StatusLine = payload + response.Headers = nil + response.Body = "" inResponseHeaders = true inResponseBody = false continue From 15a272ca81f6858622899c2cf8d8a0d8e5f1881e Mon Sep 17 00:00:00 2001 From: Rupayon Haldar <80724680+rupayon123@users.noreply.github.com> Date: Fri, 11 Sep 2026 13:23:26 -0400 Subject: [PATCH 4/4] fix(response): preserve whitespace-only HTTP response bodies --- internal/response/response.go | 2 +- internal/response/whitespace_body_test.go | 22 ++++++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) create mode 100644 internal/response/whitespace_body_test.go diff --git a/internal/response/response.go b/internal/response/response.go index 0f8ea51..41281c4 100644 --- a/internal/response/response.go +++ b/internal/response/response.go @@ -107,7 +107,7 @@ func FromHTTPResponse(statusLine string, headers http.Header, body []byte) Respo func FormatBody(body []byte, contentType string) string { trimmed := bytes.TrimSpace(body) if len(trimmed) == 0 { - return "" + return string(body) } if isJSONContentType(contentType) || json.Valid(trimmed) { diff --git a/internal/response/whitespace_body_test.go b/internal/response/whitespace_body_test.go new file mode 100644 index 0000000..b69accb --- /dev/null +++ b/internal/response/whitespace_body_test.go @@ -0,0 +1,22 @@ +package response + +import ( + "net/http" + "testing" +) + +func TestFromHTTPResponsePreservesWhitespaceOnlyBody(t *testing.T) { + for _, body := range []string{" ", "\t\r\n", "\n\n"} { + result := FromHTTPResponse("HTTP/1.1 200 OK", http.Header{"Content-Type": []string{"text/plain"}}, []byte(body)) + if result.Body != body { + t.Errorf("body %q became %q", body, result.Body) + } + if len(result.Warnings) != 0 { + t.Errorf("nonempty body reported empty: %v", result.Warnings) + } + } + empty := FromHTTPResponse("HTTP/1.1 204 No Content", nil, nil) + if empty.Body != "" || len(empty.Warnings) == 0 { + t.Fatal("truly empty body should retain its warning") + } +}