Bug: HTTP proxy incorrectly keeps close-delimited HTTP/1.0 responses alive
Summary
The HTTP proxy implementation in github.com/sagernet/sing/protocol/http/handshake.go appears to convert upstream close-delimited responses into downstream keep-alive responses without ensuring the response body is self-delimited.
This breaks old HTTP/1.0 web servers that send a response without Content-Length and rely on Connection: close to mark the end of the body.
In NekoBox for Android this is triggered by enabling Append HTTP Proxy to VPN, because supported browsers then use the mixed HTTP proxy directly.
Affected Scenario
- NekoBox for Android enables Android VPN HTTP proxy via
VpnService.Builder.setHttpProxy(...).
- Browser sends requests through the local mixed proxy, e.g.
127.0.0.1:9080.
- Target server returns:
HTTP/1.0 200 OK
Content-Type: text/html
Connection: close
<!doctype html><html><body>loaded</body></html>
This response is valid: without Content-Length or chunked encoding, the body is delimited by closing the connection.
Actual Behavior
The proxy rewrites the downstream response to keep-alive:
HTTP/1.0 200 OK
Connection: keep-alive
Proxy-Connection: keep-alive
Keep-Alive: timeout=4
Content-Type: text/html
The response body is received, but the browser cannot determine the end of the response because there is no Content-Length and the downstream connection is kept alive.
Observed in Chromium DevTools/CDP:
- Main document remains
readyState=loading.
performance.getEntriesByType("navigation")[0].responseEnd stays 0.
- The request is served from the local proxy address, e.g.
127.0.0.1:9080.
If the same upstream response includes Content-Length, the page loads normally.
Expected Behavior
The proxy should not keep the downstream connection alive unless the response can be safely delimited on a persistent connection.
For close-delimited responses without Content-Length or chunked transfer encoding, the proxy should either:
- close the downstream connection after writing the response, or
- buffer/rewrite the response with a valid
Content-Length.
Minimal Reproduction
Run a simple TCP server that returns:
HTTP/1.0 200 OK
Server: test
Content-Type: text/html
Connection: close
<!doctype html><html><body>loaded</body></html>
Then access it through the sing mixed HTTP proxy:
curl -v --proxy http://127.0.0.1:9080 http://HOST:PORT/
The body is received, but the response does not complete correctly because the proxy changes the downstream response to keep-alive without adding a body length.
Likely Cause
In github.com/sagernet/sing/protocol/http/handshake.go, handleHTTPConnection decides downstream keep-alive only from the client request:
keepAlive := !(request.ProtoMajor == 1 && request.ProtoMinor == 0) &&
strings.TrimSpace(strings.ToLower(request.Header.Get("Proxy-Connection"))) == "keep-alive"
Then it unconditionally rewrites the response when keepAlive is true:
response.Header.Set("Proxy-Connection", "keep-alive")
response.Header.Set("Connection", "keep-alive")
response.Header.Set("Keep-Alive", "timeout=4")
response.Close = !keepAlive
This ignores whether the upstream response has a safe message length for a persistent downstream connection.
Suggested Fix
Only allow downstream keep-alive when the response is self-delimited, such as:
Content-Length >= 0
- chunked transfer encoding is used
- the response has no body by definition, such as
HEAD, 204, 304, or 1xx
Otherwise, force Connection: close for the downstream response.
Bug: HTTP proxy incorrectly keeps close-delimited HTTP/1.0 responses alive
Summary
The HTTP proxy implementation in
github.com/sagernet/sing/protocol/http/handshake.goappears to convert upstream close-delimited responses into downstream keep-alive responses without ensuring the response body is self-delimited.This breaks old HTTP/1.0 web servers that send a response without
Content-Lengthand rely onConnection: closeto mark the end of the body.In NekoBox for Android this is triggered by enabling
Append HTTP Proxy to VPN, because supported browsers then use the mixed HTTP proxy directly.Affected Scenario
VpnService.Builder.setHttpProxy(...).127.0.0.1:9080.This response is valid: without
Content-Lengthor chunked encoding, the body is delimited by closing the connection.Actual Behavior
The proxy rewrites the downstream response to keep-alive:
The response body is received, but the browser cannot determine the end of the response because there is no
Content-Lengthand the downstream connection is kept alive.Observed in Chromium DevTools/CDP:
readyState=loading.performance.getEntriesByType("navigation")[0].responseEndstays0.127.0.0.1:9080.If the same upstream response includes
Content-Length, the page loads normally.Expected Behavior
The proxy should not keep the downstream connection alive unless the response can be safely delimited on a persistent connection.
For close-delimited responses without
Content-Lengthor chunked transfer encoding, the proxy should either:Content-Length.Minimal Reproduction
Run a simple TCP server that returns:
Then access it through the sing mixed HTTP proxy:
The body is received, but the response does not complete correctly because the proxy changes the downstream response to keep-alive without adding a body length.
Likely Cause
In
github.com/sagernet/sing/protocol/http/handshake.go,handleHTTPConnectiondecides downstream keep-alive only from the client request:Then it unconditionally rewrites the response when
keepAliveis true:This ignores whether the upstream response has a safe message length for a persistent downstream connection.
Suggested Fix
Only allow downstream keep-alive when the response is self-delimited, such as:
Content-Length >= 0HEAD,204,304, or1xxOtherwise, force
Connection: closefor the downstream response.