diff --git a/aop/traffic/exchange.go b/aop/traffic/exchange.go index e788f9d5..ecfbb537 100644 --- a/aop/traffic/exchange.go +++ b/aop/traffic/exchange.go @@ -119,7 +119,7 @@ func ExchangeFromHTTP(req *http.Request, resp *http.Response, requestBody, respo Method: req.Method, URL: urlString, Protocol: req.Proto, - Headers: PairsFromHTTP(req.Header), + Headers: PairsFromHTTPWithHost(req.Header, req.Host), Body: requestBody, } } @@ -387,6 +387,31 @@ func PairsFromHTTP(headers http.Header) []Pair { return out } +// containsHeaderName reports whether pairs already carry a header with this +// name, compared case-insensitively. +func containsHeaderName(pairs []Pair, name string) bool { + for _, p := range pairs { + if strings.EqualFold(p.Name, name) { + return true + } + } + return false +} + +// PairsFromHTTPWithHost is PairsFromHTTP plus the Host header net/http hides. +// The standard library parses the request-line authority into Request.Host and +// deletes "Host" from Request.Header, so a pair sequence built from the header +// map alone never carries it. When host is non-empty and no Host header is +// already present, it is prepended — Host conventionally leads the field block — +// so a request reconstructed from these pairs is complete and replayable. +func PairsFromHTTPWithHost(headers http.Header, host string) []Pair { + pairs := PairsFromHTTP(headers) + if host == "" || containsHeaderName(pairs, "Host") { + return pairs + } + return append([]Pair{{Name: "Host", Value: host}}, pairs...) +} + func pairsToProto(pairs []Pair) []*Header { if len(pairs) == 0 { return nil diff --git a/aop/traffic/exchange_test.go b/aop/traffic/exchange_test.go index 6b64c022..e762d439 100644 --- a/aop/traffic/exchange_test.go +++ b/aop/traffic/exchange_test.go @@ -20,6 +20,35 @@ func TestExchangeFromHTTPUsesCanonicalPairs(t *testing.T) { } } +func TestPairsFromHTTPWithHost(t *testing.T) { + // net/http keeps Host out of the header map, so a pair sequence built from + // the map alone lacks it; the helper prepends it. + got := PairsFromHTTPWithHost(http.Header{"Accept": {"*/*"}}, "example.test:8090") + if len(got) != 2 || got[0] != (Pair{Name: "Host", Value: "example.test:8090"}) { + t.Fatalf("Host not prepended: %#v", got) + } + + // Empty host: nothing to add, sequence is unchanged. + if got := PairsFromHTTPWithHost(http.Header{"Accept": {"*/*"}}, ""); len(got) != 1 { + t.Fatalf("empty host should not add a header: %#v", got) + } + + // An existing Host header (any case) is never duplicated. + got = PairsFromHTTPWithHost(http.Header{"host": {"already.test"}}, "example.test") + if len(got) != 1 || !containsHeaderName(got, "Host") { + t.Fatalf("existing Host must not be duplicated: %#v", got) + } +} + +func TestExchangeFromHTTPAddsHost(t *testing.T) { + u, _ := url.Parse("https://example.test:8443/a") + req := &http.Request{Method: "GET", URL: u, Host: "example.test:8443", Proto: "HTTP/1.1", Header: http.Header{"Accept": {"*/*"}}} + e := ExchangeFromHTTP(req, nil, nil, nil) + if len(e.Request.Headers) == 0 || e.Request.Headers[0] != (Pair{Name: "Host", Value: "example.test:8443"}) { + t.Fatalf("Host header not synthesized from req.Host: %#v", e.Request.Headers) + } +} + func TestFlowExchangeRoundTrip(t *testing.T) { flow := &Flow{ Id: "flow-1", diff --git a/tools/proxy/hub_test.go b/tools/proxy/hub_test.go index 38bd40ec..20a4ffa4 100644 --- a/tools/proxy/hub_test.go +++ b/tools/proxy/hub_test.go @@ -153,6 +153,42 @@ func TestCapturePostRequestBody(t *testing.T) { } } +// TestCaptureIncludesHostHeader guards the Host reconstruction: net/http parses +// the request-line authority into Request.Host and drops "Host" from the header +// map, so a flow whose headers came straight off that map would have no Host +// line and could not be replayed. The capture path must put it back. +func TestCaptureIncludesHostHeader(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + })) + defer srv.Close() + want, err := url.Parse(srv.URL) + if err != nil { + t.Fatalf("parse server url: %v", err) + } + hub, _, client := newTestHub(t, true) + + if body := get(t, client, srv.URL); body != "" { + _ = body // body is empty; the request headers are what we assert on + } + + flows := hub.Store().Query(QueryOpts{}) + var host string + for _, f := range flows { + for _, h := range f.Request.Headers { + if strings.EqualFold(h.Name, "Host") { + host = h.Value + } + } + } + if host == "" { + t.Fatal("captured request carried no Host header") + } + if host != want.Host { + t.Errorf("Host = %q, want %q", host, want.Host) + } +} + func TestCaptureFiltersAndVerbs(t *testing.T) { mux := http.NewServeMux() mux.HandleFunc("/ok", func(w http.ResponseWriter, r *http.Request) { diff --git a/tools/proxy/mitm.go b/tools/proxy/mitm.go index 7e9bfdb2..7eae777b 100644 --- a/tools/proxy/mitm.go +++ b/tools/proxy/mitm.go @@ -324,13 +324,30 @@ func newCaptureState(hub *ProxyHub, f *mitmproxy.Flow) *captureState { Method: f.Request.Method, URL: f.Request.URL.String(), Protocol: f.Request.Proto, - Headers: traffic.PairsFromHTTP(f.Request.Header), + Headers: traffic.PairsFromHTTPWithHost(f.Request.Header, requestHost(f.Request)), } flow.Host = f.Request.URL.Hostname() } return &captureState{hub: hub, owner: nil, proxy: f.Id.String(), start: f.StartTime, flow: flow} } +// requestHost recovers the Host header value net/http strips from Request.Header +// into Request.Host, so the captured flow can reconstruct a complete request +// line block. It prefers the client-sent Host (which keeps a non-default port) +// and falls back to the URL authority. +func requestHost(req *mitmproxy.Request) string { + if req == nil { + return "" + } + if raw := req.Raw(); raw != nil && raw.Host != "" { + return raw.Host + } + if req.URL != nil { + return req.URL.Host + } + return "" +} + func (s *captureState) setRequestBody(body []byte) { if len(body) == 0 { return