diff --git a/infra/conf/transport_internet.go b/infra/conf/transport_internet.go index 31701fe0aa36..8a4c057fac7d 100644 --- a/infra/conf/transport_internet.go +++ b/infra/conf/transport_internet.go @@ -223,6 +223,7 @@ type SplitHTTPConfig struct { SessionKey string `json:"sessionKey"` SeqPlacement string `json:"seqPlacement"` SeqKey string `json:"seqKey"` + PathPool []string `json:"pathPool"` UplinkDataPlacement string `json:"uplinkDataPlacement"` UplinkDataKey string `json:"uplinkDataKey"` UplinkChunkSize Int32Range `json:"uplinkChunkSize"` @@ -406,6 +407,7 @@ func (c *SplitHTTPConfig) Build() (proto.Message, error) { SeqPlacement: c.SeqPlacement, SessionKey: c.SessionKey, SeqKey: c.SeqKey, + PathPool: c.PathPool, UplinkDataPlacement: c.UplinkDataPlacement, UplinkDataKey: c.UplinkDataKey, UplinkChunkSize: newRangeConfig(c.UplinkChunkSize), diff --git a/transport/internet/splithttp/config.go b/transport/internet/splithttp/config.go index 61f861a33c77..9fa546a99712 100644 --- a/transport/internet/splithttp/config.go +++ b/transport/internet/splithttp/config.go @@ -316,6 +316,9 @@ func (c *Config) FillStreamRequest(request *http.Request, sessionId string, seqS c.ApplyXPaddingToRequest(request, config) c.ApplyMetaToRequest(request, sessionId, "") + if c.pathMetaIsOffPath() { + c.DecorateRequestPath(request) + } if request.Body != nil && !c.NoGRPCHeader { // stream-up/one request.Header.Set("Content-Type", "application/grpc") @@ -366,6 +369,9 @@ func (c *Config) FillPacketRequest(request *http.Request, sessionId string, seqS c.ApplyXPaddingToRequest(request, config) c.ApplyMetaToRequest(request, sessionId, seqStr) + if c.pathMetaIsOffPath() { + c.DecorateRequestPath(request) + } return nil } diff --git a/transport/internet/splithttp/config.pb.go b/transport/internet/splithttp/config.pb.go index 4e99d8a8c42a..27a33cfe8b58 100644 --- a/transport/internet/splithttp/config.pb.go +++ b/transport/internet/splithttp/config.pb.go @@ -187,6 +187,7 @@ type Config struct { UplinkDataKey string `protobuf:"bytes,25,opt,name=uplinkDataKey,proto3" json:"uplinkDataKey,omitempty"` UplinkChunkSize *RangeConfig `protobuf:"bytes,26,opt,name=uplinkChunkSize,proto3" json:"uplinkChunkSize,omitempty"` ServerMaxHeaderBytes int32 `protobuf:"varint,27,opt,name=serverMaxHeaderBytes,proto3" json:"serverMaxHeaderBytes,omitempty"` + PathPool []string `protobuf:"bytes,28,rep,name=pathPool,proto3" json:"pathPool,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -410,6 +411,13 @@ func (x *Config) GetServerMaxHeaderBytes() int32 { return 0 } +func (x *Config) GetPathPool() []string { + if x != nil { + return x.PathPool + } + return nil +} + var File_transport_internet_splithttp_config_proto protoreflect.FileDescriptor const file_transport_internet_splithttp_config_proto_rawDesc = "" + @@ -425,7 +433,7 @@ const file_transport_internet_splithttp_config_proto_rawDesc = "" + "\x0ecMaxReuseTimes\x18\x03 \x01(\v2..xray.transport.internet.splithttp.RangeConfigR\x0ecMaxReuseTimes\x12Z\n" + "\x10hMaxRequestTimes\x18\x04 \x01(\v2..xray.transport.internet.splithttp.RangeConfigR\x10hMaxRequestTimes\x12Z\n" + "\x10hMaxReusableSecs\x18\x05 \x01(\v2..xray.transport.internet.splithttp.RangeConfigR\x10hMaxReusableSecs\x12*\n" + - "\x10hKeepAlivePeriod\x18\x06 \x01(\x03R\x10hKeepAlivePeriod\"\xc2\v\n" + + "\x10hKeepAlivePeriod\x18\x06 \x01(\x03R\x10hKeepAlivePeriod\"\xde\v\n" + "\x06Config\x12\x12\n" + "\x04host\x18\x01 \x01(\tR\x04host\x12\x12\n" + "\x04path\x18\x02 \x01(\tR\x04path\x12\x12\n" + @@ -456,7 +464,8 @@ const file_transport_internet_splithttp_config_proto_rawDesc = "" + "\x13uplinkDataPlacement\x18\x18 \x01(\tR\x13uplinkDataPlacement\x12$\n" + "\ruplinkDataKey\x18\x19 \x01(\tR\ruplinkDataKey\x12X\n" + "\x0fuplinkChunkSize\x18\x1a \x01(\v2..xray.transport.internet.splithttp.RangeConfigR\x0fuplinkChunkSize\x122\n" + - "\x14serverMaxHeaderBytes\x18\x1b \x01(\x05R\x14serverMaxHeaderBytes\x1a:\n" + + "\x14serverMaxHeaderBytes\x18\x1b \x01(\x05R\x14serverMaxHeaderBytes\x12\x1a\n" + + "\bpathPool\x18\x1c \x03(\tR\bpathPool\x1a:\n" + "\fHeadersEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01B\x85\x01\n" + diff --git a/transport/internet/splithttp/config.proto b/transport/internet/splithttp/config.proto index 4c303d293222..1b2e7fb81658 100644 --- a/transport/internet/splithttp/config.proto +++ b/transport/internet/splithttp/config.proto @@ -50,4 +50,5 @@ message Config { string uplinkDataKey = 25; RangeConfig uplinkChunkSize = 26; int32 serverMaxHeaderBytes = 27; + repeated string pathPool = 28; } diff --git a/transport/internet/splithttp/obfs_path.go b/transport/internet/splithttp/obfs_path.go new file mode 100644 index 000000000000..801ca9b9c43d --- /dev/null +++ b/transport/internet/splithttp/obfs_path.go @@ -0,0 +1,62 @@ +package splithttp + +import ( + "net/http" + "strconv" + "strings" +) + +// Per-request path randomization (operator supplied). +// +// When the session and seq are carried off-path (cookie / header / query), the +// URL path is free decoration: the server only checks +// strings.HasPrefix(reqPath, configPath) and ignores everything after the +// configured base path. If the operator sets pathPool, each request appends a +// randomly chosen entry to the base path, so the transport stops sending every +// request to one fixed path. A "*" inside an entry is replaced with a random +// decimal number, so a short list can cover id / cursor style paths +// (e.g. "items/*", "users/*/feed"). +// +// No pool is shipped by default. A fixed list baked into the binary would just +// become a new shared signature; the operator picks entries that match the +// traffic they want to blend into. + +func randIntn(n int) int { + if n <= 0 { + return 0 + } + s, ok := randStringFromCharset(6, "0123456789") + if !ok { + return 0 + } + v, err := strconv.Atoi(s) + if err != nil { + return 0 + } + return v % n +} + +// pathMetaIsOffPath reports whether both session and seq are carried somewhere +// other than the path, which leaves the path free to decorate. +func (c *Config) pathMetaIsOffPath() bool { + return c.GetNormalizedSessionPlacement() != PlacementPath && + c.GetNormalizedSeqPlacement() != PlacementPath +} + +// DecorateRequestPath appends a random entry from PathPool to req.URL.Path. +// It is a no-op when no pool is configured. +func (c *Config) DecorateRequestPath(req *http.Request) { + if len(c.PathPool) == 0 { + return + } + seg := c.PathPool[randIntn(len(c.PathPool))] + for strings.Contains(seg, "*") { + seg = strings.Replace(seg, "*", strconv.Itoa(1000+randIntn(900000)), 1) + } + seg = strings.TrimPrefix(seg, "/") + base := req.URL.Path + if !strings.HasSuffix(base, "/") { + base += "/" + } + req.URL.Path = base + seg +} diff --git a/transport/internet/splithttp/obfs_path_test.go b/transport/internet/splithttp/obfs_path_test.go new file mode 100644 index 000000000000..2f8b43927a78 --- /dev/null +++ b/transport/internet/splithttp/obfs_path_test.go @@ -0,0 +1,34 @@ +package splithttp + +import ( + "net/http/httptest" + "strings" + "testing" +) + +func TestDecorateRequestPath(t *testing.T) { + // No pool: the path is left untouched. + c := &Config{} + req := httptest.NewRequest("GET", "https://example.com/api/v1/", nil) + c.DecorateRequestPath(req) + if req.URL.Path != "/api/v1/" { + t.Fatalf("empty pool changed the path: %q", req.URL.Path) + } + + // With a pool: append one entry, keep the base prefix, expand "*". + c = &Config{PathPool: []string{"timeline", "items/*"}} + for i := 0; i < 50; i++ { + req := httptest.NewRequest("GET", "https://example.com/api/v1/", nil) + c.DecorateRequestPath(req) + rest, ok := strings.CutPrefix(req.URL.Path, "/api/v1/") + if !ok { + t.Fatalf("lost the base prefix: %q", req.URL.Path) + } + if strings.Contains(rest, "*") { + t.Fatalf("star was not expanded: %q", rest) + } + if rest != "timeline" && !strings.HasPrefix(rest, "items/") { + t.Fatalf("unexpected segment: %q", rest) + } + } +}