Skip to content
Open
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
6 changes: 3 additions & 3 deletions .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
10 changes: 5 additions & 5 deletions .github/workflows/go.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
6 changes: 3 additions & 3 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down
15 changes: 15 additions & 0 deletions binary_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
35 changes: 35 additions & 0 deletions client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{})
Expand Down
4 changes: 2 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
@@ -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
30 changes: 2 additions & 28 deletions go.sum
Original file line number Diff line number Diff line change
@@ -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=
6 changes: 5 additions & 1 deletion httpretty.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
40 changes: 40 additions & 0 deletions httpretty_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
102 changes: 88 additions & 14 deletions printer.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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": {},
Expand Down Expand Up @@ -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)")
Expand Down Expand Up @@ -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
}
Expand All @@ -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
Expand All @@ -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
}
Expand All @@ -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()
}
Expand Down
Loading
Loading