Add FormPipeReader (#7964)

This commit is contained in:
Justin Kotalik 2019-03-01 14:41:05 -08:00 committed by GitHub
parent 9a61275fa6
commit fc9e48877c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 1009 additions and 26 deletions

View File

@ -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))
{

View File

@ -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<IHttpResponseFeature>(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<IFormFeature>(formFeature);
var formCollection = await context.Request.ReadFormAsync();
Assert.Equal("bar", formCollection["foo"]);
Assert.Equal("2", formCollection["baz"]);
// Cached
formFeature = context.Features.Get<IFormFeature>();
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\"";

View File

@ -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}

View File

@ -0,0 +1 @@
[assembly: BenchmarkDotNet.Attributes.AspNetCoreBenchmark]

View File

@ -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
{
/// <summary>
/// Test internal parsing speed of FormPipeReader without pipe
/// </summary>
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<byte>(_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);
}
}
}

View File

@ -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();
}
}
}
}

View File

@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netcoreapp3.0</TargetFramework>
<OutputType>Exe</OutputType>
<ServerGarbageCollection>true</ServerGarbageCollection>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<IsPackable>false</IsPackable>
<RootNamespace>Microsoft.AspNetCore.WebUtilities</RootNamespace>
</PropertyGroup>
<ItemGroup>
<Reference Include="BenchmarkDotNet" />
<Reference Include="Microsoft.AspNetCore.WebUtilities" />
<Reference Include="Microsoft.AspNetCore.BenchmarkRunner.Sources" />
</ItemGroup>
<ItemGroup>
<Compile Include="$(SharedSourceRoot)Buffers.Testing\**\*.cs" />
</ItemGroup>
</Project>

View File

@ -7,5 +7,6 @@
<Compile Include="Microsoft.AspNetCore.WebUtilities.netcoreapp3.0.cs" />
<Reference Include="Microsoft.Net.Http.Headers" />
<Reference Include="System.Text.Encodings.Web" />
<Reference Include="System.IO.Pipelines" />
</ItemGroup>
</Project>

View File

@ -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<string> 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<System.Collections.Generic.Dictionary<string, Microsoft.Extensions.Primitives.StringValues>> ReadFormAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; }
}
public partial class FormReader : System.IDisposable
{
public const int DefaultKeyLengthLimit = 2048;

View File

@ -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
{
/// <summary>
/// Used to read an 'application/x-www-form-urlencoded' form.
/// Internally reads from a PipeReader.
/// </summary>
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<byte> UTF8EqualEncoded => new byte[] { (byte)'=' };
private static ReadOnlySpan<byte> 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("&");
}
}
/// <summary>
/// The limit on the number of form values to allow in ReadForm or ReadFormAsync.
/// </summary>
public int ValueCountLimit { get; set; } = DefaultValueCountLimit;
/// <summary>
/// The limit on the length of form keys.
/// </summary>
public int KeyLengthLimit { get; set; } = DefaultKeyLengthLimit;
/// <summary>
/// The limit on the length of form values.
/// </summary>
public int ValueLengthLimit { get; set; } = DefaultValueLengthLimit;
/// <summary>
/// Parses an HTTP form body.
/// </summary>
/// <param name="cancellationToken">The <see cref="CancellationToken"/>.</param>
/// <returns>The collection containing the parsed HTTP form body.</returns>
public async Task<Dictionary<string, StringValues>> 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<byte> 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<byte> span,
ref KeyValueAccumulator accumulator,
bool isFinalBlock,
out int consumed)
{
ReadOnlySpan<byte> key = default;
ReadOnlySpan<byte> 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<byte>.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<byte> buffer,
ref KeyValueAccumulator accumulator,
bool isFinalBlock)
{
var sequenceReader = new SequenceReader<byte>(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<byte> key, equalsDelimiter, advancePastDelimiter: false) ||
!sequenceReader.IsNext(equalsDelimiter, true))
{
if (sequenceReader.Consumed > KeyLengthLimit)
{
ThrowKeyTooLargeException();
}
break;
}
if (key.Length > KeyLengthLimit)
{
ThrowKeyTooLargeException();
}
if (!sequenceReader.TryReadTo(out ReadOnlySequence<byte> 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<byte> ros)
{
if (ros.IsSingleSegment)
{
return GetDecodedString(ros.First.Span);
}
if (ros.Length < StackAllocThreshold)
{
Span<byte> buffer = stackalloc byte[(int)ros.Length];
ros.CopyTo(buffer);
return GetDecodedString(buffer);
}
else
{
var byteArray = ArrayPool<byte>.Shared.Rent((int)ros.Length);
try
{
Span<byte> buffer = byteArray.AsSpan(0, (int)ros.Length);
ros.CopyTo(buffer);
return GetDecodedString(buffer);
}
finally
{
ArrayPool<byte>.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<byte> 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<byte> GetEqualsForEncoding()
{
if (_encoding == Encoding.UTF8 || _encoding == Encoding.ASCII)
{
return UTF8EqualEncoded;
}
else
{
return _otherEqualEncoding;
}
}
private ReadOnlySpan<byte> GetAndForEncoding()
{
if (_encoding == Encoding.UTF8 || _encoding == Encoding.ASCII)
{
return UTF8AndEncoded;
}
else
{
return _otherAndEncoding;
}
}
}
}

View File

@ -309,4 +309,4 @@ namespace Microsoft.AspNetCore.WebUtilities
}
}
}
}
}

View File

@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<Description>ASP.NET Core utilities, such as for working with forms, multipart messages, and query strings.</Description>
@ -12,11 +12,13 @@
<ItemGroup>
<Compile Include="$(SharedSourceRoot)WebEncoders\**\*.cs" />
<Compile Include="$(SharedSourceRoot)UrlDecoder\**\*.cs" />
</ItemGroup>
<ItemGroup>
<Reference Include="Microsoft.Net.Http.Headers" />
<Reference Include="System.Text.Encodings.Web" />
<Reference Include="System.IO.Pipelines" />
</ItemGroup>
</Project>

View File

@ -4,3 +4,4 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("Microsoft.AspNetCore.WebUtilities.Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")]
[assembly: InternalsVisibleTo("Microsoft.AspNetCore.WebUtilities.Performance, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")]

View File

@ -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<InvalidDataException>(
() => 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<InvalidDataException>(
() => 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<InvalidDataException>(
() => 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<InvalidDataException>(
() => 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<Encoding> Encodings =>
new TheoryData<Encoding>
{
{ Encoding.UTF8 },
{ Encoding.UTF32 },
{ Encoding.ASCII },
{ Encoding.Unicode }
};
[Theory]
[MemberData(nameof(Encodings))]
public void TryParseFormValues_SingleSegmentWorks(Encoding encoding)
{
var readOnlySequence = new ReadOnlySequence<byte>(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<InvalidDataException>(() => 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<InvalidDataException>(() => 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<InvalidDataException>(() => 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<InvalidDataException>(() => 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<InvalidDataException>(() => 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<InvalidDataException>(() => 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<Dictionary<string, StringValues>> ReadFormAsync(FormPipeReader reader)
{
return reader.ReadFormAsync();
}
private static async Task<PipeReader> 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;
}
}
}

View File

@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netcoreapp3.0</TargetFramework>
@ -8,4 +8,8 @@
<Reference Include="Microsoft.AspNetCore.WebUtilities" />
</ItemGroup>
<ItemGroup>
<Compile Include="$(SharedSourceRoot)Buffers.Testing\**\*.cs" />
</ItemGroup>
</Project>

View File

@ -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:
//

View File

@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<Description>Core components of ASP.NET Core Kestrel cross-platform web server.</Description>
@ -13,6 +13,7 @@
<ItemGroup>
<Compile Include="$(SharedSourceRoot)CertificateGeneration\**\*.cs" />
<Compile Include="$(SharedSourceRoot)ValueTaskExtensions\**\*.cs" />
<Compile Include="$(SharedSourceRoot)UrlDecoder\**\*.cs" />
</ItemGroup>
<ItemGroup>

View File

@ -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
/// </summary>
/// <param name="source">The byte span represents a UTF8 encoding url path.</param>
/// <param name="destination">The byte span where unescaped url path is copied to.</param>
/// <param name="isFormEncoding">Whether we are doing form encoding or not.</param>
/// <returns>The length of the byte sequence of the unescaped url path.</returns>
public static int Decode(ReadOnlySpan<byte> source, Span<byte> destination)
public static int DecodeRequestLine(ReadOnlySpan<byte> source, Span<byte> 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);
}
/// <summary>
/// Unescape a URL path in place.
/// </summary>
/// <param name="buffer">The byte span represents a UTF8 encoding url path.</param>
/// <param name="isFormEncoding">Whether we are doing form encoding or not.</param>
/// <returns>The number of the bytes representing the result.</returns>
/// <remarks>
/// The unescape is done in place, which means after decoding the result is the subset of
/// the input span.
/// </remarks>
public static int DecodeInPlace(Span<byte> buffer)
public static int DecodeInPlace(Span<byte> 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
/// <param name="sourceIndex">The iterator point to the first % char</param>
/// <param name="destinationIndex">The place to write to</param>
/// <param name="buffer">The byte array</param>
private static bool DecodeCore(ref int sourceIndex, ref int destinationIndex, Span<byte> buffer)
/// <param name="isFormEncoding">Whether we are doing form encodoing</param>
private static bool DecodeCore(ref int sourceIndex, ref int destinationIndex, Span<byte> 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
/// </summary>
/// <param name="scan">The value to read</param>
/// <param name="buffer">The byte array</param>
/// <param name="isFormEncoding">Whether we are decoding a form or not. Will escape '/' if we are doing form encoding</param>
/// <returns>The unescaped byte if success. Otherwise return -1.</returns>
private static int UnescapePercentEncoding(ref int scan, Span<byte> buffer)
private static int UnescapePercentEncoding(ref int scan, Span<byte> 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;
}
}
}