From 1ad18d9b65211220c2b560ff6cfe85a49b395211 Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Mon, 23 Mar 2026 19:41:34 +0100 Subject: [PATCH 1/5] Fix path canonicalization inconsistency between origin-form and absolute-form When processing HTTP/1.1 absolute-form request targets (e.g., GET http://host/a%2Fb), Kestrel used Uri.LocalPath to extract the path, which decodes %2F to '/'. This differed from origin-form handling which uses PathDecoder.DecodePath that deliberately preserves %2F. Replace Uri.LocalPath with Uri.AbsolutePath (which preserves percent- encoding) followed by PathDecoder.DecodePath, ensuring both request- target forms produce identical HttpRequest.Path values. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Core/src/Internal/Http/Http1Connection.cs | 16 +++++++++++++++- src/Servers/Kestrel/Core/test/StartLineTests.cs | 4 ++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/Servers/Kestrel/Core/src/Internal/Http/Http1Connection.cs b/src/Servers/Kestrel/Core/src/Internal/Http/Http1Connection.cs index 380d1f768221..00b37ad33084 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,20 @@ private void OnAbsoluteFormTarget(TargetOffsetPathLength targetPath, Span } _absoluteRequestTarget = _parsedAbsoluteRequestTarget = uri; - Path = _parsedPath = uri.LocalPath; + + // Use the same path decoding as origin-form to ensure consistent + // canonicalization behavior. uri.LocalPath decodes %2F to '/' which + // differs from the origin-form path handling that preserves %2F. + // uri.AbsolutePath preserves percent-encoding, so we convert it to + // bytes and run it through PathDecoder.DecodePath for consistency. + var absolutePath = uri!.AbsolutePath; + var absolutePathLength = absolutePath.Length; + Span pathBuffer = absolutePathLength <= 256 + ? stackalloc byte[absolutePathLength] + : new byte[absolutePathLength]; + Encoding.ASCII.GetBytes(absolutePath, pathBuffer); + Path = _parsedPath = PathDecoder.DecodePath(pathBuffer, targetPath.IsEncoded, absolutePath, query.Length); + // 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(); From ed8a1d272f2a4d39a895141c90ef56801b09b636 Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Mon, 23 Mar 2026 20:07:43 +0100 Subject: [PATCH 2/5] Fix path canonicalization inconsistency between origin-form and absolute-form When processing HTTP/1.1 absolute-form request targets (e.g., GET http://host/a%2Fb), Kestrel used Uri.LocalPath to extract the path, which decodes %2F to '/'. This differed from origin-form handling which uses PathDecoder.DecodePath that deliberately preserves %2F. Replace Uri.LocalPath with Uri.AbsolutePath (which preserves percent- encoding) followed by PathDecoder.DecodePath, ensuring both request- target forms produce identical HttpRequest.Path values. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Core/src/Internal/Http/Http1Connection.cs | 13 ++++++++++++- src/Servers/Kestrel/Core/test/StartLineTests.cs | 4 ++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/Servers/Kestrel/Core/src/Internal/Http/Http1Connection.cs b/src/Servers/Kestrel/Core/src/Internal/Http/Http1Connection.cs index 380d1f768221..a2617cb373c5 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,17 @@ 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. + var absolutePath = uri!.AbsolutePath; + const int MaxPathBufferStackAllocSize = 256; + Span pathBuffer = absolutePath.Length <= MaxPathBufferStackAllocSize + ? stackalloc byte[MaxPathBufferStackAllocSize].Slice(0, absolutePath.Length) + : new byte[absolutePath.Length]; + Encoding.ASCII.GetBytes(absolutePath, pathBuffer); + Path = _parsedPath = PathDecoder.DecodePath(pathBuffer, targetPath.IsEncoded, absolutePath, query.Length); + // 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(); From 8e5caf22617e0f5e60e85f08626a1756b2e59239 Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Mon, 23 Mar 2026 20:22:24 +0100 Subject: [PATCH 3/5] Add renting of pathBuffer to improve --- .../Core/src/Internal/Http/Http1Connection.cs | 24 +++++++++++++++---- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/src/Servers/Kestrel/Core/src/Internal/Http/Http1Connection.cs b/src/Servers/Kestrel/Core/src/Internal/Http/Http1Connection.cs index a2617cb373c5..b730d85ad776 100644 --- a/src/Servers/Kestrel/Core/src/Internal/Http/Http1Connection.cs +++ b/src/Servers/Kestrel/Core/src/Internal/Http/Http1Connection.cs @@ -671,13 +671,27 @@ private void OnAbsoluteFormTarget(TargetOffsetPathLength targetPath, Span // Use PathDecoder.DecodePath (same as origin-form and HTTP/2/3) instead of // uri.LocalPath, which decodes %2F to '/' breaking path canonicalization. - var absolutePath = uri!.AbsolutePath; const int MaxPathBufferStackAllocSize = 256; + + var absolutePath = uri!.AbsolutePath; + byte[] rentedBuffer = null!; Span pathBuffer = absolutePath.Length <= MaxPathBufferStackAllocSize - ? stackalloc byte[MaxPathBufferStackAllocSize].Slice(0, absolutePath.Length) - : new byte[absolutePath.Length]; - Encoding.ASCII.GetBytes(absolutePath, pathBuffer); - Path = _parsedPath = PathDecoder.DecodePath(pathBuffer, targetPath.IsEncoded, absolutePath, query.Length); + ? (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, query.Length); + } + finally + { + if (rentedBuffer is not null) + { + ArrayPool.Shared.Return(rentedBuffer); + } + } // don't use uri.Query because we need the unescaped version previousValue = _parsedQueryString; From c5653c3c82be106b3b842e3bb5288e9c1c2173cd Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Mon, 23 Mar 2026 20:46:57 +0100 Subject: [PATCH 4/5] handle exception --- .../Kestrel/Core/src/Internal/Http/Http1Connection.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/Servers/Kestrel/Core/src/Internal/Http/Http1Connection.cs b/src/Servers/Kestrel/Core/src/Internal/Http/Http1Connection.cs index b730d85ad776..07645d97ed93 100644 --- a/src/Servers/Kestrel/Core/src/Internal/Http/Http1Connection.cs +++ b/src/Servers/Kestrel/Core/src/Internal/Http/Http1Connection.cs @@ -674,7 +674,7 @@ private void OnAbsoluteFormTarget(TargetOffsetPathLength targetPath, Span const int MaxPathBufferStackAllocSize = 256; var absolutePath = uri!.AbsolutePath; - byte[] rentedBuffer = null!; + byte[]? rentedBuffer = null; Span pathBuffer = absolutePath.Length <= MaxPathBufferStackAllocSize ? (stackalloc byte[MaxPathBufferStackAllocSize]) : (rentedBuffer = ArrayPool.Shared.Rent(absolutePath.Length)); @@ -685,6 +685,10 @@ private void OnAbsoluteFormTarget(TargetOffsetPathLength targetPath, Span Encoding.ASCII.GetBytes(absolutePath, pathBufferSliced); Path = _parsedPath = PathDecoder.DecodePath(pathBufferSliced, targetPath.IsEncoded, absolutePath, query.Length); } + catch (InvalidOperationException) + { + ThrowRequestTargetRejected(target); + } finally { if (rentedBuffer is not null) From 84600ad19360222279fa140275d94736c776df2e Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Tue, 24 Mar 2026 11:28:44 +0100 Subject: [PATCH 5/5] address PR comments --- src/Servers/Kestrel/Core/src/Internal/Http/Http1Connection.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Servers/Kestrel/Core/src/Internal/Http/Http1Connection.cs b/src/Servers/Kestrel/Core/src/Internal/Http/Http1Connection.cs index 07645d97ed93..13e27ca3671a 100644 --- a/src/Servers/Kestrel/Core/src/Internal/Http/Http1Connection.cs +++ b/src/Servers/Kestrel/Core/src/Internal/Http/Http1Connection.cs @@ -673,7 +673,7 @@ private void OnAbsoluteFormTarget(TargetOffsetPathLength targetPath, Span // uri.LocalPath, which decodes %2F to '/' breaking path canonicalization. const int MaxPathBufferStackAllocSize = 256; - var absolutePath = uri!.AbsolutePath; + var absolutePath = uri.AbsolutePath; byte[]? rentedBuffer = null; Span pathBuffer = absolutePath.Length <= MaxPathBufferStackAllocSize ? (stackalloc byte[MaxPathBufferStackAllocSize]) @@ -683,7 +683,7 @@ private void OnAbsoluteFormTarget(TargetOffsetPathLength targetPath, Span try { Encoding.ASCII.GetBytes(absolutePath, pathBufferSliced); - Path = _parsedPath = PathDecoder.DecodePath(pathBufferSliced, targetPath.IsEncoded, absolutePath, query.Length); + Path = _parsedPath = PathDecoder.DecodePath(pathBufferSliced, targetPath.IsEncoded, absolutePath, queryLength: 0); } catch (InvalidOperationException) {