diff --git a/config/config.go b/config/config.go index e066b141..17e4b29f 100644 --- a/config/config.go +++ b/config/config.go @@ -417,8 +417,13 @@ type HTTPNodeConfig struct { Rewrite []HTTPURLRewriteConfig `yaml:",omitempty" json:"rewrite,omitempty"` // rewrite URL RewriteURL []HTTPURLRewriteConfig `yaml:"rewriteURL,omitempty" json:"rewriteURL,omitempty"` - // rewrite response body + // Deprecated: use rewriteResponseBody instead RewriteBody []HTTPBodyRewriteConfig `yaml:"rewriteBody,omitempty" json:"rewriteBody,omitempty"` + // rewrite request body + RewriteRequestBody []HTTPBodyRewriteConfig `yaml:"rewriteRequestBody,omitempty" json:"rewriteRequestBody,omitempty"` + // rewrite response body + RewriteResponseBody []HTTPBodyRewriteConfig `yaml:"rewriteResponseBody,omitempty" json:"rewriteResponseBody,omitempty"` + // HTTP basic auth Auth *AuthConfig `yaml:",omitempty" json:"auth,omitempty"` } diff --git a/config/parsing/node/parse.go b/config/parsing/node/parse.go index 5c536c6f..deb77ffc 100644 --- a/config/parsing/node/parse.go +++ b/config/parsing/node/parse.go @@ -231,6 +231,24 @@ func ParseNode(hop string, cfg *config.NodeConfig, log logger.Logger) (*chain.No }) } } + for _, v := range cfg.HTTP.RewriteResponseBody { + if pattern, _ := regexp.Compile(v.Match); pattern != nil { + settings.RewriteResponseBody = append(settings.RewriteResponseBody, chain.HTTPBodyRewriteSettings{ + Type: v.Type, + Pattern: pattern, + Replacement: []byte(v.Replacement), + }) + } + } + for _, v := range cfg.HTTP.RewriteRequestBody { + if pattern, _ := regexp.Compile(v.Match); pattern != nil { + settings.RewriteRequestBody = append(settings.RewriteRequestBody, chain.HTTPBodyRewriteSettings{ + Type: v.Type, + Pattern: pattern, + Replacement: []byte(v.Replacement), + }) + } + } opts = append(opts, chain.HTTPNodeOption(settings)) } diff --git a/go.mod b/go.mod index 4a0f47ed..da189fc3 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,7 @@ require ( github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d github.com/gin-contrib/cors v1.7.2 github.com/gin-gonic/gin v1.10.1 - github.com/go-gost/core v0.4.0 + github.com/go-gost/core v0.4.1 github.com/go-gost/go-shadowsocks2 v0.1.3 github.com/go-gost/gosocks4 v0.1.0 github.com/go-gost/gosocks5 v0.5.0 diff --git a/go.sum b/go.sum index 8905f83b..8293f82d 100644 --- a/go.sum +++ b/go.sum @@ -51,6 +51,8 @@ github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ= github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= github.com/go-gost/core v0.4.0 h1:jLuIMO7ZMJX/BDCYLrYXAmIykT+HkqEuzaTeLMdrY1c= github.com/go-gost/core v0.4.0/go.mod h1:WGI43jOka7FAsSAwi/fSMaqxdR+E339ycb4NBGlFr6A= +github.com/go-gost/core v0.4.1 h1:/MZYv0tSkw9yoK2nl0GLl3GjT1/3qKl5u3E/DADyeZE= +github.com/go-gost/core v0.4.1/go.mod h1:WGI43jOka7FAsSAwi/fSMaqxdR+E339ycb4NBGlFr6A= github.com/go-gost/go-shadowsocks2 v0.1.3 h1:6CUZLp+mTWXnKP2aK8/Z9ZP+ERMX9gSbywmPu4kGX/A= github.com/go-gost/go-shadowsocks2 v0.1.3/go.mod h1:866zFNNI3He6Wef1M/IvAjTal74WhcfKfBgRpTlkKys= github.com/go-gost/gosocks4 v0.1.0 h1:eAzev6qw4fzkFQKC9uCHLVNnnPdHyqCggbnfNN80Pmk= diff --git a/internal/util/forwarder/sniffer_http.go b/internal/util/forwarder/sniffer_http.go index 8ecf85d6..b71458e7 100644 --- a/internal/util/forwarder/sniffer_http.go +++ b/internal/util/forwarder/sniffer_http.go @@ -290,6 +290,7 @@ func (h *Sniffer) httpRoundTrip(ctx context.Context, rw, cc io.ReadWriteCloser, var responseHeader map[string]string var respBodyRewrites []chain.HTTPBodyRewriteSettings + var reqBodyRewrites []chain.HTTPBodyRewriteSettings if httpSettings := node.Options().HTTP; httpSettings != nil { if auther := httpSettings.Auther; auther != nil { username, password, _ := req.BasicAuth() @@ -325,6 +326,14 @@ func (h *Sniffer) httpRoundTrip(ctx context.Context, rw, cc io.ReadWriteCloser, responseHeader = httpSettings.ResponseHeader respBodyRewrites = httpSettings.RewriteResponseBody + reqBodyRewrites = httpSettings.RewriteRequestBody + } + + // Rewrite request body before wrapping for recording, + // so the recorder sees the rewritten content. + if err = rewriteReqBody(req, reqBodyRewrites...); err != nil { + log.Errorf("rewrite request body: %v", err) + return } if bodySize := clampBodySize(h.RecorderOptions); bodySize > 0 && req.Body != nil { diff --git a/internal/util/forwarder/sniffer_rewrite.go b/internal/util/forwarder/sniffer_rewrite.go index 4bda072d..71adccd3 100644 --- a/internal/util/forwarder/sniffer_rewrite.go +++ b/internal/util/forwarder/sniffer_rewrite.go @@ -90,3 +90,38 @@ func drainBody(b io.ReadCloser) (body []byte, err error) { return buf.Bytes(), nil } +func rewriteReqBody(req *http.Request, rewrites ...chain.HTTPBodyRewriteSettings) error { + if req == nil || len(rewrites) == 0 || req.Body == nil || req.ContentLength <= 0 { + return nil + } + + if encoding := req.Header.Get("Content-Encoding"); encoding != "" { + return nil + } + + body, err := drainBody(req.Body) + if err != nil || body == nil { + return err + } + + contentType, _, _ := strings.Cut(req.Header.Get("Content-Type"), ";") + for _, rewrite := range rewrites { + rewriteType := rewrite.Type + if rewriteType == "" { + rewriteType = "text/html" + } + if rewriteType != "*" && !strings.Contains(rewriteType, contentType) { + continue + } + + if rewrite.Pattern != nil { + body = rewrite.Pattern.ReplaceAll(body, rewrite.Replacement) + } + } + + req.Body = io.NopCloser(bytes.NewReader(body)) + req.ContentLength = int64(len(body)) + + return nil +} + diff --git a/internal/util/forwarder/sniffer_test.go b/internal/util/forwarder/sniffer_test.go index f2ac358e..dd5adda7 100644 --- a/internal/util/forwarder/sniffer_test.go +++ b/internal/util/forwarder/sniffer_test.go @@ -47,6 +47,143 @@ func (m *mockBypass) IsWhitelist() bool { return m.whitelist } // Pure Function Tests // ============================================================================= +func TestRewriteReqBody(t *testing.T) { + hello := []byte("hello world") + + tests := []struct { + name string + req *http.Request + rewrites []chain.HTTPBodyRewriteSettings + wantBody string + wantCL int64 + }{ + { + name: "nil request", + }, + { + name: "no rewrites", + req: &http.Request{ + Body: io.NopCloser(bytes.NewReader(hello)), + ContentLength: int64(len(hello)), + }, + wantBody: "hello world", + wantCL: 11, + }, + { + name: "nil body skipped", + req: &http.Request{ + Body: nil, + ContentLength: 11, + }, + rewrites: []chain.HTTPBodyRewriteSettings{ + {Pattern: regexp.MustCompile("hello"), Replacement: []byte("hi")}, + }, + wantBody: "", + wantCL: 11, + }, + { + name: "zero content length skipped", + req: &http.Request{ + Body: io.NopCloser(bytes.NewReader(hello)), + ContentLength: 0, + }, + rewrites: []chain.HTTPBodyRewriteSettings{ + {Pattern: regexp.MustCompile("hello"), Replacement: []byte("hi")}, + }, + wantBody: "hello world", + wantCL: 0, + }, + { + name: "content-encoding skips rewrite", + req: &http.Request{ + Body: io.NopCloser(bytes.NewReader(hello)), + ContentLength: int64(len(hello)), + Header: http.Header{"Content-Encoding": {"gzip"}}, + }, + rewrites: []chain.HTTPBodyRewriteSettings{ + {Pattern: regexp.MustCompile("hello"), Replacement: []byte("hi")}, + }, + wantBody: "hello world", + wantCL: 11, + }, + { + name: "content type does not match default text/html", + req: &http.Request{ + Body: io.NopCloser(bytes.NewReader(hello)), + ContentLength: int64(len(hello)), + Header: http.Header{"Content-Type": {"text/plain"}}, + }, + rewrites: []chain.HTTPBodyRewriteSettings{ + {Pattern: regexp.MustCompile("hello"), Replacement: []byte("hi")}, + }, + wantBody: "hello world", + wantCL: 11, + }, + { + name: "content type match replacement", + req: &http.Request{ + Body: io.NopCloser(bytes.NewReader(hello)), + ContentLength: int64(len(hello)), + Header: http.Header{"Content-Type": {"text/html"}}, + }, + rewrites: []chain.HTTPBodyRewriteSettings{ + {Type: "text/html", Pattern: regexp.MustCompile("hello"), Replacement: []byte("hi")}, + }, + wantBody: "hi world", + wantCL: 8, + }, + { + name: "wildcard type matches everything", + req: &http.Request{ + Body: io.NopCloser(bytes.NewReader(hello)), + ContentLength: int64(len(hello)), + Header: http.Header{"Content-Type": {"application/json"}}, + }, + rewrites: []chain.HTTPBodyRewriteSettings{ + {Type: "*", Pattern: regexp.MustCompile("hello"), Replacement: []byte("hi")}, + }, + wantBody: "hi world", + wantCL: 8, + }, + { + name: "multiple rewrites applied in order", + req: &http.Request{ + Body: io.NopCloser(bytes.NewReader(hello)), + ContentLength: int64(len(hello)), + Header: http.Header{"Content-Type": {"text/html"}}, + }, + rewrites: []chain.HTTPBodyRewriteSettings{ + {Type: "text/html", Pattern: regexp.MustCompile("hello"), Replacement: []byte("hi")}, + {Type: "text/html", Pattern: regexp.MustCompile("world"), Replacement: []byte("there")}, + }, + wantBody: "hi there", + wantCL: 8, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.req != nil { + _ = rewriteReqBody(tt.req, tt.rewrites...) + if tt.req.Body != nil { + body, _ := io.ReadAll(tt.req.Body) + tt.req.Body.Close() + if string(body) != tt.wantBody { + t.Errorf("body = %q, want %q", string(body), tt.wantBody) + } + } else if tt.wantBody != "" { + t.Errorf("body = nil, want %q", tt.wantBody) + } + if tt.req.ContentLength != tt.wantCL { + t.Errorf("ContentLength = %d, want %d", tt.req.ContentLength, tt.wantCL) + } + } else { + _ = rewriteReqBody(nil) // should not panic + } + }) + } +} + func TestClampBodySize(t *testing.T) { tests := []struct { name string diff --git a/internal/util/sniffing/sniffer_http.go b/internal/util/sniffing/sniffer_http.go index d07ceaae..1e855278 100644 --- a/internal/util/sniffing/sniffer_http.go +++ b/internal/util/sniffing/sniffer_http.go @@ -216,7 +216,7 @@ func (h *Sniffer) httpRoundTrip(ctx context.Context, rw, cc io.ReadWriteCloser, xio.SetReadDeadline(cc, time.Time{}) ro.HTTP.StatusCode = resp.StatusCode - ro.HTTP.Response.Header = resp.Header + ro.HTTP.Response.Header = resp.Header.Clone() ro.HTTP.Response.ContentLength = resp.ContentLength if log.IsLevelEnabled(logger.TraceLevel) {