diff --git a/src/Servers/Kestrel/Core/src/Internal/Http/Http1Connection.cs b/src/Servers/Kestrel/Core/src/Internal/Http/Http1Connection.cs index 380d1f768221..13e27ca3671a 100644 --- a/src/Servers/Kestrel/Core/src/Internal/Http/Http1Connection.cs +++ b/src/Servers/Kestrel/Core/src/Internal/Http/Http1Connection.cs @@ -4,6 +4,7 @@ using System.Buffers; using System.Diagnostics; using System.IO.Pipelines; +using System.Text; using Microsoft.AspNetCore.Connections; using Microsoft.AspNetCore.Connections.Features; using Microsoft.AspNetCore.Http; @@ -667,7 +668,35 @@ private void OnAbsoluteFormTarget(TargetOffsetPathLength targetPath, Span } _absoluteRequestTarget = _parsedAbsoluteRequestTarget = uri; - Path = _parsedPath = uri.LocalPath; + + // Use PathDecoder.DecodePath (same as origin-form and HTTP/2/3) instead of + // uri.LocalPath, which decodes %2F to '/' breaking path canonicalization. + const int MaxPathBufferStackAllocSize = 256; + + var absolutePath = uri.AbsolutePath; + byte[]? rentedBuffer = null; + Span pathBuffer = absolutePath.Length <= MaxPathBufferStackAllocSize + ? (stackalloc byte[MaxPathBufferStackAllocSize]) + : (rentedBuffer = ArrayPool.Shared.Rent(absolutePath.Length)); + var pathBufferSliced = pathBuffer[..absolutePath.Length]; + + try + { + Encoding.ASCII.GetBytes(absolutePath, pathBufferSliced); + Path = _parsedPath = PathDecoder.DecodePath(pathBufferSliced, targetPath.IsEncoded, absolutePath, queryLength: 0); + } + catch (InvalidOperationException) + { + ThrowRequestTargetRejected(target); + } + finally + { + if (rentedBuffer is not null) + { + ArrayPool.Shared.Return(rentedBuffer); + } + } + // don't use uri.Query because we need the unescaped version previousValue = _parsedQueryString; if (disableStringReuse || diff --git a/src/Servers/Kestrel/Core/test/StartLineTests.cs b/src/Servers/Kestrel/Core/test/StartLineTests.cs index 234939468177..8fab651a25b7 100644 --- a/src/Servers/Kestrel/Core/test/StartLineTests.cs +++ b/src/Servers/Kestrel/Core/test/StartLineTests.cs @@ -183,6 +183,8 @@ public void DifferentFormsWorkTogether() [InlineData("/?q=123&w=xyz", "/", "?q=123&w=xyz")] [InlineData("/path?q=123&w=xyz", "/path", "?q=123&w=xyz")] [InlineData("/path%20with%20space?q=abc%20123", "/path with space", "?q=abc%20123")] + [InlineData("/a%2Fb", "/a%2Fb", "")] + [InlineData("/a%2Fb?q=1", "/a%2Fb", "?q=1")] public void OriginForms(string rawTarget, string path, string query) { Http1Connection.Reset(); @@ -277,6 +279,8 @@ public void OriginForms(string rawTarget, string path, string query) [InlineData("http://localhost/?q=123&w=xyz", "/", "?q=123&w=xyz")] [InlineData("http://localhost/path?q=123&w=xyz", "/path", "?q=123&w=xyz")] [InlineData("http://localhost/path%20with%20space?q=abc%20123", "/path with space", "?q=abc%20123")] + [InlineData("http://localhost/a%2Fb", "/a%2Fb", "")] + [InlineData("http://localhost/a%2Fb?q=1", "/a%2Fb", "?q=1")] public void AbsoluteForms(string rawTarget, string path, string query) { Http1Connection.Reset();