From 689769b962028b26552c7e339a2729f4455420d6 Mon Sep 17 00:00:00 2001 From: 26X23 <26X23@proton.me> Date: Mon, 23 Feb 2026 20:15:44 +0300 Subject: [PATCH 1/6] XHTTP obfuscation bugfixes. Browser Dialer fix and new HTTP methods and cookies support. OPTIONS and Access-Control-Allow-* for it. Configurable MaxHeaderBytes limit on the server and strict scMaxEachPostBytes chunking. Get rid of *-Upstream and *-Length headers and *_upstream cookie which may be used as a signature for the XHTTP detection. UplinkDataPlacement auto to support different placements through different CDN's in the single inbound on the server side. Fix XPadding in cookies on the server side. --- infra/conf/transport_internet.go | 29 ++- transport/internet/browser_dialer/dialer.go | 29 ++- transport/internet/browser_dialer/dialer.html | 239 ++++++++++-------- .../internet/splithttp/browser_client.go | 47 +++- transport/internet/splithttp/client.go | 53 ++-- transport/internet/splithttp/common.go | 1 + transport/internet/splithttp/config.go | 92 ++++++- transport/internet/splithttp/config.pb.go | 14 +- transport/internet/splithttp/config.proto | 1 + transport/internet/splithttp/dialer.go | 93 +++---- transport/internet/splithttp/hub.go | 133 +++++----- transport/internet/splithttp/xpadding.go | 27 ++ 12 files changed, 463 insertions(+), 295 deletions(-) diff --git a/infra/conf/transport_internet.go b/infra/conf/transport_internet.go index b4458096b458..440e3363cef1 100644 --- a/infra/conf/transport_internet.go +++ b/infra/conf/transport_internet.go @@ -237,6 +237,7 @@ type SplitHTTPConfig struct { ScMinPostsIntervalMs Int32Range `json:"scMinPostsIntervalMs"` ScMaxBufferedPosts int64 `json:"scMaxBufferedPosts"` ScStreamUpServerSecs Int32Range `json:"scStreamUpServerSecs"` + ServerMaxHeaderBytes int32 `json:"serverMaxHeaderBytes"` Xmux XmuxConfig `json:"xmux"` DownloadSettings *StreamConfig `json:"downloadSettings"` Extra json.RawMessage `json:"extra"` @@ -316,9 +317,9 @@ func (c *SplitHTTPConfig) Build() (proto.Message, error) { switch c.UplinkDataPlacement { case "": - c.UplinkDataPlacement = "body" - case "body": - case "cookie", "header": + c.UplinkDataPlacement = splithttp.PlacementAuto + case splithttp.PlacementAuto, splithttp.PlacementBody: + case splithttp.PlacementCookie, splithttp.PlacementHeader: if c.Mode != "packet-up" { return nil, errors.New("UplinkDataPlacement can be " + c.UplinkDataPlacement + " only in packet-up mode") } @@ -347,9 +348,6 @@ func (c *SplitHTTPConfig) Build() (proto.Message, error) { case "": c.SeqPlacement = "path" case "path", "cookie", "header", "query": - if c.SessionPlacement == "path" { - return nil, errors.New("SeqPlacement must be path when SessionPlacement is path") - } default: return nil, errors.New("unsupported seq placement: " + c.SeqPlacement) } @@ -372,26 +370,30 @@ func (c *SplitHTTPConfig) Build() (proto.Message, error) { } } - if c.UplinkDataPlacement != "body" && c.UplinkDataKey == "" { + if c.UplinkDataPlacement != splithttp.PlacementBody && c.UplinkDataKey == "" { switch c.UplinkDataPlacement { - case "cookie": + case splithttp.PlacementCookie: c.UplinkDataKey = "x_data" - case "header": + case splithttp.PlacementAuto, splithttp.PlacementHeader: c.UplinkDataKey = "X-Data" } } if c.UplinkChunkSize == 0 { switch c.UplinkDataPlacement { - case "cookie": - c.UplinkChunkSize = 3 * 1024 // 3KB - case "header": - c.UplinkChunkSize = 4 * 1024 // 4KB + case splithttp.PlacementCookie: + c.UplinkChunkSize = 3 * 1024 // 3KiB + case splithttp.PlacementHeader: + c.UplinkChunkSize = 4 * 1000 // 4KB } } else if c.UplinkChunkSize < 64 { c.UplinkChunkSize = 64 } + if c.ServerMaxHeaderBytes < 0 { + return nil, errors.New("invalid negative value of maxHeaderBytes") + } + if c.Xmux.MaxConnections.To > 0 && c.Xmux.MaxConcurrency.To > 0 { return nil, errors.New("maxConnections cannot be specified together with maxConcurrency") } @@ -429,6 +431,7 @@ func (c *SplitHTTPConfig) Build() (proto.Message, error) { ScMinPostsIntervalMs: newRangeConfig(c.ScMinPostsIntervalMs), ScMaxBufferedPosts: c.ScMaxBufferedPosts, ScStreamUpServerSecs: newRangeConfig(c.ScStreamUpServerSecs), + ServerMaxHeaderBytes: c.ServerMaxHeaderBytes, Xmux: &splithttp.XmuxConfig{ MaxConcurrency: newRangeConfig(c.Xmux.MaxConcurrency), MaxConnections: newRangeConfig(c.Xmux.MaxConnections), diff --git a/transport/internet/browser_dialer/dialer.go b/transport/internet/browser_dialer/dialer.go index 1991284d607f..be2f137edc2b 100644 --- a/transport/internet/browser_dialer/dialer.go +++ b/transport/internet/browser_dialer/dialer.go @@ -22,6 +22,7 @@ type task struct { Method string `json:"method"` URL string `json:"url"` Extra any `json:"extra,omitempty"` + StreamResponse bool `json:"streamResponse"` } var conns chan *websocket.Conn @@ -52,6 +53,7 @@ func init() { } } } else { + w.Header().Set("Access-Control-Allow-Origin", "*"); w.Write(webpage) } })) @@ -70,6 +72,7 @@ func DialWS(uri string, ed []byte) (*websocket.Conn, error) { task := task{ Method: "WS", URL: uri, + StreamResponse: true, } if ed != nil { @@ -84,9 +87,10 @@ func DialWS(uri string, ed []byte) (*websocket.Conn, error) { type httpExtra struct { Referrer string `json:"referrer,omitempty"` Headers map[string]string `json:"headers,omitempty"` + Cookies map[string]string `json:"cookies,omitempty"` } -func httpExtraFromHeaders(headers http.Header) *httpExtra { +func httpExtraFromHeadersAndCookies(headers http.Header, cookies []*http.Cookie) *httpExtra { if len(headers) == 0 { return nil } @@ -104,24 +108,37 @@ func httpExtraFromHeaders(headers http.Header) *httpExtra { } } + if len(cookies) > 0 { + extra.Cookies = make(map[string]string) + for _, cookie := range cookies { + extra.Cookies[cookie.Name] = cookie.Value + } + } + return &extra } -func DialGet(uri string, headers http.Header) (*websocket.Conn, error) { +func DialGet(uri string, headers http.Header, cookies []*http.Cookie) (*websocket.Conn, error) { task := task{ Method: "GET", URL: uri, - Extra: httpExtraFromHeaders(headers), + Extra: httpExtraFromHeadersAndCookies(headers, cookies), + StreamResponse: true, } return dialTask(task) } -func DialPost(uri string, headers http.Header, payload []byte) error { +func DialPacket(method string, uri string, headers http.Header, cookies []*http.Cookie, payload []byte) error { + return dialWithBody(method, uri, headers, cookies, payload) +} + +func dialWithBody(method string, uri string, headers http.Header, cookies []*http.Cookie, payload []byte) error { task := task{ - Method: "POST", + Method: method, URL: uri, - Extra: httpExtraFromHeaders(headers), + Extra: httpExtraFromHeadersAndCookies(headers, cookies), + StreamResponse: false, } conn, err := dialTask(task) diff --git a/transport/internet/browser_dialer/dialer.html b/transport/internet/browser_dialer/dialer.html index c62135ae6b70..56427f9aacd2 100644 --- a/transport/internet/browser_dialer/dialer.html +++ b/transport/internet/browser_dialer/dialer.html @@ -29,9 +29,37 @@ requestInit.headers = extra.headers; } + if (extra.cookies) { + requestInit.credentials = 'include'; + } + return requestInit; } + function setCookiesFromTask(task) { + if (!task.extra.cookies) { + return; + } + + const url = new URL(task.url); + + for (const [name, value] of Object.entries(task.extra.cookies)) { + document.cookie = encodeURIComponent(name) + '=' + encodeURIComponent(value) + '; path=' + url.pathname; + } + } + + function clearCookiesFromTask(task) { + if (!task.extra.cookies) { + return; + } + + const url = new URL(task.url); + + for (const [name, value] of Object.entries(task.extra.cookies)) { + document.cookie = encodeURIComponent(name) + '=; path=' + url.pathname + '; Max-Age=0'; + } + } + let check = function () { if (clientIdleCount > 0) { return; @@ -48,116 +76,121 @@ ws.onmessage = function (event) { clientIdleCount -= 1; let task = JSON.parse(event.data); - switch (task.method) { - case "WS": { - upstreamWsCount += 1; - console.log("Dial WS", task.url, task.extra.protocol); - const wss = new WebSocket(task.url, task.extra.protocol); - wss.binaryType = "arraybuffer"; - let opened = false; - ws.onmessage = function (event) { - wss.send(event.data) - }; - wss.onopen = function (event) { - opened = true; - ws.send("ok") - }; - wss.onmessage = function (event) { - ws.send(event.data) - }; - wss.onclose = function (event) { - upstreamWsCount -= 1; - console.log("Dial WS DONE, remaining: ", upstreamWsCount); - ws.close() - }; - wss.onerror = function (event) { - !opened && ws.send("fail") - wss.close() - }; - ws.onclose = function (event) { - wss.close() - }; - break; - } - case "GET": { - (async () => { - const requestInit = prepareRequestInit(task.extra); - - console.log("Dial GET", task.url); - ws.send("ok"); - const controller = new AbortController(); - - /* - Aborting a streaming response in JavaScript - requires two levers to be pulled: - - First, the streaming read itself has to be cancelled using - reader.cancel(), only then controller.abort() will actually work. - - If controller.abort() alone is called while a - reader.read() is ongoing, it will block until the server closes the - response, the page is refreshed or the network connection is lost. - */ - - let reader = null; - ws.onclose = (event) => { - try { - reader && reader.cancel(); - } catch(e) {} - - try { - controller.abort(); - } catch(e) {} - }; + if (task.method == "WS") { + upstreamWsCount += 1; + console.log("Dial WS", task.url, task.extra.protocol); + const wss = new WebSocket(task.url, task.extra.protocol); + wss.binaryType = "arraybuffer"; + let opened = false; + ws.onmessage = function (event) { + wss.send(event.data) + }; + wss.onopen = function (event) { + opened = true; + ws.send("ok") + }; + wss.onmessage = function (event) { + ws.send(event.data) + }; + wss.onclose = function (event) { + upstreamWsCount -= 1; + console.log("Dial WS DONE, remaining: ", upstreamWsCount); + ws.close() + }; + wss.onerror = function (event) { + !opened && ws.send("fail") + wss.close() + }; + ws.onclose = function (event) { + wss.close() + }; + } + else if (task.method == "GET" && task.streamResponse) { + (async () => { + const requestInit = prepareRequestInit(task.extra); + + console.log("Dial GET", task.url); + ws.send("ok"); + const controller = new AbortController(); + + /* + Aborting a streaming response in JavaScript + requires two levers to be pulled: + + First, the streaming read itself has to be cancelled using + reader.cancel(), only then controller.abort() will actually work. + + If controller.abort() alone is called while a + reader.read() is ongoing, it will block until the server closes the + response, the page is refreshed or the network connection is lost. + */ + + let reader = null; + ws.onclose = (event) => { + try { + reader && reader.cancel(); + } catch(e) {} try { - upstreamGetCount += 1; - - requestInit.signal = controller.signal; - const response = await fetch(task.url, requestInit); - - const body = await response.body; - reader = body.getReader(); - - while (true) { - const { done, value } = await reader.read(); - if (value) ws.send(value); // don't send back "undefined" string when received nothing - if (done) break; - } - } finally { - upstreamGetCount -= 1; - console.log("Dial GET DONE, remaining: ", upstreamGetCount); - ws.close(); + controller.abort(); + } catch(e) {} + }; + + try { + upstreamGetCount += 1; + + requestInit.signal = controller.signal; + setCookiesFromTask(task); + const response = await fetch(task.url, requestInit); + clearCookiesFromTask(task); + + const body = await response.body; + reader = body.getReader(); + + while (true) { + const { done, value } = await reader.read(); + if (value) ws.send(value); // don't send back "undefined" string when received nothing + if (done) break; } - })(); - break; - } - case "POST": { - upstreamPostCount += 1; + } finally { + upstreamGetCount -= 1; + console.log("Dial GET DONE, remaining: ", upstreamGetCount); + ws.close(); + } + })(); + } + else if (!task.streamResponse) { + upstreamPostCount += 1; - const requestInit = prepareRequestInit(task.extra); - requestInit.method = "POST"; + const requestInit = prepareRequestInit(task.extra); + requestInit.method = task.method; - console.log("Dial POST", task.url); - ws.send("ok"); - ws.onmessage = async (event) => { - try { + console.log("Dial", task.method, task.url); + ws.send("ok"); + ws.onmessage = async (event) => { + try { + if (["POST", "PUT", "PATCH"].includes(task.method)) { requestInit.body = event.data; - const response = await fetch(task.url, requestInit); - if (response.ok) { - ws.send("ok"); - } else { - console.error("bad status code"); - ws.send("fail"); - } - } finally { - upstreamPostCount -= 1; - console.log("Dial POST DONE, remaining: ", upstreamPostCount); - ws.close(); } - }; - break; - } + setCookiesFromTask(task); + const response = await fetch(task.url, requestInit); + clearCookiesFromTask(task); + if (response.ok) { + ws.send("ok"); + } else { + console.error("bad status code"); + ws.send("fail"); + } + } finally { + upstreamPostCount -= 1; + console.log("Dial", task.method, "packet DONE, remaining: ", upstreamPostCount); + ws.close(); + } + }; + } + else { + console.error(`Incorrect task method=${task.method} streamResponse=${task.streamResponse}.`); + ws.close(); } check(); diff --git a/transport/internet/splithttp/browser_client.go b/transport/internet/splithttp/browser_client.go index f39f94ba680c..bb0e1c584ab3 100644 --- a/transport/internet/splithttp/browser_client.go +++ b/transport/internet/splithttp/browser_client.go @@ -3,6 +3,7 @@ package splithttp import ( "context" "io" + "net/http" "github.com/xtls/xray-core/common/errors" "github.com/xtls/xray-core/common/net" @@ -19,12 +20,17 @@ func (c *BrowserDialerClient) IsClosed() bool { panic("not implemented yet") } -func (c *BrowserDialerClient) OpenStream(ctx context.Context, url string, _ string, body io.Reader, uploadOnly bool) (io.ReadCloser, net.Addr, net.Addr, error) { +func (c *BrowserDialerClient) OpenStream(ctx context.Context, url string, sessionId string, body io.Reader, uploadOnly bool) (io.ReadCloser, net.Addr, net.Addr, error) { if body != nil { return nil, nil, nil, errors.New("bidirectional streaming for browser dialer not implemented yet") } - header := c.transportConfig.GetRequestHeader() + request, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, nil, nil, err + } + + request.Header = c.transportConfig.GetRequestHeader() length := int(c.transportConfig.GetNormalizedXPaddingBytes().rand()) config := XPaddingConfig{Length: length} @@ -45,9 +51,10 @@ func (c *BrowserDialerClient) OpenStream(ctx context.Context, url string, _ stri } } - c.transportConfig.ApplyXPaddingToHeader(header, config) + c.transportConfig.ApplyXPaddingToRequest(request, config) + c.transportConfig.ApplyMetaToRequest(request, sessionId, "") - conn, err := browser_dialer.DialGet(url, header) + conn, err := browser_dialer.DialGet(request.URL.String(), request.Header, request.Cookies()) dummyAddr := &net.IPAddr{} if err != nil { return nil, dummyAddr, dummyAddr, err @@ -56,13 +63,36 @@ func (c *BrowserDialerClient) OpenStream(ctx context.Context, url string, _ stri return websocket.NewConnection(conn, dummyAddr, nil, 0), conn.RemoteAddr(), conn.LocalAddr(), nil } -func (c *BrowserDialerClient) PostPacket(ctx context.Context, url string, _ string, _ string, body io.Reader, contentLength int64) error { +func (c *BrowserDialerClient) PostPacket(ctx context.Context, url string, sessionId string, seqStr string, body io.Reader, contentLength int64) error { bytes, err := io.ReadAll(body) if err != nil { return err } - header := c.transportConfig.GetRequestHeader() + method := c.transportConfig.GetNormalizedUplinkHTTPMethod() + request, err := http.NewRequest(method, url, nil) + if err != nil { + return err + } + + dataPlacement := c.transportConfig.GetNormalizedUplinkDataPlacement() + + if dataPlacement == PlacementBody || dataPlacement == PlacementAuto { + request.Header = c.transportConfig.GetRequestHeader() + } else { + switch dataPlacement { + case PlacementHeader: + request.Header = c.transportConfig.GetRequestHeaderWithPayload(bytes) + case PlacementCookie: + request.Header = c.transportConfig.GetRequestHeader() + for _, cookie := range c.transportConfig.GetRequestCookiesWithPayload(bytes) { + request.AddCookie(cookie) + } + } + bytes = nil + } + + length := int(c.transportConfig.GetNormalizedXPaddingBytes().rand()) config := XPaddingConfig{Length: length} @@ -83,9 +113,10 @@ func (c *BrowserDialerClient) PostPacket(ctx context.Context, url string, _ stri } } - c.transportConfig.ApplyXPaddingToHeader(header, config) + c.transportConfig.ApplyXPaddingToRequest(request, config) + c.transportConfig.ApplyMetaToRequest(request, sessionId, seqStr) - err = browser_dialer.DialPost(url, header, bytes) + err = browser_dialer.DialPacket(method, request.URL.String(), request.Header, request.Cookies(), bytes) if err != nil { return err } diff --git a/transport/internet/splithttp/client.go b/transport/internet/splithttp/client.go index e26aea730a9b..d06b37d884b1 100644 --- a/transport/internet/splithttp/client.go +++ b/transport/internet/splithttp/client.go @@ -3,7 +3,6 @@ package splithttp import ( "bytes" "context" - "encoding/base64" "fmt" "io" "net/http" @@ -117,17 +116,27 @@ func (c *DefaultDialerClient) OpenStream(ctx context.Context, url string, sessio } func (c *DefaultDialerClient) PostPacket(ctx context.Context, url string, sessionId string, seqStr string, body io.Reader, contentLength int64) error { - var encodedData string + var header http.Header + var cookies []*http.Cookie + dataPlacement := c.transportConfig.GetNormalizedUplinkDataPlacement() - if dataPlacement != PlacementBody { + if dataPlacement == PlacementBody || dataPlacement == PlacementAuto { + header = c.transportConfig.GetRequestHeader() + } else { data, err := io.ReadAll(body) if err != nil { return err } - encodedData = base64.RawURLEncoding.EncodeToString(data) body = nil contentLength = 0 + switch dataPlacement { + case PlacementHeader: + header = c.transportConfig.GetRequestHeaderWithPayload(data) + case PlacementCookie: + header = c.transportConfig.GetRequestHeader() + cookies = c.transportConfig.GetRequestCookiesWithPayload(data) + } } method := c.transportConfig.GetNormalizedUplinkHTTPMethod() @@ -136,39 +145,9 @@ func (c *DefaultDialerClient) PostPacket(ctx context.Context, url string, sessio return err } req.ContentLength = contentLength - req.Header = c.transportConfig.GetRequestHeader() - - if dataPlacement != PlacementBody { - key := c.transportConfig.UplinkDataKey - chunkSize := int(c.transportConfig.UplinkChunkSize) - - switch dataPlacement { - case PlacementHeader: - for i := 0; i < len(encodedData); i += chunkSize { - end := i + chunkSize - if end > len(encodedData) { - end = len(encodedData) - } - chunk := encodedData[i:end] - headerKey := fmt.Sprintf("%s-%d", key, i/chunkSize) - req.Header.Set(headerKey, chunk) - } - - req.Header.Set(key+"-Length", fmt.Sprintf("%d", len(encodedData))) - req.Header.Set(key+"-Upstream", "1") - case PlacementCookie: - for i := 0; i < len(encodedData); i += chunkSize { - end := i + chunkSize - if end > len(encodedData) { - end = len(encodedData) - } - chunk := encodedData[i:end] - cookieName := fmt.Sprintf("%s_%d", key, i/chunkSize) - req.AddCookie(&http.Cookie{Name: cookieName, Value: chunk}) - } - - req.AddCookie(&http.Cookie{Name: key + "_upstream", Value: "1"}) - } + req.Header = header + for _, c := range cookies { + req.AddCookie(c) } length := int(c.transportConfig.GetNormalizedXPaddingBytes().rand()) diff --git a/transport/internet/splithttp/common.go b/transport/internet/splithttp/common.go index 20596180f551..d49a5afdbdb0 100644 --- a/transport/internet/splithttp/common.go +++ b/transport/internet/splithttp/common.go @@ -7,4 +7,5 @@ const ( PlacementQuery = "query" PlacementPath = "path" PlacementBody = "body" + PlacementAuto = "auto" ) diff --git a/transport/internet/splithttp/config.go b/transport/internet/splithttp/config.go index 1819f0e9daef..a0cb9457280b 100644 --- a/transport/internet/splithttp/config.go +++ b/transport/internet/splithttp/config.go @@ -1,6 +1,8 @@ package splithttp import ( + "encoding/base64" + "fmt" "net/http" "strings" @@ -54,11 +56,64 @@ func (c *Config) GetRequestHeader() http.Header { return header } -func (c *Config) WriteResponseHeader(writer http.ResponseWriter) { + +func (c *Config) GetRequestHeaderWithPayload(payload []byte) http.Header { + header := c.GetRequestHeader() + + key := c.UplinkDataKey + chunkSize := int(c.UplinkChunkSize) + encodedData := base64.RawURLEncoding.EncodeToString(payload) + + for i := 0; i < len(encodedData); i += chunkSize { + end := i + chunkSize + if end > len(encodedData) { + end = len(encodedData) + } + chunk := encodedData[i:end] + headerKey := fmt.Sprintf("%s-%d", key, i/chunkSize) + header.Set(headerKey, chunk) + } + + return header +} + +func (c *Config) GetRequestCookiesWithPayload(payload []byte) []*http.Cookie { + cookies := []*http.Cookie{} + + key := c.UplinkDataKey + chunkSize := int(c.UplinkChunkSize) + encodedData := base64.RawURLEncoding.EncodeToString(payload) + + for i := 0; i < len(encodedData); i += chunkSize { + end := i + chunkSize + if end > len(encodedData) { + end = len(encodedData) + } + chunk := encodedData[i:end] + cookieName := fmt.Sprintf("%s_%d", key, i/chunkSize) + cookies = append(cookies, &http.Cookie{Name: cookieName, Value: chunk}) + } + + return cookies +} + +func (c *Config) WriteResponseHeader(writer http.ResponseWriter, requestHeader http.Header) { // CORS headers for the browser dialer - writer.Header().Set("Access-Control-Allow-Origin", "*") + if origin := requestHeader.Get("Origin"); origin == "" { + writer.Header().Set("Access-Control-Allow-Origin", "*") + } else { + // Chrome says: The value of the 'Access-Control-Allow-Origin' header in the response must not be the wildcard '*' when the request's credentials mode is 'include'. + writer.Header().Set("Access-Control-Allow-Origin", origin) + } writer.Header().Set("Access-Control-Allow-Methods", "*") - // writer.Header().Set("X-Version", core.Version()) + writer.Header().Set("Access-Control-Allow-Headers", "*") + + if c.GetNormalizedSessionPlacement() == PlacementCookie || + c.GetNormalizedSeqPlacement() == PlacementCookie || + c.XPaddingPlacement == PlacementCookie || + c.GetNormalizedUplinkDataPlacement() == PlacementCookie { + writer.Header().Set("Access-Control-Allow-Credentials", "true") + } } func (c *Config) GetNormalizedUplinkHTTPMethod() string { @@ -110,6 +165,14 @@ func (c *Config) GetNormalizedScStreamUpServerSecs() RangeConfig { return *c.ScStreamUpServerSecs } +func (c *Config) GetNormalizedServerMaxHeaderBytes() int { + if c.ServerMaxHeaderBytes <= 0 { + return 8192 + } else { + return int(c.ServerMaxHeaderBytes) + } +} + func (c *Config) GetNormalizedSessionPlacement() string { if c.SessionPlacement == "" { return PlacementPath @@ -202,18 +265,18 @@ func (c *Config) ExtractMetaFromRequest(req *http.Request, path string) (session sessionKey := c.GetNormalizedSessionKey() seqKey := c.GetNormalizedSeqKey() - if sessionPlacement == PlacementPath && seqPlacement == PlacementPath { - subpath := strings.Split(req.URL.Path[len(path):], "/") - if len(subpath) > 0 { - sessionId = subpath[0] - } - if len(subpath) > 1 { - seqStr = subpath[1] - } - return sessionId, seqStr + var subpath []string + pathPart := 0 + if sessionPlacement == PlacementPath || seqPlacement == PlacementPath { + subpath = strings.Split(req.URL.Path[len(path):], "/") } switch sessionPlacement { + case PlacementPath: + if len(subpath) > pathPart { + sessionId = subpath[pathPart] + pathPart += 1 + } case PlacementQuery: sessionId = req.URL.Query().Get(sessionKey) case PlacementHeader: @@ -225,6 +288,11 @@ func (c *Config) ExtractMetaFromRequest(req *http.Request, path string) (session } switch seqPlacement { + case PlacementPath: + if len(subpath) > pathPart { + seqStr = subpath[pathPart] + pathPart += 1 + } case PlacementQuery: seqStr = req.URL.Query().Get(seqKey) case PlacementHeader: diff --git a/transport/internet/splithttp/config.pb.go b/transport/internet/splithttp/config.pb.go index 553532155341..895ea5e55db0 100644 --- a/transport/internet/splithttp/config.pb.go +++ b/transport/internet/splithttp/config.pb.go @@ -186,6 +186,7 @@ type Config struct { UplinkDataPlacement string `protobuf:"bytes,24,opt,name=uplinkDataPlacement,proto3" json:"uplinkDataPlacement,omitempty"` UplinkDataKey string `protobuf:"bytes,25,opt,name=uplinkDataKey,proto3" json:"uplinkDataKey,omitempty"` UplinkChunkSize uint32 `protobuf:"varint,26,opt,name=uplinkChunkSize,proto3" json:"uplinkChunkSize,omitempty"` + ServerMaxHeaderBytes int32 `protobuf:"varint,27,opt,name=serverMaxHeaderBytes,proto3" json:"serverMaxHeaderBytes,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -402,6 +403,13 @@ func (x *Config) GetUplinkChunkSize() uint32 { return 0 } +func (x *Config) GetServerMaxHeaderBytes() int32 { + if x != nil { + return x.ServerMaxHeaderBytes + } + return 0 +} + var File_transport_internet_splithttp_config_proto protoreflect.FileDescriptor const file_transport_internet_splithttp_config_proto_rawDesc = "" + @@ -417,8 +425,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\"\xde\n" + - "\n" + + "\x10hKeepAlivePeriod\x18\x06 \x01(\x03R\x10hKeepAlivePeriod\"\x92\v\n" + "\x06Config\x12\x12\n" + "\x04host\x18\x01 \x01(\tR\x04host\x12\x12\n" + "\x04path\x18\x02 \x01(\tR\x04path\x12\x12\n" + @@ -448,7 +455,8 @@ const file_transport_internet_splithttp_config_proto_rawDesc = "" + "\x06seqKey\x18\x17 \x01(\tR\x06seqKey\x120\n" + "\x13uplinkDataPlacement\x18\x18 \x01(\tR\x13uplinkDataPlacement\x12$\n" + "\ruplinkDataKey\x18\x19 \x01(\tR\ruplinkDataKey\x12(\n" + - "\x0fuplinkChunkSize\x18\x1a \x01(\rR\x0fuplinkChunkSize\x1a:\n" + + "\x0fuplinkChunkSize\x18\x1a \x01(\rR\x0fuplinkChunkSize\x122\n" + + "\x14serverMaxHeaderBytes\x18\x1b \x01(\x05R\x14serverMaxHeaderBytes\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 684717713aa1..80b799fa6f47 100644 --- a/transport/internet/splithttp/config.proto +++ b/transport/internet/splithttp/config.proto @@ -49,4 +49,5 @@ message Config { string uplinkDataPlacement = 24; string uplinkDataKey = 25; uint32 uplinkChunkSize = 26; + int32 serverMaxHeaderBytes = 27; } diff --git a/transport/internet/splithttp/dialer.go b/transport/internet/splithttp/dialer.go index 4d02a67169c2..816a72f8b9e4 100644 --- a/transport/internet/splithttp/dialer.go +++ b/transport/internet/splithttp/dialer.go @@ -396,8 +396,8 @@ func Dial(ctx context.Context, dest net.Destination, streamSettings *internet.Me scMaxEachPostBytes := transportConfiguration.GetNormalizedScMaxEachPostBytes() scMinPostsIntervalMs := transportConfiguration.GetNormalizedScMinPostsIntervalMs() - if scMaxEachPostBytes.From <= buf.Size { - panic("`scMaxEachPostBytes` should be bigger than " + strconv.Itoa(buf.Size)) + if scMaxEachPostBytes.From <= 0 { + panic("`scMaxEachPostBytes` should be bigger than 0") } maxUploadSize := scMaxEachPostBytes.rand() @@ -405,7 +405,7 @@ func Dial(ctx context.Context, dest net.Destination, streamSettings *internet.Me // code relies on this behavior. Subtract 1 so that together with // uploadWriter wrapper, exact size limits can be enforced // uploadPipeReader, uploadPipeWriter := pipe.New(pipe.WithSizeLimit(maxUploadSize - 1)) - uploadPipeReader, uploadPipeWriter := pipe.New(pipe.WithSizeLimit(maxUploadSize - buf.Size)) + uploadPipeReader, uploadPipeWriter := pipe.New(pipe.WithSizeLimit(max(0, maxUploadSize - buf.Size))) conn.writer = uploadWriter{ uploadPipeWriter, @@ -417,57 +417,64 @@ func Dial(ctx context.Context, dest net.Destination, streamSettings *internet.Me var lastWrite time.Time for { - wroteRequest := done.New() - - ctx := httptrace.WithClientTrace(ctx, &httptrace.ClientTrace{ - WroteRequest: func(httptrace.WroteRequestInfo) { - wroteRequest.Close() - }, - }) - - // this intentionally makes a shallow-copy of the struct so we - // can reassign Path (potentially concurrently) - url := requestURL - seqStr := strconv.FormatInt(seq, 10) - seq += 1 - - if scMinPostsIntervalMs.From > 0 { - time.Sleep(time.Duration(scMinPostsIntervalMs.rand())*time.Millisecond - time.Since(lastWrite)) - } - // by offloading the uploads into a buffered pipe, multiple conn.Write // calls get automatically batched together into larger POST requests. // without batching, bandwidth is extremely limited. - chunk, err := uploadPipeReader.ReadMultiBuffer() + remainder, err := uploadPipeReader.ReadMultiBuffer() if err != nil { break } - lastWrite = time.Now() + doSplit := atomic.Bool{} + for doSplit.Store(true); doSplit.Load(); { + var chunk buf.MultiBuffer + remainder, chunk = buf.SplitSize(remainder, maxUploadSize) + if chunk.IsEmpty() { + break + } - if xmuxClient != nil && (xmuxClient.LeftRequests.Add(-1) <= 0 || - (xmuxClient.UnreusableAt != time.Time{} && lastWrite.After(xmuxClient.UnreusableAt))) { - httpClient, xmuxClient = getHTTPClient(ctx, dest, streamSettings) - } + wroteRequest := done.New() - go func() { - err := httpClient.PostPacket( - ctx, - url.String(), - sessionId, - seqStr, - &buf.MultiBufferContainer{MultiBuffer: chunk}, - int64(chunk.Len()), - ) - wroteRequest.Close() - if err != nil { - errors.LogInfoInner(ctx, err, "failed to send upload") - uploadPipeReader.Interrupt() + ctx := httptrace.WithClientTrace(ctx, &httptrace.ClientTrace{ + WroteRequest: func(httptrace.WroteRequestInfo) { + wroteRequest.Close() + }, + }) + + seqStr := strconv.FormatInt(seq, 10) + seq += 1 + + if scMinPostsIntervalMs.From > 0 { + time.Sleep(time.Duration(scMinPostsIntervalMs.rand())*time.Millisecond - time.Since(lastWrite)) } - }() - if _, ok := httpClient.(*DefaultDialerClient); ok { - <-wroteRequest.Wait() + lastWrite = time.Now() + + if xmuxClient != nil && (xmuxClient.LeftRequests.Add(-1) <= 0 || + (xmuxClient.UnreusableAt != time.Time{} && lastWrite.After(xmuxClient.UnreusableAt))) { + httpClient, xmuxClient = getHTTPClient(ctx, dest, streamSettings) + } + + go func() { + err := httpClient.PostPacket( + ctx, + requestURL.String(), + sessionId, + seqStr, + &buf.MultiBufferContainer{MultiBuffer: chunk}, + int64(chunk.Len()), + ) + wroteRequest.Close() + if err != nil { + errors.LogInfoInner(ctx, err, "failed to send upload") + uploadPipeReader.Interrupt() + doSplit.Store(false) + } + }() + + if _, ok := httpClient.(*DefaultDialerClient); ok { + <-wroteRequest.Wait() + } } } }() diff --git a/transport/internet/splithttp/hub.go b/transport/internet/splithttp/hub.go index 7e15724646c5..1017ce922135 100644 --- a/transport/internet/splithttp/hub.go +++ b/transport/internet/splithttp/hub.go @@ -8,6 +8,7 @@ import ( "fmt" "io" "net/http" + "slices" "strconv" "strings" "sync" @@ -100,7 +101,7 @@ func (h *requestHandler) ServeHTTP(writer http.ResponseWriter, request *http.Req return } - h.config.WriteResponseHeader(writer) + h.config.WriteResponseHeader(writer, request.Header) length := int(h.config.GetNormalizedXPaddingBytes().rand()) config := XPaddingConfig{Length: length} @@ -118,7 +119,12 @@ func (h *requestHandler) ServeHTTP(writer http.ResponseWriter, request *http.Req } } - h.config.ApplyXPaddingToHeader(writer.Header(), config) + h.config.ApplyXPaddingToResponse(writer, config) + + if request.Method == "OPTIONS" { + writer.WriteHeader(http.StatusOK) + return + } /* clientVer := []int{0, 0, 0} @@ -183,27 +189,17 @@ func (h *requestHandler) ServeHTTP(writer http.ResponseWriter, request *http.Req currentSession = h.upsertSession(sessionId) } scMaxEachPostBytes := int(h.ln.config.GetNormalizedScMaxEachPostBytes().To) - uplinkHTTPMethod := h.config.GetNormalizedUplinkHTTPMethod() isUplinkRequest := false - if uplinkHTTPMethod != "GET" && request.Method == uplinkHTTPMethod { + switch request.Method { + case "GET": + isUplinkRequest = seqStr != "" + default: isUplinkRequest = true } - uplinkDataPlacement := h.config.GetNormalizedUplinkDataPlacement() uplinkDataKey := h.config.UplinkDataKey - switch uplinkDataPlacement { - case PlacementHeader: - if request.Header.Get(uplinkDataKey+"-Upstream") == "1" { - isUplinkRequest = true - } - case PlacementCookie: - if c, _ := request.Cookie(uplinkDataKey + "_upstream"); c != nil && c.Value == "1" { - isUplinkRequest = true - } - } - if isUplinkRequest && sessionId != "" { // stream-up, packet-up if seqStr == "" { if h.config.Mode != "" && h.config.Mode != "auto" && h.config.Mode != "stream-up" { @@ -254,75 +250,66 @@ func (h *requestHandler) ServeHTTP(writer http.ResponseWriter, request *http.Req return } - var payload []byte - - if uplinkDataPlacement != PlacementBody { - var encodedStr string - switch uplinkDataPlacement { - case PlacementHeader: - dataLenStr := request.Header.Get(uplinkDataKey + "-Length") - - if dataLenStr != "" { - dataLen, _ := strconv.Atoi(dataLenStr) - var chunks []string - i := 0 - - for { - chunk := request.Header.Get(fmt.Sprintf("%s-%d", uplinkDataKey, i)) - if chunk == "" { - break - } - chunks = append(chunks, chunk) - i++ - } - - encodedStr = strings.Join(chunks, "") - if len(encodedStr) != dataLen { - encodedStr = "" - } - } - case PlacementCookie: - var chunks []string - i := 0 - - for { - cookieName := fmt.Sprintf("%s_%d", uplinkDataKey, i) - if c, _ := request.Cookie(cookieName); c != nil { - chunks = append(chunks, c.Value) - i++ - } else { - break - } + dataPlacement := h.config.GetNormalizedUplinkDataPlacement() + var headerPayload []byte + if dataPlacement == PlacementAuto || dataPlacement == PlacementHeader { + var headerPayloadChunks [] string + for i := 0; true; { + chunk := request.Header.Get(fmt.Sprintf("%s-%d", uplinkDataKey, i)) + if chunk == "" { + break } + headerPayloadChunks = append(headerPayloadChunks, chunk) + i++ + } + headerPayloadEncoded := strings.Join(headerPayloadChunks, "") + headerPayload, err = base64.RawURLEncoding.DecodeString(headerPayloadEncoded) + if err != nil { + errors.LogInfo(context.Background(), "Invalid base64 in header's payload: ", err.Error()) + writer.WriteHeader(http.StatusBadRequest) + return + } + } - if len(chunks) > 0 { - encodedStr = strings.Join(chunks, "") + var cookiePayload []byte + if dataPlacement == PlacementAuto || dataPlacement == PlacementCookie { + var cookiePayloadChunks []string + for i := 0; true; { + cookieName := fmt.Sprintf("%s_%d", uplinkDataKey, i) + if c, _ := request.Cookie(cookieName); c != nil { + cookiePayloadChunks = append(cookiePayloadChunks, c.Value) + i++ + } else { + break } } + cookiePayloadEncoded := strings.Join(cookiePayloadChunks, "") + cookiePayload, err = base64.RawURLEncoding.DecodeString(cookiePayloadEncoded) + if err != nil { + errors.LogInfo(context.Background(), "Invalid base64 in cookies' payload: ", err.Error()) + writer.WriteHeader(http.StatusBadRequest) + return + } + } - if encodedStr != "" { - payload, err = base64.RawURLEncoding.DecodeString(encodedStr) - } else { - errors.LogInfoInner(context.Background(), err, "failed to extract data from key "+uplinkDataKey+" placed in "+uplinkDataPlacement) + var bodyPayload []byte + if dataPlacement == PlacementAuto || dataPlacement == PlacementBody { + bodyPayload, err = io.ReadAll(io.LimitReader(request.Body, int64(scMaxEachPostBytes)+1)) + if err != nil { + errors.LogInfoInner(context.Background(), err, "failed to upload (ReadAll)") writer.WriteHeader(http.StatusInternalServerError) return } - } else { - payload, err = io.ReadAll(io.LimitReader(request.Body, int64(scMaxEachPostBytes)+1)) } + payload := slices.Concat(headerPayload, cookiePayload, bodyPayload) + if len(payload) > scMaxEachPostBytes { errors.LogInfo(context.Background(), "Too large upload. scMaxEachPostBytes is set to ", scMaxEachPostBytes, "but request size exceed it. Adjust scMaxEachPostBytes on the server to be at least as large as client.") writer.WriteHeader(http.StatusRequestEntityTooLarge) return } - if err != nil { - errors.LogInfoInner(context.Background(), err, "failed to upload (ReadAll)") - writer.WriteHeader(http.StatusInternalServerError) - return - } - seq, err := strconv.ParseUint(seqStr, 10, 64) if err != nil { errors.LogInfoInner(context.Background(), err, "failed to upload (ParseUint)") @@ -341,6 +328,12 @@ func (h *requestHandler) ServeHTTP(writer http.ResponseWriter, request *http.Req return } + switch request.Method { + case "POST", "PUT", "PATCH": + default: + writer.Header().Set("Cache-Control", "no-store") + } + writer.WriteHeader(http.StatusOK) } else if request.Method == "GET" || sessionId == "" { // stream-down, stream-one if sessionId != "" { @@ -519,7 +512,7 @@ func ListenXH(ctx context.Context, address net.Address, port net.Port, streamSet l.server = http.Server{ Handler: handler, ReadHeaderTimeout: time.Second * 4, - MaxHeaderBytes: 8192, + MaxHeaderBytes: l.config.GetNormalizedServerMaxHeaderBytes(), Protocols: protocols, } go func() { diff --git a/transport/internet/splithttp/xpadding.go b/transport/internet/splithttp/xpadding.go index ce22436969f2..1dcbc3e2f05b 100644 --- a/transport/internet/splithttp/xpadding.go +++ b/transport/internet/splithttp/xpadding.go @@ -156,6 +156,17 @@ func ApplyPaddingToCookie(req *http.Request, name, value string) { }) } +func ApplyPaddingToResponseCookie(writer http.ResponseWriter, name, value string) { + if name == "" || value == "" { + return + } + http.SetCookie(writer, &http.Cookie{ + Name: name, + Value: value, + Path: "/", + }) +} + func ApplyPaddingToQuery(u *url.URL, key, value string) { if u == nil || key == "" || value == "" { return @@ -221,6 +232,22 @@ func (c *Config) ApplyXPaddingToRequest(req *http.Request, config XPaddingConfig } } +func (c *Config) ApplyXPaddingToResponse(writer http.ResponseWriter, config XPaddingConfig) { + placement := config.Placement.Placement + + if placement == PlacementHeader || placement == PlacementQueryInHeader { + c.ApplyXPaddingToHeader(writer.Header(), config) + return + } + + paddingValue := GeneratePadding(config.Method, config.Length) + + switch placement { + case PlacementCookie: + ApplyPaddingToResponseCookie(writer, config.Placement.Key, paddingValue) + } +} + func (c *Config) ExtractXPaddingFromRequest(req *http.Request, obfsMode bool) (string, string) { if req == nil { return "", "" From 39da1267de283523263ae8dc8f83ceb9ae738fa6 Mon Sep 17 00:00:00 2001 From: 26X23 <26X23@proton.me> Date: Tue, 24 Feb 2026 20:57:31 +0300 Subject: [PATCH 2/6] Reduce copy-paste. --- .../internet/splithttp/browser_client.go | 74 +++------------- transport/internet/splithttp/client.go | 79 +---------------- transport/internet/splithttp/config.go | 84 +++++++++++++++++++ transport/internet/splithttp/hub.go | 6 +- 4 files changed, 98 insertions(+), 145 deletions(-) diff --git a/transport/internet/splithttp/browser_client.go b/transport/internet/splithttp/browser_client.go index bb0e1c584ab3..1ae3ae95410e 100644 --- a/transport/internet/splithttp/browser_client.go +++ b/transport/internet/splithttp/browser_client.go @@ -30,29 +30,7 @@ func (c *BrowserDialerClient) OpenStream(ctx context.Context, url string, sessio return nil, nil, nil, err } - request.Header = c.transportConfig.GetRequestHeader() - length := int(c.transportConfig.GetNormalizedXPaddingBytes().rand()) - config := XPaddingConfig{Length: length} - - if c.transportConfig.XPaddingObfsMode { - config.Placement = XPaddingPlacement{ - Placement: c.transportConfig.XPaddingPlacement, - Key: c.transportConfig.XPaddingKey, - Header: c.transportConfig.XPaddingHeader, - RawURL: url, - } - config.Method = PaddingMethod(c.transportConfig.XPaddingMethod) - } else { - config.Placement = XPaddingPlacement{ - Placement: PlacementQueryInHeader, - Key: "x_padding", - Header: "Referer", - RawURL: url, - } - } - - c.transportConfig.ApplyXPaddingToRequest(request, config) - c.transportConfig.ApplyMetaToRequest(request, sessionId, "") + c.transportConfig.FillStreamRequest(request, sessionId, "") conn, err := browser_dialer.DialGet(request.URL.String(), request.Header, request.Cookies()) dummyAddr := &net.IPAddr{} @@ -64,58 +42,26 @@ func (c *BrowserDialerClient) OpenStream(ctx context.Context, url string, sessio } func (c *BrowserDialerClient) PostPacket(ctx context.Context, url string, sessionId string, seqStr string, body io.Reader, contentLength int64) error { - bytes, err := io.ReadAll(body) + method := c.transportConfig.GetNormalizedUplinkHTTPMethod() + request, err := http.NewRequest(method, url, body) if err != nil { return err } - method := c.transportConfig.GetNormalizedUplinkHTTPMethod() - request, err := http.NewRequest(method, url, nil) + request.ContentLength = contentLength + err = c.transportConfig.FillPacketRequest(request, sessionId, seqStr) if err != nil { return err } - dataPlacement := c.transportConfig.GetNormalizedUplinkDataPlacement() - - if dataPlacement == PlacementBody || dataPlacement == PlacementAuto { - request.Header = c.transportConfig.GetRequestHeader() - } else { - switch dataPlacement { - case PlacementHeader: - request.Header = c.transportConfig.GetRequestHeaderWithPayload(bytes) - case PlacementCookie: - request.Header = c.transportConfig.GetRequestHeader() - for _, cookie := range c.transportConfig.GetRequestCookiesWithPayload(bytes) { - request.AddCookie(cookie) - } + var bytes []byte + if (request.Body != nil) { + bytes, err = io.ReadAll(request.Body) + if err != nil { + return err } - bytes = nil } - - length := int(c.transportConfig.GetNormalizedXPaddingBytes().rand()) - config := XPaddingConfig{Length: length} - - if c.transportConfig.XPaddingObfsMode { - config.Placement = XPaddingPlacement{ - Placement: c.transportConfig.XPaddingPlacement, - Key: c.transportConfig.XPaddingKey, - Header: c.transportConfig.XPaddingHeader, - RawURL: url, - } - config.Method = PaddingMethod(c.transportConfig.XPaddingMethod) - } else { - config.Placement = XPaddingPlacement{ - Placement: PlacementQueryInHeader, - Key: "x_padding", - Header: "Referer", - RawURL: url, - } - } - - c.transportConfig.ApplyXPaddingToRequest(request, config) - c.transportConfig.ApplyMetaToRequest(request, sessionId, seqStr) - err = browser_dialer.DialPacket(method, request.URL.String(), request.Header, request.Cookies(), bytes) if err != nil { return err diff --git a/transport/internet/splithttp/client.go b/transport/internet/splithttp/client.go index d06b37d884b1..38990c1ad6a5 100644 --- a/transport/internet/splithttp/client.go +++ b/transport/internet/splithttp/client.go @@ -59,33 +59,7 @@ func (c *DefaultDialerClient) OpenStream(ctx context.Context, url string, sessio method = c.transportConfig.GetNormalizedUplinkHTTPMethod() // stream-up/one } req, _ := http.NewRequestWithContext(context.WithoutCancel(ctx), method, url, body) - req.Header = c.transportConfig.GetRequestHeader() - length := int(c.transportConfig.GetNormalizedXPaddingBytes().rand()) - config := XPaddingConfig{Length: length} - - if c.transportConfig.XPaddingObfsMode { - config.Placement = XPaddingPlacement{ - Placement: c.transportConfig.XPaddingPlacement, - Key: c.transportConfig.XPaddingKey, - Header: c.transportConfig.XPaddingHeader, - RawURL: url, - } - config.Method = PaddingMethod(c.transportConfig.XPaddingMethod) - } else { - config.Placement = XPaddingPlacement{ - Placement: PlacementQueryInHeader, - Key: "x_padding", - Header: "Referer", - RawURL: url, - } - } - - c.transportConfig.ApplyXPaddingToRequest(req, config) - c.transportConfig.ApplyMetaToRequest(req, sessionId, "") - - if method == c.transportConfig.GetNormalizedUplinkHTTPMethod() && !c.transportConfig.NoGRPCHeader { - req.Header.Set("Content-Type", "application/grpc") - } + c.transportConfig.FillStreamRequest(req, sessionId, "") wrc = &WaitReadCloser{Wait: make(chan struct{})} go func() { @@ -116,62 +90,13 @@ func (c *DefaultDialerClient) OpenStream(ctx context.Context, url string, sessio } func (c *DefaultDialerClient) PostPacket(ctx context.Context, url string, sessionId string, seqStr string, body io.Reader, contentLength int64) error { - var header http.Header - var cookies []*http.Cookie - - dataPlacement := c.transportConfig.GetNormalizedUplinkDataPlacement() - - if dataPlacement == PlacementBody || dataPlacement == PlacementAuto { - header = c.transportConfig.GetRequestHeader() - } else { - data, err := io.ReadAll(body) - if err != nil { - return err - } - body = nil - contentLength = 0 - switch dataPlacement { - case PlacementHeader: - header = c.transportConfig.GetRequestHeaderWithPayload(data) - case PlacementCookie: - header = c.transportConfig.GetRequestHeader() - cookies = c.transportConfig.GetRequestCookiesWithPayload(data) - } - } - method := c.transportConfig.GetNormalizedUplinkHTTPMethod() req, err := http.NewRequestWithContext(context.WithoutCancel(ctx), method, url, body) if err != nil { return err } req.ContentLength = contentLength - req.Header = header - for _, c := range cookies { - req.AddCookie(c) - } - - length := int(c.transportConfig.GetNormalizedXPaddingBytes().rand()) - config := XPaddingConfig{Length: length} - - if c.transportConfig.XPaddingObfsMode { - config.Placement = XPaddingPlacement{ - Placement: c.transportConfig.XPaddingPlacement, - Key: c.transportConfig.XPaddingKey, - Header: c.transportConfig.XPaddingHeader, - RawURL: url, - } - config.Method = PaddingMethod(c.transportConfig.XPaddingMethod) - } else { - config.Placement = XPaddingPlacement{ - Placement: PlacementQueryInHeader, - Key: "x_padding", - Header: "Referer", - RawURL: url, - } - } - - c.transportConfig.ApplyXPaddingToRequest(req, config) - c.transportConfig.ApplyMetaToRequest(req, sessionId, seqStr) + c.transportConfig.FillPacketRequest(req, sessionId, seqStr) if c.httpVersion != "1.1" { resp, err := c.client.Do(req) diff --git a/transport/internet/splithttp/config.go b/transport/internet/splithttp/config.go index a0cb9457280b..ee1740a9bba0 100644 --- a/transport/internet/splithttp/config.go +++ b/transport/internet/splithttp/config.go @@ -3,6 +3,7 @@ package splithttp import ( "encoding/base64" "fmt" + "io" "net/http" "strings" @@ -259,6 +260,89 @@ func (c *Config) ApplyMetaToRequest(req *http.Request, sessionId string, seqStr } } +func (c *Config) FillStreamRequest(request *http.Request, sessionId string, seqStr string) { + request.Header = c.GetRequestHeader() + length := int(c.GetNormalizedXPaddingBytes().rand()) + config := XPaddingConfig{Length: length} + + if c.XPaddingObfsMode { + config.Placement = XPaddingPlacement{ + Placement: c.XPaddingPlacement, + Key: c.XPaddingKey, + Header: c.XPaddingHeader, + RawURL: request.URL.String(), + } + config.Method = PaddingMethod(c.XPaddingMethod) + } else { + config.Placement = XPaddingPlacement{ + Placement: PlacementQueryInHeader, + Key: "x_padding", + Header: "Referer", + RawURL: request.URL.String(), + } + } + + c.ApplyXPaddingToRequest(request, config) + c.ApplyMetaToRequest(request, sessionId, "") + + if request.Body != nil && !c.NoGRPCHeader { // stream-up/one + request.Header.Set("Content-Type", "application/grpc") + } +} + +func (c *Config) FillPacketRequest(request *http.Request, sessionId string, seqStr string) error { + dataPlacement := c.GetNormalizedUplinkDataPlacement() + + if dataPlacement == PlacementBody || dataPlacement == PlacementAuto { + request.Header = c.GetRequestHeader() + } else { + var data []byte + var err error + if request.Body != nil { + data, err = io.ReadAll(request.Body) + if err != nil { + return err + } + } + request.Body = nil + request.ContentLength = 0 + switch dataPlacement { + case PlacementHeader: + request.Header = c.GetRequestHeaderWithPayload(data) + case PlacementCookie: + request.Header = c.GetRequestHeader() + for _, cookie := range c.GetRequestCookiesWithPayload(data) { + request.AddCookie(cookie) + } + } + } + + length := int(c.GetNormalizedXPaddingBytes().rand()) + config := XPaddingConfig{Length: length} + + if c.XPaddingObfsMode { + config.Placement = XPaddingPlacement{ + Placement: c.XPaddingPlacement, + Key: c.XPaddingKey, + Header: c.XPaddingHeader, + RawURL: request.URL.String(), + } + config.Method = PaddingMethod(c.XPaddingMethod) + } else { + config.Placement = XPaddingPlacement{ + Placement: PlacementQueryInHeader, + Key: "x_padding", + Header: "Referer", + RawURL: request.URL.String(), + } + } + + c.ApplyXPaddingToRequest(request, config) + c.ApplyMetaToRequest(request, sessionId, seqStr) + + return nil +} + func (c *Config) ExtractMetaFromRequest(req *http.Request, path string) (sessionId string, seqStr string) { sessionPlacement := c.GetNormalizedSessionPlacement() seqPlacement := c.GetNormalizedSeqPlacement() diff --git a/transport/internet/splithttp/hub.go b/transport/internet/splithttp/hub.go index 1017ce922135..37388f1ea8f6 100644 --- a/transport/internet/splithttp/hub.go +++ b/transport/internet/splithttp/hub.go @@ -254,13 +254,12 @@ func (h *requestHandler) ServeHTTP(writer http.ResponseWriter, request *http.Req var headerPayload []byte if dataPlacement == PlacementAuto || dataPlacement == PlacementHeader { var headerPayloadChunks [] string - for i := 0; true; { + for i := 0; true; i++ { chunk := request.Header.Get(fmt.Sprintf("%s-%d", uplinkDataKey, i)) if chunk == "" { break } headerPayloadChunks = append(headerPayloadChunks, chunk) - i++ } headerPayloadEncoded := strings.Join(headerPayloadChunks, "") headerPayload, err = base64.RawURLEncoding.DecodeString(headerPayloadEncoded) @@ -274,11 +273,10 @@ func (h *requestHandler) ServeHTTP(writer http.ResponseWriter, request *http.Req var cookiePayload []byte if dataPlacement == PlacementAuto || dataPlacement == PlacementCookie { var cookiePayloadChunks []string - for i := 0; true; { + for i := 0; true; i++ { cookieName := fmt.Sprintf("%s_%d", uplinkDataKey, i) if c, _ := request.Cookie(cookieName); c != nil { cookiePayloadChunks = append(cookiePayloadChunks, c.Value) - i++ } else { break } From ab9c519ecc6f2db117ba206f8745c3c57f63a8b0 Mon Sep 17 00:00:00 2001 From: 26X23 <26X23@proton.me> Date: Tue, 24 Feb 2026 21:52:11 +0300 Subject: [PATCH 3/6] Random UplinkChunkSize --- infra/conf/transport_internet.go | 15 +----- transport/internet/splithttp/config.go | 61 +++++++++++++++++------ transport/internet/splithttp/config.pb.go | 23 +++++---- transport/internet/splithttp/config.proto | 2 +- 4 files changed, 60 insertions(+), 41 deletions(-) diff --git a/infra/conf/transport_internet.go b/infra/conf/transport_internet.go index 440e3363cef1..b726ef0aca13 100644 --- a/infra/conf/transport_internet.go +++ b/infra/conf/transport_internet.go @@ -230,7 +230,7 @@ type SplitHTTPConfig struct { SeqKey string `json:"seqKey"` UplinkDataPlacement string `json:"uplinkDataPlacement"` UplinkDataKey string `json:"uplinkDataKey"` - UplinkChunkSize uint32 `json:"uplinkChunkSize"` + UplinkChunkSize Int32Range `json:"uplinkChunkSize"` NoGRPCHeader bool `json:"noGRPCHeader"` NoSSEHeader bool `json:"noSSEHeader"` ScMaxEachPostBytes Int32Range `json:"scMaxEachPostBytes"` @@ -379,17 +379,6 @@ func (c *SplitHTTPConfig) Build() (proto.Message, error) { } } - if c.UplinkChunkSize == 0 { - switch c.UplinkDataPlacement { - case splithttp.PlacementCookie: - c.UplinkChunkSize = 3 * 1024 // 3KiB - case splithttp.PlacementHeader: - c.UplinkChunkSize = 4 * 1000 // 4KB - } - } else if c.UplinkChunkSize < 64 { - c.UplinkChunkSize = 64 - } - if c.ServerMaxHeaderBytes < 0 { return nil, errors.New("invalid negative value of maxHeaderBytes") } @@ -424,7 +413,7 @@ func (c *SplitHTTPConfig) Build() (proto.Message, error) { SeqKey: c.SeqKey, UplinkDataPlacement: c.UplinkDataPlacement, UplinkDataKey: c.UplinkDataKey, - UplinkChunkSize: c.UplinkChunkSize, + UplinkChunkSize: newRangeConfig(c.UplinkChunkSize), NoGRPCHeader: c.NoGRPCHeader, NoSSEHeader: c.NoSSEHeader, ScMaxEachPostBytes: newRangeConfig(c.ScMaxEachPostBytes), diff --git a/transport/internet/splithttp/config.go b/transport/internet/splithttp/config.go index ee1740a9bba0..24288c205f72 100644 --- a/transport/internet/splithttp/config.go +++ b/transport/internet/splithttp/config.go @@ -9,6 +9,7 @@ import ( "github.com/xtls/xray-core/common" "github.com/xtls/xray-core/common/crypto" + "github.com/xtls/xray-core/common/errors" "github.com/xtls/xray-core/common/utils" "github.com/xtls/xray-core/transport/internet" ) @@ -62,16 +63,13 @@ func (c *Config) GetRequestHeaderWithPayload(payload []byte) http.Header { header := c.GetRequestHeader() key := c.UplinkDataKey - chunkSize := int(c.UplinkChunkSize) encodedData := base64.RawURLEncoding.EncodeToString(payload) - for i := 0; i < len(encodedData); i += chunkSize { - end := i + chunkSize - if end > len(encodedData) { - end = len(encodedData) - } - chunk := encodedData[i:end] - headerKey := fmt.Sprintf("%s-%d", key, i/chunkSize) + for i := 0; len(encodedData) > 0; i++ { + chunkSize := min(int(c.GetNormalizedUplinkChunkSize().rand()), len(encodedData)) + chunk := encodedData[:chunkSize] + encodedData = encodedData[chunkSize:] + headerKey := fmt.Sprintf("%s-%d", key, i) header.Set(headerKey, chunk) } @@ -82,16 +80,13 @@ func (c *Config) GetRequestCookiesWithPayload(payload []byte) []*http.Cookie { cookies := []*http.Cookie{} key := c.UplinkDataKey - chunkSize := int(c.UplinkChunkSize) encodedData := base64.RawURLEncoding.EncodeToString(payload) - for i := 0; i < len(encodedData); i += chunkSize { - end := i + chunkSize - if end > len(encodedData) { - end = len(encodedData) - } - chunk := encodedData[i:end] - cookieName := fmt.Sprintf("%s_%d", key, i/chunkSize) + for i := 0; len(encodedData) > 0; i++ { + chunkSize := min(int(c.GetNormalizedUplinkChunkSize().rand()), len(encodedData)) + chunk := encodedData[:chunkSize] + encodedData = encodedData[chunkSize:] + cookieName := fmt.Sprintf("%s_%d", key, i) cookies = append(cookies, &http.Cookie{Name: cookieName, Value: chunk}) } @@ -166,6 +161,32 @@ func (c *Config) GetNormalizedScStreamUpServerSecs() RangeConfig { return *c.ScStreamUpServerSecs } +func (c *Config) GetNormalizedUplinkChunkSize() RangeConfig { + if c.UplinkChunkSize == nil || c.UplinkChunkSize.To == 0 { + switch c.UplinkDataPlacement { + case PlacementCookie: + return RangeConfig{ + From: 2 * 1024, // 2 KiB + To: 3 * 1024, // 3 KiB + } + case PlacementHeader: + return RangeConfig{ + From: 3 * 1000, // 3 KB + To: 4 * 1000, // 4 KB + } + default: + return c.GetNormalizedScMaxEachPostBytes() + } + } else if c.UplinkChunkSize.From < 64 { + return RangeConfig{ + From: 64, + To: max(64, c.UplinkChunkSize.To), + } + } + + return *c.UplinkChunkSize +} + func (c *Config) GetNormalizedServerMaxHeaderBytes() int { if c.ServerMaxHeaderBytes <= 0 { return 8192 @@ -317,6 +338,14 @@ func (c *Config) FillPacketRequest(request *http.Request, sessionId string, seqS } } + switch request.Method { + case "POST", "PUT", "PATCH": + default: + if request.Body != nil { + return errors.New("Can't make " + request.Method + " with body") + } + } + length := int(c.GetNormalizedXPaddingBytes().rand()) config := XPaddingConfig{Length: length} diff --git a/transport/internet/splithttp/config.pb.go b/transport/internet/splithttp/config.pb.go index 895ea5e55db0..4e99d8a8c42a 100644 --- a/transport/internet/splithttp/config.pb.go +++ b/transport/internet/splithttp/config.pb.go @@ -185,7 +185,7 @@ type Config struct { SeqKey string `protobuf:"bytes,23,opt,name=seqKey,proto3" json:"seqKey,omitempty"` UplinkDataPlacement string `protobuf:"bytes,24,opt,name=uplinkDataPlacement,proto3" json:"uplinkDataPlacement,omitempty"` UplinkDataKey string `protobuf:"bytes,25,opt,name=uplinkDataKey,proto3" json:"uplinkDataKey,omitempty"` - UplinkChunkSize uint32 `protobuf:"varint,26,opt,name=uplinkChunkSize,proto3" json:"uplinkChunkSize,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"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache @@ -396,11 +396,11 @@ func (x *Config) GetUplinkDataKey() string { return "" } -func (x *Config) GetUplinkChunkSize() uint32 { +func (x *Config) GetUplinkChunkSize() *RangeConfig { if x != nil { return x.UplinkChunkSize } - return 0 + return nil } func (x *Config) GetServerMaxHeaderBytes() int32 { @@ -425,7 +425,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\"\x92\v\n" + + "\x10hKeepAlivePeriod\x18\x06 \x01(\x03R\x10hKeepAlivePeriod\"\xc2\v\n" + "\x06Config\x12\x12\n" + "\x04host\x18\x01 \x01(\tR\x04host\x12\x12\n" + "\x04path\x18\x02 \x01(\tR\x04path\x12\x12\n" + @@ -454,8 +454,8 @@ const file_transport_internet_splithttp_config_proto_rawDesc = "" + "\fseqPlacement\x18\x16 \x01(\tR\fseqPlacement\x12\x16\n" + "\x06seqKey\x18\x17 \x01(\tR\x06seqKey\x120\n" + "\x13uplinkDataPlacement\x18\x18 \x01(\tR\x13uplinkDataPlacement\x12$\n" + - "\ruplinkDataKey\x18\x19 \x01(\tR\ruplinkDataKey\x12(\n" + - "\x0fuplinkChunkSize\x18\x1a \x01(\rR\x0fuplinkChunkSize\x122\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" + "\fHeadersEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + @@ -495,11 +495,12 @@ var file_transport_internet_splithttp_config_proto_depIdxs = []int32{ 0, // 9: xray.transport.internet.splithttp.Config.scStreamUpServerSecs:type_name -> xray.transport.internet.splithttp.RangeConfig 1, // 10: xray.transport.internet.splithttp.Config.xmux:type_name -> xray.transport.internet.splithttp.XmuxConfig 4, // 11: xray.transport.internet.splithttp.Config.downloadSettings:type_name -> xray.transport.internet.StreamConfig - 12, // [12:12] is the sub-list for method output_type - 12, // [12:12] is the sub-list for method input_type - 12, // [12:12] is the sub-list for extension type_name - 12, // [12:12] is the sub-list for extension extendee - 0, // [0:12] is the sub-list for field type_name + 0, // 12: xray.transport.internet.splithttp.Config.uplinkChunkSize:type_name -> xray.transport.internet.splithttp.RangeConfig + 13, // [13:13] is the sub-list for method output_type + 13, // [13:13] is the sub-list for method input_type + 13, // [13:13] is the sub-list for extension type_name + 13, // [13:13] is the sub-list for extension extendee + 0, // [0:13] is the sub-list for field type_name } func init() { file_transport_internet_splithttp_config_proto_init() } diff --git a/transport/internet/splithttp/config.proto b/transport/internet/splithttp/config.proto index 80b799fa6f47..4c303d293222 100644 --- a/transport/internet/splithttp/config.proto +++ b/transport/internet/splithttp/config.proto @@ -48,6 +48,6 @@ message Config { string seqKey = 23; string uplinkDataPlacement = 24; string uplinkDataKey = 25; - uint32 uplinkChunkSize = 26; + RangeConfig uplinkChunkSize = 26; int32 serverMaxHeaderBytes = 27; } From 66b8834535739944ede8fcdd65cc534e0728e1dc Mon Sep 17 00:00:00 2001 From: 26X23 <26X23@proton.me> Date: Thu, 26 Feb 2026 13:56:53 +0300 Subject: [PATCH 4/6] Access-Control-Allow-{Methods,Headers} from corresponding Access-Control-Request-* in XHTTP. --- transport/internet/splithttp/config.go | 20 +++++++++++++++++--- transport/internet/splithttp/hub.go | 2 +- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/transport/internet/splithttp/config.go b/transport/internet/splithttp/config.go index 24288c205f72..415b4db355c8 100644 --- a/transport/internet/splithttp/config.go +++ b/transport/internet/splithttp/config.go @@ -93,7 +93,7 @@ func (c *Config) GetRequestCookiesWithPayload(payload []byte) []*http.Cookie { return cookies } -func (c *Config) WriteResponseHeader(writer http.ResponseWriter, requestHeader http.Header) { +func (c *Config) WriteResponseHeader(writer http.ResponseWriter, requestMethod string, requestHeader http.Header) { // CORS headers for the browser dialer if origin := requestHeader.Get("Origin"); origin == "" { writer.Header().Set("Access-Control-Allow-Origin", "*") @@ -101,8 +101,22 @@ func (c *Config) WriteResponseHeader(writer http.ResponseWriter, requestHeader h // Chrome says: The value of the 'Access-Control-Allow-Origin' header in the response must not be the wildcard '*' when the request's credentials mode is 'include'. writer.Header().Set("Access-Control-Allow-Origin", origin) } - writer.Header().Set("Access-Control-Allow-Methods", "*") - writer.Header().Set("Access-Control-Allow-Headers", "*") + + requestedMethod := requestHeader.Get("Access-Control-Request-Method") + if requestedMethod != "" { + writer.Header().Set("Access-Control-Allow-Methods", requestedMethod) + } else if requestMethod != "" && requestMethod != "OPTIONS" { + writer.Header().Set("Access-Control-Allow-Methods", requestMethod) + } else { + writer.Header().Set("Access-Control-Allow-Methods", "*") + } + + requestedHeaders := requestHeader.Get("Access-Control-Request-Headers") + if requestedHeaders == "" { + writer.Header().Set("Access-Control-Allow-Headers", "*") + } else { + writer.Header().Set("Access-Control-Allow-Headers", requestedHeaders) + } if c.GetNormalizedSessionPlacement() == PlacementCookie || c.GetNormalizedSeqPlacement() == PlacementCookie || diff --git a/transport/internet/splithttp/hub.go b/transport/internet/splithttp/hub.go index 37388f1ea8f6..c827533bacc0 100644 --- a/transport/internet/splithttp/hub.go +++ b/transport/internet/splithttp/hub.go @@ -101,7 +101,7 @@ func (h *requestHandler) ServeHTTP(writer http.ResponseWriter, request *http.Req return } - h.config.WriteResponseHeader(writer, request.Header) + h.config.WriteResponseHeader(writer, request.Method, request.Header) length := int(h.config.GetNormalizedXPaddingBytes().rand()) config := XPaddingConfig{Length: length} From ffd48a933a441c377a18a91aecdab89833355e26 Mon Sep 17 00:00:00 2001 From: 26X23 <26X23@proton.me> Date: Fri, 27 Feb 2026 19:33:13 +0300 Subject: [PATCH 5/6] Return preflight-specific headers only for OPTIONS. Disable favicon.ico. --- transport/internet/browser_dialer/dialer.html | 1 + transport/internet/splithttp/config.go | 32 +++++++++---------- 2 files changed, 17 insertions(+), 16 deletions(-) diff --git a/transport/internet/browser_dialer/dialer.html b/transport/internet/browser_dialer/dialer.html index 56427f9aacd2..c73beb4a0cc8 100644 --- a/transport/internet/browser_dialer/dialer.html +++ b/transport/internet/browser_dialer/dialer.html @@ -2,6 +2,7 @@ Browser Dialer +