diff --git a/adapter/outbound/vless.go b/adapter/outbound/vless.go index dbd822bb..5c294043 100644 --- a/adapter/outbound/vless.go +++ b/adapter/outbound/vless.go @@ -85,6 +85,19 @@ type XHTTPOptions struct { Headers map[string]string `proxy:"headers,omitempty"` NoGRPCHeader bool `proxy:"no-grpc-header,omitempty"` XPaddingBytes string `proxy:"x-padding-bytes,omitempty"` + XPaddingObfsMode bool `proxy:"x-padding-obfs-mode,omitempty"` + XPaddingKey string `proxy:"x-padding-key,omitempty"` + XPaddingHeader string `proxy:"x-padding-header,omitempty"` + XPaddingPlacement string `proxy:"x-padding-placement,omitempty"` + XPaddingMethod string `proxy:"x-padding-method,omitempty"` + UplinkHTTPMethod string `proxy:"uplink-http-method,omitempty"` + SessionPlacement string `proxy:"session-placement,omitempty"` + SessionKey string `proxy:"session-key,omitempty"` + SeqPlacement string `proxy:"seq-placement,omitempty"` + SeqKey string `proxy:"seq-key,omitempty"` + UplinkDataPlacement string `proxy:"uplink-data-placement,omitempty"` + UplinkDataKey string `proxy:"uplink-data-key,omitempty"` + UplinkChunkSize string `proxy:"uplink-chunk-size,omitempty"` ScMaxEachPostBytes string `proxy:"sc-max-each-post-bytes,omitempty"` ScMinPostsIntervalMs string `proxy:"sc-min-posts-interval-ms,omitempty"` ReuseSettings *XHTTPReuseSettings `proxy:"reuse-settings,omitempty"` // aka XMUX @@ -107,6 +120,19 @@ type XHTTPDownloadSettings struct { Headers *map[string]string `proxy:"headers,omitempty"` NoGRPCHeader *bool `proxy:"no-grpc-header,omitempty"` XPaddingBytes *string `proxy:"x-padding-bytes,omitempty"` + XPaddingObfsMode *bool `proxy:"x-padding-obfs-mode,omitempty"` + XPaddingKey *string `proxy:"x-padding-key,omitempty"` + XPaddingHeader *string `proxy:"x-padding-header,omitempty"` + XPaddingPlacement *string `proxy:"x-padding-placement,omitempty"` + XPaddingMethod *string `proxy:"x-padding-method,omitempty"` + UplinkHTTPMethod *string `proxy:"uplink-http-method,omitempty"` + SessionPlacement *string `proxy:"session-placement,omitempty"` + SessionKey *string `proxy:"session-key,omitempty"` + SeqPlacement *string `proxy:"seq-placement,omitempty"` + SeqKey *string `proxy:"seq-key,omitempty"` + UplinkDataPlacement *string `proxy:"uplink-data-placement,omitempty"` + UplinkDataKey *string `proxy:"uplink-data-key,omitempty"` + UplinkChunkSize *string `proxy:"uplink-chunk-size,omitempty"` ScMaxEachPostBytes *string `proxy:"sc-max-each-post-bytes,omitempty"` ScMinPostsIntervalMs *string `proxy:"sc-min-posts-interval-ms,omitempty"` ReuseSettings *XHTTPReuseSettings `proxy:"reuse-settings,omitempty"` // aka XMUX @@ -552,6 +578,19 @@ func NewVless(option VlessOption) (*Vless, error) { Headers: v.option.XHTTPOpts.Headers, NoGRPCHeader: v.option.XHTTPOpts.NoGRPCHeader, XPaddingBytes: v.option.XHTTPOpts.XPaddingBytes, + XPaddingObfsMode: v.option.XHTTPOpts.XPaddingObfsMode, + XPaddingKey: v.option.XHTTPOpts.XPaddingKey, + XPaddingHeader: v.option.XHTTPOpts.XPaddingHeader, + XPaddingPlacement: v.option.XHTTPOpts.XPaddingPlacement, + XPaddingMethod: v.option.XHTTPOpts.XPaddingMethod, + UplinkHTTPMethod: v.option.XHTTPOpts.UplinkHTTPMethod, + SessionPlacement: v.option.XHTTPOpts.SessionPlacement, + SessionKey: v.option.XHTTPOpts.SessionKey, + SeqPlacement: v.option.XHTTPOpts.SeqPlacement, + SeqKey: v.option.XHTTPOpts.SeqKey, + UplinkDataPlacement: v.option.XHTTPOpts.UplinkDataPlacement, + UplinkDataKey: v.option.XHTTPOpts.UplinkDataKey, + UplinkChunkSize: v.option.XHTTPOpts.UplinkChunkSize, ScMaxEachPostBytes: v.option.XHTTPOpts.ScMaxEachPostBytes, ScMinPostsIntervalMs: v.option.XHTTPOpts.ScMinPostsIntervalMs, ReuseConfig: reuseCfg, @@ -667,6 +706,19 @@ func NewVless(option VlessOption) (*Vless, error) { Headers: lo.FromPtrOr(ds.Headers, v.option.XHTTPOpts.Headers), NoGRPCHeader: lo.FromPtrOr(ds.NoGRPCHeader, v.option.XHTTPOpts.NoGRPCHeader), XPaddingBytes: lo.FromPtrOr(ds.XPaddingBytes, v.option.XHTTPOpts.XPaddingBytes), + XPaddingObfsMode: lo.FromPtrOr(ds.XPaddingObfsMode, v.option.XHTTPOpts.XPaddingObfsMode), + XPaddingKey: lo.FromPtrOr(ds.XPaddingKey, v.option.XHTTPOpts.XPaddingKey), + XPaddingHeader: lo.FromPtrOr(ds.XPaddingHeader, v.option.XHTTPOpts.XPaddingHeader), + XPaddingPlacement: lo.FromPtrOr(ds.XPaddingPlacement, v.option.XHTTPOpts.XPaddingPlacement), + XPaddingMethod: lo.FromPtrOr(ds.XPaddingMethod, v.option.XHTTPOpts.XPaddingMethod), + UplinkHTTPMethod: lo.FromPtrOr(ds.UplinkHTTPMethod, v.option.XHTTPOpts.UplinkHTTPMethod), + SessionPlacement: lo.FromPtrOr(ds.SessionPlacement, v.option.XHTTPOpts.SessionPlacement), + SessionKey: lo.FromPtrOr(ds.SessionKey, v.option.XHTTPOpts.SessionKey), + SeqPlacement: lo.FromPtrOr(ds.SeqPlacement, v.option.XHTTPOpts.SeqPlacement), + SeqKey: lo.FromPtrOr(ds.SeqKey, v.option.XHTTPOpts.SeqKey), + UplinkDataPlacement: lo.FromPtrOr(ds.UplinkDataPlacement, v.option.XHTTPOpts.UplinkDataPlacement), + UplinkDataKey: lo.FromPtrOr(ds.UplinkDataKey, v.option.XHTTPOpts.UplinkDataKey), + UplinkChunkSize: lo.FromPtrOr(ds.UplinkChunkSize, v.option.XHTTPOpts.UplinkChunkSize), ScMaxEachPostBytes: lo.FromPtrOr(ds.ScMaxEachPostBytes, v.option.XHTTPOpts.ScMaxEachPostBytes), ScMinPostsIntervalMs: lo.FromPtrOr(ds.ScMinPostsIntervalMs, v.option.XHTTPOpts.ScMinPostsIntervalMs), ReuseConfig: downloadReuseCfg, diff --git a/docs/config.yaml b/docs/config.yaml index 42f8ef4e..6e62e8f7 100644 --- a/docs/config.yaml +++ b/docs/config.yaml @@ -825,6 +825,19 @@ proxies: # socks5 # X-Forwarded-For: "" # no-grpc-header: false # x-padding-bytes: "100-1000" + # x-padding-obfs-mode: false + # x-padding-key: x_padding + # x-padding-header: Referer + # x-padding-placement: queryInHeader # Available: queryInHeader, cookie, header, query + # x-padding-method: repeat-x # Available: repeat-x, tokenish + # uplink-http-method: POST # Available: POST, PUT, PATCH, DELETE + # session-placement: path # Available: path, query, cookie, header + # session-key: "" + # seq-placement: path # Available: path, query, cookie, header + # seq-key: "" + # uplink-data-placement: body # Available: body, cookie, header + # uplink-data-key: "" + # uplink-chunk-size: 0 # only applicable when uplink-data-placement is not body # sc-max-each-post-bytes: 1000000 # sc-min-posts-interval-ms: 30 # reuse-settings: # aka XMUX @@ -842,6 +855,19 @@ proxies: # socks5 # X-Forwarded-For: "" # no-grpc-header: false # x-padding-bytes: "100-1000" + # x-padding-obfs-mode: false + # x-padding-key: x_padding + # x-padding-header: Referer + # x-padding-placement: queryInHeader # Available: queryInHeader, cookie, header, query + # x-padding-method: repeat-x # Available: repeat-x, tokenish + # uplink-http-method: POST # Available: POST, PUT, PATCH, DELETE + # session-placement: path # Available: path, query, cookie, header + # session-key: "" + # seq-placement: path # Available: path, query, cookie, header + # seq-key: "" + # uplink-data-placement: body # Available: body, cookie, header + # uplink-data-key: "" + # uplink-chunk-size: 0 # only applicable when uplink-data-placement is not body # sc-max-each-post-bytes: 1000000 # sc-min-posts-interval-ms: 30 # reuse-settings: # aka XMUX @@ -1687,6 +1713,20 @@ listeners: # host: "" # mode: auto # Available: "stream-one", "stream-up" or "packet-up" # no-sse-header: false + # x-padding-bytes: "100-1000" + # x-padding-obfs-mode: false + # x-padding-key: x_padding + # x-padding-header: Referer + # x-padding-placement: queryInHeader # Available: queryInHeader, cookie, header, query + # x-padding-method: repeat-x # Available: repeat-x, tokenish + # uplink-http-method: POST # Available: POST, PUT, PATCH, DELETE + # session-placement: path # Available: path, query, cookie, header + # session-key: "" + # seq-placement: path # Available: path, query, cookie, header + # seq-key: "" + # uplink-data-placement: body # Available: body, cookie, header + # uplink-data-key: "" + # uplink-chunk-size: 0 # only applicable when uplink-data-placement is not body # sc-max-buffered-posts: 30 # sc-stream-up-server-secs: "20-80" # sc-max-each-post-bytes: 1000000 diff --git a/listener/config/vless.go b/listener/config/vless.go index 5e67e3f7..a4de7f57 100644 --- a/listener/config/vless.go +++ b/listener/config/vless.go @@ -34,6 +34,20 @@ type XHTTPConfig struct { Path string Host string Mode string + XPaddingBytes string + XPaddingObfsMode bool + XPaddingKey string + XPaddingHeader string + XPaddingPlacement string + XPaddingMethod string + UplinkHTTPMethod string + SessionPlacement string + SessionKey string + SeqPlacement string + SeqKey string + UplinkDataPlacement string + UplinkDataKey string + UplinkChunkSize string NoSSEHeader bool ScStreamUpServerSecs string ScMaxBufferedPosts string diff --git a/listener/inbound/vless.go b/listener/inbound/vless.go index aad9e70e..02e3f747 100644 --- a/listener/inbound/vless.go +++ b/listener/inbound/vless.go @@ -35,6 +35,20 @@ type XHTTPConfig struct { Path string `inbound:"path,omitempty"` Host string `inbound:"host,omitempty"` Mode string `inbound:"mode,omitempty"` + XPaddingBytes string `inbound:"xpadding-bytes,omitempty"` + XPaddingObfsMode bool `inbound:"xpadding-obfs-mode,omitempty"` + XPaddingKey string `inbound:"xpadding-key,omitempty"` + XPaddingHeader string `inbound:"xpadding-header,omitempty"` + XPaddingPlacement string `inbound:"xpadding-placement,omitempty"` + XPaddingMethod string `inbound:"xpadding-method,omitempty"` + UplinkHTTPMethod string `inbound:"uplink-http-method,omitempty"` + SessionPlacement string `inbound:"session-placement,omitempty"` + SessionKey string `inbound:"session-key,omitempty"` + SeqPlacement string `inbound:"seq-placement,omitempty"` + SeqKey string `inbound:"seq-key,omitempty"` + UplinkDataPlacement string `inbound:"uplink-data-placement,omitempty"` + UplinkDataKey string `inbound:"uplink-data-key,omitempty"` + UplinkChunkSize string `inbound:"uplink-chunk-size,omitempty"` NoSSEHeader bool `inbound:"no-sse-header,omitempty"` ScStreamUpServerSecs string `inbound:"sc-stream-up-server-secs,omitempty"` ScMaxBufferedPosts string `inbound:"sc-max-buffered-posts,omitempty"` @@ -47,6 +61,19 @@ func (o XHTTPConfig) Build() LC.XHTTPConfig { Host: o.Host, Mode: o.Mode, NoSSEHeader: o.NoSSEHeader, + XPaddingBytes: o.XPaddingBytes, + XPaddingObfsMode: o.XPaddingObfsMode, + XPaddingKey: o.XPaddingKey, + XPaddingHeader: o.XPaddingHeader, + XPaddingPlacement: o.XPaddingPlacement, + UplinkHTTPMethod: o.UplinkHTTPMethod, + SessionPlacement: o.SessionPlacement, + SessionKey: o.SessionKey, + SeqPlacement: o.SeqPlacement, + SeqKey: o.SeqKey, + UplinkDataPlacement: o.UplinkDataPlacement, + UplinkDataKey: o.UplinkDataKey, + UplinkChunkSize: o.UplinkChunkSize, ScStreamUpServerSecs: o.ScStreamUpServerSecs, ScMaxBufferedPosts: o.ScMaxBufferedPosts, ScMaxEachPostBytes: o.ScMaxEachPostBytes, diff --git a/listener/sing_vless/server.go b/listener/sing_vless/server.go index d684056b..4e41f6be 100644 --- a/listener/sing_vless/server.go +++ b/listener/sing_vless/server.go @@ -159,6 +159,20 @@ func New(config LC.VlessServer, tunnel C.Tunnel, additions ...inbound.Addition) Host: config.XHTTPConfig.Host, Path: config.XHTTPConfig.Path, Mode: config.XHTTPConfig.Mode, + XPaddingBytes: config.XHTTPConfig.XPaddingBytes, + XPaddingObfsMode: config.XHTTPConfig.XPaddingObfsMode, + XPaddingKey: config.XHTTPConfig.XPaddingKey, + XPaddingHeader: config.XHTTPConfig.XPaddingHeader, + XPaddingPlacement: config.XHTTPConfig.XPaddingPlacement, + XPaddingMethod: config.XHTTPConfig.XPaddingMethod, + UplinkHTTPMethod: config.XHTTPConfig.UplinkHTTPMethod, + SessionPlacement: config.XHTTPConfig.SessionPlacement, + SessionKey: config.XHTTPConfig.SessionKey, + SeqPlacement: config.XHTTPConfig.SeqPlacement, + SeqKey: config.XHTTPConfig.SeqKey, + UplinkDataPlacement: config.XHTTPConfig.UplinkDataPlacement, + UplinkDataKey: config.XHTTPConfig.UplinkDataKey, + UplinkChunkSize: config.XHTTPConfig.UplinkChunkSize, NoSSEHeader: config.XHTTPConfig.NoSSEHeader, ScStreamUpServerSecs: config.XHTTPConfig.ScStreamUpServerSecs, ScMaxBufferedPosts: config.XHTTPConfig.ScMaxBufferedPosts, diff --git a/transport/xhttp/client.go b/transport/xhttp/client.go index 3e7c726d..223d2db2 100644 --- a/transport/xhttp/client.go +++ b/transport/xhttp/client.go @@ -115,7 +115,7 @@ func (c *PacketUpWriter) write(b []byte) (int, error) { Path: c.cfg.NormalizedPath(), } - req, err := http.NewRequestWithContext(c.ctx, http.MethodPost, u.String(), nil) + req, err := http.NewRequestWithContext(c.ctx, c.cfg.GetNormalizedUplinkHTTPMethod(), u.String(), nil) if err != nil { return 0, err } @@ -359,7 +359,7 @@ func (c *Client) DialStreamOne() (net.Conn, error) { }, }) - req, err := http.NewRequestWithContext(ctx, http.MethodPost, requestURL.String(), pr) + req, err := http.NewRequestWithContext(ctx, c.cfg.GetNormalizedUplinkHTTPMethod(), requestURL.String(), pr) if err != nil { _ = pr.Close() _ = pw.Close() @@ -470,7 +470,7 @@ func (c *Client) DialStreamUp() (net.Conn, error) { uploadReq, err := http.NewRequestWithContext( c.ctx, - http.MethodPost, + c.cfg.GetNormalizedUplinkHTTPMethod(), streamURL.String(), pr, ) diff --git a/transport/xhttp/config.go b/transport/xhttp/config.go index 0c6d2a78..cfecc89b 100644 --- a/transport/xhttp/config.go +++ b/transport/xhttp/config.go @@ -2,6 +2,7 @@ package xhttp import ( "bytes" + "encoding/base64" "fmt" "io" "math/rand" @@ -11,6 +12,16 @@ import ( "github.com/metacubex/http" ) +const ( + PlacementQueryInHeader = "queryInHeader" + PlacementCookie = "cookie" + PlacementHeader = "header" + PlacementQuery = "query" + PlacementPath = "path" + PlacementBody = "body" + PlacementAuto = "auto" +) + type Config struct { Host string Path string @@ -18,6 +29,19 @@ type Config struct { Headers map[string]string NoGRPCHeader bool XPaddingBytes string + XPaddingObfsMode bool + XPaddingKey string + XPaddingHeader string + XPaddingPlacement string + XPaddingMethod string + UplinkHTTPMethod string + SessionPlacement string + SessionKey string + SeqPlacement string + SeqKey string + UplinkDataPlacement string + UplinkDataKey string + UplinkChunkSize string NoSSEHeader bool // server only ScStreamUpServerSecs string // server only ScMaxBufferedPosts string // server only @@ -70,7 +94,7 @@ func (c *Config) NormalizedPath() string { return path } -func (c *Config) RequestHeader() http.Header { +func (c *Config) GetRequestHeader() http.Header { h := http.Header{} for k, v := range c.Headers { h.Set(k, v) @@ -95,12 +119,83 @@ func (c *Config) RequestHeader() http.Header { return h } -func (c *Config) RandomPadding() (string, error) { - r, err := ParseRange(c.XPaddingBytes, "100-1000") - if err != nil { - return "", fmt.Errorf("invalid x-padding-bytes: %w", err) +func (c *Config) GetRequestHeaderWithPayload(payload []byte, uplinkChunkSize Range) http.Header { + header := c.GetRequestHeader() + + key := c.UplinkDataKey + encodedData := base64.RawURLEncoding.EncodeToString(payload) + + for i := 0; len(encodedData) > 0; i++ { + chunkSize := uplinkChunkSize.Rand() + if len(encodedData) < chunkSize { + chunkSize = len(encodedData) + } + chunk := encodedData[:chunkSize] + encodedData = encodedData[chunkSize:] + headerKey := fmt.Sprintf("%s-%d", key, i) + header.Set(headerKey, chunk) } - return strings.Repeat("X", r.Rand()), nil + + return header +} + +func (c *Config) GetRequestCookiesWithPayload(payload []byte, uplinkChunkSize Range) []*http.Cookie { + cookies := []*http.Cookie{} + + key := c.UplinkDataKey + encodedData := base64.RawURLEncoding.EncodeToString(payload) + + for i := 0; len(encodedData) > 0; i++ { + chunkSize := uplinkChunkSize.Rand() + if len(encodedData) < chunkSize { + chunkSize = len(encodedData) + } + chunk := encodedData[:chunkSize] + encodedData = encodedData[chunkSize:] + cookieName := fmt.Sprintf("%s_%d", key, i) + cookies = append(cookies, &http.Cookie{Name: cookieName, Value: chunk}) + } + + return cookies +} + +func (c *Config) WriteResponseHeader(writer http.ResponseWriter, requestMethod string, requestHeader http.Header) { + 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) + } + + if c.GetNormalizedSessionPlacement() == PlacementCookie || + c.GetNormalizedSeqPlacement() == PlacementCookie || + c.XPaddingPlacement == PlacementCookie || + c.GetNormalizedUplinkDataPlacement() == PlacementCookie { + writer.Header().Set("Access-Control-Allow-Credentials", "true") + } + + if requestMethod == "OPTIONS" { + requestedMethod := requestHeader.Get("Access-Control-Request-Method") + if requestedMethod != "" { + writer.Header().Set("Access-Control-Allow-Methods", requestedMethod) + } 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) + } + } +} + +func (c *Config) GetNormalizedUplinkHTTPMethod() string { + if c.UplinkHTTPMethod == "" { + return "POST" + } + return c.UplinkHTTPMethod } func (c *Config) GetNormalizedScStreamUpServerSecs() (Range, error) { @@ -144,6 +239,84 @@ func (c *Config) GetNormalizedScMinPostsIntervalMs() (Range, error) { return r, nil } +func (c *Config) GetNormalizedUplinkChunkSize() (Range, error) { + uplinkChunkSize, err := ParseRange(c.UplinkChunkSize, "") + if err != nil { + return Range{}, fmt.Errorf("invalid uplink-chunk-size: %w", err) + } + if uplinkChunkSize.Max == 0 { + switch c.GetNormalizedUplinkDataPlacement() { + case PlacementCookie: + return Range{ + Min: 2 * 1024, // 2 KiB + Max: 3 * 1024, // 3 KiB + }, nil + case PlacementHeader: + return Range{ + Min: 3 * 1024, // 3 KiB + Max: 4 * 1024, // 4 KiB + }, nil + default: + return c.GetNormalizedScMaxEachPostBytes() + } + } else if uplinkChunkSize.Min < 64 { + uplinkChunkSize.Min = 64 + if uplinkChunkSize.Max < 64 { + uplinkChunkSize.Max = 64 + } + } + return uplinkChunkSize, nil +} + +func (c *Config) GetNormalizedSessionPlacement() string { + if c.SessionPlacement == "" { + return PlacementPath + } + return c.SessionPlacement +} + +func (c *Config) GetNormalizedSeqPlacement() string { + if c.SeqPlacement == "" { + return PlacementPath + } + return c.SeqPlacement +} + +func (c *Config) GetNormalizedUplinkDataPlacement() string { + if c.UplinkDataPlacement == "" { + return PlacementBody + } + return c.UplinkDataPlacement +} + +func (c *Config) GetNormalizedSessionKey() string { + if c.SessionKey != "" { + return c.SessionKey + } + switch c.GetNormalizedSessionPlacement() { + case PlacementHeader: + return "X-Session" + case PlacementCookie, PlacementQuery: + return "x_session" + default: + return "" + } +} + +func (c *Config) GetNormalizedSeqKey() string { + if c.SeqKey != "" { + return c.SeqKey + } + switch c.GetNormalizedSeqPlacement() { + case PlacementHeader: + return "X-Seq" + case PlacementCookie, PlacementQuery: + return "x_seq" + default: + return "" + } +} + type Range struct { Min int Max int @@ -231,32 +404,6 @@ func (c *ReuseConfig) ResolveEntryConfig() (Range, Range, Range, error) { return cMaxReuseTimes, hMaxRequestTimes, hMaxReusableSecs, nil } -func (c *Config) FillStreamRequest(req *http.Request, sessionID string) error { - req.Header = c.RequestHeader() - - paddingValue, err := c.RandomPadding() - if err != nil { - return err - } - - if paddingValue != "" { - rawURL := req.URL.String() - sep := "?" - if strings.Contains(rawURL, "?") { - sep = "&" - } - req.Header.Set("Referer", rawURL+sep+"x_padding="+paddingValue) - } - - c.ApplyMetaToRequest(req, sessionID, "") - - if req.Body != nil && !c.NoGRPCHeader { - req.Header.Set("Content-Type", "application/grpc") - } - - return nil -} - func appendToPath(path, value string) string { if strings.HasSuffix(path, "/") { return path + value @@ -264,53 +411,185 @@ func appendToPath(path, value string) string { return path + "/" + value } -func (c *Config) ApplyMetaToRequest(req *http.Request, sessionID string, seqStr string) { - if sessionID != "" { - req.URL.Path = appendToPath(req.URL.Path, sessionID) +func (c *Config) ApplyMetaToRequest(req *http.Request, sessionId string, seqStr string) { + sessionPlacement := c.GetNormalizedSessionPlacement() + seqPlacement := c.GetNormalizedSeqPlacement() + sessionKey := c.GetNormalizedSessionKey() + seqKey := c.GetNormalizedSeqKey() + + if sessionId != "" { + switch sessionPlacement { + case PlacementPath: + req.URL.Path = appendToPath(req.URL.Path, sessionId) + case PlacementQuery: + q := req.URL.Query() + q.Set(sessionKey, sessionId) + req.URL.RawQuery = q.Encode() + case PlacementHeader: + req.Header.Set(sessionKey, sessionId) + case PlacementCookie: + req.AddCookie(&http.Cookie{Name: sessionKey, Value: sessionId}) + } } + if seqStr != "" { - req.URL.Path = appendToPath(req.URL.Path, seqStr) + switch seqPlacement { + case PlacementPath: + req.URL.Path = appendToPath(req.URL.Path, seqStr) + case PlacementQuery: + q := req.URL.Query() + q.Set(seqKey, seqStr) + req.URL.RawQuery = q.Encode() + case PlacementHeader: + req.Header.Set(seqKey, seqStr) + case PlacementCookie: + req.AddCookie(&http.Cookie{Name: seqKey, Value: seqStr}) + } } } -func (c *Config) FillPacketRequest(req *http.Request, sessionID string, seqStr string, payload []byte) error { - req.Header = c.RequestHeader() - req.Body = io.NopCloser(bytes.NewReader(payload)) - req.ContentLength = int64(len(payload)) +func (c *Config) ExtractMetaFromRequest(req *http.Request, path string) (sessionId string, seqStr string) { + sessionPlacement := c.GetNormalizedSessionPlacement() + seqPlacement := c.GetNormalizedSeqPlacement() + sessionKey := c.GetNormalizedSessionKey() + seqKey := c.GetNormalizedSeqKey() - paddingValue, err := c.RandomPadding() + 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: + sessionId = req.Header.Get(sessionKey) + case PlacementCookie: + if cookie, e := req.Cookie(sessionKey); e == nil { + sessionId = cookie.Value + } + } + + switch seqPlacement { + case PlacementPath: + if len(subpath) > pathPart { + seqStr = subpath[pathPart] + pathPart += 1 + } + case PlacementQuery: + seqStr = req.URL.Query().Get(seqKey) + case PlacementHeader: + seqStr = req.Header.Get(seqKey) + case PlacementCookie: + if cookie, e := req.Cookie(seqKey); e == nil { + seqStr = cookie.Value + } + } + + return sessionId, seqStr +} + +func (c *Config) FillStreamRequest(req *http.Request, sessionID string) error { + req.Header = c.GetRequestHeader() + xPaddingBytes, err := c.GetNormalizedXPaddingBytes() if err != nil { return err } - if paddingValue != "" { - rawURL := req.URL.String() - sep := "?" - if strings.Contains(rawURL, "?") { - sep = "&" + length := xPaddingBytes.Rand() + config := XPaddingConfig{Length: length} + + if c.XPaddingObfsMode { + config.Placement = XPaddingPlacement{ + Placement: c.XPaddingPlacement, + Key: c.XPaddingKey, + Header: c.XPaddingHeader, + RawURL: req.URL.String(), + } + config.Method = PaddingMethod(c.XPaddingMethod) + } else { + config.Placement = XPaddingPlacement{ + Placement: PlacementQueryInHeader, + Key: "x_padding", + Header: "Referer", + RawURL: req.URL.String(), } - req.Header.Set("Referer", rawURL+sep+"x_padding="+paddingValue) } - c.ApplyMetaToRequest(req, sessionID, seqStr) + c.ApplyXPaddingToRequest(req, config) + c.ApplyMetaToRequest(req, sessionID, "") + + if req.Body != nil && !c.NoGRPCHeader { // stream-up/one + req.Header.Set("Content-Type", "application/grpc") + } + return nil } func (c *Config) FillDownloadRequest(req *http.Request, sessionID string) error { - req.Header = c.RequestHeader() + return c.FillStreamRequest(req, sessionID) +} - paddingValue, err := c.RandomPadding() +func (c *Config) FillPacketRequest(request *http.Request, sessionId string, seqStr string, data []byte) error { + dataPlacement := c.GetNormalizedUplinkDataPlacement() + + if dataPlacement == PlacementBody || dataPlacement == PlacementAuto { + request.Header = c.GetRequestHeader() + request.Body = io.NopCloser(bytes.NewReader(data)) + request.ContentLength = int64(len(data)) + } else { + request.Body = nil + request.ContentLength = 0 + switch dataPlacement { + case PlacementHeader: + uplinkChunkSize, err := c.GetNormalizedUplinkChunkSize() + if err != nil { + return err + } + request.Header = c.GetRequestHeaderWithPayload(data, uplinkChunkSize) + case PlacementCookie: + request.Header = c.GetRequestHeader() + uplinkChunkSize, err := c.GetNormalizedUplinkChunkSize() + if err != nil { + return err + } + for _, cookie := range c.GetRequestCookiesWithPayload(data, uplinkChunkSize) { + request.AddCookie(cookie) + } + } + } + + xPaddingBytes, err := c.GetNormalizedXPaddingBytes() if err != nil { return err } - if paddingValue != "" { - rawURL := req.URL.String() - sep := "?" - if strings.Contains(rawURL, "?") { - sep = "&" + length := xPaddingBytes.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(), } - req.Header.Set("Referer", rawURL+sep+"x_padding="+paddingValue) } - c.ApplyMetaToRequest(req, sessionID, "") + c.ApplyXPaddingToRequest(request, config) + c.ApplyMetaToRequest(request, sessionId, seqStr) + return nil } diff --git a/transport/xhttp/server.go b/transport/xhttp/server.go index c8b4fa84..4f41019c 100644 --- a/transport/xhttp/server.go +++ b/transport/xhttp/server.go @@ -1,6 +1,9 @@ package xhttp import ( + "bytes" + "encoding/base64" + "fmt" "io" "net" "strconv" @@ -98,6 +101,7 @@ type requestHandler struct { connHandler func(net.Conn) httpHandler http.Handler + xPaddingBytes Range scMaxEachPostBytes Range scStreamUpServerSecs Range scMaxBufferedPosts Range @@ -107,6 +111,10 @@ type requestHandler struct { } func NewServerHandler(opt ServerOption) (http.Handler, error) { + xPaddingBytes, err := opt.Config.GetNormalizedXPaddingBytes() + if err != nil { + return nil, err + } scMaxEachPostBytes, err := opt.Config.GetNormalizedScMaxEachPostBytes() if err != nil { return nil, err @@ -125,6 +133,7 @@ func NewServerHandler(opt ServerOption) (http.Handler, error) { config: opt.Config, connHandler: opt.ConnHandler, httpHandler: opt.HttpHandler, + xPaddingBytes: xPaddingBytes, scMaxEachPostBytes: scMaxEachPostBytes, scStreamUpServerSecs: scStreamUpServerSecs, scMaxBufferedPosts: scMaxBufferedPosts, @@ -134,7 +143,7 @@ func NewServerHandler(opt ServerOption) (http.Handler, error) { }), nil } -func (h *requestHandler) getOrCreateSession(sessionID string) *httpSession { +func (h *requestHandler) upsertSession(sessionID string) *httpSession { h.mu.Lock() defer h.mu.Unlock() @@ -161,8 +170,6 @@ func (h *requestHandler) getOrCreateSession(sessionID string) *httpSession { return s } - - func (h *requestHandler) deleteSession(sessionID string) { h.mu.Lock() defer h.mu.Unlock() @@ -239,11 +246,227 @@ func (h *requestHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } - rest := strings.TrimPrefix(r.URL.Path, path) - parts := splitNonEmpty(rest) + h.config.WriteResponseHeader(w, r.Method, r.Header) + length := h.xPaddingBytes.Rand() + config := XPaddingConfig{Length: length} + + if h.config.XPaddingObfsMode { + config.Placement = XPaddingPlacement{ + Placement: h.config.XPaddingPlacement, + Key: h.config.XPaddingKey, + Header: h.config.XPaddingHeader, + } + config.Method = PaddingMethod(h.config.XPaddingMethod) + } else { + config.Placement = XPaddingPlacement{ + Placement: PlacementHeader, + Header: "X-Padding", + } + } + + h.config.ApplyXPaddingToResponse(w, config) + + if r.Method == "OPTIONS" { + w.WriteHeader(http.StatusOK) + return + } + + paddingValue, _ := h.config.ExtractXPaddingFromRequest(r, h.config.XPaddingObfsMode) + if !h.config.IsPaddingValid(paddingValue, h.xPaddingBytes.Min, h.xPaddingBytes.Max, PaddingMethod(h.config.XPaddingMethod)) { + http.Error(w, "invalid xpadding", http.StatusBadRequest) + return + } + sessionId, seqStr := h.config.ExtractMetaFromRequest(r, path) + + var currentSession *httpSession + if sessionId != "" { + currentSession = h.upsertSession(sessionId) + } + + // stream-up upload: POST /path/{session} + if r.Method != http.MethodGet && sessionId != "" && seqStr == "" && h.allowStreamUpUpload() { + httpSC := newHTTPServerConn(w, r.Body) + err := currentSession.uploadQueue.Push(Packet{ + Reader: httpSC, + }) + if err != nil { + http.Error(w, err.Error(), http.StatusConflict) + return + } + + // magic header instructs nginx + apache to not buffer response body + w.Header().Set("X-Accel-Buffering", "no") + // A web-compliant header telling all middleboxes to disable caching. + // Should be able to prevent overloading the cache, or stop CDNs from + // teeing the response stream into their cache, causing slowdowns. + w.Header().Set("Cache-Control", "no-store") + if !h.config.NoSSEHeader { + // magic header to make the HTTP middle box consider this as SSE to disable buffer + w.Header().Set("Content-Type", "text/event-stream") + } + w.WriteHeader(http.StatusOK) + referrer := r.Header.Get("Referer") + if referrer != "" && h.scStreamUpServerSecs.Max > 0 { + go func() { + for { + _, err := httpSC.Write(bytes.Repeat([]byte{'X'}, int(h.xPaddingBytes.Rand()))) + if err != nil { + break + } + time.Sleep(time.Duration(h.scStreamUpServerSecs.Rand()) * time.Second) + } + }() + } + + select { + case <-r.Context().Done(): + case <-httpSC.Wait(): + } + + _ = httpSC.Close() + return + } + + // packet-up upload: POST /path/{session}/{seq} + if r.Method != http.MethodGet && sessionId != "" && seqStr != "" && h.allowPacketUpUpload() { + scMaxEachPostBytes := h.scMaxEachPostBytes.Max + dataPlacement := h.config.GetNormalizedUplinkDataPlacement() + uplinkDataKey := h.config.UplinkDataKey + var headerPayload []byte + var err error + if dataPlacement == PlacementAuto || dataPlacement == PlacementHeader { + var headerPayloadChunks []string + for i := 0; true; i++ { + chunk := r.Header.Get(fmt.Sprintf("%s-%d", uplinkDataKey, i)) + if chunk == "" { + break + } + headerPayloadChunks = append(headerPayloadChunks, chunk) + } + headerPayloadEncoded := strings.Join(headerPayloadChunks, "") + headerPayload, err = base64.RawURLEncoding.DecodeString(headerPayloadEncoded) + if err != nil { + http.Error(w, "invalid base64 in header's payload", http.StatusBadRequest) + return + } + } + + var cookiePayload []byte + if dataPlacement == PlacementAuto || dataPlacement == PlacementCookie { + var cookiePayloadChunks []string + for i := 0; true; i++ { + cookieName := fmt.Sprintf("%s_%d", uplinkDataKey, i) + if c, _ := r.Cookie(cookieName); c != nil { + cookiePayloadChunks = append(cookiePayloadChunks, c.Value) + } else { + break + } + } + cookiePayloadEncoded := strings.Join(cookiePayloadChunks, "") + cookiePayload, err = base64.RawURLEncoding.DecodeString(cookiePayloadEncoded) + if err != nil { + http.Error(w, "invalid base64 in cookies' payload", http.StatusBadRequest) + return + } + } + + var bodyPayload []byte + if dataPlacement == PlacementAuto || dataPlacement == PlacementBody { + if r.ContentLength > int64(scMaxEachPostBytes) { + http.Error(w, "body too large", http.StatusRequestEntityTooLarge) + return + } + bodyPayload, err = io.ReadAll(io.LimitReader(r.Body, int64(scMaxEachPostBytes)+1)) + if err != nil { + http.Error(w, "failed to read body", http.StatusBadRequest) + return + } + } + + var payload []byte + switch dataPlacement { + case PlacementHeader: + payload = headerPayload + case PlacementCookie: + payload = cookiePayload + case PlacementBody: + payload = bodyPayload + case PlacementAuto: + payload = headerPayload + payload = append(payload, cookiePayload...) + payload = append(payload, bodyPayload...) + } + + if len(payload) > h.scMaxEachPostBytes.Max { + http.Error(w, "body too large", http.StatusRequestEntityTooLarge) + return + } + + seq, err := strconv.ParseUint(seqStr, 10, 64) + if err != nil { + http.Error(w, "invalid xhttp seq", http.StatusBadRequest) + return + } + + err = currentSession.uploadQueue.Push(Packet{ + Seq: seq, + Payload: payload, + }) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + if len(payload) == 0 { + // Methods without a body are usually cached by default. + w.Header().Set("Cache-Control", "no-store") + } + w.WriteHeader(http.StatusOK) + return + } + + // stream-up/packet-up download: GET /path/{session} + if r.Method == http.MethodGet && sessionId != "" && seqStr == "" && h.allowSessionDownload() { + currentSession.markConnected() + + // magic header instructs nginx + apache to not buffer response body + w.Header().Set("X-Accel-Buffering", "no") + // A web-compliant header telling all middleboxes to disable caching. + // Should be able to prevent overloading the cache, or stop CDNs from + // teeing the response stream into their cache, causing slowdowns. + w.Header().Set("Cache-Control", "no-store") + if !h.config.NoSSEHeader { + // magic header to make the HTTP middle box consider this as SSE to disable buffer + w.Header().Set("Content-Type", "text/event-stream") + } + w.WriteHeader(http.StatusOK) + if flusher, ok := w.(http.Flusher); ok { + flusher.Flush() + } + + httpSC := newHTTPServerConn(w, r.Body) + conn := &Conn{ + writer: httpSC, + reader: currentSession.uploadQueue, + onClose: func() { + h.deleteSession(sessionId) + }, + } + httputils.SetAddrFromRequest(&conn.NetAddr, r) + + go h.connHandler(N.NewDeadlineConn(conn)) + + select { + case <-r.Context().Done(): + case <-httpSC.Wait(): + } + + _ = conn.Close() + return + } // stream-one: POST /path - if r.Method == http.MethodPost && len(parts) == 0 && h.allowStreamOne() { + if r.Method != http.MethodGet && sessionId == "" && seqStr == "" && h.allowStreamOne() { w.Header().Set("X-Accel-Buffering", "no") w.Header().Set("Cache-Control", "no-store") w.WriteHeader(http.StatusOK) @@ -269,137 +492,6 @@ func (h *requestHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } - // stream-up/packet-up download: GET /path/{session} - if r.Method == http.MethodGet && len(parts) == 1 && h.allowSessionDownload() { - sessionID := parts[0] - session := h.getOrCreateSession(sessionID) - session.markConnected() - - // magic header instructs nginx + apache to not buffer response body - w.Header().Set("X-Accel-Buffering", "no") - // A web-compliant header telling all middleboxes to disable caching. - // Should be able to prevent overloading the cache, or stop CDNs from - // teeing the response stream into their cache, causing slowdowns. - w.Header().Set("Cache-Control", "no-store") - if !h.config.NoSSEHeader { - // magic header to make the HTTP middle box consider this as SSE to disable buffer - w.Header().Set("Content-Type", "text/event-stream") - } - w.WriteHeader(http.StatusOK) - if flusher, ok := w.(http.Flusher); ok { - flusher.Flush() - } - - httpSC := newHTTPServerConn(w, r.Body) - conn := &Conn{ - writer: httpSC, - reader: session.uploadQueue, - onClose: func() { - h.deleteSession(sessionID) - }, - } - httputils.SetAddrFromRequest(&conn.NetAddr, r) - - go h.connHandler(N.NewDeadlineConn(conn)) - - select { - case <-r.Context().Done(): - case <-httpSC.Wait(): - } - - _ = conn.Close() - return - } - - // stream-up upload: POST /path/{session} - if r.Method == http.MethodPost && len(parts) == 1 && h.allowStreamUpUpload() { - sessionID := parts[0] - session := h.getOrCreateSession(sessionID) - - httpSC := newHTTPServerConn(w, r.Body) - err := session.uploadQueue.Push(Packet{ - Reader: httpSC, - }) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - // magic header instructs nginx + apache to not buffer response body - w.Header().Set("X-Accel-Buffering", "no") - // A web-compliant header telling all middleboxes to disable caching. - // Should be able to prevent overloading the cache, or stop CDNs from - // teeing the response stream into their cache, causing slowdowns. - w.Header().Set("Cache-Control", "no-store") - if !h.config.NoSSEHeader { - // magic header to make the HTTP middle box consider this as SSE to disable buffer - w.Header().Set("Content-Type", "text/event-stream") - } - w.WriteHeader(http.StatusOK) - referrer := r.Header.Get("Referer") - if referrer != "" && h.scStreamUpServerSecs.Max > 0 { - go func() { - for { - paddingValue, _ := h.config.RandomPadding() - if paddingValue == "" { - break - } - _, err = httpSC.Write([]byte(paddingValue)) - if err != nil { - break - } - time.Sleep(time.Duration(h.scStreamUpServerSecs.Rand()) * time.Second) - } - }() - } - - select { - case <-r.Context().Done(): - case <-httpSC.Wait(): - } - - _ = httpSC.Close() - return - } - - // packet-up upload: POST /path/{session}/{seq} - if r.Method == http.MethodPost && len(parts) == 2 && h.allowPacketUpUpload() { - sessionID := parts[0] - seq, err := strconv.ParseUint(parts[1], 10, 64) - if err != nil { - http.Error(w, "invalid xhttp seq", http.StatusBadRequest) - return - } - - session := h.getOrCreateSession(sessionID) - - if r.ContentLength > int64(h.scMaxEachPostBytes.Max) { - http.Error(w, "body too large", http.StatusRequestEntityTooLarge) - return - } - - body, err := io.ReadAll(io.LimitReader(r.Body, int64(h.scMaxEachPostBytes.Max)+1)) - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - - err = session.uploadQueue.Push(Packet{ - Seq: seq, - Payload: body, - }) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - if len(body) == 0 { - w.Header().Set("Cache-Control", "no-store") - } - w.WriteHeader(http.StatusOK) - return - } - http.NotFound(w, r) } diff --git a/transport/xhttp/server_test.go b/transport/xhttp/server_test.go deleted file mode 100644 index f7d951d8..00000000 --- a/transport/xhttp/server_test.go +++ /dev/null @@ -1,102 +0,0 @@ -package xhttp - -import ( - "io" - "net" - "testing" - - "github.com/metacubex/http" - "github.com/metacubex/http/httptest" - "github.com/stretchr/testify/assert" -) - -func TestServerHandlerModeRestrictions(t *testing.T) { - testCases := []struct { - name string - mode string - method string - target string - wantStatus int - }{ - { - name: "StreamOneAcceptsStreamOne", - mode: "stream-one", - method: http.MethodPost, - target: "https://example.com/xhttp/", - wantStatus: http.StatusOK, - }, - { - name: "StreamOneRejectsSessionDownload", - mode: "stream-one", - method: http.MethodGet, - target: "https://example.com/xhttp/session", - wantStatus: http.StatusNotFound, - }, - { - name: "StreamUpAcceptsStreamOne", - mode: "stream-up", - method: http.MethodPost, - target: "https://example.com/xhttp/", - wantStatus: http.StatusOK, - }, - { - name: "StreamUpAllowsDownloadEndpoint", - mode: "stream-up", - method: http.MethodGet, - target: "https://example.com/xhttp/session", - wantStatus: http.StatusOK, - }, - { - name: "StreamUpRejectsPacketUpload", - mode: "stream-up", - method: http.MethodPost, - target: "https://example.com/xhttp/session/0", - wantStatus: http.StatusNotFound, - }, - { - name: "PacketUpAllowsDownloadEndpoint", - mode: "packet-up", - method: http.MethodGet, - target: "https://example.com/xhttp/session", - wantStatus: http.StatusOK, - }, - { - name: "PacketUpRejectsStreamOne", - mode: "packet-up", - method: http.MethodPost, - target: "https://example.com/xhttp/", - wantStatus: http.StatusNotFound, - }, - { - name: "PacketUpRejectsStreamUpUpload", - mode: "packet-up", - method: http.MethodPost, - target: "https://example.com/xhttp/session", - wantStatus: http.StatusNotFound, - }, - } - - for _, testCase := range testCases { - t.Run(testCase.name, func(t *testing.T) { - handler, err := NewServerHandler(ServerOption{ - Config: Config{ - Path: "/xhttp", - Mode: testCase.mode, - }, - ConnHandler: func(conn net.Conn) { - _ = conn.Close() - }, - }) - if err != nil { - panic(err) - } - - req := httptest.NewRequest(testCase.method, testCase.target, io.NopCloser(http.NoBody)) - recorder := httptest.NewRecorder() - - handler.ServeHTTP(recorder, req) - - assert.Equal(t, testCase.wantStatus, recorder.Result().StatusCode) - }) - } -} diff --git a/transport/xhttp/xpadding.go b/transport/xhttp/xpadding.go new file mode 100644 index 00000000..3b22a019 --- /dev/null +++ b/transport/xhttp/xpadding.go @@ -0,0 +1,333 @@ +package xhttp + +import ( + "crypto/rand" + "fmt" + "math" + "net/url" + "strings" + + "github.com/metacubex/http" + "golang.org/x/net/http2/hpack" +) + +type PaddingMethod string + +const ( + PaddingMethodRepeatX PaddingMethod = "repeat-x" + PaddingMethodTokenish PaddingMethod = "tokenish" +) + +const charsetBase62 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" + +// Huffman encoding gives ~20% size reduction for base62 sequences +const avgHuffmanBytesPerCharBase62 = 0.8 + +const validationTolerance = 2 + +type XPaddingPlacement struct { + Placement string + Key string + Header string + RawURL string +} + +type XPaddingConfig struct { + Length int + Placement XPaddingPlacement + Method PaddingMethod +} + +func randStringFromCharset(n int, charset string) (string, bool) { + if n <= 0 || len(charset) == 0 { + return "", false + } + + m := len(charset) + limit := byte(256 - (256 % m)) + + result := make([]byte, n) + i := 0 + + buf := make([]byte, 256) + for i < n { + if _, err := rand.Read(buf); err != nil { + return "", false + } + for _, rb := range buf { + if rb >= limit { + continue + } + result[i] = charset[int(rb)%m] + i++ + if i == n { + break + } + } + } + + return string(result), true +} + +func absInt(x int) int { + if x < 0 { + return -x + } + return x +} + +func GenerateTokenishPaddingBase62(targetHuffmanBytes int) string { + n := int(math.Ceil(float64(targetHuffmanBytes) / avgHuffmanBytesPerCharBase62)) + if n < 1 { + n = 1 + } + + randBase62Str, ok := randStringFromCharset(n, charsetBase62) + if !ok { + return "" + } + + const maxIter = 150 + adjustChar := byte('X') + + // Adjust until close enough + for iter := 0; iter < maxIter; iter++ { + currentLength := int(hpack.HuffmanEncodeLength(randBase62Str)) + diff := currentLength - targetHuffmanBytes + + if absInt(diff) <= validationTolerance { + return randBase62Str + } + + if diff < 0 { + // Too small -> append padding char(s) + randBase62Str += string(adjustChar) + + // Avoid a long run of identical chars + if adjustChar == 'X' { + adjustChar = 'Z' + } else { + adjustChar = 'X' + } + } else { + // Too big -> remove from the end + if len(randBase62Str) <= 1 { + return randBase62Str + } + randBase62Str = randBase62Str[:len(randBase62Str)-1] + } + } + + return randBase62Str +} + +func GeneratePadding(method PaddingMethod, length int) string { + if length <= 0 { + return "" + } + + // https://www.rfc-editor.org/rfc/rfc7541.html#appendix-B + // h2's HPACK Header Compression feature employs a huffman encoding using a static table. + // 'X' and 'Z' are assigned an 8 bit code, so HPACK compression won't change actual padding length on the wire. + // https://www.rfc-editor.org/rfc/rfc9204.html#section-4.1.2-2 + // h3's similar QPACK feature uses the same huffman table. + + switch method { + case PaddingMethodRepeatX: + return strings.Repeat("X", length) + case PaddingMethodTokenish: + paddingValue := GenerateTokenishPaddingBase62(length) + if paddingValue == "" { + return strings.Repeat("X", length) + } + return paddingValue + default: + return strings.Repeat("X", length) + } +} + +func ApplyPaddingToCookie(req *http.Request, name, value string) { + if req == nil || name == "" || value == "" { + return + } + req.AddCookie(&http.Cookie{ + Name: name, + Value: value, + Path: "/", + }) +} + +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 + } + q := u.Query() + q.Set(key, value) + u.RawQuery = q.Encode() +} + +func (c *Config) GetNormalizedXPaddingBytes() (Range, error) { + r, err := ParseRange(c.XPaddingBytes, "100-1000") + if err != nil { + return Range{}, fmt.Errorf("invalid x-padding-bytes: %w", err) + } + return r, nil +} + +func (c *Config) ApplyXPaddingToHeader(h http.Header, config XPaddingConfig) { + if h == nil { + return + } + + paddingValue := GeneratePadding(config.Method, config.Length) + + switch p := config.Placement; p.Placement { + case PlacementHeader: + h.Set(p.Header, paddingValue) + case PlacementQueryInHeader: + u, err := url.Parse(p.RawURL) + if err != nil || u == nil { + return + } + u.RawQuery = p.Key + "=" + paddingValue + h.Set(p.Header, u.String()) + } +} + +func (c *Config) ApplyXPaddingToRequest(req *http.Request, config XPaddingConfig) { + if req == nil { + return + } + if req.Header == nil { + req.Header = make(http.Header) + } + + placement := config.Placement.Placement + + if placement == PlacementHeader || placement == PlacementQueryInHeader { + c.ApplyXPaddingToHeader(req.Header, config) + return + } + + paddingValue := GeneratePadding(config.Method, config.Length) + + switch placement { + case PlacementCookie: + ApplyPaddingToCookie(req, config.Placement.Key, paddingValue) + case PlacementQuery: + ApplyPaddingToQuery(req.URL, config.Placement.Key, paddingValue) + } +} + +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 "", "" + } + + if !obfsMode { + referrer := req.Header.Get("Referer") + + if referrer != "" { + if referrerURL, err := url.Parse(referrer); err == nil { + paddingValue := referrerURL.Query().Get("x_padding") + paddingPlacement := PlacementQueryInHeader + "=Referer, key=x_padding" + return paddingValue, paddingPlacement + } + } else { + paddingValue := req.URL.Query().Get("x_padding") + return paddingValue, PlacementQuery + ", key=x_padding" + } + } + + key := c.XPaddingKey + header := c.XPaddingHeader + + if cookie, err := req.Cookie(key); err == nil { + if cookie != nil && cookie.Value != "" { + paddingValue := cookie.Value + paddingPlacement := PlacementCookie + ", key=" + key + return paddingValue, paddingPlacement + } + } + + headerValue := req.Header.Get(header) + + if headerValue != "" { + if c.XPaddingPlacement == PlacementHeader { + paddingPlacement := PlacementHeader + "=" + header + return headerValue, paddingPlacement + } + + if parsedURL, err := url.Parse(headerValue); err == nil { + paddingPlacement := PlacementQueryInHeader + "=" + header + ", key=" + key + + return parsedURL.Query().Get(key), paddingPlacement + } + } + + queryValue := req.URL.Query().Get(key) + + if queryValue != "" { + paddingPlacement := PlacementQuery + ", key=" + key + return queryValue, paddingPlacement + } + + return "", "" +} + +func (c *Config) IsPaddingValid(paddingValue string, from, to int, method PaddingMethod) bool { + if paddingValue == "" { + return false + } + if to <= 0 { + if r, err := c.GetNormalizedXPaddingBytes(); err == nil { + from, to = r.Min, r.Max + } + } + + switch method { + case PaddingMethodRepeatX: + n := len(paddingValue) + return n >= from && n <= to + case PaddingMethodTokenish: + const tolerance = validationTolerance + + n := int(hpack.HuffmanEncodeLength(paddingValue)) + f := from - tolerance + t := to + tolerance + if f < 0 { + f = 0 + } + return n >= f && n <= t + default: + n := len(paddingValue) + return n >= from && n <= to + } +}