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)