diff --git a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/http1/HeadersReader.kt b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/http1/HeadersReader.kt index 673f05329cb2..aa6ecbca05b2 100644 --- a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/http1/HeadersReader.kt +++ b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/http1/HeadersReader.kt @@ -15,6 +15,8 @@ */ package okhttp3.internal.http1 +import java.io.EOFException +import java.net.ProtocolException import okhttp3.Headers import okhttp3.internal.HEADER_LIMIT import okio.BufferedSource @@ -29,7 +31,23 @@ class HeadersReader( /** Read a single line counted against the header size limit. */ fun readLine(): String { - val line = source.readUtf8LineStrict(headerLimit) + val line = + try { + source.readUtf8LineStrict(headerLimit) + } catch (e: EOFException) { + // readUtf8LineStrict() throws EOFException both when the line exceeds the byte limit + // and when the stream ends before a line terminator. Only the former means the response + // head is too large; a genuinely truncated response must still surface as an EOFException. + // A line of exactly headerLimit bytes is legal (okio accepts headerLimit bytes plus a + // terminator), so buffer.size == headerLimit is a truncated legal line, not an oversized + // one; only a strictly larger buffer proves the limit was exceeded. + if (source.buffer.size > headerLimit) { + throw ProtocolException( + "response headers exceed the ${HEADER_LIMIT / 1024} KiB limit", + ).initCause(e) + } + throw e + } headerLimit -= line.length.toLong() return line } diff --git a/okhttp/src/jvmTest/kotlin/okhttp3/internal/http1/HeadersReaderTest.kt b/okhttp/src/jvmTest/kotlin/okhttp3/internal/http1/HeadersReaderTest.kt new file mode 100644 index 000000000000..e6425e886a45 --- /dev/null +++ b/okhttp/src/jvmTest/kotlin/okhttp3/internal/http1/HeadersReaderTest.kt @@ -0,0 +1,80 @@ +/* + * Copyright (C) 2026 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package okhttp3.internal.http1 + +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isInstanceOf +import assertk.assertions.isNotNull +import java.io.EOFException +import java.net.ProtocolException +import kotlin.test.assertFailsWith +import okhttp3.internal.HEADER_LIMIT +import okio.Buffer +import org.junit.jupiter.api.Test + +class HeadersReaderTest { + @Test + fun readsNormalHeaders() { + val source = + Buffer().writeUtf8( + "Content-Type: text/plain\r\n" + + "Content-Length: 0\r\n" + + "\r\n", + ) + val headers = HeadersReader(source).readHeaders() + assertThat(headers.size).isEqualTo(2) + assertThat(headers["Content-Type"]).isEqualTo("text/plain") + assertThat(headers["Content-Length"]).isEqualTo("0") + } + + /** A header line larger than the limit reports the limit clearly instead of a raw okio dump. */ + @Test + fun headerLineExceedingLimitReportsLimit() { + val source = Buffer().writeUtf8("a".repeat((HEADER_LIMIT + 1).toInt())) + val exception = + assertFailsWith { + HeadersReader(source).readHeaders() + } + assertThat(exception.message).isEqualTo("response headers exceed the ${HEADER_LIMIT / 1024} KiB limit") + assertThat(exception.cause).isNotNull().isInstanceOf(EOFException::class.java) + } + + /** + * A response that is truncated before the limit is genuine end-of-stream, not an oversized + * header, so it must still surface as an [EOFException] rather than the limit message. + */ + @Test + fun truncatedHeadersBeforeLimitStillThrowEof() { + val source = Buffer().writeUtf8("Content-Type: text/pl") + assertFailsWith { + HeadersReader(source).readHeaders() + } + } + + /** + * A line of exactly [HEADER_LIMIT] bytes is a legal header line, so a stream that ends after that + * many bytes with no terminator is a truncated legal line, not an oversized header. It must still + * surface as an [EOFException]; the limit message is reserved for lines strictly over the limit. + */ + @Test + fun truncatedHeadersAtLimitStillThrowEof() { + val source = Buffer().writeUtf8("a".repeat(HEADER_LIMIT.toInt())) + assertFailsWith { + HeadersReader(source).readHeaders() + } + } +}