Skip to content

Commit d6c3a51

Browse files
authored
KTOR-7483 Avoid caching requests with Authorization header (#4337)
* KTOR-7483 Avoid caching requests with Authorization header
1 parent fa90875 commit d6c3a51

6 files changed

Lines changed: 153 additions & 4 deletions

File tree

ktor-client/ktor-client-core/api/ktor-client-core.api

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -598,7 +598,7 @@ public final class io/ktor/client/plugins/api/TransformResponseBodyContext {
598598

599599
public final class io/ktor/client/plugins/cache/HttpCache {
600600
public static final field Companion Lio/ktor/client/plugins/cache/HttpCache$Companion;
601-
public synthetic fun <init> (Lio/ktor/client/plugins/cache/storage/HttpCacheStorage;Lio/ktor/client/plugins/cache/storage/HttpCacheStorage;Lio/ktor/client/plugins/cache/storage/CacheStorage;Lio/ktor/client/plugins/cache/storage/CacheStorage;ZZLkotlin/jvm/internal/DefaultConstructorMarker;)V
601+
public synthetic fun <init> (Lio/ktor/client/plugins/cache/storage/HttpCacheStorage;Lio/ktor/client/plugins/cache/storage/HttpCacheStorage;Lio/ktor/client/plugins/cache/storage/CacheStorage;Lio/ktor/client/plugins/cache/storage/CacheStorage;ZZZLkotlin/jvm/internal/DefaultConstructorMarker;)V
602602
}
603603

604604
public final class io/ktor/client/plugins/cache/HttpCache$Companion : io/ktor/client/plugins/HttpClientPlugin {
@@ -612,11 +612,13 @@ public final class io/ktor/client/plugins/cache/HttpCache$Companion : io/ktor/cl
612612

613613
public final class io/ktor/client/plugins/cache/HttpCache$Config {
614614
public fun <init> ()V
615+
public final fun getCacheRequestWithAuth ()Z
615616
public final fun getPrivateStorage ()Lio/ktor/client/plugins/cache/storage/HttpCacheStorage;
616617
public final fun getPublicStorage ()Lio/ktor/client/plugins/cache/storage/HttpCacheStorage;
617618
public final fun isShared ()Z
618619
public final fun privateStorage (Lio/ktor/client/plugins/cache/storage/CacheStorage;)V
619620
public final fun publicStorage (Lio/ktor/client/plugins/cache/storage/CacheStorage;)V
621+
public final fun setCacheRequestWithAuth (Z)V
620622
public final fun setPrivateStorage (Lio/ktor/client/plugins/cache/storage/HttpCacheStorage;)V
621623
public final fun setPublicStorage (Lio/ktor/client/plugins/cache/storage/HttpCacheStorage;)V
622624
public final fun setShared (Z)V

ktor-client/ktor-client-core/api/ktor-client-core.klib.api

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,9 @@ final class io.ktor.client.plugins.cache/HttpCache { // io.ktor.client.plugins.c
333333
final class Config { // io.ktor.client.plugins.cache/HttpCache.Config|null[0]
334334
constructor <init>() // io.ktor.client.plugins.cache/HttpCache.Config.<init>|<init>(){}[0]
335335

336+
final var cacheRequestWithAuth // io.ktor.client.plugins.cache/HttpCache.Config.cacheRequestWithAuth|{}cacheRequestWithAuth[0]
337+
final fun <get-cacheRequestWithAuth>(): kotlin/Boolean // io.ktor.client.plugins.cache/HttpCache.Config.cacheRequestWithAuth.<get-cacheRequestWithAuth>|<get-cacheRequestWithAuth>(){}[0]
338+
final fun <set-cacheRequestWithAuth>(kotlin/Boolean) // io.ktor.client.plugins.cache/HttpCache.Config.cacheRequestWithAuth.<set-cacheRequestWithAuth>|<set-cacheRequestWithAuth>(kotlin.Boolean){}[0]
336339
final var isShared // io.ktor.client.plugins.cache/HttpCache.Config.isShared|{}isShared[0]
337340
final fun <get-isShared>(): kotlin/Boolean // io.ktor.client.plugins.cache/HttpCache.Config.isShared.<get-isShared>|<get-isShared>(){}[0]
338341
final fun <set-isShared>(kotlin/Boolean) // io.ktor.client.plugins.cache/HttpCache.Config.isShared.<set-isShared>|<set-isShared>(kotlin.Boolean){}[0]

ktor-client/ktor-client-core/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ kotlin.sourceSets {
4040
dependencies {
4141
api(project(":ktor-test-dispatcher"))
4242
api(project(":ktor-client:ktor-client-mock"))
43+
api(project(":ktor-server:ktor-server-test-host"))
4344
}
4445
}
4546
}

ktor-client/ktor-client-core/common/src/io/ktor/client/plugins/cache/HttpCache.kt

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,8 @@ public class HttpCache private constructor(
5050
private val publicStorageNew: CacheStorage,
5151
private val privateStorageNew: CacheStorage,
5252
private val useOldStorage: Boolean,
53-
internal val isSharedClient: Boolean
53+
internal val isSharedClient: Boolean,
54+
internal val cacheRequestWithAuth: Boolean
5455
) {
5556
/**
5657
* A configuration for the [HttpCache] plugin.
@@ -61,6 +62,17 @@ public class HttpCache private constructor(
6162
internal var privateStorageNew: CacheStorage = CacheStorage.Unlimited()
6263
internal var useOldStorage = false
6364

65+
/**
66+
* Specifies if requests with Authorization header should be cached.
67+
*
68+
* According to specification, enabling this flag has security implications.
69+
* See https://datatracker.ietf.org/doc/html/rfc2616#section-14.8,
70+
* https://datatracker.ietf.org/doc/html/rfc7234#section-3,
71+
* and https://datatracker.ietf.org/doc/html/rfc9111#section-3 for the details
72+
*/
73+
@Deprecated("Changing this flag has security implication", level = DeprecationLevel.WARNING)
74+
public var cacheRequestWithAuth: Boolean = false
75+
6476
/**
6577
* Specifies if the client where this plugin is installed is shared among multiple users.
6678
* When set to true, all responses with `private` Cache-Control directive will not be cached.
@@ -138,7 +150,8 @@ public class HttpCache private constructor(
138150
publicStorageNew = publicStorageNew,
139151
privateStorageNew = privateStorageNew,
140152
useOldStorage = useOldStorage,
141-
isSharedClient = isShared
153+
isSharedClient = isShared,
154+
cacheRequestWithAuth = cacheRequestWithAuth
142155
)
143156
}
144157
}
@@ -152,6 +165,10 @@ public class HttpCache private constructor(
152165
if (content !is OutgoingContent.NoContent) return@intercept
153166
if (context.method != HttpMethod.Get || !context.url.protocol.canStore()) return@intercept
154167

168+
if (!plugin.cacheRequestWithAuth && context.headers.contains(HttpHeaders.Authorization)) {
169+
return@intercept
170+
}
171+
155172
if (plugin.useOldStorage) {
156173
interceptSendLegacy(plugin, content, scope)
157174
return@intercept
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
/*
2+
* Copyright 2014-2024 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license.
3+
*/
4+
5+
import io.ktor.client.plugins.cache.*
6+
import io.ktor.client.request.*
7+
import io.ktor.client.statement.*
8+
import io.ktor.http.*
9+
import io.ktor.server.response.*
10+
import io.ktor.server.routing.*
11+
import io.ktor.server.testing.*
12+
import kotlinx.coroutines.*
13+
import kotlin.test.*
14+
import kotlin.time.Duration.Companion.milliseconds
15+
16+
class HttpCacheTest {
17+
18+
@Test
19+
fun `should not mix ETags when Authorization header is present`() = testApplication {
20+
application {
21+
routing {
22+
get("/me") {
23+
val user = call.request.headers["Authorization"]!!
24+
if (user == "user-a") {
25+
// Simulate slower network for one of the requests
26+
delay(100.milliseconds)
27+
}
28+
val etag = "etag-of-$user"
29+
if (call.request.headers["If-None-Match"] == etag) {
30+
call.respond(HttpStatusCode.NotModified)
31+
return@get
32+
}
33+
call.response.header("Cache-Control", "no-cache")
34+
call.response.header("ETag", etag)
35+
call.respondText(user)
36+
}
37+
}
38+
}
39+
40+
val client = createClient {
41+
install(HttpCache) {
42+
isShared = true
43+
}
44+
}
45+
46+
assertEquals(
47+
client.get("/me") {
48+
headers["Authorization"] = "user-a"
49+
}.bodyAsText(),
50+
"user-a"
51+
)
52+
withContext(Dispatchers.Default) {
53+
listOf(
54+
launch {
55+
val response = client.get("/me") {
56+
headers["Authorization"] = "user-a"
57+
}.bodyAsText()
58+
59+
assertEquals("user-a", response)
60+
},
61+
launch {
62+
val response = client.get("/me") {
63+
headers["Authorization"] = "user-b"
64+
}.bodyAsText()
65+
66+
assertEquals("user-b", response)
67+
}
68+
).joinAll()
69+
}
70+
}
71+
72+
@Test
73+
fun `should mix ETags when Authorization header is present and flag enabled`() = testApplication {
74+
application {
75+
routing {
76+
get("/me") {
77+
val user = call.request.headers["Authorization"]!!
78+
if (user == "user-a") {
79+
// Simulate slower network for one of the requests
80+
delay(100.milliseconds)
81+
}
82+
val etag = "etag-of-$user"
83+
if (call.request.headers["If-None-Match"] == etag) {
84+
call.respond(HttpStatusCode.NotModified)
85+
return@get
86+
}
87+
call.response.header("Cache-Control", "no-cache")
88+
call.response.header("ETag", etag)
89+
call.respondText(user)
90+
}
91+
}
92+
}
93+
94+
val client = createClient {
95+
install(HttpCache) {
96+
isShared = true
97+
@Suppress("DEPRECATION")
98+
cacheRequestWithAuth = true
99+
}
100+
}
101+
102+
assertEquals(
103+
client.get("/me") {
104+
headers["Authorization"] = "user-a"
105+
}.bodyAsText(),
106+
"user-a"
107+
)
108+
withContext(Dispatchers.Default) {
109+
listOf(
110+
launch {
111+
val response = client.get("/me") {
112+
headers["Authorization"] = "user-a"
113+
}.bodyAsText()
114+
115+
assertEquals("user-b", response)
116+
},
117+
launch {
118+
val response = client.get("/me") {
119+
headers["Authorization"] = "user-b"
120+
}.bodyAsText()
121+
122+
assertEquals("user-b", response)
123+
}
124+
).joinAll()
125+
}
126+
}
127+
}

ktor-io/common/test/ByteReadChannelOperationsTest.kt

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,5 +82,4 @@ class ByteReadChannelOperationsTest {
8282
assertContentEquals(expected.copyOfRange(0, 5), actual.copyOfRange(3, 8))
8383
assertContentEquals(ByteArray(2) { 0 }, actual.copyOfRange(8, 10))
8484
}
85-
8685
}

0 commit comments

Comments
 (0)