fix work around surf bug

Signed-off-by: RonniSkansing <rskansing@gmail.com>
This commit is contained in:
RonniSkansing committed 2026-09-29 19:41:58 +02:00
1 parent 256913b923
commit d30b788c2b
2 files changed
+57 -6

No files matched your search

+39 -2
View File
@@ -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
+18 -4
View File
@@ -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 != "" {