Skip to content

Commit 04421bc

Browse files
[ResponseCaching] Correctly handle Vary: * in delimited or multi-value headers (RFC 9111) (#69210)
* Do not cache responses with Vary: * across multiple or delimited headers - Comply with RFC 9111 § 4.1 by inspecting each delimited token within Vary headers. - Iterate StringValues directly with StringTokenizer to prevent string allocations on hot paths. - Add unit and integration tests covering multi-entry and delimited '*' headers. Fixes #69192 * Use Span.Split for zero-allocation Vary header tokenization * Update src/Middleware/ResponseCaching/src/ResponseCachingPolicyProvider.cs Co-authored-by: Jiri Cincura ↹ <jiri@cincura.net> * Update src/Middleware/ResponseCaching/src/ResponseCachingPolicyProvider.cs Co-authored-by: Jiri Cincura ↹ <jiri@cincura.net> --------- Co-authored-by: Jiri Cincura ↹ <jiri@cincura.net>
1 parent cc827b0 commit 04421bc

3 files changed

Lines changed: 120 additions & 7 deletions

File tree

src/Middleware/ResponseCaching/src/ResponseCachingPolicyProvider.cs

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -99,10 +99,22 @@ public bool IsResponseCacheable(ResponseCachingContext context)
9999

100100
// Do not cache responses varying by *
101101
var varyHeader = response.Headers.Vary;
102-
if (varyHeader.Count == 1 && string.Equals(varyHeader, "*", StringComparison.OrdinalIgnoreCase))
102+
for (var i = 0; i < varyHeader.Count; i++)
103103
{
104-
context.Logger.ResponseWithVaryStarNotCacheable();
105-
return false;
104+
var rawHeader = varyHeader[i].AsSpan();
105+
if (rawHeader.IsEmpty)
106+
{
107+
continue;
108+
}
109+
110+
foreach (var segment in rawHeader.Split(','))
111+
{
112+
if (rawHeader[segment].Trim() is ['*'])
113+
{
114+
context.Logger.ResponseWithVaryStarNotCacheable();
115+
return false;
116+
}
117+
}
106118
}
107119

108120
// Check private

src/Middleware/ResponseCaching/test/ResponseCachingMiddlewareTests.cs

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
11
// Licensed to the .NET Foundation under one or more agreements.
22
// The .NET Foundation licenses this file to you under the MIT license.
33

4+
using System.Net.Http;
5+
using Microsoft.AspNetCore.Builder;
6+
using Microsoft.AspNetCore.Hosting;
47
using Microsoft.AspNetCore.Http;
58
using Microsoft.AspNetCore.Http.Features;
69
using Microsoft.AspNetCore.InternalTesting;
10+
using Microsoft.AspNetCore.TestHost;
711
using Microsoft.Extensions.Caching.Memory;
12+
using Microsoft.Extensions.DependencyInjection;
13+
using Microsoft.Extensions.Hosting;
814
using Microsoft.Extensions.Logging.Testing;
915
using Microsoft.Extensions.Primitives;
1016
using Microsoft.Extensions.Time.Testing;
@@ -1051,4 +1057,67 @@ public void GetOrderCasingNormalizedStringValues_PreservesCommas()
10511057

10521058
Assert.Equal(originalStrings, normalizedStrings);
10531059
}
1060+
1061+
[Theory]
1062+
[InlineData(true)]
1063+
[InlineData(false)]
1064+
public async Task ResponseWithVaryStar_AndDownstreamMiddlewareAppendedVaryHeader_IsNotServedFromCache(bool appendStarFirst)
1065+
{
1066+
using var host = new HostBuilder()
1067+
.ConfigureWebHost(webHostBuilder =>
1068+
{
1069+
webHostBuilder
1070+
.UseTestServer()
1071+
.ConfigureServices(services =>
1072+
{
1073+
services.AddResponseCaching();
1074+
})
1075+
.Configure(app =>
1076+
{
1077+
app.UseResponseCaching();
1078+
app.Use(async (context, next) =>
1079+
{
1080+
if (!appendStarFirst)
1081+
{
1082+
context.Response.Headers.Append("Vary", "Accept-Encoding");
1083+
}
1084+
await next(context);
1085+
});
1086+
app.Run(async context =>
1087+
{
1088+
context.Response.Headers.CacheControl = new CacheControlHeaderValue
1089+
{
1090+
Public = true,
1091+
MaxAge = TimeSpan.FromSeconds(10)
1092+
}.ToString();
1093+
if (appendStarFirst)
1094+
{
1095+
context.Response.Headers.Vary = "*";
1096+
context.Response.Headers.Append("Vary", "Accept-Encoding");
1097+
}
1098+
else
1099+
{
1100+
context.Response.Headers.Append("Vary", "*");
1101+
}
1102+
await context.Response.WriteAsync(Guid.NewGuid().ToString());
1103+
});
1104+
});
1105+
})
1106+
.Build();
1107+
1108+
await host.StartAsync();
1109+
1110+
using var server = host.GetTestServer();
1111+
var client = server.CreateClient();
1112+
var initialResponse = await client.GetAsync("");
1113+
var subsequentResponse = await client.GetAsync("");
1114+
1115+
initialResponse.EnsureSuccessStatusCode();
1116+
subsequentResponse.EnsureSuccessStatusCode();
1117+
1118+
Assert.False(subsequentResponse.Headers.Contains(HeaderNames.Age));
1119+
var initialContent = await initialResponse.Content.ReadAsStringAsync();
1120+
var subsequentContent = await subsequentResponse.Content.ReadAsStringAsync();
1121+
Assert.NotEqual(initialContent, subsequentContent);
1122+
}
10541123
}

src/Middleware/ResponseCaching/test/ResponseCachingPolicyProviderTests.cs

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1-
// Licensed to the .NET Foundation under one or more agreements.
1+
// Licensed to the .NET Foundation under one or more agreements.
22
// The .NET Foundation licenses this file to you under the MIT license.
33

44
using Microsoft.AspNetCore.Http;
55
using Microsoft.Extensions.Logging.Testing;
6+
using Microsoft.Extensions.Primitives;
67
using Microsoft.Net.Http.Headers;
78

89
namespace Microsoft.AspNetCore.ResponseCaching.Tests;
@@ -229,23 +230,54 @@ public void IsResponseCacheable_SetCookieHeader_NotAllowed()
229230
LoggedMessage.ResponseWithSetCookieNotCacheable);
230231
}
231232

232-
[Fact]
233-
public void IsResponseCacheable_VaryHeaderByStar_NotAllowed()
233+
public static TheoryData<StringValues> VaryHeaderWithStarData
234+
{
235+
get
236+
{
237+
return new TheoryData<StringValues>
238+
{
239+
"*",
240+
new StringValues(["*", "Accept-Encoding"]),
241+
"*, Accept-Encoding",
242+
"Accept-Encoding, *",
243+
"gzip, *, br"
244+
};
245+
}
246+
}
247+
248+
[Theory]
249+
[MemberData(nameof(VaryHeaderWithStarData))]
250+
public void IsResponseCacheable_VaryHeaderByStar_NotAllowed(StringValues vary)
234251
{
235252
var sink = new TestSink();
236253
var context = TestUtils.CreateTestContext(sink);
237254
context.HttpContext.Response.Headers.CacheControl = new CacheControlHeaderValue()
238255
{
239256
Public = true
240257
}.ToString();
241-
context.HttpContext.Response.Headers.Vary = "*";
258+
context.HttpContext.Response.Headers.Vary = vary;
242259

243260
Assert.False(new ResponseCachingPolicyProvider().IsResponseCacheable(context));
244261
TestUtils.AssertLoggedMessages(
245262
sink.Writes,
246263
LoggedMessage.ResponseWithVaryStarNotCacheable);
247264
}
248265

266+
[Fact]
267+
public void IsResponseCacheable_ValidVaryHeaderWithoutAsterisk_Allowed()
268+
{
269+
var sink = new TestSink();
270+
var context = TestUtils.CreateTestContext(sink);
271+
context.HttpContext.Response.Headers.CacheControl = new CacheControlHeaderValue()
272+
{
273+
Public = true
274+
}.ToString();
275+
context.HttpContext.Response.Headers.Vary = "Accept-Encoding, User-Agent";
276+
277+
Assert.True(new ResponseCachingPolicyProvider().IsResponseCacheable(context));
278+
Assert.Empty(sink.Writes);
279+
}
280+
249281
[Fact]
250282
public void IsResponseCacheable_Private_NotAllowed()
251283
{

0 commit comments

Comments
 (0)