Skip to content

Commit

Permalink
KTOR-8015 CIO: Do not accept CR as line delimiter in requests (#4668)
Browse files Browse the repository at this point in the history
* Stricter request/response parsing
  • Loading branch information
osipxd authored Feb 19, 2025
1 parent 8b73519 commit 274b09a
Show file tree
Hide file tree
Showing 11 changed files with 235 additions and 48 deletions.
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
/*
* Copyright 2014-2021 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license.
*/
* Copyright 2014-2025 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license.
*/

package io.ktor.http.cio

import io.ktor.http.cio.internals.*
import io.ktor.utils.io.*
import io.ktor.utils.io.bits.*
import io.ktor.utils.io.core.*
import io.ktor.utils.io.pool.*
import kotlinx.coroutines.*
import kotlinx.io.*
import kotlin.coroutines.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.io.EOFException
import kotlin.coroutines.CoroutineContext

private const val MAX_CHUNK_SIZE_LENGTH = 128
private const val CHUNK_BUFFER_POOL_SIZE = 2048
Expand All @@ -34,7 +35,6 @@ public typealias DecoderJob = WriterJob
*
* [Report a problem](https://ktor.io/feedback/?fqname=io.ktor.http.cio.decodeChunked)
*/
@Suppress("TYPEALIAS_EXPANSION_DEPRECATION")
@Deprecated(
"Specify content length if known or pass -1L",
ReplaceWith("decodeChunked(input, -1L)"),
Expand All @@ -48,7 +48,7 @@ public fun CoroutineScope.decodeChunked(input: ByteReadChannel): DecoderJob =
*
* [Report a problem](https://ktor.io/feedback/?fqname=io.ktor.http.cio.decodeChunked)
*/
@Suppress("UNUSED_PARAMETER", "TYPEALIAS_EXPANSION_DEPRECATION")
@Suppress("UNUSED_PARAMETER")
public fun CoroutineScope.decodeChunked(input: ByteReadChannel, contentLength: Long): DecoderJob =
writer(coroutineContext) {
decodeChunked(input, channel)
Expand All @@ -63,14 +63,15 @@ public fun CoroutineScope.decodeChunked(input: ByteReadChannel, contentLength: L
* @throws EOFException if stream has ended unexpectedly.
* @throws ParserException if the format is invalid.
*/
@OptIn(InternalAPI::class)
public suspend fun decodeChunked(input: ByteReadChannel, out: ByteWriteChannel) {
val chunkSizeBuffer = ChunkSizeBufferPool.borrow()
var totalBytesCopied = 0L

try {
while (true) {
chunkSizeBuffer.clear()
if (!input.readUTF8LineTo(chunkSizeBuffer, MAX_CHUNK_SIZE_LENGTH)) {
if (!input.readUTF8LineTo(chunkSizeBuffer, MAX_CHUNK_SIZE_LENGTH, httpLineEndings)) {
throw EOFException("Chunked stream has ended unexpectedly: no chunk size")
} else if (chunkSizeBuffer.isEmpty()) {
throw EOFException("Invalid chunk size: empty")
Expand All @@ -86,7 +87,7 @@ public suspend fun decodeChunked(input: ByteReadChannel, out: ByteWriteChannel)
}

chunkSizeBuffer.clear()
if (!input.readUTF8LineTo(chunkSizeBuffer, 2)) {
if (!input.readUTF8LineTo(chunkSizeBuffer, 2, httpLineEndings)) {
throw EOFException("Invalid chunk: content block of size $chunkSize ended unexpectedly")
}
if (chunkSizeBuffer.isNotEmpty()) {
Expand Down
23 changes: 18 additions & 5 deletions ktor-http/ktor-http-cio/common/src/io/ktor/http/cio/HttpParser.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/*
* Copyright 2014-2021 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license.
*/
* Copyright 2014-2025 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license.
*/

package io.ktor.http.cio

Expand All @@ -20,18 +20,29 @@ private const val HTTP_STATUS_CODE_MIN_RANGE = 100
private const val HTTP_STATUS_CODE_MAX_RANGE = 999
private val hostForbiddenSymbols = setOf('/', '?', '#', '@')

/**
* Line endings allowed as a separator for HTTP fields and start line.
*
* "Although the line terminator for the start-line and fields is the sequence CRLF,
* a recipient MAY recognize a single LF as a line terminator and ignore any preceding CR."
* https://datatracker.ietf.org/doc/html/rfc9112#section-2.2-3
*/
@OptIn(InternalAPI::class)
internal val httpLineEndings: LineEndingMode = LineEndingMode.CRLF + LineEndingMode.LF

/**
* Parse an HTTP request line and headers
*
* [Report a problem](https://ktor.io/feedback/?fqname=io.ktor.http.cio.parseRequest)
*/
@OptIn(InternalAPI::class)
public suspend fun parseRequest(input: ByteReadChannel): Request? {
val builder = CharArrayBuilder()
val range = MutableRange(0, 0)

try {
while (true) {
if (!input.readUTF8LineTo(builder, HTTP_LINE_LIMIT)) return null
if (!input.readUTF8LineTo(builder, HTTP_LINE_LIMIT, httpLineEndings)) return null
range.end = builder.length
if (range.start == range.end) continue

Expand Down Expand Up @@ -61,12 +72,13 @@ public suspend fun parseRequest(input: ByteReadChannel): Request? {
*
* [Report a problem](https://ktor.io/feedback/?fqname=io.ktor.http.cio.parseResponse)
*/
@OptIn(InternalAPI::class)
public suspend fun parseResponse(input: ByteReadChannel): Response? {
val builder = CharArrayBuilder()
val range = MutableRange(0, 0)

try {
if (!input.readUTF8LineTo(builder, HTTP_LINE_LIMIT)) return null
if (!input.readUTF8LineTo(builder, HTTP_LINE_LIMIT, httpLineEndings)) return null
range.end = builder.length

val version = parseVersion(builder, range)
Expand Down Expand Up @@ -97,6 +109,7 @@ public suspend fun parseHeaders(input: ByteReadChannel): HttpHeadersMap {
/**
* Parse HTTP headers. Not applicable to request and response status lines.
*/
@OptIn(InternalAPI::class)
internal suspend fun parseHeaders(
input: ByteReadChannel,
builder: CharArrayBuilder,
Expand All @@ -106,7 +119,7 @@ internal suspend fun parseHeaders(

try {
while (true) {
if (!input.readUTF8LineTo(builder, HTTP_LINE_LIMIT)) {
if (!input.readUTF8LineTo(builder, HTTP_LINE_LIMIT, httpLineEndings)) {
headers.release()
return null
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,22 +1,26 @@
/*
* Copyright 2014-2019 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license.
* Copyright 2014-2025 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license.
*/

package io.ktor.tests.http.cio

import io.ktor.http.cio.*
import io.ktor.utils.io.*
import io.ktor.utils.io.streams.*
import kotlinx.coroutines.*
import kotlinx.coroutines.test.*
import kotlinx.io.*
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.yield
import kotlinx.io.Buffer
import org.junit.jupiter.api.*
import java.io.EOFException
import java.io.IOException
import java.nio.*
import kotlin.test.*
import kotlinx.io.EOFException
import kotlinx.io.IOException
import kotlinx.io.Sink
import java.nio.ByteBuffer
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertTrue

class ChunkedTest {

Expand All @@ -26,7 +30,7 @@ class ChunkedTest {
val ch = ByteReadChannel(bodyText.toByteArray())
val parsed = ByteChannel()

assertThrows<EOFException> {
assertFailsWith<EOFException> {
decodeChunked(ch, parsed)
}
}
Expand Down Expand Up @@ -62,7 +66,7 @@ class ChunkedTest {
val ch = ByteReadChannel(bodyText.toByteArray())
val parsed = ByteChannel()

assertThrows<EOFException> {
assertFailsWith<EOFException> {
decodeChunked(ch, parsed)
}
}
Expand Down Expand Up @@ -116,7 +120,7 @@ class ChunkedTest {

@Test
fun testContentMixedLineEndings() = runBlocking {
val bodyText = "3\n123\n2\r\n45\r\n1\r6\r0\r\n\n"
val bodyText = "3\n123\n2\r\n45\r\n1\n6\n0\r\n\n"
val ch = ByteReadChannel(bodyText.toByteArray())
val parsed = ByteChannel()

Expand All @@ -125,6 +129,21 @@ class ChunkedTest {
assertEquals("123456", parsed.readUTF8Line())
}

@Test
fun testContentWithRcLineEnding() = runTest {
val bodyText = "3\r\n" +
"123\r1\r\n" + // <- CR line ending after chunk body
"2\r\n" +
"45\r\n" +
"0\r\n\r\n"
val ch = ByteReadChannel(bodyText.toByteArray())
val parsed = ByteChannel()

assertFailsWith<IOException> {
decodeChunked(ch, parsed)
}
}

@Test
fun testEncodeEmpty() = runBlocking {
val encoded = ByteChannel()
Expand Down
24 changes: 24 additions & 0 deletions ktor-io/api/ktor-io.api
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,8 @@ public final class io/ktor/utils/io/ByteReadChannelOperationsKt {
public static synthetic fun readUTF8Line$default (Lio/ktor/utils/io/ByteReadChannel;ILkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object;
public static final fun readUTF8LineTo (Lio/ktor/utils/io/ByteReadChannel;Ljava/lang/Appendable;ILkotlin/coroutines/Continuation;)Ljava/lang/Object;
public static synthetic fun readUTF8LineTo$default (Lio/ktor/utils/io/ByteReadChannel;Ljava/lang/Appendable;ILkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object;
public static final fun readUTF8LineTo-RRvyBJ8 (Lio/ktor/utils/io/ByteReadChannel;Ljava/lang/Appendable;IILkotlin/coroutines/Continuation;)Ljava/lang/Object;
public static synthetic fun readUTF8LineTo-RRvyBJ8$default (Lio/ktor/utils/io/ByteReadChannel;Ljava/lang/Appendable;IILkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object;
public static final fun readUntil (Lio/ktor/utils/io/ByteReadChannel;Lkotlinx/io/bytestring/ByteString;Lio/ktor/utils/io/ByteWriteChannel;JZLkotlin/coroutines/Continuation;)Ljava/lang/Object;
public static synthetic fun readUntil$default (Lio/ktor/utils/io/ByteReadChannel;Lkotlinx/io/bytestring/ByteString;Lio/ktor/utils/io/ByteWriteChannel;JZLkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object;
public static final fun reader (Lkotlinx/coroutines/CoroutineScope;Lkotlin/coroutines/CoroutineContext;Lio/ktor/utils/io/ByteChannel;Lkotlin/jvm/functions/Function2;)Lio/ktor/utils/io/ReaderJob;
Expand Down Expand Up @@ -278,6 +280,28 @@ public abstract interface annotation class io/ktor/utils/io/KtorDsl : java/lang/
public abstract interface annotation class io/ktor/utils/io/KtorExperimentalAPI : java/lang/annotation/Annotation {
}

public final class io/ktor/utils/io/LineEndingMode {
public static final field Companion Lio/ktor/utils/io/LineEndingMode$Companion;
public static final synthetic fun box-impl (I)Lio/ktor/utils/io/LineEndingMode;
public static final fun contains-lTjpP64 (II)Z
public fun equals (Ljava/lang/Object;)Z
public static fun equals-impl (ILjava/lang/Object;)Z
public static final fun equals-impl0 (II)Z
public fun hashCode ()I
public static fun hashCode-impl (I)I
public static final fun plus-1Ter-O4 (II)I
public fun toString ()Ljava/lang/String;
public static fun toString-impl (I)Ljava/lang/String;
public final synthetic fun unbox-impl ()I
}

public final class io/ktor/utils/io/LineEndingMode$Companion {
public final fun getAny-f0jXZW8 ()I
public final fun getCR-f0jXZW8 ()I
public final fun getCRLF-f0jXZW8 ()I
public final fun getLF-f0jXZW8 ()I
}

public final class io/ktor/utils/io/LookAheadSessionKt {
public static final fun lookAhead (Lio/ktor/utils/io/ByteReadChannel;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public static final fun lookAheadSuspend (Lio/ktor/utils/io/ByteReadChannel;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
Expand Down
20 changes: 20 additions & 0 deletions ktor-io/api/ktor-io.klib.api
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,25 @@ final class io.ktor.utils.io/WriterScope : kotlinx.coroutines/CoroutineScope { /
final fun <get-coroutineContext>(): kotlin.coroutines/CoroutineContext // io.ktor.utils.io/WriterScope.coroutineContext.<get-coroutineContext>|<get-coroutineContext>(){}[0]
}

final value class io.ktor.utils.io/LineEndingMode { // io.ktor.utils.io/LineEndingMode|null[0]
final fun contains(io.ktor.utils.io/LineEndingMode): kotlin/Boolean // io.ktor.utils.io/LineEndingMode.contains|contains(io.ktor.utils.io.LineEndingMode){}[0]
final fun equals(kotlin/Any?): kotlin/Boolean // io.ktor.utils.io/LineEndingMode.equals|equals(kotlin.Any?){}[0]
final fun hashCode(): kotlin/Int // io.ktor.utils.io/LineEndingMode.hashCode|hashCode(){}[0]
final fun plus(io.ktor.utils.io/LineEndingMode): io.ktor.utils.io/LineEndingMode // io.ktor.utils.io/LineEndingMode.plus|plus(io.ktor.utils.io.LineEndingMode){}[0]
final fun toString(): kotlin/String // io.ktor.utils.io/LineEndingMode.toString|toString(){}[0]

final object Companion { // io.ktor.utils.io/LineEndingMode.Companion|null[0]
final val Any // io.ktor.utils.io/LineEndingMode.Companion.Any|{}Any[0]
final fun <get-Any>(): io.ktor.utils.io/LineEndingMode // io.ktor.utils.io/LineEndingMode.Companion.Any.<get-Any>|<get-Any>(){}[0]
final val CR // io.ktor.utils.io/LineEndingMode.Companion.CR|{}CR[0]
final fun <get-CR>(): io.ktor.utils.io/LineEndingMode // io.ktor.utils.io/LineEndingMode.Companion.CR.<get-CR>|<get-CR>(){}[0]
final val CRLF // io.ktor.utils.io/LineEndingMode.Companion.CRLF|{}CRLF[0]
final fun <get-CRLF>(): io.ktor.utils.io/LineEndingMode // io.ktor.utils.io/LineEndingMode.Companion.CRLF.<get-CRLF>|<get-CRLF>(){}[0]
final val LF // io.ktor.utils.io/LineEndingMode.Companion.LF|{}LF[0]
final fun <get-LF>(): io.ktor.utils.io/LineEndingMode // io.ktor.utils.io/LineEndingMode.Companion.LF.<get-LF>|<get-LF>(){}[0]
}
}

open class io.ktor.utils.io.charsets/MalformedInputException : kotlinx.io/IOException { // io.ktor.utils.io.charsets/MalformedInputException|null[0]
constructor <init>(kotlin/String) // io.ktor.utils.io.charsets/MalformedInputException.<init>|<init>(kotlin.String){}[0]
}
Expand Down Expand Up @@ -461,6 +480,7 @@ final suspend fun (io.ktor.utils.io/ByteReadChannel).io.ktor.utils.io/readRemain
final suspend fun (io.ktor.utils.io/ByteReadChannel).io.ktor.utils.io/readShort(): kotlin/Short // io.ktor.utils.io/readShort|readShort@io.ktor.utils.io.ByteReadChannel(){}[0]
final suspend fun (io.ktor.utils.io/ByteReadChannel).io.ktor.utils.io/readUTF8Line(kotlin/Int = ...): kotlin/String? // io.ktor.utils.io/readUTF8Line|readUTF8Line@io.ktor.utils.io.ByteReadChannel(kotlin.Int){}[0]
final suspend fun (io.ktor.utils.io/ByteReadChannel).io.ktor.utils.io/readUTF8LineTo(kotlin.text/Appendable, kotlin/Int = ...): kotlin/Boolean // io.ktor.utils.io/readUTF8LineTo|readUTF8LineTo@io.ktor.utils.io.ByteReadChannel(kotlin.text.Appendable;kotlin.Int){}[0]
final suspend fun (io.ktor.utils.io/ByteReadChannel).io.ktor.utils.io/readUTF8LineTo(kotlin.text/Appendable, kotlin/Int = ..., io.ktor.utils.io/LineEndingMode = ...): kotlin/Boolean // io.ktor.utils.io/readUTF8LineTo|readUTF8LineTo@io.ktor.utils.io.ByteReadChannel(kotlin.text.Appendable;kotlin.Int;io.ktor.utils.io.LineEndingMode){}[0]
final suspend fun (io.ktor.utils.io/ByteReadChannel).io.ktor.utils.io/readUntil(kotlinx.io.bytestring/ByteString, io.ktor.utils.io/ByteWriteChannel, kotlin/Long = ..., kotlin/Boolean = ...): kotlin/Long // io.ktor.utils.io/readUntil|readUntil@io.ktor.utils.io.ByteReadChannel(kotlinx.io.bytestring.ByteString;io.ktor.utils.io.ByteWriteChannel;kotlin.Long;kotlin.Boolean){}[0]
final suspend fun (io.ktor.utils.io/ByteReadChannel).io.ktor.utils.io/skipIfFound(kotlinx.io.bytestring/ByteString): kotlin/Boolean // io.ktor.utils.io/skipIfFound|skipIfFound@io.ktor.utils.io.ByteReadChannel(kotlinx.io.bytestring.ByteString){}[0]
final suspend fun (io.ktor.utils.io/ByteReadChannel).io.ktor.utils.io/toByteArray(): kotlin/ByteArray // io.ktor.utils.io/toByteArray|toByteArray@io.ktor.utils.io.ByteReadChannel(){}[0]
Expand Down
19 changes: 10 additions & 9 deletions ktor-io/common/src/io/ktor/utils/io/ByteChannel.kt
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
/*
* Copyright 2014-2024 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license.
* Copyright 2014-2025 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license.
*/

package io.ktor.utils.io

import io.ktor.utils.io.locks.*
import kotlinx.atomicfu.*
import kotlinx.coroutines.*
import kotlinx.io.*
import kotlinx.atomicfu.AtomicRef
import kotlinx.atomicfu.atomic
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.io.Buffer
import kotlinx.io.Sink
import kotlinx.io.Source
import kotlin.concurrent.Volatile
import kotlin.coroutines.*
import kotlin.jvm.*
import kotlin.coroutines.Continuation
import kotlin.jvm.JvmStatic

internal expect val DEVELOPMENT_MODE: Boolean
internal const val CHANNEL_MAX_SIZE: Int = 1024 * 1024
Expand Down Expand Up @@ -171,9 +174,7 @@ public class ByteChannel(public val autoFlush: Boolean = false) : ByteReadChanne
private fun closeSlot(cause: Throwable?) {
val closeContinuation = if (cause != null) Slot.Closed(cause) else Slot.CLOSED
val continuation = suspensionSlot.getAndSet(closeContinuation)
if (continuation !is Slot.Task) return

continuation.resume(cause)
if (continuation is Slot.Task) continuation.resume(cause)
}

private inline fun <reified TaskType : Slot.Task> trySuspend(
Expand Down
Loading

0 comments on commit 274b09a

Please sign in to comment.