Skip to content

Commit aec52eb

Browse files
authored
[6.0] Match strangely formatted PathBase #42751 (#44753)
* Match strangely formatted PathBase #42751 * Match strangely formatted PathBase in IIS #42751
1 parent c6175ed commit aec52eb

File tree

14 files changed

+2152
-64
lines changed

14 files changed

+2152
-64
lines changed

src/Servers/HttpSys/src/RequestProcessing/Request.cs

Lines changed: 85 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -63,38 +63,104 @@ internal Request(RequestContext requestContext)
6363

6464
PathBase = string.Empty;
6565
Path = originalPath;
66+
var prefix = requestContext.Server.Options.UrlPrefixes.GetPrefix((int)requestContext.UrlContext);
6667

6768
// 'OPTIONS * HTTP/1.1'
6869
if (KnownMethod == HttpApiTypes.HTTP_VERB.HttpVerbOPTIONS && string.Equals(RawUrl, "*", StringComparison.Ordinal))
6970
{
7071
PathBase = string.Empty;
7172
Path = string.Empty;
7273
}
73-
else
74+
// Prefix may be null if the request has been transfered to our queue
75+
else if (prefix is not null)
7476
{
75-
var prefix = requestContext.Server.Options.UrlPrefixes.GetPrefix((int)requestContext.UrlContext);
76-
// Prefix may be null if the requested has been transfered to our queue
77-
if (!(prefix is null))
77+
var pathBase = prefix.PathWithoutTrailingSlash;
78+
// url: /base/path, prefix: /base/, base: /base, path: /path
79+
// url: /, prefix: /, base: , path: /
80+
if (originalPath.Equals(pathBase, StringComparison.Ordinal))
7881
{
79-
if (originalPath.Length == prefix.PathWithoutTrailingSlash.Length)
80-
{
81-
// They matched exactly except for the trailing slash.
82-
PathBase = originalPath;
83-
Path = string.Empty;
84-
}
85-
else
86-
{
87-
// url: /base/path, prefix: /base/, base: /base, path: /path
88-
// url: /, prefix: /, base: , path: /
89-
PathBase = originalPath.Substring(0, prefix.PathWithoutTrailingSlash.Length); // Preserve the user input casing
90-
Path = originalPath.Substring(prefix.PathWithoutTrailingSlash.Length);
91-
}
82+
// Exact match, no need to preserve the casing
83+
PathBase = pathBase;
84+
Path = string.Empty;
85+
}
86+
else if (originalPath.Equals(pathBase, StringComparison.OrdinalIgnoreCase))
87+
{
88+
// Preserve the user input casing
89+
PathBase = originalPath;
90+
Path = string.Empty;
9291
}
93-
else if (requestContext.Server.Options.UrlPrefixes.TryMatchLongestPrefix(IsHttps, cookedUrl.GetHost()!, originalPath, out var pathBase, out var path))
92+
else if (originalPath.StartsWith(prefix.Path, StringComparison.Ordinal))
9493
{
94+
// Exact match, no need to preserve the casing
9595
PathBase = pathBase;
96-
Path = path;
96+
Path = originalPath[pathBase.Length..];
9797
}
98+
else if (originalPath.StartsWith(prefix.Path, StringComparison.OrdinalIgnoreCase))
99+
{
100+
// Preserve the user input casing
101+
PathBase = originalPath[..pathBase.Length];
102+
Path = originalPath[pathBase.Length..];
103+
}
104+
else
105+
{
106+
// Http.Sys path base matching is based on the cooked url which applies some non-standard normalizations that we don't use
107+
// like collapsing duplicate slashes "//", converting '\' to '/', and un-escaping "%2F" to '/'. Find the right split and
108+
// ignore the normalizations.
109+
var originalOffset = 0;
110+
var baseOffset = 0;
111+
while (originalOffset < originalPath.Length && baseOffset < pathBase.Length)
112+
{
113+
var baseValue = pathBase[baseOffset];
114+
var offsetValue = originalPath[originalOffset];
115+
if (baseValue == offsetValue
116+
|| char.ToUpperInvariant(baseValue) == char.ToUpperInvariant(offsetValue))
117+
{
118+
// case-insensitive match, continue
119+
originalOffset++;
120+
baseOffset++;
121+
}
122+
else if (baseValue == '/' && offsetValue == '\\')
123+
{
124+
// Http.Sys considers these equivalent
125+
originalOffset++;
126+
baseOffset++;
127+
}
128+
else if (baseValue == '/' && originalPath.AsSpan(originalOffset).StartsWith("%2F", StringComparison.OrdinalIgnoreCase))
129+
{
130+
// Http.Sys un-escapes this
131+
originalOffset += 3;
132+
baseOffset++;
133+
}
134+
else if (baseOffset > 0 && pathBase[baseOffset - 1] == '/'
135+
&& (offsetValue == '/' || offsetValue == '\\'))
136+
{
137+
// Duplicate slash, skip
138+
originalOffset++;
139+
}
140+
else if (baseOffset > 0 && pathBase[baseOffset - 1] == '/'
141+
&& originalPath.AsSpan(originalOffset).StartsWith("%2F", StringComparison.OrdinalIgnoreCase))
142+
{
143+
// Duplicate slash equivalent, skip
144+
originalOffset += 3;
145+
}
146+
else
147+
{
148+
// Mismatch, fall back
149+
// The failing test case here is "/base/call//../ball//path1//path2", reduced to "/base/call/ball//path1//path2",
150+
// where http.sys collapses "//" before "../", but we do "../" first. We've lost the context that there were dot segments,
151+
// or duplicate slashes, how do we figure out that "call/" can be eliminated?
152+
originalOffset = 0;
153+
break;
154+
}
155+
}
156+
PathBase = originalPath[..originalOffset];
157+
Path = originalPath[originalOffset..];
158+
}
159+
}
160+
else if (requestContext.Server.Options.UrlPrefixes.TryMatchLongestPrefix(IsHttps, cookedUrl.GetHost()!, originalPath, out var pathBase, out var path))
161+
{
162+
PathBase = pathBase;
163+
Path = path;
98164
}
99165

100166
ProtocolVersion = RequestContext.GetVersion();

src/Servers/HttpSys/test/FunctionalTests/Listener/RequestTests.cs

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Licensed to the .NET Foundation under one or more agreements.
1+
// Licensed to the .NET Foundation under one or more agreements.
22
// The .NET Foundation licenses this file to you under the MIT license.
33

44
using System;
@@ -138,6 +138,42 @@ public async Task Request_OverlongUTF8Path(string requestPath, string expectedPa
138138
}
139139
}
140140

141+
[ConditionalTheory]
142+
[InlineData("/", "/", "", "/")]
143+
[InlineData("/base", "/base", "/base", "")]
144+
[InlineData("/base", "/baSe", "/baSe", "")]
145+
[InlineData("/base", "/base/path", "/base", "/path")]
146+
[InlineData("/base", "///base/path1/path2", "///base", "/path1/path2")]
147+
[InlineData("/base/ball", @"/baSe\ball//path1//path2", @"/baSe\ball", "//path1//path2")]
148+
[InlineData("/base/ball", @"/base%2fball//path1//path2", @"/base%2fball", "//path1//path2")]
149+
[InlineData("/base/ball", @"/base%2Fball//path1//path2", @"/base%2Fball", "//path1//path2")]
150+
[InlineData("/base/ball", @"/base%5cball//path1//path2", @"/base\ball", "//path1//path2")]
151+
[InlineData("/base/ball", @"/base%5Cball//path1//path2", @"/base\ball", "//path1//path2")]
152+
[InlineData("/base/ball", "///baSe//ball//path1//path2", "///baSe//ball", "//path1//path2")]
153+
[InlineData("/base/ball", @"/base/\ball//path1//path2", @"/base/\ball", "//path1//path2")]
154+
[InlineData("/base/ball", @"/base/%2fball//path1//path2", @"/base/%2fball", "//path1//path2")]
155+
[InlineData("/base/ball", @"/base/%2Fball//path1//path2", @"/base/%2Fball", "//path1//path2")]
156+
[InlineData("/base/ball", @"/base/%5cball//path1//path2", @"/base/\ball", "//path1//path2")]
157+
[InlineData("/base/ball", @"/base/%5Cball//path1//path2", @"/base/\ball", "//path1//path2")]
158+
[InlineData("/base/ball", @"/base/call/../ball//path1//path2", @"/base/ball", "//path1//path2")]
159+
// The results should be "/base/ball", "//path1//path2", but Http.Sys collapses the "//" before the "../"
160+
// and we don't have a good way of emulating that.
161+
[InlineData("/base/ball", @"/base/call//../ball//path1//path2", @"", "/base/call/ball//path1//path2")]
162+
[InlineData("/base/ball", @"/base/call/.%2e/ball//path1//path2", @"/base/ball", "//path1//path2")]
163+
[InlineData("/base/ball", @"/base/call/.%2E/ball//path1//path2", @"/base/ball", "//path1//path2")]
164+
public async Task Request_WithPathBase(string pathBase, string requestPath, string expectedPathBase, string expectedPath)
165+
{
166+
using var server = Utilities.CreateHttpServerReturnRoot(pathBase, out var root);
167+
var responseTask = SendSocketRequestAsync(root, requestPath);
168+
var context = await server.AcceptAsync(Utilities.DefaultTimeout).Before(responseTask);
169+
Assert.Equal(expectedPathBase, context.Request.PathBase);
170+
Assert.Equal(expectedPath, context.Request.Path);
171+
context.Dispose();
172+
173+
var response = await responseTask;
174+
Assert.Equal("200", response.Substring(9));
175+
}
176+
141177
private async Task<string> SendSocketRequestAsync(string address, string path, string method = "GET")
142178
{
143179
var uri = new Uri(address);

src/Servers/HttpSys/test/FunctionalTests/RequestTests.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,7 @@ public async Task Request_FieldsCanBeSetToNull_Set()
208208
[InlineData("/base path/", "/base%20path/sub%20path", "/base path", "/sub path")]
209209
[InlineData("/base葉path/", "/base%E8%91%89path/sub%E8%91%89path", "/base葉path", "/sub葉path")]
210210
[InlineData("/basepath/", "/basepath/sub%2Fpath", "/basepath", "/sub%2Fpath")]
211+
[InlineData("/base", "///base/path1/path2", "///base", "/path1/path2")]
211212
public async Task Request_PathSplitting(string pathBase, string requestPath, string expectedPathBase, string expectedPath)
212213
{
213214
string root;

src/Servers/IIS/IIS/src/Core/IISHttpContext.cs

Lines changed: 91 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -149,19 +149,105 @@ protected void InitializeContext()
149149
KnownMethod = VerbId;
150150
StatusCode = 200;
151151

152-
var originalPath = GetOriginalPath();
152+
var originalPath = GetOriginalPath() ?? string.Empty;
153+
var pathBase = _server.VirtualPath ?? string.Empty;
154+
if (pathBase.Length > 1 && pathBase[^1] == '/')
155+
{
156+
pathBase = pathBase[..^1];
157+
}
153158

154159
if (KnownMethod == HttpApiTypes.HTTP_VERB.HttpVerbOPTIONS && string.Equals(RawTarget, "*", StringComparison.Ordinal))
155160
{
156161
PathBase = string.Empty;
157162
Path = string.Empty;
158163
}
159-
else
164+
else if (string.IsNullOrEmpty(pathBase) || pathBase == "/")
160165
{
161-
// Path and pathbase are unescaped by RequestUriBuilder
162-
// The UsePathBase middleware will modify the pathbase and path correctly
163166
PathBase = string.Empty;
164-
Path = originalPath ?? string.Empty;
167+
Path = originalPath;
168+
}
169+
else if (originalPath.Equals(pathBase, StringComparison.Ordinal))
170+
{
171+
// Exact match, no need to preserve the casing
172+
PathBase = pathBase;
173+
Path = string.Empty;
174+
}
175+
else if (originalPath.Equals(pathBase, StringComparison.OrdinalIgnoreCase))
176+
{
177+
// Preserve the user input casing
178+
PathBase = originalPath;
179+
Path = string.Empty;
180+
}
181+
else if (originalPath.Length == pathBase.Length + 1
182+
&& originalPath[^1] == '/'
183+
&& originalPath.StartsWith(pathBase, StringComparison.Ordinal))
184+
{
185+
// Exact match, no need to preserve the casing
186+
PathBase = pathBase;
187+
Path = "/";
188+
}
189+
else if (originalPath.Length == pathBase.Length + 1
190+
&& originalPath[^1] == '/'
191+
&& originalPath.StartsWith(pathBase, StringComparison.OrdinalIgnoreCase))
192+
{
193+
// Preserve the user input casing
194+
PathBase = originalPath[..pathBase.Length];
195+
Path = "/";
196+
}
197+
else
198+
{
199+
// Http.Sys path base matching is based on the cooked url which applies some non-standard normalizations that we don't use
200+
// like collapsing duplicate slashes "//", converting '\' to '/', and un-escaping "%2F" to '/'. Find the right split and
201+
// ignore the normalizations.
202+
var originalOffset = 0;
203+
var baseOffset = 0;
204+
while (originalOffset < originalPath.Length && baseOffset < pathBase.Length)
205+
{
206+
var baseValue = pathBase[baseOffset];
207+
var offsetValue = originalPath[originalOffset];
208+
if (baseValue == offsetValue
209+
|| char.ToUpperInvariant(baseValue) == char.ToUpperInvariant(offsetValue))
210+
{
211+
// case-insensitive match, continue
212+
originalOffset++;
213+
baseOffset++;
214+
}
215+
else if (baseValue == '/' && offsetValue == '\\')
216+
{
217+
// Http.Sys considers these equivalent
218+
originalOffset++;
219+
baseOffset++;
220+
}
221+
else if (baseValue == '/' && originalPath.AsSpan(originalOffset).StartsWith("%2F", StringComparison.OrdinalIgnoreCase))
222+
{
223+
// Http.Sys un-escapes this
224+
originalOffset += 3;
225+
baseOffset++;
226+
}
227+
else if (baseOffset > 0 && pathBase[baseOffset - 1] == '/'
228+
&& (offsetValue == '/' || offsetValue == '\\'))
229+
{
230+
// Duplicate slash, skip
231+
originalOffset++;
232+
}
233+
else if (baseOffset > 0 && pathBase[baseOffset - 1] == '/'
234+
&& originalPath.AsSpan(originalOffset).StartsWith("%2F", StringComparison.OrdinalIgnoreCase))
235+
{
236+
// Duplicate slash equivalent, skip
237+
originalOffset += 3;
238+
}
239+
else
240+
{
241+
// Mismatch, fall back
242+
// The failing test case here is "/base/call//../ball//path1//path2", reduced to "/base/call/ball//path1//path2",
243+
// where http.sys collapses "//" before "../", but we do "../" first. We've lost the context that there were dot segments,
244+
// or duplicate slashes, how do we figure out that "call/" can be eliminated?
245+
originalOffset = 0;
246+
break;
247+
}
248+
}
249+
PathBase = originalPath[..originalOffset];
250+
Path = originalPath[originalOffset..];
165251
}
166252

167253
var cookedUrl = GetCookedUrl();

src/Servers/IIS/IIS/src/Core/IISHttpServer.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ internal class IISHttpServer : IServer
3030
private readonly IISServerOptions _options;
3131
private readonly IISNativeApplication _nativeApplication;
3232
private readonly ServerAddressesFeature _serverAddressesFeature;
33+
private readonly string? _virtualPath;
3334

3435
private readonly TaskCompletionSource _shutdownSignal = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
3536
private bool? _websocketAvailable;
@@ -69,6 +70,8 @@ ILogger<IISHttpServer> logger
6970
_logger = logger;
7071
_options = options.Value;
7172
_serverAddressesFeature = new ServerAddressesFeature();
73+
var iisConfigData = NativeMethods.HttpGetApplicationProperties();
74+
_virtualPath = iisConfigData.pwzVirtualApplicationPath;
7275

7376
if (_options.ForwardWindowsAuthentication)
7477
{
@@ -83,6 +86,8 @@ ILogger<IISHttpServer> logger
8386
}
8487
}
8588

89+
public string? VirtualPath => _virtualPath;
90+
8691
public unsafe Task StartAsync<TContext>(IHttpApplication<TContext> application, CancellationToken cancellationToken) where TContext : notnull
8792
{
8893
_httpServerHandle = GCHandle.Alloc(this);

src/Servers/IIS/IIS/src/Core/IISServerSetupFilter.cs

Lines changed: 0 additions & 38 deletions
This file was deleted.

src/Servers/IIS/IIS/src/WebHostBuilderIISExtensions.cs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,6 @@ public static IWebHostBuilder UseIIS(this IWebHostBuilder hostBuilder)
4242
services.AddSingleton(new IISNativeApplication(new NativeSafeHandle(iisConfigData.pNativeApplication)));
4343
services.AddSingleton<IServer, IISHttpServer>();
4444
services.AddTransient<IISServerAuthenticationHandlerInternal>();
45-
services.AddSingleton<IStartupFilter>(new IISServerSetupFilter(iisConfigData.pwzVirtualApplicationPath));
4645
services.AddAuthenticationCore();
4746
services.AddSingleton<IServerIntegratedAuth>(_ => new ServerIntegratedAuth()
4847
{

0 commit comments

Comments
 (0)