fix(upstream): parse bare-LF HTTP proxy headers; document benign protect() failure

This commit is contained in:
heaven 2026-06-16 12:30:53 +03:00
parent 6b75c65503
commit 1b06c3c01f
2 changed files with 19 additions and 7 deletions

View file

@ -67,17 +67,20 @@ class HttpUpstream(
/** Reads the response head up to the blank line and returns the status code. */
private fun readStatusLine(inp: InputStream): Int {
val head = StringBuilder()
var last3 = 0 // rolling match for "\r\n\r\n"
// End of head = two consecutive line terminators. Count consecutive LFs and
// ignore CRs, so CRLFCRLF, bare LFLF, and mixed CRLF/LF proxies all terminate
// (a CR-only build would otherwise spin to the 16 KB cap and fail every CONNECT).
var consecutiveLf = 0
while (true) {
val c = inp.read()
if (c < 0) throw IOException("eof during http proxy response")
head.append(c.toChar())
last3 = when {
c == '\r'.code -> if (last3 == 2) 3 else 1
c == '\n'.code -> if (last3 == 1) 2 else if (last3 == 3) 4 else 0
else -> 0
when (c) {
'\n'.code -> consecutiveLf++
'\r'.code -> { /* part of CRLF; don't reset the LF run */ }
else -> consecutiveLf = 0
}
if (last3 == 4) break // saw \r\n\r\n
if (consecutiveLf == 2) break
if (head.length > 16 * 1024) throw IOException("http proxy response head too large")
}
val firstLine = head.lineSequence().firstOrNull()?.trim().orEmpty()

View file

@ -65,7 +65,16 @@ interface Upstream {
const val CONNECT_TIMEOUT_MS = 10_000
const val HANDSHAKE_TIMEOUT_MS = 15_000
/** Creates an unconnected, VPN-protected TCP socket and connects it to the proxy server. */
/**
* Creates a VPN-protected TCP socket and connects it to the proxy server.
*
* NOTE: protect()'s return value is intentionally ignored. The app's own sockets are
* already kept out of the tun by the VpnService app filter (blacklist disallows self;
* whitelist simply doesn't include us), so protect() is redundant belt-and-suspenders
* here and on this platform it returns false for a freshly-created unconnected socket
* even though traffic is correctly excluded. Treating that false as fatal breaks every
* upstream connection (verified on-device), so we don't.
*/
internal fun SocketProtector.connectedSocket(host: String, port: Int): Socket {
val socket = Socket()
protect(socket)