[Fixes #105] Disable caching when response uses antiforgery

This commit is contained in:
Kiran Challa 2016-10-27 17:18:50 -07:00
parent 08cb67b7e4
commit 3fc090e2fe
6 changed files with 333 additions and 1 deletions

View File

@ -14,7 +14,8 @@ namespace Microsoft.AspNetCore.Antiforgery
{
/// <summary>
/// Generates an <see cref="AntiforgeryTokenSet"/> for this request and stores the cookie token
/// in the response.
/// in the response. This operation also sets the "Cache-control" and "Pragma" headers to "no-cache" and
/// the "X-Frame-Options" header to "SAMEORIGIN".
/// </summary>
/// <param name="httpContext">The <see cref="HttpContext"/> associated with the current request.</param>
/// <returns>An <see cref="AntiforgeryTokenSet" /> with tokens for the response.</returns>

View File

@ -15,6 +15,7 @@ namespace Microsoft.AspNetCore.Antiforgery.Internal
private static readonly Action<ILogger, Exception> _newCookieToken;
private static readonly Action<ILogger, Exception> _reusedCookieToken;
private static readonly Action<ILogger, Exception> _tokenDeserializeException;
private static readonly Action<ILogger, Exception> _responseCacheHeadersOverridenToNoCache;
static AntiforgeryLoggerExtensions()
{
@ -47,6 +48,11 @@ namespace Microsoft.AspNetCore.Antiforgery.Internal
LogLevel.Error,
7,
"An exception was thrown while deserializing the token.");
_responseCacheHeadersOverridenToNoCache = LoggerMessage.Define(
LogLevel.Warning,
8,
"The 'Cache-Control' and 'Pragma' headers have been overridden and set to 'no-cache' to prevent " +
"caching of this response. Any response that uses antiforgery should not be cached.");
}
public static void ValidationFailed(this ILogger logger, string message)
@ -83,5 +89,10 @@ namespace Microsoft.AspNetCore.Antiforgery.Internal
{
_tokenDeserializeException(logger, exception);
}
public static void ResponseCacheHeadersOverridenToNoCache(this ILogger logger)
{
_responseCacheHeadersOverridenToNoCache(logger, null);
}
}
}

View File

@ -7,6 +7,7 @@ using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.Net.Http.Headers;
namespace Microsoft.AspNetCore.Antiforgery.Internal
{
@ -66,6 +67,10 @@ namespace Microsoft.AspNetCore.Antiforgery.Internal
}
}
// Explicitly set the cache headers to 'no-cache'. This could override any user set value but this is fine
// as a response with antiforgery token must never be cached.
SetDoNotCacheHeaders(httpContext);
return tokenSet;
}
@ -237,6 +242,10 @@ namespace Microsoft.AspNetCore.Antiforgery.Internal
{
_logger.ReusedCookieToken();
}
// Explicitly set the cache headers to 'no-cache'. This could override any user set value but this is fine
// as a response with antiforgery token must never be cached.
SetDoNotCacheHeaders(httpContext);
}
private void SaveCookieTokenAndHeader(HttpContext httpContext, string cookieToken)
@ -358,6 +367,43 @@ namespace Microsoft.AspNetCore.Antiforgery.Internal
return antiforgeryFeature;
}
private void SetDoNotCacheHeaders(HttpContext httpContext)
{
// Since antifogery token generation is not very obvious to the end users (ex: MVC's form tag generates them
// by default), log a warning to let users know of the change in behavior to any cache headers they might
// have set explicitly.
LogCacheHeaderOverrideWarning(httpContext.Response);
httpContext.Response.Headers[HeaderNames.CacheControl] = "no-cache";
httpContext.Response.Headers[HeaderNames.Pragma] = "no-cache";
}
private void LogCacheHeaderOverrideWarning(HttpResponse response)
{
var logWarning = false;
CacheControlHeaderValue cacheControlHeaderValue;
if (CacheControlHeaderValue.TryParse(response.Headers[HeaderNames.CacheControl], out cacheControlHeaderValue))
{
if (!cacheControlHeaderValue.NoCache)
{
logWarning = true;
}
}
var pragmaHeader = response.Headers[HeaderNames.Pragma];
if (!logWarning
&& !string.IsNullOrEmpty(pragmaHeader)
&& string.Compare(pragmaHeader, "no-cache", ignoreCase: true) != 0)
{
logWarning = true;
}
if (logWarning)
{
_logger.ResponseCacheHeadersOverridenToNoCache();
}
}
private AntiforgeryTokenSet Serialize(IAntiforgeryFeature antiforgeryFeature)
{
// Should only be called after new tokens have been generated.

View File

@ -22,6 +22,7 @@
"dependencies": {
"Microsoft.AspNetCore.DataProtection": "1.1.0-*",
"Microsoft.AspNetCore.Http.Abstractions": "1.1.0-*",
"Microsoft.AspNetCore.Http.Extensions": "1.1.0-*",
"Microsoft.AspNetCore.WebUtilities": "1.1.0-*",
"Microsoft.Extensions.ObjectPool": "1.1.0-*",
"NETStandard.Library": "1.6.1-*"

View File

@ -2,12 +2,15 @@
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System;
using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Testing;
using Microsoft.Extensions.Options;
using Microsoft.Net.Http.Headers;
using Moq;
using Xunit;
@ -15,6 +18,10 @@ namespace Microsoft.AspNetCore.Antiforgery.Internal
{
public class DefaultAntiforgeryTest
{
private const string ResponseCacheHeadersOverrideWarningMessage =
"The 'Cache-Control' and 'Pragma' headers have been overridden and set to 'no-cache' to prevent caching " +
"of this response. Any response that uses antiforgery should not be cached.";
[Fact]
public async Task ChecksSSL_ValidateRequestAsync_Throws()
{
@ -275,6 +282,67 @@ namespace Microsoft.AspNetCore.Antiforgery.Internal
Assert.Equal(context.TestTokenSet.FormTokenString, antiforgeryFeature.NewRequestTokenString);
}
[Fact]
public void GetAndStoreTokens_ExistingValidCookieToken_NotOverriden_AndSetsDoNotCacheHeaders()
{
// Arrange
var antiforgeryFeature = new AntiforgeryFeature();
var context = CreateMockContext(
new AntiforgeryOptions(),
useOldCookie: true,
isOldCookieValid: true,
antiforgeryFeature: antiforgeryFeature);
var antiforgery = GetAntiforgery(context);
// Act
var tokenSet = antiforgery.GetAndStoreTokens(context.HttpContext);
// Assert
// We shouldn't have saved the cookie because it already existed.
context.TokenStore.Verify(
t => t.SaveCookieToken(It.IsAny<HttpContext>(), It.IsAny<string>()),
Times.Never);
Assert.Null(tokenSet.CookieToken);
Assert.Equal(context.TestTokenSet.FormTokenString, tokenSet.RequestToken);
Assert.NotNull(antiforgeryFeature);
Assert.Equal(context.TestTokenSet.OldCookieToken, antiforgeryFeature.CookieToken);
Assert.Equal("no-cache", context.HttpContext.Response.Headers[HeaderNames.CacheControl]);
Assert.Equal("no-cache", context.HttpContext.Response.Headers[HeaderNames.Pragma]);
}
[Fact]
public void GetAndStoreTokens_ExistingCachingHeaders_Overriden()
{
// Arrange
var antiforgeryFeature = new AntiforgeryFeature();
var context = CreateMockContext(
new AntiforgeryOptions(),
useOldCookie: true,
isOldCookieValid: true,
antiforgeryFeature: antiforgeryFeature);
var antiforgery = GetAntiforgery(context);
context.HttpContext.Response.Headers["Cache-Control"] = "public";
// Act
var tokenSet = antiforgery.GetAndStoreTokens(context.HttpContext);
// Assert
// We shouldn't have saved the cookie because it already existed.
context.TokenStore.Verify(
t => t.SaveCookieToken(It.IsAny<HttpContext>(), It.IsAny<string>()),
Times.Never);
Assert.Null(tokenSet.CookieToken);
Assert.Equal(context.TestTokenSet.FormTokenString, tokenSet.RequestToken);
Assert.NotNull(antiforgeryFeature);
Assert.Equal(context.TestTokenSet.OldCookieToken, antiforgeryFeature.CookieToken);
Assert.Equal("no-cache", context.HttpContext.Response.Headers[HeaderNames.CacheControl]);
Assert.Equal("no-cache", context.HttpContext.Response.Headers[HeaderNames.Pragma]);
}
[Fact]
public void GetAndStoreTokens_NoExistingCookieToken_Saved()
{
@ -309,6 +377,36 @@ namespace Microsoft.AspNetCore.Antiforgery.Internal
Assert.True(antiforgeryFeature.HaveStoredNewCookieToken);
}
[Fact]
public void GetAndStoreTokens_NoExistingCookieToken_Saved_AndSetsDoNotCacheHeaders()
{
// Arrange
var antiforgeryFeature = new AntiforgeryFeature();
var context = CreateMockContext(
new AntiforgeryOptions(),
useOldCookie: false,
isOldCookieValid: false,
antiforgeryFeature: antiforgeryFeature);
var antiforgery = GetAntiforgery(context);
// Act
var tokenSet = antiforgery.GetAndStoreTokens(context.HttpContext);
// Assert
context.TokenStore.Verify(
t => t.SaveCookieToken(It.IsAny<HttpContext>(), context.TestTokenSet.NewCookieTokenString),
Times.Once);
Assert.Equal(context.TestTokenSet.NewCookieTokenString, tokenSet.CookieToken);
Assert.Equal(context.TestTokenSet.FormTokenString, tokenSet.RequestToken);
Assert.NotNull(antiforgeryFeature);
Assert.True(antiforgeryFeature.HaveDeserializedCookieToken);
Assert.Equal(context.TestTokenSet.OldCookieToken, antiforgeryFeature.CookieToken);
Assert.Equal("no-cache", context.HttpContext.Response.Headers[HeaderNames.CacheControl]);
Assert.Equal("no-cache", context.HttpContext.Response.Headers[HeaderNames.Pragma]);
}
[Fact]
public void GetAndStoreTokens_DoesNotSerializeTwice()
{
@ -808,6 +906,76 @@ namespace Microsoft.AspNetCore.Antiforgery.Internal
Assert.Equal(expectedHeaderValue, xFrameOptions);
}
[Fact]
public void SetCookieTokenAndHeader_NewCookieToken_SetsDoNotCacheHeaders()
{
// Arrange
var options = new AntiforgeryOptions();
var antiforgeryFeature = new AntiforgeryFeature();
// Generate a new cookie.
var context = CreateMockContext(
options,
useOldCookie: false,
isOldCookieValid: false,
antiforgeryFeature: antiforgeryFeature);
var antiforgery = GetAntiforgery(context);
// Act
antiforgery.SetCookieTokenAndHeader(context.HttpContext);
// Assert
Assert.Equal("no-cache", context.HttpContext.Response.Headers["Cache-Control"]);
Assert.Equal("no-cache", context.HttpContext.Response.Headers["Pragma"]);
}
[Fact]
public void SetCookieTokenAndHeader_ValidOldCookieToken_SetsDoNotCacheHeaders()
{
// Arrange
var options = new AntiforgeryOptions();
var antiforgeryFeature = new AntiforgeryFeature();
// Generate a new cookie.
var context = CreateMockContext(
options,
useOldCookie: true,
isOldCookieValid: true,
antiforgeryFeature: antiforgeryFeature);
var antiforgery = GetAntiforgery(context);
// Act
antiforgery.SetCookieTokenAndHeader(context.HttpContext);
// Assert
Assert.Equal("no-cache", context.HttpContext.Response.Headers["Cache-Control"]);
Assert.Equal("no-cache", context.HttpContext.Response.Headers["Pragma"]);
}
[Fact]
public void SetCookieTokenAndHeader_OverridesExistingCachingHeaders()
{
// Arrange
var options = new AntiforgeryOptions();
var antiforgeryFeature = new AntiforgeryFeature();
// Generate a new cookie.
var context = CreateMockContext(
options,
useOldCookie: true,
isOldCookieValid: true,
antiforgeryFeature: antiforgeryFeature);
var antiforgery = GetAntiforgery(context);
context.HttpContext.Response.Headers["Cache-Control"] = "public";
// Act
antiforgery.SetCookieTokenAndHeader(context.HttpContext);
// Assert
Assert.Equal("no-cache", context.HttpContext.Response.Headers["Cache-Control"]);
Assert.Equal("no-cache", context.HttpContext.Response.Headers["Pragma"]);
}
[Theory]
[InlineData(false, "SAMEORIGIN")]
[InlineData(true, null)]
@ -965,6 +1133,110 @@ namespace Microsoft.AspNetCore.Antiforgery.Internal
context.TokenSerializer.Verify(s => s.Deserialize(null), Times.Never);
}
[Fact]
public void GetAndStoreTokens_DoesNotLogWarning_IfNoExistingCacheHeadersPresent()
{
// Arrange
var testSink = new TestSink();
var loggerFactory = new Mock<ILoggerFactory>();
loggerFactory
.Setup(lf => lf.CreateLogger(typeof(DefaultAntiforgery).FullName))
.Returns(new TestLogger("test logger", testSink, enabled: true));
var services = new ServiceCollection();
services.AddSingleton(loggerFactory.Object);
var antiforgeryFeature = new AntiforgeryFeature();
var context = CreateMockContext(
new AntiforgeryOptions(),
useOldCookie: false,
isOldCookieValid: false,
antiforgeryFeature: antiforgeryFeature);
context.HttpContext.RequestServices = services.BuildServiceProvider();
var antiforgery = GetAntiforgery(context);
// Act
var tokenSet = antiforgery.GetAndStoreTokens(context.HttpContext);
// Assert
var hasWarningMessage = testSink.Writes
.Where(wc => wc.LogLevel == LogLevel.Warning)
.Select(wc => wc.State?.ToString())
.Contains(ResponseCacheHeadersOverrideWarningMessage);
Assert.False(hasWarningMessage);
}
[Theory]
[InlineData("Cache-Control", "Public")]
[InlineData("Cache-Control", "PuBlic")]
[InlineData("Cache-Control", "Private")]
[InlineData("Cache-Control", "PriVate")]
[InlineData("Cache-Control", "No-Store")]
[InlineData("Cache-Control", "No-store")]
[InlineData("Pragma", "Foo")]
public void GetAndStoreTokens_LogsWarning_NonNoCacheHeadersAlreadyPresent(string headerName, string headerValue)
{
// Arrange
var testSink = new TestSink();
var loggerFactory = new Mock<ILoggerFactory>();
loggerFactory
.Setup(lf => lf.CreateLogger(typeof(DefaultAntiforgery).FullName))
.Returns(new TestLogger("test logger", testSink, enabled: true));
var services = new ServiceCollection();
services.AddSingleton(loggerFactory.Object);
var antiforgeryFeature = new AntiforgeryFeature();
var context = CreateMockContext(
new AntiforgeryOptions(),
useOldCookie: false,
isOldCookieValid: false,
antiforgeryFeature: antiforgeryFeature);
context.HttpContext.RequestServices = services.BuildServiceProvider();
var antiforgery = GetAntiforgery(context);
context.HttpContext.Response.Headers[headerName] = headerValue;
// Act
var tokenSet = antiforgery.GetAndStoreTokens(context.HttpContext);
// Assert
var hasWarningMessage = testSink.Writes
.Where(wc => wc.LogLevel == LogLevel.Warning)
.Select(wc => wc.State?.ToString())
.Contains(ResponseCacheHeadersOverrideWarningMessage);
Assert.True(hasWarningMessage);
}
[Theory]
[InlineData("Cache-Control", "no-cache")]
[InlineData("Pragma", "no-cache")]
public void GetAndStoreTokens_DoesNotLogsWarning_ForNoCacheHeaders_AlreadyPresent(string headerName, string headerValue)
{
// Arrange
var testSink = new TestSink();
var loggerFactory = new Mock<ILoggerFactory>();
loggerFactory
.Setup(lf => lf.CreateLogger(typeof(DefaultAntiforgery).FullName))
.Returns(new TestLogger("test logger", testSink, enabled: true));
var services = new ServiceCollection();
services.AddSingleton(loggerFactory.Object);
var antiforgeryFeature = new AntiforgeryFeature();
var context = CreateMockContext(
new AntiforgeryOptions(),
useOldCookie: false,
isOldCookieValid: false,
antiforgeryFeature: antiforgeryFeature);
context.HttpContext.RequestServices = services.BuildServiceProvider();
var antiforgery = GetAntiforgery(context);
context.HttpContext.Response.Headers[headerName] = headerValue;
// Act
var tokenSet = antiforgery.GetAndStoreTokens(context.HttpContext);
// Assert
var hasWarningMessage = testSink.Writes
.Where(wc => wc.LogLevel == LogLevel.Warning)
.Select(wc => wc.State?.ToString())
.Contains(ResponseCacheHeadersOverrideWarningMessage);
Assert.False(hasWarningMessage);
}
private DefaultAntiforgery GetAntiforgery(
HttpContext httpContext,
AntiforgeryOptions options = null,

View File

@ -10,6 +10,7 @@
"Microsoft.AspNetCore.Testing": "1.1.0-*",
"Microsoft.Extensions.DependencyInjection": "1.1.0-*",
"Microsoft.Extensions.Logging": "1.1.0-*",
"Microsoft.Extensions.Logging.Testing": "1.1.0-*",
"Microsoft.Extensions.WebEncoders": "1.1.0-*",
"Moq": "4.6.36-*",
"xunit": "2.2.0-*"