diff --git a/src/Microsoft.Net.Http.Headers/HeaderUtilities.cs b/src/Microsoft.Net.Http.Headers/HeaderUtilities.cs
index abcf102076..20b4319252 100644
--- a/src/Microsoft.Net.Http.Headers/HeaderUtilities.cs
+++ b/src/Microsoft.Net.Http.Headers/HeaderUtilities.cs
@@ -594,13 +594,133 @@ namespace Microsoft.Net.Http.Headers
public static StringSegment RemoveQuotes(StringSegment input)
{
- if (!StringSegment.IsNullOrEmpty(input) && input.Length >= 2 && input[0] == '"' && input[input.Length - 1] == '"')
+ if (IsQuoted(input))
{
input = input.Subsegment(1, input.Length - 2);
}
return input;
}
+ public static bool IsQuoted(StringSegment input)
+ {
+ return !StringSegment.IsNullOrEmpty(input) && input.Length >= 2 && input[0] == '"' && input[input.Length - 1] == '"';
+ }
+
+ ///
+ /// Given a quoted-string as defined by the RFC specification,
+ /// removes quotes and unescapes backslashes and quotes. This assumes that the input is a valid quoted-string.
+ ///
+ /// The quoted-string to be unescaped.
+ /// An unescaped version of the quoted-string.
+ public static StringSegment UnescapeAsQuotedString(StringSegment input)
+ {
+ input = RemoveQuotes(input);
+
+ // First pass to calculate the size of the InplaceStringBuilder
+ var backSlashCount = CountBackslashesForDecodingQuotedString(input);
+
+ if (backSlashCount == 0)
+ {
+ return input;
+ }
+
+ var stringBuilder = new InplaceStringBuilder(input.Length - backSlashCount);
+
+ for (var i = 0; i < input.Length; i++)
+ {
+ if (i < input.Length - 1 && input[i] == '\\')
+ {
+ // If there is an backslash character as the last character in the string,
+ // we will assume that it should be included literally in the unescaped string
+ // Ex: "hello\\" => "hello\\"
+ // Also, if a sender adds a quoted pair like '\\''n',
+ // we will assume it is over escaping and just add a n to the string.
+ // Ex: "he\\llo" => "hello"
+ stringBuilder.Append(input[i + 1]);
+ i++;
+ continue;
+ }
+ stringBuilder.Append(input[i]);
+ }
+
+ return stringBuilder.ToString();
+ }
+
+ private static int CountBackslashesForDecodingQuotedString(StringSegment input)
+ {
+ var numberBackSlashes = 0;
+ for (var i = 0; i < input.Length; i++)
+ {
+ if (i < input.Length - 1 && input[i] == '\\')
+ {
+ // If there is an backslash character as the last character in the string,
+ // we will assume that it should be included literally in the unescaped string
+ // Ex: "hello\\" => "hello\\"
+ // Also, if a sender adds a quoted pair like '\\''n',
+ // we will assume it is over escaping and just add a n to the string.
+ // Ex: "he\\llo" => "hello"
+ if (input[i + 1] == '\\')
+ {
+ // Only count escaped backslashes once
+ i++;
+ }
+ numberBackSlashes++;
+ }
+ }
+ return numberBackSlashes;
+ }
+
+ ///
+ /// Escapes a as a quoted-string, which is defined by
+ /// the RFC specification.
+ ///
+ ///
+ /// This will add a backslash before each backslash and quote and add quotes
+ /// around the input. Assumes that the input does not have quotes around it,
+ /// as this method will add them. Throws if the input contains any invalid escape characters,
+ /// as defined by rfc7230.
+ ///
+ /// The input to be escaped.
+ /// An escaped version of the quoted-string.
+ public static StringSegment EscapeAsQuotedString(StringSegment input)
+ {
+ // By calling this, we know that the string requires quotes around it to be a valid token.
+ var backSlashCount = CountAndCheckCharactersNeedingBackslashesWhenEncoding(input);
+
+ var stringBuilder = new InplaceStringBuilder(input.Length + backSlashCount + 2); // 2 for quotes
+ stringBuilder.Append('\"');
+
+ for (var i = 0; i < input.Length; i++)
+ {
+ if (input[i] == '\\' || input[i] == '\"')
+ {
+ stringBuilder.Append('\\');
+ }
+ else if ((input[i] <= 0x1F || input[i] == 0x7F) && input[i] != 0x09)
+ {
+ // Control characters are not allowed in a quoted-string, which include all characters
+ // below 0x1F (except for 0x09 (TAB)) and 0x7F.
+ throw new FormatException($"Invalid control character '{input[i]}' in input.");
+ }
+ stringBuilder.Append(input[i]);
+ }
+ stringBuilder.Append('\"');
+ return stringBuilder.ToString();
+ }
+
+ private static int CountAndCheckCharactersNeedingBackslashesWhenEncoding(StringSegment input)
+ {
+ var numberOfCharactersNeedingEscaping = 0;
+ for (var i = 0; i < input.Length; i++)
+ {
+ if (input[i] == '\\' || input[i] == '\"')
+ {
+ numberOfCharactersNeedingEscaping++;
+ }
+ }
+ return numberOfCharactersNeedingEscaping;
+ }
+
internal static void ThrowIfReadOnly(bool isReadOnly)
{
if (isReadOnly)
diff --git a/src/Microsoft.Net.Http.Headers/NameValueHeaderValue.cs b/src/Microsoft.Net.Http.Headers/NameValueHeaderValue.cs
index 76b48e0093..ba197e986c 100644
--- a/src/Microsoft.Net.Http.Headers/NameValueHeaderValue.cs
+++ b/src/Microsoft.Net.Http.Headers/NameValueHeaderValue.cs
@@ -4,6 +4,7 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.Contracts;
+using System.Globalization;
using System.Text;
using Microsoft.Extensions.Primitives;
@@ -142,6 +143,28 @@ namespace Microsoft.Net.Http.Headers
}
}
+ public StringSegment GetUnescapedValue()
+ {
+ if (!HeaderUtilities.IsQuoted(_value))
+ {
+ return _value;
+ }
+ return HeaderUtilities.UnescapeAsQuotedString(_value);
+ }
+
+ public void SetAndEscapeValue(StringSegment value)
+ {
+ HeaderUtilities.ThrowIfReadOnly(IsReadOnly);
+ if (StringSegment.IsNullOrEmpty(value) || (GetValueLength(value, 0) == value.Length))
+ {
+ _value = value;
+ }
+ else
+ {
+ Value = HeaderUtilities.EscapeAsQuotedString(value);
+ }
+ }
+
public static NameValueHeaderValue Parse(StringSegment input)
{
var index = 0;
@@ -390,7 +413,7 @@ namespace Microsoft.Net.Http.Headers
// Either value is null/empty or a valid token/quoted string
if (!(StringSegment.IsNullOrEmpty(value) || (GetValueLength(value, 0) == value.Length)))
{
- throw new FormatException(string.Format(System.Globalization.CultureInfo.InvariantCulture, "The header value is invalid: '{0}'", value));
+ throw new FormatException(string.Format(CultureInfo.InvariantCulture, "The header value is invalid: '{0}'", value));
}
}
diff --git a/test/Microsoft.Net.Http.Headers.Tests/HeaderUtilitiesTest.cs b/test/Microsoft.Net.Http.Headers.Tests/HeaderUtilitiesTest.cs
index 6af446ce25..b72796d618 100644
--- a/test/Microsoft.Net.Http.Headers.Tests/HeaderUtilitiesTest.cs
+++ b/test/Microsoft.Net.Http.Headers.Tests/HeaderUtilitiesTest.cs
@@ -210,5 +210,76 @@ namespace Microsoft.Net.Http.Headers
Assert.True(HeaderUtilities.TryParseNonNegativeInt32(valueString, out value));
Assert.Equal(expected, value);
}
+
+ [Theory]
+ [InlineData("\"hello\"", "hello")]
+ [InlineData("\"hello", "\"hello")]
+ [InlineData("hello\"", "hello\"")]
+ [InlineData("\"\"hello\"\"", "\"hello\"")]
+ public void RemoveQuotes_BehaviorCheck(string input, string expected)
+ {
+ var actual = HeaderUtilities.RemoveQuotes(input);
+
+ Assert.Equal(expected, actual);
+ }
+ [Theory]
+ [InlineData("\"hello\"", true)]
+ [InlineData("\"hello", false)]
+ [InlineData("hello\"", false)]
+ [InlineData("\"\"hello\"\"", true)]
+ public void IsQuoted_BehaviorCheck(string input, bool expected)
+ {
+ var actual = HeaderUtilities.IsQuoted(input);
+
+ Assert.Equal(expected, actual);
+ }
+
+ [Theory]
+ [InlineData("value", "value")]
+ [InlineData("\"value\"", "value")]
+ [InlineData("\"hello\\\\\"", "hello\\")]
+ [InlineData("\"hello\\\"\"", "hello\"")]
+ [InlineData("\"hello\\\"foo\\\\bar\\\\baz\\\\\"", "hello\"foo\\bar\\baz\\")]
+ [InlineData("\"quoted value\"", "quoted value")]
+ [InlineData("\"quoted\\\"valuewithquote\"", "quoted\"valuewithquote")]
+ [InlineData("\"hello\\\"", "hello\\")]
+ public void UnescapeAsQuotedString_BehaviorCheck(string input, string expected)
+ {
+ var actual = HeaderUtilities.UnescapeAsQuotedString(input);
+
+ Assert.Equal(expected, actual);
+ }
+
+ [Theory]
+ [InlineData("value", "\"value\"")]
+ [InlineData("23", "\"23\"")]
+ [InlineData(";;;", "\";;;\"")]
+ [InlineData("\"value\"", "\"\\\"value\\\"\"")]
+ [InlineData("unquoted \"value", "\"unquoted \\\"value\"")]
+ [InlineData("value\\morevalues\\evenmorevalues", "\"value\\\\morevalues\\\\evenmorevalues\"")]
+ // We have to assume that the input needs to be quoted here
+ [InlineData("\"\"double quoted string\"\"", "\"\\\"\\\"double quoted string\\\"\\\"\"")]
+ [InlineData("\t", "\"\t\"")]
+ public void SetAndEscapeValue_BehaviorCheck(string input, string expected)
+ {
+ var actual = HeaderUtilities.EscapeAsQuotedString(input);
+
+ Assert.Equal(expected, actual);
+ }
+
+ [Theory]
+ [InlineData("\n")]
+ [InlineData("\b")]
+ [InlineData("\r")]
+ public void SetAndEscapeValue_ControlCharactersThrowFormatException(string input)
+ {
+ Assert.Throws(() => { var actual = HeaderUtilities.EscapeAsQuotedString(input); });
+ }
+
+ [Fact]
+ public void SetAndEscapeValue_ThrowsFormatExceptionOnDelCharacter()
+ {
+ Assert.Throws(() => { var actual = HeaderUtilities.EscapeAsQuotedString($"{(char)0x7F}"); });
+ }
}
}
diff --git a/test/Microsoft.Net.Http.Headers.Tests/NameValueHeaderValueTest.cs b/test/Microsoft.Net.Http.Headers.Tests/NameValueHeaderValueTest.cs
index 8310fa2864..cac18debbb 100644
--- a/test/Microsoft.Net.Http.Headers.Tests/NameValueHeaderValueTest.cs
+++ b/test/Microsoft.Net.Http.Headers.Tests/NameValueHeaderValueTest.cs
@@ -575,6 +575,87 @@ namespace Microsoft.Net.Http.Headers
Assert.False(NameValueHeaderValue.TryParseStrictList(inputs, out results));
}
+ [Theory]
+ [InlineData("value", "value")]
+ [InlineData("\"value\"", "value")]
+ [InlineData("\"hello\\\\\"", "hello\\")]
+ [InlineData("\"hello\\\"\"", "hello\"")]
+ [InlineData("\"hello\\\"foo\\\\bar\\\\baz\\\\\"", "hello\"foo\\bar\\baz\\")]
+ [InlineData("\"quoted value\"", "quoted value")]
+ [InlineData("\"quoted\\\"valuewithquote\"", "quoted\"valuewithquote")]
+ [InlineData("\"hello\\\"", "hello\\")]
+ public void GetUnescapedValue_ReturnsExpectedValue(string input, string expected)
+ {
+ var header = new NameValueHeaderValue("test", input);
+
+ var actual = header.GetUnescapedValue();
+
+ Assert.Equal(expected, actual);
+ }
+
+ [Theory]
+ [InlineData("value", "value")]
+ [InlineData("23", "23")]
+ [InlineData(";;;", "\";;;\"")]
+ [InlineData("\"value\"", "\"value\"")]
+ [InlineData("\"assumes already encoded \\\"\"", "\"assumes already encoded \\\"\"")]
+ [InlineData("unquoted \"value", "\"unquoted \\\"value\"")]
+ [InlineData("value\\morevalues\\evenmorevalues", "\"value\\\\morevalues\\\\evenmorevalues\"")]
+ // We have to assume that the input needs to be quoted here
+ [InlineData("\"\"double quoted string\"\"", "\"\\\"\\\"double quoted string\\\"\\\"\"")]
+ [InlineData("\t", "\"\t\"")]
+ public void SetAndEscapeValue_ReturnsExpectedValue(string input, string expected)
+ {
+ var header = new NameValueHeaderValue("test");
+ header.SetAndEscapeValue(input);
+
+ var actual = header.Value;
+
+ Assert.Equal(expected, actual);
+ }
+
+
+ [Theory]
+ [InlineData("\n")]
+ [InlineData("\b")]
+ [InlineData("\r")]
+ public void SetAndEscapeValue_ThrowsOnInvalidValues(string input)
+ {
+ var header = new NameValueHeaderValue("test");
+ Assert.Throws(() => header.SetAndEscapeValue(input));
+ }
+
+ [Theory]
+ [InlineData("value")]
+ [InlineData("\"value\\\\morevalues\\\\evenmorevalues\"")]
+ [InlineData("\"quoted \\\"value\"")]
+ public void GetAndSetEncodeValueRoundTrip_ReturnsExpectedValue(string input)
+ {
+ var header = new NameValueHeaderValue("test");
+ header.Value = input;
+ var valueHeader = header.GetUnescapedValue();
+ header.SetAndEscapeValue(valueHeader);
+
+ var actual = header.Value;
+
+ Assert.Equal(input, actual);
+ }
+
+ [Theory]
+ [InlineData("val\\nue")]
+ [InlineData("val\\bue")]
+ public void OverescapingValuesDoNotRoundTrip(string input)
+ {
+ var header = new NameValueHeaderValue("test");
+ header.SetAndEscapeValue(input);
+ var valueHeader = header.GetUnescapedValue();
+
+ var actual = header.Value;
+
+ Assert.NotEqual(input, actual);
+ }
+
+
#region Helper methods
private void CheckValidParse(string input, NameValueHeaderValue expectedResult)