Skip to content

Bug: HTTP proxy incorrectly keeps close-delimited HTTP/1.0 responses alive #106

Description

@zz5678

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions