diff --git a/src/Http/Http/src/Features/FormFeature.cs b/src/Http/Http/src/Features/FormFeature.cs index 7be6a576bf..b50b31853f 100644 --- a/src/Http/Http/src/Features/FormFeature.cs +++ b/src/Http/Http/src/Features/FormFeature.cs @@ -150,15 +150,13 @@ namespace Microsoft.AspNetCore.Http.Features if (HasApplicationFormContentType(contentType)) { var encoding = FilterEncoding(contentType.Encoding); - using (var formReader = new FormReader(_request.Body, encoding) + var formReader = new FormPipeReader(_request.BodyPipe, encoding) { ValueCountLimit = _options.ValueCountLimit, KeyLengthLimit = _options.KeyLengthLimit, ValueLengthLimit = _options.ValueLengthLimit, - }) - { - formFields = new FormCollection(await formReader.ReadFormAsync(cancellationToken)); - } + }; + formFields = new FormCollection(await formReader.ReadFormAsync(cancellationToken)); } else if (HasMultipartFormContentType(contentType)) { diff --git a/src/Http/Http/test/Features/FormFeatureTests.cs b/src/Http/Http/test/Features/FormFeatureTests.cs index f8595564de..2574ffbb47 100644 --- a/src/Http/Http/test/Features/FormFeatureTests.cs +++ b/src/Http/Http/test/Features/FormFeatureTests.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.IO.Pipelines; using System.Text; using System.Threading.Tasks; using Xunit; @@ -82,6 +83,41 @@ namespace Microsoft.AspNetCore.Http.Features await responseFeature.CompleteAsync(); } + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task ReadFormAsync_SimpleData_ReplacePipeReader_ReturnsParsedFormCollection(bool bufferRequest) + { + var formContent = Encoding.UTF8.GetBytes("foo=bar&baz=2"); + var context = new DefaultHttpContext(); + var responseFeature = new FakeResponseFeature(); + context.Features.Set(responseFeature); + context.Request.ContentType = "application/x-www-form-urlencoded; charset=utf-8"; + + var pipe = new Pipe(); + await pipe.Writer.WriteAsync(formContent); + pipe.Writer.Complete(); + + context.Request.BodyPipe = pipe.Reader; + + IFormFeature formFeature = new FormFeature(context.Request, new FormOptions() { BufferBody = bufferRequest }); + context.Features.Set(formFeature); + + var formCollection = await context.Request.ReadFormAsync(); + + Assert.Equal("bar", formCollection["foo"]); + Assert.Equal("2", formCollection["baz"]); + + // Cached + formFeature = context.Features.Get(); + Assert.NotNull(formFeature); + Assert.NotNull(formFeature.Form); + Assert.Same(formFeature.Form, formCollection); + + // Cleanup + await responseFeature.CompleteAsync(); + } + private const string MultipartContentType = "multipart/form-data; boundary=WebKitFormBoundary5pDRpGheQXaM8k3T"; private const string MultipartContentTypeWithSpecialCharacters = "multipart/form-data; boundary=\"WebKitFormBoundary/:5pDRpGheQXaM8k3T\""; diff --git a/src/Http/HttpAbstractions.sln b/src/Http/HttpAbstractions.sln index 762c87a0cb..07fdd02c67 100644 --- a/src/Http/HttpAbstractions.sln +++ b/src/Http/HttpAbstractions.sln @@ -113,6 +113,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.HttpOv EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Server.IISIntegration", "..\Servers\IIS\IISIntegration\src\Microsoft.AspNetCore.Server.IISIntegration.csproj", "{1062FCDE-E145-40EC-B175-FDBCAA0C59A0}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.AspNetCore.WebUtilities.Performance", "WebUtilities\perf\Microsoft.AspNetCore.WebUtilities.Performance\Microsoft.AspNetCore.WebUtilities.Performance.csproj", "{21AC56E7-4E77-4B0E-B63E-C8E836E4D14E}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -603,6 +605,18 @@ Global {1062FCDE-E145-40EC-B175-FDBCAA0C59A0}.Release|x64.Build.0 = Release|Any CPU {1062FCDE-E145-40EC-B175-FDBCAA0C59A0}.Release|x86.ActiveCfg = Release|Any CPU {1062FCDE-E145-40EC-B175-FDBCAA0C59A0}.Release|x86.Build.0 = Release|Any CPU + {21AC56E7-4E77-4B0E-B63E-C8E836E4D14E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {21AC56E7-4E77-4B0E-B63E-C8E836E4D14E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {21AC56E7-4E77-4B0E-B63E-C8E836E4D14E}.Debug|x64.ActiveCfg = Debug|Any CPU + {21AC56E7-4E77-4B0E-B63E-C8E836E4D14E}.Debug|x64.Build.0 = Debug|Any CPU + {21AC56E7-4E77-4B0E-B63E-C8E836E4D14E}.Debug|x86.ActiveCfg = Debug|Any CPU + {21AC56E7-4E77-4B0E-B63E-C8E836E4D14E}.Debug|x86.Build.0 = Debug|Any CPU + {21AC56E7-4E77-4B0E-B63E-C8E836E4D14E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {21AC56E7-4E77-4B0E-B63E-C8E836E4D14E}.Release|Any CPU.Build.0 = Release|Any CPU + {21AC56E7-4E77-4B0E-B63E-C8E836E4D14E}.Release|x64.ActiveCfg = Release|Any CPU + {21AC56E7-4E77-4B0E-B63E-C8E836E4D14E}.Release|x64.Build.0 = Release|Any CPU + {21AC56E7-4E77-4B0E-B63E-C8E836E4D14E}.Release|x86.ActiveCfg = Release|Any CPU + {21AC56E7-4E77-4B0E-B63E-C8E836E4D14E}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -651,6 +665,7 @@ Global {DC519C5E-CA6E-48CA-BF35-B46305B83013} = {14A7B3DE-46C8-4245-B0BD-9AFF3795C163} {611794D2-EF3A-422A-A077-23E61C7ADE49} = {793FFE24-138A-4C3D-81AB-18D625E36230} {1062FCDE-E145-40EC-B175-FDBCAA0C59A0} = {793FFE24-138A-4C3D-81AB-18D625E36230} + {21AC56E7-4E77-4B0E-B63E-C8E836E4D14E} = {80A090C8-ED02-4DE3-875A-30DCCDBD84BA} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {85B5E151-2E9D-419C-83DD-0DDCF446C83A} diff --git a/src/Http/WebUtilities/perf/Microsoft.AspNetCore.WebUtilities.Performance/AssemblyInfo.cs b/src/Http/WebUtilities/perf/Microsoft.AspNetCore.WebUtilities.Performance/AssemblyInfo.cs new file mode 100644 index 0000000000..32248e0d1b --- /dev/null +++ b/src/Http/WebUtilities/perf/Microsoft.AspNetCore.WebUtilities.Performance/AssemblyInfo.cs @@ -0,0 +1 @@ +[assembly: BenchmarkDotNet.Attributes.AspNetCoreBenchmark] diff --git a/src/Http/WebUtilities/perf/Microsoft.AspNetCore.WebUtilities.Performance/FormPipeReaderInternalsBenchmark.cs b/src/Http/WebUtilities/perf/Microsoft.AspNetCore.WebUtilities.Performance/FormPipeReaderInternalsBenchmark.cs new file mode 100644 index 0000000000..64019bf0d9 --- /dev/null +++ b/src/Http/WebUtilities/perf/Microsoft.AspNetCore.WebUtilities.Performance/FormPipeReaderInternalsBenchmark.cs @@ -0,0 +1,44 @@ +// 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.Buffers; +using System.Text; +using BenchmarkDotNet.Attributes; + +namespace Microsoft.AspNetCore.WebUtilities.Performance +{ + /// + /// Test internal parsing speed of FormPipeReader without pipe + /// + public class FormPipeReaderInternalsBenchmark + { + private byte[] _singleUtf8 = Encoding.UTF8.GetBytes("foo=bar&baz=boo&haha=hehe&lol=temp"); + private byte[] _firstUtf8 = Encoding.UTF8.GetBytes("foo=bar&baz=boo"); + private byte[] _secondUtf8 = Encoding.UTF8.GetBytes("&haha=hehe&lol=temp"); + private FormPipeReader _formPipeReader; + + [IterationSetup] + public void Setup() + { + _formPipeReader = new FormPipeReader(null); + } + + [Benchmark] + public void ReadUtf8Data() + { + var buffer = new ReadOnlySequence(_singleUtf8); + KeyValueAccumulator accum = default; + + _formPipeReader.ParseFormValues(ref buffer, ref accum, isFinalBlock: true); + } + + [Benchmark] + public void ReadUtf8MultipleBlockData() + { + var buffer = ReadOnlySequenceFactory.CreateSegments(_firstUtf8, _secondUtf8); + KeyValueAccumulator accum = default; + + _formPipeReader.ParseFormValues(ref buffer, ref accum, isFinalBlock: true); + } + } +} diff --git a/src/Http/WebUtilities/perf/Microsoft.AspNetCore.WebUtilities.Performance/FormReaderBenchmark.cs b/src/Http/WebUtilities/perf/Microsoft.AspNetCore.WebUtilities.Performance/FormReaderBenchmark.cs new file mode 100644 index 0000000000..ad40faec19 --- /dev/null +++ b/src/Http/WebUtilities/perf/Microsoft.AspNetCore.WebUtilities.Performance/FormReaderBenchmark.cs @@ -0,0 +1,49 @@ +// 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.Buffers; +using System.Collections.Generic; +using System.IO; +using System.IO.Pipelines; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using BenchmarkDotNet.Attributes; +using Microsoft.Extensions.Primitives; + +namespace Microsoft.AspNetCore.WebUtilities +{ + public class FormReaderBenchmark + { + [Benchmark] + public async Task ReadSmallFormAsyncStream() + { + var bytes = Encoding.UTF8.GetBytes("foo=bar&baz=boo"); + var stream = new MemoryStream(bytes); + + for (var i = 0; i < 1000; i++) + { + var formReader = new FormReader(stream); + await formReader.ReadFormAsync(); + stream.Position = 0; + } + } + + [Benchmark] + public async Task ReadSmallFormAsyncPipe() + { + var pipe = new Pipe(); + var bytes = Encoding.UTF8.GetBytes("foo=bar&baz=boo"); + + for (var i = 0; i < 1000; i++) + { + pipe.Writer.Write(bytes); + pipe.Writer.Complete(); + var formReader = new FormPipeReader(pipe.Reader); + await formReader.ReadFormAsync(); + pipe.Reader.Complete(); + pipe.Reset(); + } + } + } +} diff --git a/src/Http/WebUtilities/perf/Microsoft.AspNetCore.WebUtilities.Performance/Microsoft.AspNetCore.WebUtilities.Performance.csproj b/src/Http/WebUtilities/perf/Microsoft.AspNetCore.WebUtilities.Performance/Microsoft.AspNetCore.WebUtilities.Performance.csproj new file mode 100644 index 0000000000..3829484d8d --- /dev/null +++ b/src/Http/WebUtilities/perf/Microsoft.AspNetCore.WebUtilities.Performance/Microsoft.AspNetCore.WebUtilities.Performance.csproj @@ -0,0 +1,22 @@ + + + + netcoreapp3.0 + Exe + true + true + false + Microsoft.AspNetCore.WebUtilities + + + + + + + + + + + + + diff --git a/src/Http/WebUtilities/ref/Microsoft.AspNetCore.WebUtilities.csproj b/src/Http/WebUtilities/ref/Microsoft.AspNetCore.WebUtilities.csproj index a407303b74..8398660b76 100644 --- a/src/Http/WebUtilities/ref/Microsoft.AspNetCore.WebUtilities.csproj +++ b/src/Http/WebUtilities/ref/Microsoft.AspNetCore.WebUtilities.csproj @@ -7,5 +7,6 @@ + diff --git a/src/Http/WebUtilities/ref/Microsoft.AspNetCore.WebUtilities.netcoreapp3.0.cs b/src/Http/WebUtilities/ref/Microsoft.AspNetCore.WebUtilities.netcoreapp3.0.cs index 4c8cfff2e6..921c505623 100644 --- a/src/Http/WebUtilities/ref/Microsoft.AspNetCore.WebUtilities.netcoreapp3.0.cs +++ b/src/Http/WebUtilities/ref/Microsoft.AspNetCore.WebUtilities.netcoreapp3.0.cs @@ -79,6 +79,16 @@ namespace Microsoft.AspNetCore.WebUtilities public Microsoft.AspNetCore.WebUtilities.MultipartSection Section { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } } public System.Threading.Tasks.Task GetValueAsync() { throw null; } } + public partial class FormPipeReader + { + public FormPipeReader(System.IO.Pipelines.PipeReader pipeReader) { } + public FormPipeReader(System.IO.Pipelines.PipeReader pipeReader, System.Text.Encoding encoding) { } + public int KeyLengthLimit { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute]set { } } + public int ValueCountLimit { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute]set { } } + public int ValueLengthLimit { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute]set { } } + [System.Diagnostics.DebuggerStepThroughAttribute] + public System.Threading.Tasks.Task> ReadFormAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } + } public partial class FormReader : System.IDisposable { public const int DefaultKeyLengthLimit = 2048; diff --git a/src/Http/WebUtilities/src/FormPipeReader.cs b/src/Http/WebUtilities/src/FormPipeReader.cs new file mode 100644 index 0000000000..3d7c99f083 --- /dev/null +++ b/src/Http/WebUtilities/src/FormPipeReader.cs @@ -0,0 +1,381 @@ +// 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.Buffers; +using System.Collections.Generic; +using System.IO; +using System.IO.Pipelines; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Internal; +using Microsoft.Extensions.Primitives; + +namespace Microsoft.AspNetCore.WebUtilities +{ + /// + /// Used to read an 'application/x-www-form-urlencoded' form. + /// Internally reads from a PipeReader. + /// + public class FormPipeReader + { + private const int StackAllocThreshold = 128; + private const int DefaultValueCountLimit = 1024; + private const int DefaultKeyLengthLimit = 1024 * 2; + private const int DefaultValueLengthLimit = 1024 * 1024 * 4; + + // Used for UTF8/ASCII (precalculated for fast path) + private static ReadOnlySpan UTF8EqualEncoded => new byte[] { (byte)'=' }; + private static ReadOnlySpan UTF8AndEncoded => new byte[] { (byte)'&' }; + + // Used for other encodings + private byte[] _otherEqualEncoding; + private byte[] _otherAndEncoding; + + private readonly PipeReader _pipeReader; + private readonly Encoding _encoding; + + public FormPipeReader(PipeReader pipeReader) + : this(pipeReader, Encoding.UTF8) + { + } + + public FormPipeReader(PipeReader pipeReader, Encoding encoding) + { + if (encoding == Encoding.UTF7) + { + throw new ArgumentException("UTF7 is unsupported and insecure. Please select a different encoding."); + } + + _pipeReader = pipeReader; + _encoding = encoding; + + if (_encoding != Encoding.UTF8 && _encoding != Encoding.ASCII) + { + _otherEqualEncoding = _encoding.GetBytes("="); + _otherAndEncoding = _encoding.GetBytes("&"); + } + } + + /// + /// The limit on the number of form values to allow in ReadForm or ReadFormAsync. + /// + public int ValueCountLimit { get; set; } = DefaultValueCountLimit; + + /// + /// The limit on the length of form keys. + /// + public int KeyLengthLimit { get; set; } = DefaultKeyLengthLimit; + + /// + /// The limit on the length of form values. + /// + public int ValueLengthLimit { get; set; } = DefaultValueLengthLimit; + + /// + /// Parses an HTTP form body. + /// + /// The . + /// The collection containing the parsed HTTP form body. + public async Task> ReadFormAsync(CancellationToken cancellationToken = default) + { + KeyValueAccumulator accumulator = default; + while (true) + { + var readResult = await _pipeReader.ReadAsync(cancellationToken); + + var buffer = readResult.Buffer; + + if (!buffer.IsEmpty) + { + ParseFormValues(ref buffer, ref accumulator, readResult.IsCompleted); + } + + if (readResult.IsCompleted) + { + _pipeReader.AdvanceTo(buffer.End); + + if (!buffer.IsEmpty) + { + throw new InvalidOperationException("End of body before form was fully parsed."); + } + break; + } + + _pipeReader.AdvanceTo(buffer.Start, buffer.End); + } + + return accumulator.GetResults(); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + internal void ParseFormValues( + ref ReadOnlySequence buffer, + ref KeyValueAccumulator accumulator, + bool isFinalBlock) + { + if (buffer.IsSingleSegment) + { + ParseFormValuesFast(buffer.First.Span, + ref accumulator, + isFinalBlock, + out var consumed); + + buffer = buffer.Slice(consumed); + return; + } + + ParseValuesSlow(ref buffer, + ref accumulator, + isFinalBlock); + } + + // Fast parsing for single span in ReadOnlySequence + private void ParseFormValuesFast(ReadOnlySpan span, + ref KeyValueAccumulator accumulator, + bool isFinalBlock, + out int consumed) + { + ReadOnlySpan key = default; + ReadOnlySpan value = default; + consumed = 0; + var equalsDelimiter = GetEqualsForEncoding(); + var andDelimiter = GetAndForEncoding(); + + while (span.Length > 0) + { + var equals = span.IndexOf(equalsDelimiter); + + if (equals == -1) + { + if (span.Length > KeyLengthLimit) + { + ThrowKeyTooLargeException(); + } + break; + } + + if (equals > KeyLengthLimit) + { + ThrowKeyTooLargeException(); + } + + key = span.Slice(0, equals); + + span = span.Slice(key.Length + equalsDelimiter.Length); + value = span; + + var ampersand = span.IndexOf(andDelimiter); + + if (ampersand == -1) + { + if (span.Length > ValueLengthLimit) + { + ThrowValueTooLargeException(); + return; + } + + if (!isFinalBlock) + { + // We can't know that what is currently read is the end of the form value, that's only the case if this is the final block + // If we're not in the final block, then consume nothing + break; + } + + // If we are on the final block, the remaining content in value is what we want to add to the KVAccumulator. + // Clear out the remaining span such that the loop will exit. + span = Span.Empty; + } + else + { + if (ampersand > ValueLengthLimit) + { + ThrowValueTooLargeException(); + } + + value = span.Slice(0, ampersand); + span = span.Slice(ampersand + andDelimiter.Length); + } + + var decodedKey = GetDecodedString(key); + var decodedValue = GetDecodedString(value); + + AppendAndVerify(ref accumulator, decodedKey, decodedValue); + + // Cover case where we don't have an ampersand at the end. + consumed += key.Length + value.Length + (ampersand == -1 ? equalsDelimiter.Length : equalsDelimiter.Length + andDelimiter.Length); + } + } + + // For multi-segment parsing of a read only sequence + private void ParseValuesSlow( + ref ReadOnlySequence buffer, + ref KeyValueAccumulator accumulator, + bool isFinalBlock) + { + var sequenceReader = new SequenceReader(buffer); + var consumed = sequenceReader.Position; + var equalsDelimiter = GetEqualsForEncoding(); + var andDelimiter = GetAndForEncoding(); + + while (!sequenceReader.End) + { + // TODO seems there is a bug with TryReadTo (advancePastDelimiter: true). It isn't advancing past the delimiter on second read. + if (!sequenceReader.TryReadTo(out ReadOnlySequence key, equalsDelimiter, advancePastDelimiter: false) || + !sequenceReader.IsNext(equalsDelimiter, true)) + { + if (sequenceReader.Consumed > KeyLengthLimit) + { + ThrowKeyTooLargeException(); + } + + break; + } + + if (key.Length > KeyLengthLimit) + { + ThrowKeyTooLargeException(); + } + + if (!sequenceReader.TryReadTo(out ReadOnlySequence value, andDelimiter, false) || + !sequenceReader.IsNext(andDelimiter, true)) + { + if (!isFinalBlock) + { + if (sequenceReader.Consumed - key.Length > ValueLengthLimit) + { + ThrowValueTooLargeException(); + } + break; + } + + value = buffer.Slice(sequenceReader.Position); + + sequenceReader.Advance(value.Length); + } + + if (value.Length > ValueLengthLimit) + { + ThrowValueTooLargeException(); + } + + // Need to call ToArray if the key/value spans multiple segments + var decodedKey = GetDecodedStringFromReadOnlySequence(key); + var decodedValue = GetDecodedStringFromReadOnlySequence(value); + + AppendAndVerify(ref accumulator, decodedKey, decodedValue); + + consumed = sequenceReader.Position; + } + + buffer = buffer.Slice(consumed); + } + + private void ThrowKeyTooLargeException() + { + throw new InvalidDataException($"Form key length limit {KeyLengthLimit} exceeded."); + } + + private void ThrowValueTooLargeException() + { + throw new InvalidDataException($"Form value length limit {ValueLengthLimit} exceeded."); + } + + private string GetDecodedStringFromReadOnlySequence(ReadOnlySequence ros) + { + if (ros.IsSingleSegment) + { + return GetDecodedString(ros.First.Span); + } + + if (ros.Length < StackAllocThreshold) + { + Span buffer = stackalloc byte[(int)ros.Length]; + ros.CopyTo(buffer); + return GetDecodedString(buffer); + } + else + { + var byteArray = ArrayPool.Shared.Rent((int)ros.Length); + + try + { + Span buffer = byteArray.AsSpan(0, (int)ros.Length); + ros.CopyTo(buffer); + return GetDecodedString(buffer); + } + finally + { + ArrayPool.Shared.Return(byteArray); + } + } + } + + // Check that key/value constraints are met and appends value to accumulator. + private void AppendAndVerify(ref KeyValueAccumulator accumulator, string decodedKey, string decodedValue) + { + accumulator.Append(decodedKey, decodedValue); + + if (accumulator.ValueCount > ValueCountLimit) + { + throw new InvalidDataException($"Form value count limit {ValueCountLimit} exceeded."); + } + } + + private string GetDecodedString(ReadOnlySpan readOnlySpan) + { + if (readOnlySpan.Length == 0) + { + return string.Empty; + } + else if (_encoding == Encoding.UTF8 || _encoding == Encoding.ASCII) + { + // UrlDecoder only works on UTF8 (and implicitly ASCII) + + // We need to create a Span from a ReadOnlySpan. This cast is safe because the memory is still held by the pipe + // We will also create a string from it by the end of the function. + var span = MemoryMarshal.CreateSpan(ref Unsafe.AsRef(readOnlySpan[0]), readOnlySpan.Length); + + var bytes = UrlDecoder.DecodeInPlace(span, isFormEncoding: true); + span = span.Slice(0, bytes); + + return _encoding.GetString(span); + } + else + { + // Slow path for Unicode and other encodings. + // Just do raw string replacement. + var decodedString = _encoding.GetString(readOnlySpan); + decodedString = decodedString.Replace('+', ' '); + return Uri.UnescapeDataString(decodedString); + } + } + + private ReadOnlySpan GetEqualsForEncoding() + { + if (_encoding == Encoding.UTF8 || _encoding == Encoding.ASCII) + { + return UTF8EqualEncoded; + } + else + { + return _otherEqualEncoding; + } + } + + private ReadOnlySpan GetAndForEncoding() + { + if (_encoding == Encoding.UTF8 || _encoding == Encoding.ASCII) + { + return UTF8AndEncoded; + } + else + { + return _otherAndEncoding; + } + } + } +} diff --git a/src/Http/WebUtilities/src/FormReader.cs b/src/Http/WebUtilities/src/FormReader.cs index 65bfc37be4..474899ea34 100644 --- a/src/Http/WebUtilities/src/FormReader.cs +++ b/src/Http/WebUtilities/src/FormReader.cs @@ -309,4 +309,4 @@ namespace Microsoft.AspNetCore.WebUtilities } } } -} \ No newline at end of file +} diff --git a/src/Http/WebUtilities/src/Microsoft.AspNetCore.WebUtilities.csproj b/src/Http/WebUtilities/src/Microsoft.AspNetCore.WebUtilities.csproj index 5e123dfff8..9bd01e7b96 100644 --- a/src/Http/WebUtilities/src/Microsoft.AspNetCore.WebUtilities.csproj +++ b/src/Http/WebUtilities/src/Microsoft.AspNetCore.WebUtilities.csproj @@ -1,4 +1,4 @@ - + ASP.NET Core utilities, such as for working with forms, multipart messages, and query strings. @@ -12,11 +12,13 @@ + + diff --git a/src/Http/WebUtilities/src/Properties/AssemblyInfo.cs b/src/Http/WebUtilities/src/Properties/AssemblyInfo.cs index aa80ef1d7e..6888ff886d 100644 --- a/src/Http/WebUtilities/src/Properties/AssemblyInfo.cs +++ b/src/Http/WebUtilities/src/Properties/AssemblyInfo.cs @@ -4,3 +4,4 @@ using System.Runtime.CompilerServices; [assembly: InternalsVisibleTo("Microsoft.AspNetCore.WebUtilities.Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] +[assembly: InternalsVisibleTo("Microsoft.AspNetCore.WebUtilities.Performance, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] diff --git a/src/Http/WebUtilities/test/FormPipeReaderTests.cs b/src/Http/WebUtilities/test/FormPipeReaderTests.cs new file mode 100644 index 0000000000..a95a23db63 --- /dev/null +++ b/src/Http/WebUtilities/test/FormPipeReaderTests.cs @@ -0,0 +1,406 @@ +// 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.Buffers; +using System.Collections.Generic; +using System.IO; +using System.IO.Pipelines; +using System.Text; +using System.Threading.Tasks; +using Microsoft.Extensions.Primitives; +using Xunit; + +namespace Microsoft.AspNetCore.WebUtilities.Test +{ + public class FormPipeReaderTests + { + [Fact] + public async Task ReadFormAsync_EmptyKeyAtEndAllowed() + { + var bodyPipe = await MakePipeReader("=bar"); + + var formCollection = await ReadFormAsync(new FormPipeReader(bodyPipe)); + + Assert.Equal("bar", formCollection[""].ToString()); + } + + [Fact] + public async Task ReadFormAsync_EmptyKeyWithAdditionalEntryAllowed() + { + var bodyPipe = await MakePipeReader("=bar&baz=2"); + + var formCollection = await ReadFormAsync(new FormPipeReader(bodyPipe)); + + Assert.Equal("bar", formCollection[""].ToString()); + Assert.Equal("2", formCollection["baz"].ToString()); + } + + [Fact] + public async Task ReadFormAsync_EmptyValuedAtEndAllowed() + { + var bodyPipe = await MakePipeReader("foo="); + + var formCollection = await ReadFormAsync(new FormPipeReader(bodyPipe)); + + Assert.Equal("", formCollection["foo"].ToString()); + } + + [Fact] + public async Task ReadFormAsync_EmptyValuedWithAdditionalEntryAllowed() + { + var bodyPipe = await MakePipeReader("foo=&baz=2"); + + var formCollection = await ReadFormAsync(new FormPipeReader(bodyPipe)); + + Assert.Equal("", formCollection["foo"].ToString()); + Assert.Equal("2", formCollection["baz"].ToString()); + } + + [Fact] + public async Task ReadFormAsync_ValueCountLimitMet_Success() + { + var bodyPipe = await MakePipeReader("foo=1&bar=2&baz=3"); + + var formCollection = await ReadFormAsync(new FormPipeReader(bodyPipe) { ValueCountLimit = 3 }); + + Assert.Equal("1", formCollection["foo"].ToString()); + Assert.Equal("2", formCollection["bar"].ToString()); + Assert.Equal("3", formCollection["baz"].ToString()); + Assert.Equal(3, formCollection.Count); + } + + [Fact] + public async Task ReadFormAsync_ValueCountLimitExceeded_Throw() + { + var bodyPipe = await MakePipeReader("foo=1&baz=2&bar=3&baz=4&baf=5"); + + var exception = await Assert.ThrowsAsync( + () => ReadFormAsync(new FormPipeReader(bodyPipe) { ValueCountLimit = 3 })); + Assert.Equal("Form value count limit 3 exceeded.", exception.Message); + } + + [Fact] + public async Task ReadFormAsync_ValueCountLimitExceededSameKey_Throw() + { + var bodyPipe = await MakePipeReader("baz=1&baz=2&baz=3&baz=4"); + + var exception = await Assert.ThrowsAsync( + () => ReadFormAsync(new FormPipeReader(bodyPipe) { ValueCountLimit = 3 })); + Assert.Equal("Form value count limit 3 exceeded.", exception.Message); + } + + [Fact] + public async Task ReadFormAsync_KeyLengthLimitMet_Success() + { + var bodyPipe = await MakePipeReader("fooooooooo=1&bar=2&baz=3&baz=4"); + + var formCollection = await ReadFormAsync(new FormPipeReader(bodyPipe) { KeyLengthLimit = 10 }); + + Assert.Equal("1", formCollection["fooooooooo"].ToString()); + Assert.Equal("2", formCollection["bar"].ToString()); + Assert.Equal("3,4", formCollection["baz"].ToString()); + Assert.Equal(3, formCollection.Count); + } + + [Fact] + public async Task ReadFormAsync_KeyLengthLimitExceeded_Throw() + { + var bodyPipe = await MakePipeReader("foo=1&baz12345678=2"); + + var exception = await Assert.ThrowsAsync( + () => ReadFormAsync(new FormPipeReader(bodyPipe) { KeyLengthLimit = 10 })); + Assert.Equal("Form key length limit 10 exceeded.", exception.Message); + } + + [Fact] + public async Task ReadFormAsync_ValueLengthLimitMet_Success() + { + var bodyPipe = await MakePipeReader("foo=1&bar=1234567890&baz=3&baz=4"); + + var formCollection = await ReadFormAsync(new FormPipeReader(bodyPipe) { ValueLengthLimit = 10 }); + + Assert.Equal("1", formCollection["foo"].ToString()); + Assert.Equal("1234567890", formCollection["bar"].ToString()); + Assert.Equal("3,4", formCollection["baz"].ToString()); + Assert.Equal(3, formCollection.Count); + } + + [Fact] + public async Task ReadFormAsync_ValueLengthLimitExceeded_Throw() + { + var bodyPipe = await MakePipeReader("foo=1&baz=12345678901"); + + var exception = await Assert.ThrowsAsync( + () => ReadFormAsync(new FormPipeReader(bodyPipe) { ValueLengthLimit = 10 })); + Assert.Equal("Form value length limit 10 exceeded.", exception.Message); + } + + // https://en.wikipedia.org/wiki/Percent-encoding + [Theory] + [InlineData("++=hello", " ", "hello")] + [InlineData("a=1+1", "a", "1 1")] + [InlineData("%22%25%2D%2E%3C%3E%5C%5E%5F%60%7B%7C%7D%7E=%22%25%2D%2E%3C%3E%5C%5E%5F%60%7B%7C%7D%7E", "\"%-.<>\\^_`{|}~", "\"%-.<>\\^_`{|}~")] + [InlineData("a=%41", "a", "A")] // ascii encoded hex + [InlineData("a=%C3%A1", "a", "\u00e1")] // utf8 code points + [InlineData("a=%u20AC", "a", "%u20AC")] // utf16 not supported + public async Task ReadForm_Decoding(string formData, string key, string expectedValue) + { + var bodyPipe = await MakePipeReader(text: formData); + + var form = await ReadFormAsync(new FormPipeReader(bodyPipe)); + + Assert.Equal(expectedValue, form[key]); + } + + public static TheoryData Encodings => + new TheoryData + { + { Encoding.UTF8 }, + { Encoding.UTF32 }, + { Encoding.ASCII }, + { Encoding.Unicode } + }; + + [Theory] + [MemberData(nameof(Encodings))] + public void TryParseFormValues_SingleSegmentWorks(Encoding encoding) + { + var readOnlySequence = new ReadOnlySequence(encoding.GetBytes("foo=bar&baz=boo")); + + KeyValueAccumulator accumulator = default; + var formReader = new FormPipeReader(null, encoding); + + formReader.ParseFormValues(ref readOnlySequence, ref accumulator, isFinalBlock: true); + + Assert.Equal(2, accumulator.KeyCount); + var dict = accumulator.GetResults(); + Assert.Equal("bar", dict["foo"]); + Assert.Equal("boo", dict["baz"]); + } + + [Theory] + [MemberData(nameof(Encodings))] + public void TryParseFormValues_MultiSegmentWorks(Encoding encoding) + { + var readOnlySequence = ReadOnlySequenceFactory.SingleSegmentFactory.CreateWithContent(encoding.GetBytes("foo=bar&baz=boo&t=")); + + KeyValueAccumulator accumulator = default; + + var formReader = new FormPipeReader(null, encoding); + formReader.ParseFormValues(ref readOnlySequence, ref accumulator, isFinalBlock: true); + + Assert.Equal(3, accumulator.KeyCount); + var dict = accumulator.GetResults(); + Assert.Equal("bar", dict["foo"]); + Assert.Equal("boo", dict["baz"]); + Assert.Equal("", dict["t"]); + } + + [Theory] + [MemberData(nameof(Encodings))] + public void TryParseFormValues_MultiSegmentSplitAcrossSegmentsWorks(Encoding encoding) + { + var readOnlySequence = ReadOnlySequenceFactory.SingleSegmentFactory.CreateWithContent(encoding.GetBytes("foo=bar&baz=boo&t=")); + + KeyValueAccumulator accumulator = default; + + var formReader = new FormPipeReader(null, encoding); + formReader.ParseFormValues(ref readOnlySequence, ref accumulator, isFinalBlock: true); + + Assert.Equal(3, accumulator.KeyCount); + var dict = accumulator.GetResults(); + Assert.Equal("bar", dict["foo"]); + Assert.Equal("boo", dict["baz"]); + Assert.Equal("", dict["t"]); + } + + [Theory] + [MemberData(nameof(Encodings))] + public void TryParseFormValues_MultiSegmentWithArrayPoolAcrossSegmentsWorks(Encoding encoding) + { + var readOnlySequence = ReadOnlySequenceFactory.SingleSegmentFactory.CreateWithContent(encoding.GetBytes("foo=bar&baz=bo" + new string('a', 128))); + + KeyValueAccumulator accumulator = default; + + var formReader = new FormPipeReader(null, encoding); + formReader.ParseFormValues(ref readOnlySequence, ref accumulator, isFinalBlock: true); + + Assert.Equal(2, accumulator.KeyCount); + var dict = accumulator.GetResults(); + Assert.Equal("bar", dict["foo"]); + Assert.Equal("bo" + new string('a', 128), dict["baz"]); + } + + [Theory] + [MemberData(nameof(Encodings))] + public void TryParseFormValues_MultiSegmentSplitAcrossSegmentsWithPlusesWorks(Encoding encoding) + { + var readOnlySequence = ReadOnlySequenceFactory.SingleSegmentFactory.CreateWithContent(encoding.GetBytes("+++=+++&++++=++++&+=")); + + KeyValueAccumulator accumulator = default; + + var formReader = new FormPipeReader(null, encoding); + formReader.ParseFormValues(ref readOnlySequence, ref accumulator, isFinalBlock: true); + + Assert.Equal(3, accumulator.KeyCount); + var dict = accumulator.GetResults(); + Assert.Equal(" ", dict[" "]); + Assert.Equal(" ", dict[" "]); + Assert.Equal("", dict[" "]); + } + + [Theory] + [MemberData(nameof(Encodings))] + public void TryParseFormValues_DecodedPlusesWorks(Encoding encoding) + { + var readOnlySequence = ReadOnlySequenceFactory.SingleSegmentFactory.CreateWithContent(encoding.GetBytes("++%2B=+++%2B&++++=++++&+=")); + + KeyValueAccumulator accumulator = default; + + var formReader = new FormPipeReader(null, encoding); + formReader.ParseFormValues(ref readOnlySequence, ref accumulator, isFinalBlock: true); + + Assert.Equal(3, accumulator.KeyCount); + var dict = accumulator.GetResults(); + Assert.Equal(" ", dict[" "]); + Assert.Equal(" +", dict[" +"]); + Assert.Equal("", dict[" "]); + } + + [Theory] + [MemberData(nameof(Encodings))] + public void TryParseFormValues_MultiSegmentSplitAcrossSegmentsThatNeedDecodingWorks(Encoding encoding) + { + var readOnlySequence = ReadOnlySequenceFactory.SingleSegmentFactory.CreateWithContent(encoding.GetBytes("\"%-.<>\\^_`{|}~=\"%-.<>\\^_`{|}~&\"%-.<>\\^_`{|}=wow")); + + KeyValueAccumulator accumulator = default; + + var formReader = new FormPipeReader(null, encoding); + formReader.ParseFormValues(ref readOnlySequence, ref accumulator, isFinalBlock: true); + + Assert.Equal(2, accumulator.KeyCount); + var dict = accumulator.GetResults(); + Assert.Equal("\"%-.<>\\^_`{|}~", dict["\"%-.<>\\^_`{|}~"]); + Assert.Equal("wow", dict["\"%-.<>\\^_`{|}"]); + } + + [Fact] + public void TryParseFormValues_MultiSegmentExceedKeyLengthThrows() + { + var readOnlySequence = ReadOnlySequenceFactory.SingleSegmentFactory.CreateWithContent(Encoding.UTF8.GetBytes("foo=bar&baz=boo&t=")); + + KeyValueAccumulator accumulator = default; + + var formReader = new FormPipeReader(null); + formReader.KeyLengthLimit = 2; + + var exception = Assert.Throws(() => formReader.ParseFormValues(ref readOnlySequence, ref accumulator, isFinalBlock: true)); + Assert.Equal("Form key length limit 2 exceeded.", exception.Message); + } + + [Fact] + public void TryParseFormValues_MultiSegmentExceedKeyLengthThrowsInSplitSegment() + { + var readOnlySequence = ReadOnlySequenceFactory.CreateSegments(Encoding.UTF8.GetBytes("fo=bar&ba"), Encoding.UTF8.GetBytes("z=boo&t=")); + + KeyValueAccumulator accumulator = default; + + var formReader = new FormPipeReader(null); + formReader.KeyLengthLimit = 2; + + var exception = Assert.Throws(() => formReader.ParseFormValues(ref readOnlySequence, ref accumulator, isFinalBlock: true)); + Assert.Equal("Form key length limit 2 exceeded.", exception.Message); + } + + [Fact] + public void TryParseFormValues_MultiSegmentExceedValueLengthThrows() + { + var readOnlySequence = ReadOnlySequenceFactory.CreateSegments(Encoding.UTF8.GetBytes("foo=bar&baz=bo"), Encoding.UTF8.GetBytes("o&t=")); + + KeyValueAccumulator accumulator = default; + + var formReader = new FormPipeReader(null); + formReader.ValueLengthLimit = 2; + + var exception = Assert.Throws(() => formReader.ParseFormValues(ref readOnlySequence, ref accumulator, isFinalBlock: true)); + Assert.Equal("Form value length limit 2 exceeded.", exception.Message); + } + + [Fact] + public void TryParseFormValues_MultiSegmentExceedValueLengthThrowsInSplitSegment() + { + var readOnlySequence = ReadOnlySequenceFactory.CreateSegments(Encoding.UTF8.GetBytes("foo=ba&baz=bo"), Encoding.UTF8.GetBytes("o&t=")); + + KeyValueAccumulator accumulator = default; + + var formReader = new FormPipeReader(null); + formReader.ValueLengthLimit = 2; + + var exception = Assert.Throws(() => formReader.ParseFormValues(ref readOnlySequence, ref accumulator, isFinalBlock: true)); + Assert.Equal("Form value length limit 2 exceeded.", exception.Message); + } + + [Fact] + public void TryParseFormValues_MultiSegmentExceedKeLengthThrowsInSplitSegmentEnd() + { + var readOnlySequence = ReadOnlySequenceFactory.CreateSegments(Encoding.UTF8.GetBytes("foo=ba&baz=bo"), Encoding.UTF8.GetBytes("o&asdfasdfasd=")); + + KeyValueAccumulator accumulator = default; + + var formReader = new FormPipeReader(null); + formReader.KeyLengthLimit = 10; + + var exception = Assert.Throws(() => formReader.ParseFormValues(ref readOnlySequence, ref accumulator, isFinalBlock: true)); + Assert.Equal("Form key length limit 10 exceeded.", exception.Message); + } + + [Fact] + public void TryParseFormValues_MultiSegmentExceedValueLengthThrowsInSplitSegmentEnd() + { + var readOnlySequence = ReadOnlySequenceFactory.CreateSegments(Encoding.UTF8.GetBytes("foo=ba&baz=bo"), Encoding.UTF8.GetBytes("o&t=asdfasdfasd")); + + KeyValueAccumulator accumulator = default; + + var formReader = new FormPipeReader(null); + formReader.ValueLengthLimit = 10; + + var exception = Assert.Throws(() => formReader.ParseFormValues(ref readOnlySequence, ref accumulator, isFinalBlock: true)); + Assert.Equal("Form value length limit 10 exceeded.", exception.Message); + } + + [Fact] + public async Task ResetPipeWorks() + { + // Same test that is in the benchmark + var pipe = new Pipe(); + var bytes = Encoding.UTF8.GetBytes("foo=bar&baz=boo"); + + for (var i = 0; i < 1000; i++) + { + pipe.Writer.Write(bytes); + pipe.Writer.Complete(); + var formReader = new FormPipeReader(pipe.Reader); + await formReader.ReadFormAsync(); + pipe.Reader.Complete(); + pipe.Reset(); + } + } + + internal virtual Task> ReadFormAsync(FormPipeReader reader) + { + return reader.ReadFormAsync(); + } + + private static async Task MakePipeReader(string text) + { + var formContent = Encoding.UTF8.GetBytes(text); + Pipe bodyPipe = new Pipe(); + + await bodyPipe.Writer.WriteAsync(formContent); + + // Complete the writer so the reader will complete after processing all data. + bodyPipe.Writer.Complete(); + return bodyPipe.Reader; + } + } +} diff --git a/src/Http/WebUtilities/test/Microsoft.AspNetCore.WebUtilities.Tests.csproj b/src/Http/WebUtilities/test/Microsoft.AspNetCore.WebUtilities.Tests.csproj index c5a7649de2..dbea182d73 100644 --- a/src/Http/WebUtilities/test/Microsoft.AspNetCore.WebUtilities.Tests.csproj +++ b/src/Http/WebUtilities/test/Microsoft.AspNetCore.WebUtilities.Tests.csproj @@ -1,4 +1,4 @@ - + netcoreapp3.0 @@ -8,4 +8,8 @@ + + + + diff --git a/src/Servers/Kestrel/Core/src/Internal/Http/PathNormalizer.cs b/src/Servers/Kestrel/Core/src/Internal/Http/PathNormalizer.cs index 6d0513b5b6..c5059bdac7 100644 --- a/src/Servers/Kestrel/Core/src/Internal/Http/PathNormalizer.cs +++ b/src/Servers/Kestrel/Core/src/Internal/Http/PathNormalizer.cs @@ -1,10 +1,10 @@ -// Copyright (c) .NET Foundation. All rights reserved. +// 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.Diagnostics; using System.Text; -using Microsoft.AspNetCore.Connections.Abstractions; +using Microsoft.AspNetCore.Internal; using Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Infrastructure; namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http @@ -20,8 +20,7 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http if (pathEncoded) { // URI was encoded, unescape and then parse as UTF-8 - // Disabling warning temporary - pathLength = UrlDecoder.DecodeInPlace(path); + pathLength = UrlDecoder.DecodeInPlace(path, isFormEncoding: false); // Removing dot segments must be done after unescaping. From RFC 3986: // diff --git a/src/Servers/Kestrel/Core/src/Microsoft.AspNetCore.Server.Kestrel.Core.csproj b/src/Servers/Kestrel/Core/src/Microsoft.AspNetCore.Server.Kestrel.Core.csproj index 40589fab69..a528049cf6 100644 --- a/src/Servers/Kestrel/Core/src/Microsoft.AspNetCore.Server.Kestrel.Core.csproj +++ b/src/Servers/Kestrel/Core/src/Microsoft.AspNetCore.Server.Kestrel.Core.csproj @@ -1,4 +1,4 @@ - + Core components of ASP.NET Core Kestrel cross-platform web server. @@ -13,6 +13,7 @@ + diff --git a/src/Servers/Kestrel/Core/src/Internal/Http/UrlDecoder.cs b/src/Shared/UrlDecoder/UrlDecoder.cs similarity index 88% rename from src/Servers/Kestrel/Core/src/Internal/Http/UrlDecoder.cs rename to src/Shared/UrlDecoder/UrlDecoder.cs index 3f6533e3cd..5666f5b34f 100644 --- a/src/Servers/Kestrel/Core/src/Internal/Http/UrlDecoder.cs +++ b/src/Shared/UrlDecoder/UrlDecoder.cs @@ -4,7 +4,7 @@ using System; -namespace Microsoft.AspNetCore.Connections.Abstractions +namespace Microsoft.AspNetCore.Internal { internal class UrlDecoder { @@ -13,8 +13,9 @@ namespace Microsoft.AspNetCore.Connections.Abstractions /// /// The byte span represents a UTF8 encoding url path. /// The byte span where unescaped url path is copied to. + /// Whether we are doing form encoding or not. /// The length of the byte sequence of the unescaped url path. - public static int Decode(ReadOnlySpan source, Span destination) + public static int DecodeRequestLine(ReadOnlySpan source, Span destination, bool isFormEncoding) { if (destination.Length < source.Length) { @@ -25,19 +26,20 @@ namespace Microsoft.AspNetCore.Connections.Abstractions // This requires the destination span to be larger or equal to source span source.CopyTo(destination); - return DecodeInPlace(destination); + return DecodeInPlace(destination, isFormEncoding); } /// /// Unescape a URL path in place. /// /// The byte span represents a UTF8 encoding url path. + /// Whether we are doing form encoding or not. /// The number of the bytes representing the result. /// /// The unescape is done in place, which means after decoding the result is the subset of /// the input span. /// - public static int DecodeInPlace(Span buffer) + public static int DecodeInPlace(Span buffer, bool isFormEncoding) { // the slot to read the input var sourceIndex = 0; @@ -52,7 +54,12 @@ namespace Microsoft.AspNetCore.Connections.Abstractions break; } - if (buffer[sourceIndex] == '%') + if (buffer[sourceIndex] == '+' && isFormEncoding) + { + // Set it to ' ' when we are doing form encoding. + buffer[sourceIndex] = 0x20; + } + else if (buffer[sourceIndex] == '%') { var decodeIndex = sourceIndex; @@ -63,7 +70,7 @@ namespace Microsoft.AspNetCore.Connections.Abstractions // The decodeReader iterator is always moved to the first byte not yet // be scanned after the process. A failed decoding means the chars // between the reader and decodeReader can be copied to output untouched. - if (!DecodeCore(ref decodeIndex, ref destinationIndex, buffer)) + if (!DecodeCore(ref decodeIndex, ref destinationIndex, buffer, isFormEncoding)) { Copy(sourceIndex, decodeIndex, ref destinationIndex, buffer); } @@ -85,11 +92,12 @@ namespace Microsoft.AspNetCore.Connections.Abstractions /// The iterator point to the first % char /// The place to write to /// The byte array - private static bool DecodeCore(ref int sourceIndex, ref int destinationIndex, Span buffer) + /// Whether we are doing form encodoing + private static bool DecodeCore(ref int sourceIndex, ref int destinationIndex, Span buffer, bool isFormEncoding) { // preserves the original head. if the percent-encodings cannot be interpreted as sequence of UTF-8 octets, // bytes from this till the last scanned one will be copied to the memory pointed by writer. - var byte1 = UnescapePercentEncoding(ref sourceIndex, buffer); + var byte1 = UnescapePercentEncoding(ref sourceIndex, buffer, isFormEncoding); if (byte1 == -1) { return false; @@ -150,7 +158,7 @@ namespace Microsoft.AspNetCore.Connections.Abstractions } var nextSourceIndex = sourceIndex; - var nextByte = UnescapePercentEncoding(ref nextSourceIndex, buffer); + var nextByte = UnescapePercentEncoding(ref nextSourceIndex, buffer, isFormEncoding); if (nextByte == -1) { return false; @@ -249,8 +257,9 @@ namespace Microsoft.AspNetCore.Connections.Abstractions /// /// The value to read /// The byte array + /// Whether we are decoding a form or not. Will escape '/' if we are doing form encoding /// The unescaped byte if success. Otherwise return -1. - private static int UnescapePercentEncoding(ref int scan, Span buffer) + private static int UnescapePercentEncoding(ref int scan, Span buffer, bool isFormEncoding) { if (buffer[scan++] != '%') { @@ -271,7 +280,7 @@ namespace Microsoft.AspNetCore.Connections.Abstractions return -1; } - if (SkipUnescape(value1, value2)) + if (SkipUnescape(value1, value2, isFormEncoding)) { return -1; } @@ -321,8 +330,13 @@ namespace Microsoft.AspNetCore.Connections.Abstractions } } - private static bool SkipUnescape(int value1, int value2) + private static bool SkipUnescape(int value1, int value2, bool isFormEncoding) { + if (isFormEncoding) + { + return false; + } + // skip %2F - '/' if (value1 == 2 && value2 == 15) { @@ -331,6 +345,5 @@ namespace Microsoft.AspNetCore.Connections.Abstractions return false; } - } }