Skip to content

Ktor 8345: Make VaryHeader check lenient #4816

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 9 commits into from
May 30, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,17 @@ public class HttpCache private constructor(
plugin.findAndRefresh(response.call.request, response) ?: throw InvalidCacheStateException(
response.call.request.url
)
if (responseFromCache.varyKeys().size != response.varyKeys().size) {
LOGGER.warn(
"Vary header mismatch on cached response for ${response.call.request.url}. " +
"Received 304 Not Modified with Vary: ${response.varyKeys()} " +
"but cached response has Vary: ${responseFromCache.varyKeys()}. " +
"According to RFC 7232 §4.1 and RFC 9111 §4.1, " +
"the server must include the full Vary header in 304 responses. " +
"Falling back to missing cache logic. " +
"Consider reporting this issue to the server maintainers."
)
}

scope.monitor.raise(HttpResponseFromCache, responseFromCache)
proceedWith(responseFromCache)
Expand Down Expand Up @@ -329,10 +340,8 @@ public class HttpCache private constructor(
else -> publicStorageNew
}

val varyKeysFrom304 = response.varyKeys()
val cache = findResponse(storage, varyKeysFrom304, url, request) ?: return null
val newVaryKeys = varyKeysFrom304.ifEmpty { cache.varyKeys }
storage.store(request.url, cache.copy(newVaryKeys, response.cacheExpires(isSharedClient)))
val cache = findResponse(storage, response.varyKeys(), url, request) ?: return null
storage.store(request.url, cache.copy(cache.varyKeys, response.cacheExpires(isSharedClient)))
return cache.createResponse(request.call.client, request, response.coroutineContext)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,17 @@ internal suspend fun PipelineContext<HttpResponse, Unit>.interceptReceiveLegacy(
if (response.status == HttpStatusCode.NotModified) {
val responseFromCache = plugin.findAndRefresh(response.call.request, response)
?: throw InvalidCacheStateException(response.call.request.url)
if (responseFromCache.varyKeys().size != response.varyKeys().size) {
LOGGER.warn(
"Vary header mismatch on cached response for ${response.call.request.url}. " +
"Received 304 Not Modified with Vary: ${response.varyKeys()} " +
"but cached response has Vary: ${responseFromCache.varyKeys()}. " +
"According to RFC 7232 §4.1 and RFC 9111 §4.1, " +
"the server must include the full Vary header in 304 responses. " +
"Proceeding with cached response despite mismatch. " +
"Consider reporting this issue to the server maintainers."
)
}

scope.monitor.raise(HttpCache.HttpResponseFromCache, responseFromCache)
proceedWith(responseFromCache)
Expand Down Expand Up @@ -116,10 +127,11 @@ private fun HttpCache.findAndRefresh(request: HttpRequest, response: HttpRespons

val storage = if (CacheControl.PRIVATE in cacheControl) privateStorage else publicStorage

val varyKeysFrom304 = response.varyKeys()
val cache = findResponse(storage, varyKeysFrom304, url, request) ?: return null
val newVaryKeys = varyKeysFrom304.ifEmpty { cache.varyKeys }
storage.store(url, HttpCacheEntry(response.cacheExpires(isSharedClient), newVaryKeys, cache.response, cache.body))
val cache = findResponse(storage, response.varyKeys(), url, request) ?: return null
storage.store(
url,
HttpCacheEntry(response.cacheExpires(isSharedClient), cache.varyKeys, cache.response, cache.body)
)
return cache.produceResponse()
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ internal class UnlimitedCacheStorage : HttpCacheStorage() {
override fun find(url: Url, varyKeys: Map<String, String>): HttpCacheEntry? {
val data = store.computeIfAbsent(url) { ConcurrentSet() }
return data.find {
varyKeys.all { (key, value) -> it.varyKeys[key] == value } && varyKeys.size == it.varyKeys.size
varyKeys.all { (key, value) -> it.varyKeys[key] == value }
}
}

Expand All @@ -45,7 +45,7 @@ internal class UnlimitedStorage : CacheStorage {
override suspend fun find(url: Url, varyKeys: Map<String, String>): CachedResponseData? {
val data = store.computeIfAbsent(url) { ConcurrentSet() }
return data.find {
varyKeys.all { (key, value) -> it.varyKeys[key] == value } && varyKeys.size == it.varyKeys.size
varyKeys.all { (key, value) -> it.varyKeys[key] == value }
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ internal class CachingCacheStorage(
}
val data = store.getValue(url)
return data.find {
varyKeys.all { (key, value) -> it.varyKeys[key] == value } && varyKeys.size == it.varyKeys.size
varyKeys.all { (key, value) -> it.varyKeys[key] == value }
}
}

Expand Down Expand Up @@ -83,7 +83,7 @@ private class FileCacheStorage(
override suspend fun find(url: Url, varyKeys: Map<String, String>): CachedResponseData? {
val data = readCache(key(url))
return data.find {
varyKeys.all { (key, value) -> it.varyKeys[key] == value } && varyKeys.size == it.varyKeys.size
varyKeys.all { (key, value) -> it.varyKeys[key] == value }
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ private class InMemoryCacheStorage : CacheStorage {
findCalledCount++
val cache = store.computeIfAbsent(url) { mutableSetOf() }
return cache.find {
varyKeys.all { (key, value) -> it.varyKeys[key] == value } && varyKeys.size == it.varyKeys.size
varyKeys.all { (key, value) -> it.varyKeys[key] == value }
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -846,15 +846,14 @@ class CacheTest : ClientLoader() {
}

test { client ->
client.get("$TEST_SERVER/cache/different-vary") {
val url = "$TEST_SERVER/cache/different-vary"
val first = client.get(url) {
header("200", "true")
header("Set-Vary", "X-Requested-With,Accept-Encoding")
}
assertFailsWith<InvalidCacheStateException> {
client.get("$TEST_SERVER/cache/different-vary") {
header("Set-Vary", "X-Requested-With")
}
}
val second = client.get(url)

assertEquals(first.bodyAsText(), second.bodyAsText())
assertEquals(storage.findAll(Url("$TEST_SERVER/cache/different-vary")).size, 1)
}
}

Expand Down
4 changes: 2 additions & 2 deletions ktor-test-server/src/main/kotlin/test/server/tests/Cache.kt
Original file line number Diff line number Diff line change
Expand Up @@ -120,8 +120,8 @@ internal fun Application.cacheTestServer() {
}
get("/different-vary") {
if (call.request.headers.contains("200")) {
call.response.header("Vary", "X-Requested-With,Accept-Encoding")
call.respond(HttpStatusCode.OK)
call.response.header("Vary", "X-Requested-With, Accept-Encoding")
call.respondText { "Ok" }
} else {
call.response.header("Vary", "X-Requested-With")
call.respond(HttpStatusCode.NotModified)
Expand Down