Implement ChangeCookieAction and add factory to parse from mod_rewrite.

Resolves #94
This commit is contained in:
Nate McMaster 2016-10-04 14:13:03 -07:00
parent 3cb0fc640a
commit 1db5a9e58f
No known key found for this signature in database
GPG Key ID: BD729980AA6A21BD
12 changed files with 433 additions and 50 deletions

View File

@ -0,0 +1,120 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System;
using System.Globalization;
using Microsoft.AspNetCore.Rewrite.Internal.UrlActions;
namespace Microsoft.AspNetCore.Rewrite.Internal.ApacheModRewrite
{
public class CookieActionFactory
{
/// <summary>
/// Creates a <see cref="ChangeCookieAction" /> <see href="https://httpd.apache.org/docs/current/rewrite/flags.html#flag_co" /> for details.
/// </summary>
/// <param name="flagValue">The flag</param>
/// <returns>The action</returns>
public ChangeCookieAction Create(string flagValue)
{
if (string.IsNullOrEmpty(flagValue))
{
throw new ArgumentException(nameof(flagValue));
}
var i = 0;
var separator = ':';
if (flagValue[0] == ';')
{
separator = ';';
i++;
}
ChangeCookieAction action = null;
var currentField = Fields.Name;
var start = i;
for (; i < flagValue.Length; i++)
{
if (flagValue[i] == separator)
{
var length = i - start;
SetActionOption(flagValue.Substring(start, length).Trim(), currentField, ref action);
currentField++;
start = i + 1;
}
}
if (i != start)
{
SetActionOption(flagValue.Substring(start).Trim(new[] { ' ', separator }), currentField, ref action);
}
if (currentField < Fields.Domain)
{
throw new FormatException(Resources.FormatError_InvalidChangeCookieFlag(flagValue));
}
return action;
}
private static void SetActionOption(string value, Fields tokenType, ref ChangeCookieAction action)
{
switch (tokenType)
{
case Fields.Name:
action = new ChangeCookieAction(value);
break;
case Fields.Value:
action.Value = value;
break;
case Fields.Domain:
// despite what spec says, an empty domain field is allowed in mod_rewrite
// by specifying NAME:VALUE:;
action.Domain = string.IsNullOrEmpty(value) || value == ";"
? null
: value;
break;
case Fields.Lifetime:
if (string.IsNullOrEmpty(value))
{
break;
}
uint minutes;
if (!uint.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out minutes))
{
throw new FormatException(Resources.FormatError_CouldNotParseInteger(value));
}
action.Lifetime = TimeSpan.FromMinutes(minutes);
break;
case Fields.Path:
action.Path = value;
break;
case Fields.Secure:
action.Secure = "secure".Equals(value, StringComparison.OrdinalIgnoreCase)
|| "true".Equals(value, StringComparison.OrdinalIgnoreCase)
|| value == "1";
break;
case Fields.HttpOnly:
action.HttpOnly = "httponly".Equals(value, StringComparison.OrdinalIgnoreCase)
|| "true".Equals(value, StringComparison.OrdinalIgnoreCase)
|| value == "1";
break;
}
}
// order matters
// see https://httpd.apache.org/docs/current/rewrite/flags.html#flag_co
private enum Fields
{
Name,
Value,
Domain,
Lifetime,
Path,
Secure,
HttpOnly
}
}
}

View File

@ -59,7 +59,7 @@ namespace Microsoft.AspNetCore.Rewrite.Internal.ApacheModRewrite
{
if (string.IsNullOrEmpty(flagString))
{
throw new ArgumentNullException(nameof(flagString));
throw new ArgumentException(nameof(flagString));
}
// Check that flags are contained within []

View File

@ -14,6 +14,7 @@ namespace Microsoft.AspNetCore.Rewrite.Internal.ApacheModRewrite
private IList<Condition> _conditions;
private IList<UrlAction> _actions = new List<UrlAction>();
private UrlMatch _match;
private CookieActionFactory _cookieActionFactory = new CookieActionFactory();
private readonly TimeSpan RegexTimeout = TimeSpan.FromMilliseconds(1);
@ -172,8 +173,8 @@ namespace Microsoft.AspNetCore.Rewrite.Internal.ApacheModRewrite
string flag;
if (flags.GetValue(FlagType.Cookie, out flag))
{
// parse cookie
_actions.Add(new ChangeCookieAction(flag));
var action = _cookieActionFactory.Create(flag);
_actions.Add(action);
}
if (flags.GetValue(FlagType.Env, out flag))

View File

@ -18,12 +18,12 @@ namespace Microsoft.AspNetCore.Rewrite.Internal
{
if (string.IsNullOrEmpty(regex))
{
throw new ArgumentNullException(nameof(regex));
throw new ArgumentException(nameof(regex));
}
if (string.IsNullOrEmpty(replacement))
{
throw new ArgumentNullException(nameof(replacement));
throw new ArgumentException(nameof(replacement));
}
InitialMatch = new Regex(regex, RegexOptions.Compiled | RegexOptions.CultureInvariant, _regexTimeout);

View File

@ -18,12 +18,12 @@ namespace Microsoft.AspNetCore.Rewrite.Internal
{
if (string.IsNullOrEmpty(regex))
{
throw new ArgumentNullException(nameof(regex));
throw new ArgumentException(nameof(regex));
}
if (string.IsNullOrEmpty(replacement))
{
throw new ArgumentNullException(nameof(replacement));
throw new ArgumentException(nameof(replacement));
}
InitialMatch = new Regex(regex, RegexOptions.Compiled | RegexOptions.CultureInvariant, _regexTimeout);

View File

@ -2,21 +2,74 @@
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System;
using Microsoft.AspNetCore.Http;
namespace Microsoft.AspNetCore.Rewrite.Internal.UrlActions
{
public class ChangeCookieAction : UrlAction
{
public ChangeCookieAction(string cookie)
private readonly Func<DateTimeOffset> _timeSource;
private CookieOptions _cachedOptions;
public ChangeCookieAction(string name)
: this(name, () => DateTimeOffset.UtcNow)
{
// TODO
throw new NotImplementedException("Changing the cookie is not implemented");
}
// for testing
internal ChangeCookieAction(string name, Func<DateTimeOffset> timeSource)
{
_timeSource = timeSource;
if (string.IsNullOrEmpty(name))
{
throw new ArgumentException(nameof(name));
}
Name = name;
}
public string Name { get; }
public string Value { get; set; }
public string Domain { get; set; }
public TimeSpan Lifetime { get; set; }
public string Path { get; set; }
public bool Secure { get; set; }
public bool HttpOnly { get; set; }
public override void ApplyAction(RewriteContext context, MatchResults ruleMatch, MatchResults condMatch)
{
// modify the cookies
throw new NotImplementedException("Changing the cookie is not implemented");
var options = GetOrCreateOptions();
context.HttpContext.Response.Cookies.Append(Name, Value ?? string.Empty, options);
}
private CookieOptions GetOrCreateOptions()
{
if (Lifetime > TimeSpan.Zero)
{
var now = _timeSource();
return new CookieOptions()
{
Domain = Domain,
HttpOnly = HttpOnly,
Secure = Secure,
Path = Path,
Expires = now.Add(Lifetime)
};
}
if (_cachedOptions == null)
{
_cachedOptions = new CookieOptions()
{
Domain = Domain,
HttpOnly = HttpOnly,
Secure = Secure,
Path = Path
};
}
return _cachedOptions;
}
}
}

View File

@ -11,19 +11,35 @@ namespace Microsoft.AspNetCore.Rewrite
= new ResourceManager("Microsoft.AspNetCore.Rewrite.Resources", typeof(Resources).GetTypeInfo().Assembly);
/// <summary>
/// Could not parse the UrlRewrite file. Message: '{0}'. Line number '{1}': '{2}'.
/// Error adding a mod_rewrite rule. The change environment flag is not supported.
/// </summary>
internal static string Error_UrlRewriteParseError
internal static string Error_ChangeEnvironmentNotSupported
{
get { return GetString("Error_UrlRewriteParseError"); }
get { return GetString("Error_ChangeEnvironmentNotSupported"); }
}
/// <summary>
/// Could not parse the UrlRewrite file. Message: '{0}'. Line number '{1}': '{2}'.
/// Error adding a mod_rewrite rule. The change environment flag is not supported.
/// </summary>
internal static string FormatError_UrlRewriteParseError(object p0, object p1, object p2)
internal static string FormatError_ChangeEnvironmentNotSupported()
{
return string.Format(CultureInfo.CurrentCulture, GetString("Error_UrlRewriteParseError"), p0, p1, p2);
return GetString("Error_ChangeEnvironmentNotSupported");
}
/// <summary>
/// Could not parse integer from value '{0}'.
/// </summary>
internal static string Error_CouldNotParseInteger
{
get { return GetString("Error_CouldNotParseInteger"); }
}
/// <summary>
/// Could not parse integer from value '{0}'.
/// </summary>
internal static string FormatError_CouldNotParseInteger(object p0)
{
return string.Format(CultureInfo.CurrentCulture, GetString("Error_CouldNotParseInteger"), p0);
}
/// <summary>
@ -106,6 +122,38 @@ namespace Microsoft.AspNetCore.Rewrite
return string.Format(CultureInfo.CurrentCulture, GetString("Error_InputParserUnrecognizedParameter"), p0, p1);
}
/// <summary>
/// Syntax error for integers in comparison.
/// </summary>
internal static string Error_IntegerMatch_FormatExceptionMessage
{
get { return GetString("Error_IntegerMatch_FormatExceptionMessage"); }
}
/// <summary>
/// Syntax error for integers in comparison.
/// </summary>
internal static string FormatError_IntegerMatch_FormatExceptionMessage()
{
return GetString("Error_IntegerMatch_FormatExceptionMessage");
}
/// <summary>
/// Error parsing the mod_rewrite rule. The cookie flag (CO) has an incorrect format '{0}'.
/// </summary>
internal static string Error_InvalidChangeCookieFlag
{
get { return GetString("Error_InvalidChangeCookieFlag"); }
}
/// <summary>
/// Error parsing the mod_rewrite rule. The cookie flag (CO) has an incorrect format '{0}'.
/// </summary>
internal static string FormatError_InvalidChangeCookieFlag(object p0)
{
return string.Format(CultureInfo.CurrentCulture, GetString("Error_InvalidChangeCookieFlag"), p0);
}
/// <summary>
/// Could not parse the mod_rewrite file. Message: '{0}'. Line number '{1}'.
/// </summary>
@ -139,35 +187,19 @@ namespace Microsoft.AspNetCore.Rewrite
}
/// <summary>
/// Syntax error for integers in comparison.
/// Could not parse the UrlRewrite file. Message: '{0}'. Line number '{1}': '{2}'.
/// </summary>
internal static string Error_IntegerMatch_FormatExceptionMessage
internal static string Error_UrlRewriteParseError
{
get { return GetString("Error_IntegerMatch_FormatExceptionMessage"); }
get { return GetString("Error_UrlRewriteParseError"); }
}
/// <summary>
/// Syntax error for integers in comparison.
/// Could not parse the UrlRewrite file. Message: '{0}'. Line number '{1}': '{2}'.
/// </summary>
internal static string FormatError_IntegerMatch_FormatExceptionMessage()
internal static string FormatError_UrlRewriteParseError(object p0, object p1, object p2)
{
return GetString("Error_IntegerMatch_FormatExceptionMessage");
}
/// <summary>
/// Error adding a mod_rewrite rule. The change environment flag is not supported.
/// </summary>
internal static string Error_ChangeEnvironmentNotSupported
{
get { return GetString("Error_ChangeEnvironmentNotSupported"); }
}
/// <summary>
/// Error adding a mod_rewrite rule. The change environment flag is not supported.
/// </summary>
internal static string FormatError_ChangeEnvironmentNotSupported()
{
return GetString("Error_ChangeEnvironmentNotSupported");
return string.Format(CultureInfo.CurrentCulture, GetString("Error_UrlRewriteParseError"), p0, p1, p2);
}
private static string GetString(string name, params string[] formatterNames)

View File

@ -117,8 +117,11 @@
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="Error_UrlRewriteParseError" xml:space="preserve">
<value>Could not parse the UrlRewrite file. Message: '{0}'. Line number '{1}': '{2}'.</value>
<data name="Error_ChangeEnvironmentNotSupported" xml:space="preserve">
<value>Error adding a mod_rewrite rule. The change environment flag is not supported.</value>
</data>
<data name="Error_CouldNotParseInteger" xml:space="preserve">
<value>Could not parse integer from value '{0}'.</value>
</data>
<data name="Error_InputParserIndexOutOfRange" xml:space="preserve">
<value>Index out of range for backreference: '{0}' at string index: '{1}'</value>
@ -135,16 +138,19 @@
<data name="Error_InputParserUnrecognizedParameter" xml:space="preserve">
<value>Unrecognized parameter type: '{0}', terminated at string index: '{1}'</value>
</data>
<data name="Error_IntegerMatch_FormatExceptionMessage" xml:space="preserve">
<value>Syntax error for integers in comparison.</value>
</data>
<data name="Error_InvalidChangeCookieFlag" xml:space="preserve">
<value>Error parsing the mod_rewrite rule. The cookie flag (CO) has an incorrect format '{0}'.</value>
</data>
<data name="Error_ModRewriteParseError" xml:space="preserve">
<value>Could not parse the mod_rewrite file. Message: '{0}'. Line number '{1}'.</value>
</data>
<data name="Error_ModRewriteGeneralParseError" xml:space="preserve">
<value>Could not parse the mod_rewrite file. Line number '{0}'.</value>
</data>
<data name="Error_IntegerMatch_FormatExceptionMessage" xml:space="preserve">
<value>Syntax error for integers in comparison.</value>
</data>
<data name="Error_ChangeEnvironmentNotSupported" xml:space="preserve">
<value>Error adding a mod_rewrite rule. The change environment flag is not supported.</value>
<data name="Error_UrlRewriteParseError" xml:space="preserve">
<value>Could not parse the UrlRewrite file. Message: '{0}'. Line number '{1}': '{2}'.</value>
</data>
</root>

View File

@ -0,0 +1,93 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System;
using Microsoft.AspNetCore.Rewrite.Internal.ApacheModRewrite;
using Xunit;
namespace Microsoft.AspNetCore.Rewrite.Test
{
public class CookieActionFactoryTest
{
[Fact]
public void Creates_OneCookie()
{
var cookie = new CookieActionFactory().Create("NAME:VALUE:DOMAIN:1440:path:secure:httponly");
Assert.Equal("NAME", cookie.Name);
Assert.Equal("VALUE", cookie.Value);
Assert.Equal("DOMAIN", cookie.Domain);
Assert.Equal(TimeSpan.FromMinutes(1440), cookie.Lifetime);
Assert.Equal("path", cookie.Path);
Assert.True(cookie.Secure);
Assert.True(cookie.HttpOnly);
}
[Fact]
public void Creates_OneCookie_AltSeparator()
{
var action = new CookieActionFactory().Create(";NAME;VALUE:WithColon;DOMAIN;1440;path;secure;httponly");
Assert.Equal("NAME", action.Name);
Assert.Equal("VALUE:WithColon", action.Value);
Assert.Equal("DOMAIN", action.Domain);
Assert.Equal(TimeSpan.FromMinutes(1440), action.Lifetime);
Assert.Equal("path", action.Path);
Assert.True(action.Secure);
Assert.True(action.HttpOnly);
}
[Fact]
public void Creates_HttpOnly()
{
var action = new CookieActionFactory().Create(";NAME;VALUE;DOMAIN;;;;httponly");
Assert.Equal("NAME", action.Name);
Assert.Equal("VALUE", action.Value);
Assert.Equal("DOMAIN", action.Domain);
Assert.Equal(0, action.Lifetime.TotalSeconds);
Assert.Equal(string.Empty, action.Path);
Assert.False(action.Secure);
Assert.True(action.HttpOnly);
}
[Theory]
[InlineData("NAME::", "", null)]
[InlineData("NAME::domain", "", "domain")]
[InlineData("NAME:VALUE:;", "VALUE", null)] // special case with dangling ';'
[InlineData("NAME:value:", "value", null)]
[InlineData(" NAME : v : ", "v", null)] // trims values
public void TrimsValues(string flagValue, string value, string domain)
{
var factory = new CookieActionFactory();
var action = factory.Create(flagValue);
Assert.Equal("NAME", action.Name);
Assert.NotNull(action.Value);
Assert.Equal(value, action.Value);
Assert.Equal(domain, action.Domain);
}
[Theory]
[InlineData("NAME")] // missing value and domain
[InlineData("NAME: ")] // missing domain
[InlineData("NAME:VALUE")] // missing domain
[InlineData(";NAME;VAL:UE")] // missing domain
public void ThrowsForInvalidFormat(string flagValue)
{
var factory = new CookieActionFactory();
var ex = Assert.Throws<FormatException>(() => factory.Create(flagValue));
Assert.Equal(Resources.FormatError_InvalidChangeCookieFlag(flagValue), ex.Message);
}
[Theory]
[InlineData("bad_number")]
[InlineData("-1")]
[InlineData("0.9")]
public void ThrowsForInvalidIntFormat(string badInt)
{
var factory = new CookieActionFactory();
var ex = Assert.Throws<FormatException>(() => factory.Create("NAME:VALUE:DOMAIN:" + badInt));
Assert.Equal(Resources.FormatError_CouldNotParseInteger(badInt), ex.Message);
}
}
}

View File

@ -62,8 +62,20 @@ namespace Microsoft.AspNetCore.Rewrite.Tests.ModRewrite
[Fact]
public void FlagParser_AssertArgumentExceptionWhenFlagsAreNullOrEmpty()
{
Assert.Throws<ArgumentNullException>(() => new FlagParser().Parse(null));
Assert.Throws<ArgumentNullException>(() => new FlagParser().Parse(string.Empty));
Assert.Throws<ArgumentException>(() => new FlagParser().Parse(null));
Assert.Throws<ArgumentException>(() => new FlagParser().Parse(string.Empty));
}
[Theory]
[InlineData("[CO=VAR:VAL]", "VAR:VAL")]
[InlineData("[CO=!VAR]", "!VAR")]
[InlineData("[CO=;NAME:VALUE;ABC:123]", ";NAME:VALUE;ABC:123")]
public void Flag_ParserHandlesComplexFlags(string flagString, string expected)
{
var results = new FlagParser().Parse(flagString);
string value;
Assert.True(results.GetValue(FlagType.Cookie, out value));
Assert.Equal(expected, value);
}
public bool DictionaryContentsEqual<TKey, TValue>(IDictionary<TKey, TValue> dictionary, IDictionary<TKey, TValue> other)

View File

@ -0,0 +1,66 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Rewrite.Internal.UrlActions;
using Microsoft.Net.Http.Headers;
using Xunit;
namespace Microsoft.AspNetCore.Rewrite.Tests.UrlActions
{
public class ChangeCookieActionTests
{
[Fact]
public void SetsCookie()
{
var now = DateTimeOffset.UtcNow;
var context = new RewriteContext { HttpContext = new DefaultHttpContext() };
var action = new ChangeCookieAction("Cookie", () => now)
{
Value = "Chocolate Chip",
Domain = "contoso.com",
Lifetime = TimeSpan.FromMinutes(1440),
Path = "/recipes",
Secure = true,
HttpOnly = true
};
action.ApplyAction(context, null, null);
var cookieHeaders = context.HttpContext.Response.Headers[HeaderNames.SetCookie];
var header = Assert.Single(cookieHeaders);
Assert.Equal($"Cookie=Chocolate%20Chip; expires={HeaderUtilities.FormatDate(now.AddMinutes(1440))}; domain=contoso.com; path=/recipes; secure; httponly", header);
}
[Fact]
public void ZeroLifetime()
{
var context = new RewriteContext { HttpContext = new DefaultHttpContext() };
var action = new ChangeCookieAction("Cookie")
{
Value = "Chocolate Chip",
};
action.ApplyAction(context, null, null);
var cookieHeaders = context.HttpContext.Response.Headers[HeaderNames.SetCookie];
var header = Assert.Single(cookieHeaders);
Assert.Equal($"Cookie=Chocolate%20Chip", header);
}
[Fact]
public void UnsetCookie()
{
var context = new RewriteContext { HttpContext = new DefaultHttpContext() };
var action = new ChangeCookieAction("Cookie");
action.ApplyAction(context, null, null);
var cookieHeaders = context.HttpContext.Response.Headers[HeaderNames.SetCookie];
var header = Assert.Single(cookieHeaders);
Assert.Equal($"Cookie=", header);
}
}
}

View File

@ -13,7 +13,7 @@ namespace Microsoft.AspNetCore.Rewrite.Tests.UrlActions
public void Forbidden_Verify403IsInStatusCode()
{
var context = new RewriteContext {HttpContext = new DefaultHttpContext()};
var context = new RewriteContext { HttpContext = new DefaultHttpContext() };
var action = new ForbiddenAction();
action.ApplyAction(context, null, null);