diff --git a/backend/proxy/proxy.go b/backend/proxy/proxy.go index 0145f153..c53fe7da 100644 --- a/backend/proxy/proxy.go +++ b/backend/proxy/proxy.go @@ -734,10 +734,42 @@ func (m *ProxyHandler) patchRequestBodyWithContext(req *http.Request, reqCtx *Re req.ContentLength = int64(len(body)) } +// setReplayableBody buffers the outbound request body and sets GetBody so the +// client transport can resend it. Without GetBody a failed HTTP/2 handshake +// cannot fall back to HTTP/1.1 for a request that carries a body. An empty body +// is set to http.NoBody so the fallback guard treats it as bodyless. +func (m *ProxyHandler) setReplayableBody(req *http.Request) { + var body []byte + if req.Body != nil { + b, err := io.ReadAll(req.Body) + if err != nil { + m.logger.Errorw("failed to read request body for replay", "error", err) + return + } + req.Body.Close() + body = b + } + + if len(body) == 0 { + req.Body = http.NoBody + req.ContentLength = 0 + req.GetBody = func() (io.ReadCloser, error) { return http.NoBody, nil } + return + } + + req.Body = io.NopCloser(bytes.NewReader(body)) + req.ContentLength = int64(len(body)) + req.GetBody = func() (io.ReadCloser, error) { + return io.NopCloser(bytes.NewReader(body)), nil + } +} + func (m *ProxyHandler) prepareRequestForTarget(req *http.Request, client *http.Client, usedImpersonation bool) { req.RequestURI = "" - // we always use surf now, which handles decompression automatically - // keep accept-encoding headers for browser fingerprinting + // keep accept-encoding headers for browser fingerprinting. surf's own + // response decompression is disabled (see createSurfClient) because it + // decodes eagerly and errors on empty-body encoded responses like 302s; + // readAndDecompressBody handles decompression instead. // note: usedImpersonation tracks if impersonation features are enabled, not if surf is used req.Header.Del(HEADER_JA4) @@ -748,6 +780,11 @@ func (m *ProxyHandler) prepareRequestForTarget(req *http.Request, client *http.C // header sends the length twice and produces a malformed request. req.Header.Del("Content-Length") + // finalize the outbound body so the transport can replay it. GetBody lets + // surf fall back from HTTP/2 to HTTP/1.1 when h2 negotiation fails. an empty + // body becomes http.NoBody so the fallback path is not blocked at all. + m.setReplayableBody(req) + // setup cookie jar for redirect handling jar, _ := cookiejar.New(nil) client.Jar = jar diff --git a/backend/proxy/surf_impersonate.go b/backend/proxy/surf_impersonate.go index 20d9d1d7..5602a317 100644 --- a/backend/proxy/surf_impersonate.go +++ b/backend/proxy/surf_impersonate.go @@ -96,6 +96,15 @@ func (m *ProxyHandler) createSurfClient(userAgent string, proxyConfig *service.P m.logger.Debugw("applying default windows platform impersonation", "userAgent", userAgent) } + // KNOWN BUG (surf impersonation, upstream utls): the impersonation + // profiles are unstable in the current surf/utls versions. + // - Firefox: the handshake fails with "tls: invalid server key share" + // against many targets, so the Firefox profile is effectively broken. + // - Chrome: works most of the time but the per connection ClientHello + // extension shuffle occasionally produces a hello the target resets, + // so a request can intermittently fail to connect. + // Impersonation is off by default. The non impersonated client is + // reliable and still uses HTTP/2. Revisit when surf/utls is updated. // apply browser impersonation based on detected profile switch { case profile.isChrome || profile.isEdge: @@ -103,7 +112,8 @@ func (m *ProxyHandler) createSurfClient(userAgent string, proxyConfig *service.P builder = impersonate.Chrome() m.logger.Debugw("applying chrome browser impersonation") case profile.isFirefox: - // firefox impersonation + // firefox impersonation. see KNOWN BUG above: the firefox profile + // currently fails the tls handshake with invalid server key share builder = impersonate.Firefox() m.logger.Debugw("applying firefox browser impersonation") case profile.isSafari: @@ -127,9 +137,13 @@ func (m *ProxyHandler) createSurfClient(userAgent string, proxyConfig *service.P // configure timeout builder = builder.Timeout(30 * time.Second) - // note: surf automatically decompresses response bodies via decodeBodyMW middleware - // even when using .Std(), but keeps the Content-Encoding header - // our proxy code will detect this and remove the header before sending to client + // disable surf's response decompression. surf decodes eagerly: it builds the + // gzip reader as soon as the headers arrive, so a response that advertises + // Content-Encoding with an empty body (a 302 redirect from the login flow, a + // 304, a 204) makes the gzip reader read from an empty stream and return EOF, + // which surf surfaces as the whole request failing. readAndDecompressBody + // decompresses from the fully buffered body instead and handles empty bodies. + builder = builder.DisableCompression() // preserve client's accept-language header if provided if acceptLanguage != "" {