diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 74ea26b..f99b687 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -21,12 +21,12 @@ jobs: steps: - name: Check out code - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Initialize CodeQL - uses: github/codeql-action/init@v3 + uses: github/codeql-action/init@v4 with: languages: go - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3 + uses: github/codeql-action/analyze@v4 diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 05f74b4..e39286f 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -13,30 +13,30 @@ jobs: strategy: matrix: os: [ubuntu-latest, macos-latest, windows-latest] - go: [1.23.x, 1.22.x] # when updating versions, update it below too. + go: [1.25.x, 1.26.x] # when updating versions, update it below too. runs-on: ${{ matrix.os }} name: Test steps: - name: Set up Go ${{ matrix.go }} - uses: actions/setup-go@v5 + uses: actions/setup-go@v6 with: go-version: ${{ matrix.go }} - name: Check out code - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Test # TODO(henvic): Skip generating code coverage when not sending it to Coveralls to speed up testing. # Remove example directory from code coverage explicitly since after #26 it # started being considered on the code coverage report and we don't want that. - continue-on-error: ${{ matrix.os != 'ubuntu-latest' || matrix.go != '1.23.x' }} + continue-on-error: ${{ matrix.os != 'ubuntu-latest' || matrix.go != '1.26.x' }} run: | go test -race -covermode atomic -coverprofile=profile.cov ./... sed -i '/^github\.com\/henvic\/httpretty\/example\//d' profile.cov - name: Code coverage - if: ${{ matrix.os == 'ubuntu-latest' && matrix.go == '1.23.x' }} + if: ${{ matrix.os == 'ubuntu-latest' && matrix.go == '1.26.x' }} uses: shogo82148/actions-goveralls@v1 with: path-to-profile: profile.cov diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 77ba7df..b9a8329 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -16,12 +16,12 @@ jobs: steps: - name: Set up Go 1.x - uses: actions/setup-go@v5 + uses: actions/setup-go@v6 with: - go-version: "1.23.x" + go-version: "1.26.x" - name: Check out code - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Verify dependencies run: | diff --git a/binary_test.go b/binary_test.go index 08b480d..16483e0 100644 --- a/binary_test.go +++ b/binary_test.go @@ -50,6 +50,21 @@ func TestIsBinary(t *testing.T) { data: bytes.Repeat([]byte{1, 2, 3, 4, 5, 6, 7, 8}, 65), binary: true, }, + { + desc: "Binary header (exactly 512 bytes) with text trailer", + data: append(bytes.Repeat([]byte{1, 2, 3, 4, 5, 6, 7, 8}, 64), []byte("plain text trailer")...), + binary: true, + }, + { + desc: "Text over 512 bytes with binary trailer", + data: append(bytes.Repeat([]byte("plain text "), 50), []byte{1, 2, 3, 4, 5}...), + binary: false, + }, + { + desc: "Large text with leading UTF-8 BOM and binary trailer", // https://www.unicode.org/faq/utf_bom#BOM + data: append(append([]byte("\xEF\xBB\xBF"), bytes.Repeat([]byte("plain text "), 50)...), []byte{1, 2, 3, 4, 5}...), + binary: false, + }, { desc: "JPEG image", data: []byte("\xFF\xD8\xFF"), diff --git a/client_test.go b/client_test.go index 30ca788..fcee01f 100644 --- a/client_test.go +++ b/client_test.go @@ -1336,6 +1336,41 @@ func TestOutgoingTLS(t *testing.T) { testBody(t, resp.Body, []byte("Hello, world!")) } +func TestOutgoingTLSIPSAN(t *testing.T) { + t.Parallel() + ts := httptest.NewTLSServer(&helloHandler{}) + defer ts.Close() + + logger := &Logger{ + TLS: true, + RequestHeader: true, + RequestBody: true, + ResponseHeader: true, + ResponseBody: true, + } + var buf bytes.Buffer + logger.SetOutput(&buf) + client := ts.Client() + client.Transport = logger.RoundTripper(client.Transport) + + req, err := http.NewRequest(http.MethodGet, ts.URL, nil) + if err != nil { + t.Errorf("cannot create request: %v", err) + } + req.Host = "127.0.0.1" // hit the IP SAN path; the httptest cert has 127.0.0.1 in IPAddresses + req.Header.Add("User-Agent", "Robot/0.1 crawler@example.com") + resp, err := client.Do(req) + if err != nil { + t.Errorf("cannot connect to the server: %v", err) + } + defer resp.Body.Close() + want := fmt.Sprintf(golden(t.Name()), ts.URL) + if got := buf.String(); !regexp.MustCompile(want).MatchString(got) { + t.Errorf("logged HTTP request %s; want %s", got, want) + } + testBody(t, resp.Body, []byte("Hello, world!")) +} + func TestOutgoingTLSInsecureSkipVerify(t *testing.T) { t.Parallel() ts := httptest.NewTLSServer(&helloHandler{}) diff --git a/go.mod b/go.mod index 8f164c1..63a2a67 100644 --- a/go.mod +++ b/go.mod @@ -1,5 +1,5 @@ module github.com/henvic/httpretty -go 1.22 +go 1.25.0 -require golang.org/x/tools v0.14.0 +require golang.org/x/tools v0.45.0 diff --git a/go.sum b/go.sum index abb29e5..46bf538 100644 --- a/go.sum +++ b/go.sum @@ -1,28 +1,2 @@ -github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.1.10 h1:QjFRCZxdOhBJ/UNgnBZLbNV13DlbnK0quyivTnXJM20= -golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E= -golang.org/x/tools v0.14.0 h1:jvNa2pY0M4r62jkRQ6RwEZZyPcymeL9XZMLBbV7U2nc= -golang.org/x/tools v0.14.0/go.mod h1:uYBEerGOWcJyEORxN+Ek8+TT266gXkNlHdJBwexUsBg= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= +golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= diff --git a/httpretty.go b/httpretty.go index 3bedebb..59f3592 100644 --- a/httpretty.go +++ b/httpretty.go @@ -364,7 +364,11 @@ func (h httpHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) { buf: &bytes.Buffer{}, } defer p.printServerResponse(req, rec) - h.next.ServeHTTP(rec, req) + var rw http.ResponseWriter = rec + if _, ok := w.(http.Flusher); ok { + rw = &flushingRecorder{rec} + } + h.next.ServeHTTP(rw, req) } // PrintRequest prints a request, even when WithHide is used to hide it. diff --git a/httpretty_test.go b/httpretty_test.go index 5feca64..8ca78f8 100644 --- a/httpretty_test.go +++ b/httpretty_test.go @@ -248,6 +248,46 @@ func TestPrintResponseNil(t *testing.T) { } } +func TestStatusColor(t *testing.T) { + t.Parallel() + // proto is always blue+bold; only the status text color varies by class. + const proto = "\x1b[34;1mHTTP/1.1\x1b[0m" + testCases := []struct { + desc string + status string + want string // expected colored status text, hardcoded + }{ + {"empty status", "", "\x1b[31m\x1b[0m"}, + {"informational 1xx", "100 Continue", "\x1b[34m100 Continue\x1b[0m"}, + {"success 2xx", "200 OK", "\x1b[32m200 OK\x1b[0m"}, + {"redirect 3xx", "301 Moved Permanently", "\x1b[33m301 Moved Permanently\x1b[0m"}, + {"client error 4xx", "404 Not Found", "\x1b[31m404 Not Found\x1b[0m"}, + {"server error 5xx", "500 Internal Server Error", "\x1b[1;31m500 Internal Server Error\x1b[0m"}, + {"non-standard 6xx", "678 Teapot Overheated", "\x1b[34m678 Teapot Overheated\x1b[0m"}, + {"non-numeric status", "OK", "\x1b[31mOK\x1b[0m"}, + } + for _, tc := range testCases { + t.Run(tc.desc, func(t *testing.T) { + t.Parallel() + logger := &Logger{ + ResponseHeader: true, + Colors: true, + } + var buf bytes.Buffer + logger.SetOutput(&buf) + logger.PrintResponse(&http.Response{ + Proto: "HTTP/1.1", + Status: tc.status, + Header: http.Header{}, + }) + want := "< " + proto + " " + tc.want + "\n\n" + if got := buf.String(); got != want { + t.Errorf("PrintResponse(%q) status line = %q, want %q", tc.status, got, want) + } + }) + } +} + func testBody(t *testing.T, r io.Reader, want []byte) { t.Helper() got, err := io.ReadAll(r) diff --git a/printer.go b/printer.go index b85b28f..6fc92a1 100644 --- a/printer.go +++ b/printer.go @@ -111,7 +111,7 @@ func (p *printer) printRequestInfo(req *http.Request) { } } -// checkFilter checkes if the request is filtered and if the Request value is nil. +// checkFilter checks if the request is filtered and if the Request value is nil. func (p *printer) checkFilter(req *http.Request) (skip bool) { filter := p.logger.getFilter() if req == nil { @@ -205,7 +205,7 @@ func (p *printer) printResponseBodyOut(resp *http.Response) { // See discussion at https://groups.google.com/forum/#!topic/golang-nuts/YeLL7L7SwWs func isBinary(body []byte) bool { if len(body) > 512 { - body = body[512:] + body = body[:512] } // If file contains UTF-8 OR UTF-16 BOM, consider it non-binary. // Reference: https://tools.ietf.org/html/draft-ietf-websec-mime-sniff-03#section-5 @@ -243,6 +243,7 @@ var binaryMediatypes = map[string]struct{}{ "video": {}, "application/vnd.ms-fontobject": {}, "font": {}, + "application/gzip": {}, "application/x-gzip": {}, "application/zip": {}, "application/x-rar-compressed": {}, @@ -309,14 +310,8 @@ func (p *printer) printTLSInfo(state *tls.ConnectionState, skipVerifyChains bool if state == nil { return } - protocol := tlsProtocolVersions[state.Version] - if protocol == "" { - protocol = fmt.Sprintf("%#v", state.Version) - } - cipher := tlsCiphers[state.CipherSuite] - if cipher == "" { - cipher = fmt.Sprintf("%#v", state.CipherSuite) - } + protocol := tls.VersionName(state.Version) + cipher := tls.CipherSuiteName(state.CipherSuite) p.printf("* TLS connection using %s / %s", p.format(color.FgBlue, protocol), p.format(color.FgBlue, cipher)) if !skipVerifyChains && state.VerifiedChains == nil { p.print(" (insecure=true)") @@ -377,13 +372,24 @@ func (p *printer) printCertificate(hostname string, cert *x509.Certificate) { p.printf(`* subject: %v * start date: %v * expire date: %v -* issuer: %v `, p.format(color.FgBlue, cert.Subject), p.format(color.FgBlue, cert.NotBefore.Format(time.UnixDate)), p.format(color.FgBlue, cert.NotAfter.Format(time.UnixDate)), - p.format(color.FgBlue, cert.Issuer), ) + if hostname != "" { + if san, ok := matchedSAN(hostname, cert); ok { + if san == "" { + p.printf("* subjectAltName: \"%s\" matches cert's IP address!\n", + p.format(color.FgBlue, hostname)) + } else { + p.printf("* subjectAltName: \"%s\" matches cert's \"%s\"\n", + p.format(color.FgBlue, hostname), + p.format(color.FgBlue, san)) + } + } + } + p.printf("* issuer: %v\n", p.format(color.FgBlue, cert.Issuer)) if hostname == "" { return } @@ -394,6 +400,48 @@ func (p *printer) printCertificate(hostname string, cert *x509.Certificate) { p.println("* TLS certificate verify ok.") } +// matchedSAN finds the cert SAN entry that matches hostname, following the +// RFC 6125 wildcard rule (leftmost label only). For IP-literal hostnames it +// scans IPAddresses and returns "" with ok=true to signal an IP match. +func matchedSAN(hostname string, cert *x509.Certificate) (string, bool) { + if ip := net.ParseIP(hostname); ip != nil { + for _, certIP := range cert.IPAddresses { + if certIP.Equal(ip) { + return "", true + } + } + return "", false + } + host := strings.TrimSuffix(strings.ToLower(hostname), ".") + for _, name := range cert.DNSNames { + if matchHostname(strings.ToLower(name), host) { + return name, true + } + } + return "", false +} + +func matchHostname(pattern, host string) bool { + pattern = strings.TrimSuffix(pattern, ".") + if pattern == "" || host == "" { + return false + } + patternParts := strings.Split(pattern, ".") + hostParts := strings.Split(host, ".") + if len(patternParts) != len(hostParts) { + return false + } + for i, part := range patternParts { + if i == 0 && part == "*" { + continue + } + if part != hostParts[i] { + return false + } + } + return true +} + func (p *printer) printServerResponse(req *http.Request, rec *responseRecorder) { if p.logger.ResponseHeader { // TODO(henvic): see how httptest.ResponseRecorder adds extra headers due to Content-Type detection @@ -410,7 +458,7 @@ func (p *printer) printServerResponse(req *http.Request, rec *responseRecorder) if skip { return } - if mediatype := req.Header.Get("Content-Type"); mediatype != "" && isBinaryMediatype(mediatype) { + if mediatype := rec.Header().Get("Content-Type"); mediatype != "" && isBinaryMediatype(mediatype) { p.println("* body contains binary data") return } @@ -421,10 +469,36 @@ func (p *printer) printServerResponse(req *http.Request, rec *responseRecorder) p.printBodyReader(rec.Header().Get("Content-Type"), rec.buf) } +// statusColor returns color attributes for an HTTP status line +// based on the status class: +// 1xx (informational), 2xx (success) is green, 3xx (redirection) is yellow, +// 4xx (client error) is red, and 5xx (server error) is bold red. +// Any non-standard classes (0xx, 6xx-9xx) are blue, +// and an empty status or one that doesn't start with a digit, is shown red. +func statusColor(status string) []color.Attribute { + if len(status) == 0 { + return []color.Attribute{color.FgRed} + } + switch status[0] { + case '2': + return []color.Attribute{color.FgGreen} + case '3': + return []color.Attribute{color.FgYellow} + case '4': + return []color.Attribute{color.FgRed} + case '5': + return []color.Attribute{color.Bold, color.FgRed} + case '0', '1', '6', '7', '8', '9': + return []color.Attribute{color.FgBlue} + default: + return []color.Attribute{color.FgRed} + } +} + func (p *printer) printResponseHeader(proto, status string, h http.Header) { p.printf("< %s %s\n", p.format(color.FgBlue, color.Bold, proto), - p.format(color.FgRed, status)) + p.format(statusColor(status), status)) p.printHeaders('<', h) p.println() } diff --git a/recorder.go b/recorder.go index 9b55a65..7079027 100644 --- a/recorder.go +++ b/recorder.go @@ -51,3 +51,20 @@ func (rr *responseRecorder) WriteHeader(statusCode int) { rr.ResponseWriter.WriteHeader(statusCode) rr.statusCode = statusCode } + +// Unwrap returns the underlying ResponseWriter so that callers using +// http.NewResponseController can reach interfaces (Flusher, Hijacker, +// Pusher, deadline setters) implemented by the original writer. +func (rr *responseRecorder) Unwrap() http.ResponseWriter { + return rr.ResponseWriter +} + +// flushingRecorder is used only when the underlying writer implements +// http.Flusher, so that rw.(http.Flusher) works. +type flushingRecorder struct { + *responseRecorder +} + +func (fr *flushingRecorder) Flush() { + fr.ResponseWriter.(http.Flusher).Flush() +} diff --git a/server_test.go b/server_test.go index b820cc9..ec14d82 100644 --- a/server_test.go +++ b/server_test.go @@ -105,7 +105,7 @@ func TestIncomingNotFound(t *testing.T) { t.Errorf("cannot connect to the server: %v", err) } if resp.StatusCode != http.StatusNotFound { - t.Errorf("got status codem %v, wanted %v", resp.StatusCode, http.StatusNotFound) + t.Errorf("got status code %v, wanted %v", resp.StatusCode, http.StatusNotFound) } }() is.Wait() @@ -790,6 +790,183 @@ func TestIncomingBinaryBodyNoMediatypeHeader(t *testing.T) { } } +func TestIncomingBinaryResponseTextRequest(t *testing.T) { + t.Parallel() + logger := &Logger{ + RequestHeader: true, + RequestBody: true, + ResponseHeader: true, + ResponseBody: true, + } + var buf bytes.Buffer + logger.SetOutput(&buf) + is := inspect(logger.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header()["Date"] = nil + // Respond with a binary Content-Type but a body that has no binary bytes, + // so only the Content-Type header check can catch it (not the byte-level heuristic). + w.Header().Set("Content-Type", "application/pdf") + fmt.Fprint(w, "not really a pdf") + })), 1) + + ts := httptest.NewServer(is) + defer ts.Close() + go func() { + client := newServerClient() + req, err := http.NewRequest(http.MethodPost, ts.URL, strings.NewReader(`{"query":"convert"}`)) + if err != nil { + t.Errorf("cannot create request: %v", err) + } + req.Header.Set("Content-Type", "application/json") + if _, err = client.Do(req); err != nil { + t.Errorf("cannot connect to the server: %v", err) + } + }() + is.Wait() + got := buf.String() + // The response body should be detected as binary based on the response Content-Type (application/pdf), + // not the request Content-Type (application/json). + if !strings.Contains(got, "* body contains binary data") { + t.Errorf("expected response body to be detected as binary based on response Content-Type, got:\n%s", got) + } + if !strings.Contains(got, `{"query":"convert"}`) { + t.Errorf("expected request body to be printed, but it was missing from:\n%s", got) + } +} + +func TestIncomingFlusher(t *testing.T) { + t.Parallel() + logger := &Logger{ + ResponseHeader: true, + ResponseBody: true, + } + var buf bytes.Buffer + logger.SetOutput(&buf) + is := inspect(logger.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header()["Date"] = nil + w.Header().Set("Content-Type", "text/plain") + // Flush must not panic when the middleware wraps the ResponseWriter. + if f, ok := w.(http.Flusher); ok { + fmt.Fprint(w, "streamed") + f.Flush() + } else { + t.Error("expected ResponseWriter to implement http.Flusher") + } + })), 1) + + ts := httptest.NewServer(is) + defer ts.Close() + go func() { + client := newServerClient() + resp, err := client.Get(ts.URL) + if err != nil { + t.Errorf("cannot connect to the server: %v", err) + } + defer resp.Body.Close() + testBody(t, resp.Body, []byte("streamed")) + }() + is.Wait() + got := buf.String() + if !strings.Contains(got, "200 OK") { + t.Errorf("expected 200 OK in output, got:\n%s", got) + } + if !strings.Contains(got, "streamed") { + t.Errorf("expected streamed body in output, got:\n%s", got) + } +} + +// plainWriter is a minimal http.ResponseWriter that does not implement +// http.Flusher, used to verify the middleware does not falsely advertise +// flushing when the underlying writer cannot. +type plainWriter struct { + h http.Header + statusCode int + body bytes.Buffer +} + +func (p *plainWriter) Header() http.Header { + if p.h == nil { + p.h = http.Header{} + } + return p.h +} +func (p *plainWriter) Write(b []byte) (int, error) { return p.body.Write(b) } +func (p *plainWriter) WriteHeader(c int) { p.statusCode = c } + +func TestIncomingNonFlushableUnderlying(t *testing.T) { + t.Parallel() + logger := &Logger{ + ResponseHeader: true, + ResponseBody: true, + } + var logBuf bytes.Buffer + logger.SetOutput(&logBuf) + + var sawFlusher bool + var rcFlushErr error + handler := logger.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, sawFlusher = w.(http.Flusher) + rcFlushErr = http.NewResponseController(w).Flush() + w.Header().Set("Content-Type", "text/plain") + fmt.Fprint(w, "ok") + })) + + pw := &plainWriter{} + req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil) + handler.ServeHTTP(pw, req) + + if sawFlusher { + t.Error("inner handler should not see http.Flusher when underlying is not flushable") + } + if rcFlushErr == nil { + t.Error("expected http.NewResponseController(w).Flush() to return an error") + } + // A non-flushable writer must not stop the response: it still reaches the + // client and is recorded by the middleware. + if got := pw.body.String(); got != "ok" { + t.Errorf("underlying writer body = %q, want %q", got, "ok") + } + if got := logBuf.String(); !strings.Contains(got, "ok") { + t.Errorf("expected recorded body in log output, got:\n%s", got) + } +} + +func TestIncomingResponseController(t *testing.T) { + t.Parallel() + logger := &Logger{ + ResponseHeader: true, + ResponseBody: true, + } + var buf bytes.Buffer + logger.SetOutput(&buf) + is := inspect(logger.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header()["Date"] = nil + w.Header().Set("Content-Type", "text/plain") + rc := http.NewResponseController(w) + fmt.Fprint(w, "streamed") + if err := rc.Flush(); err != nil { + t.Errorf("rc.Flush(): %v", err) + } + // SetReadDeadline is not on the wrapper; the controller must walk + // Unwrap to reach it on the underlying writer. + if err := rc.SetReadDeadline(time.Time{}); err != nil { + t.Errorf("rc.SetReadDeadline(): %v", err) + } + })), 1) + + ts := httptest.NewServer(is) + defer ts.Close() + go func() { + client := newServerClient() + resp, err := client.Get(ts.URL) + if err != nil { + t.Errorf("cannot connect to the server: %v", err) + } + defer resp.Body.Close() + testBody(t, resp.Body, []byte("streamed")) + }() + is.Wait() +} + func TestIncomingLongRequest(t *testing.T) { t.Parallel() logger := &Logger{ diff --git a/testdata/log.txtar b/testdata/log.txtar index 13d36d3..4258d15 100644 --- a/testdata/log.txtar +++ b/testdata/log.txtar @@ -505,6 +505,7 @@ form received \* subject: CN=localhost,OU=Cloud,O=Plifk,L=Carmel-by-the-Sea,ST=California,C=US \* start date: Wed Aug 12 22:20:45 UTC 2020 \* expire date: Fri Jul 19 22:20:45 UTC 2120 +\* subjectAltName: "localhost" matches cert's "localhost" \* issuer: CN=localhost,OU=Cloud,O=Plifk,L=Carmel-by-the-Sea,ST=California,C=US \* TLS certificate verify ok\. < HTTP/2\.0 200 OK @@ -626,6 +627,7 @@ Hello, world! \* subject: O=Acme Co \* start date: Thu Jan 1 00:00:00 UTC 1970 \* expire date: Sat Jan 29 16:00:00 UTC 2084 +\* subjectAltName: "example\.com" matches cert's "example\.com" \* issuer: O=Acme Co \* TLS certificate verify ok\. < HTTP/1\.1 200 OK @@ -645,6 +647,25 @@ Hello, world! > User-Agent: Robot/0.1 crawler@example.com * remote error: tls: %s +-- TestOutgoingTLSIPSAN -- +^\* Request to %s +> GET / HTTP/1\.1 +> Host: 127\.0\.0\.1 +> User-Agent: Robot/0\.1 crawler@example\.com + +\* TLS connection using TLS \d+\.\d+ / \w+ +\* Server certificate: +\* subject: O=Acme Co +\* start date: Thu Jan 1 00:00:00 UTC 1970 +\* expire date: Sat Jan 29 16:00:00 UTC 2084 +\* subjectAltName: "127\.0\.0\.1" matches cert's IP address! +\* issuer: O=Acme Co +\* TLS certificate verify ok\. +< HTTP/1\.1 200 OK +< Content-Length: 13 +< Content-Type: text/plain; charset=utf-8 + +Hello, world! -- TestOutgoingTLSInsecureSkipVerify -- ^\* Request to %s \* Skipping TLS verification: connection is susceptible to man-in-the-middle attacks\. @@ -657,6 +678,7 @@ Hello, world! \* subject: O=Acme Co \* start date: Thu Jan 1 00:00:00 UTC 1970 \* expire date: Sat Jan 29 16:00:00 UTC 2084 +\* subjectAltName: "example\.com" matches cert's "example\.com" \* issuer: O=Acme Co \* TLS certificate verify ok\. < HTTP/1\.1 200 OK diff --git a/tls.go b/tls.go deleted file mode 100644 index 70c09d1..0000000 --- a/tls.go +++ /dev/null @@ -1,49 +0,0 @@ -package httpretty - -// A list of cipher suite IDs that are, or have been, implemented by the -// crypto/tls package. -// See https://www.iana.org/assignments/tls-parameters/tls-parameters.xml -// See https://github.com/golang/go/blob/c2edcf4b1253fdebc13df8a25979904c3ef01c66/src/crypto/tls/cipher_suites.go -var tlsCiphers = map[uint16]string{ - // TLS 1.0 - 1.2 cipher suites. - 0x0005: "TLS_RSA_WITH_RC4_128_SHA", - 0x000a: "TLS_RSA_WITH_3DES_EDE_CBC_SHA", - 0x002f: "TLS_RSA_WITH_AES_128_CBC_SHA", - 0x0035: "TLS_RSA_WITH_AES_256_CBC_SHA", - 0x003c: "TLS_RSA_WITH_AES_128_CBC_SHA256", - 0x009c: "TLS_RSA_WITH_AES_128_GCM_SHA256", - 0x009d: "TLS_RSA_WITH_AES_256_GCM_SHA384", - 0xc007: "TLS_ECDHE_ECDSA_WITH_RC4_128_SHA", - 0xc009: "TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA", - 0xc00a: "TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA", - 0xc011: "TLS_ECDHE_RSA_WITH_RC4_128_SHA", - 0xc012: "TLS_ECDHE_RSA_WITH_3DES_EDE_CBC_SHA", - 0xc013: "TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA", - 0xc014: "TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA", - 0xc023: "TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256", - 0xc027: "TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256", - 0xc02f: "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256", - 0xc02b: "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256", - 0xc030: "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384", - 0xc02c: "TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384", - 0xcca8: "TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256", - 0xcca9: "TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256", - - // TLS 1.3 cipher suites. - 0x1301: "TLS_AES_128_GCM_SHA256", - 0x1302: "TLS_AES_256_GCM_SHA384", - 0x1303: "TLS_CHACHA20_POLY1305_SHA256", - - // TLS_FALLBACK_SCSV isn't a standard cipher suite but an indicator - // that the client is doing version fallback. See RFC 7507. - 0x5600: "TLS_FALLBACK_SCSV", -} - -// List of TLS protocol versions supported by Go. -// See https://github.com/golang/go/blob/f4a8bf128364e852cff87cf404a5c16c457ef8f6/src/crypto/tls/common.go -var tlsProtocolVersions = map[uint16]string{ - 0x0301: "TLS 1.0", - 0x0302: "TLS 1.1", - 0x0303: "TLS 1.2", - 0x0304: "TLS 1.3", -}