Skip to content

Commit 1e4a18f

Browse files
[main] Match strangely formatted PathBase in HttpSys & IIS #42751 (#45011)
1 parent 21e6175 commit 1e4a18f

File tree

14 files changed

+2140
-34
lines changed

14 files changed

+2140
-34
lines changed

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

Lines changed: 86 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -60,39 +60,106 @@ internal Request(RequestContext requestContext)
6060

6161
PathBase = string.Empty;
6262
Path = originalPath;
63+
var prefix = requestContext.Server.Options.UrlPrefixes.GetPrefix((int)requestContext.UrlContext);
6364

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

97164
ProtocolVersion = RequestContext.GetVersion();
98165

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
@@ -143,19 +143,105 @@ protected void InitializeContext()
143143
KnownMethod = VerbId;
144144
StatusCode = 200;
145145

146-
var originalPath = GetOriginalPath();
146+
var originalPath = GetOriginalPath() ?? string.Empty;
147+
var pathBase = _server.VirtualPath ?? string.Empty;
148+
if (pathBase.Length > 1 && pathBase[^1] == '/')
149+
{
150+
pathBase = pathBase[..^1];
151+
}
147152

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

161247
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
@@ -27,6 +27,7 @@ internal sealed class IISHttpServer : IServer
2727
private readonly IISServerOptions _options;
2828
private readonly IISNativeApplication _nativeApplication;
2929
private readonly ServerAddressesFeature _serverAddressesFeature;
30+
private readonly string? _virtualPath;
3031

3132
private readonly TaskCompletionSource _shutdownSignal = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
3233
private bool? _websocketAvailable;
@@ -66,6 +67,8 @@ ILogger<IISHttpServer> logger
6667
_logger = logger;
6768
_options = options.Value;
6869
_serverAddressesFeature = new ServerAddressesFeature();
70+
var iisConfigData = NativeMethods.HttpGetApplicationProperties();
71+
_virtualPath = iisConfigData.pwzVirtualApplicationPath;
6972

7073
if (_options.ForwardWindowsAuthentication)
7174
{
@@ -80,6 +83,8 @@ ILogger<IISHttpServer> logger
8083
}
8184
}
8285

86+
public string? VirtualPath => _virtualPath;
87+
8388
public unsafe Task StartAsync<TContext>(IHttpApplication<TContext> application, CancellationToken cancellationToken) where TContext : notnull
8489
{
8590
_httpServerHandle = GCHandle.Alloc(this);

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

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,6 @@ namespace Microsoft.AspNetCore.Server.IIS.Core;
1010

1111
internal sealed class IISServerSetupFilter : IStartupFilter
1212
{
13-
private readonly string _virtualPath;
14-
15-
public IISServerSetupFilter(string virtualPath)
16-
{
17-
_virtualPath = virtualPath;
18-
}
19-
2013
public Action<IApplicationBuilder> Configure(Action<IApplicationBuilder> next)
2114
{
2215
return app =>
@@ -27,7 +20,6 @@ public Action<IApplicationBuilder> Configure(Action<IApplicationBuilder> next)
2720
throw new InvalidOperationException("Application is running inside IIS process but is not configured to use IIS server.");
2821
}
2922

30-
app.UsePathBase(_virtualPath);
3123
next(app);
3224
};
3325
}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ public static IWebHostBuilder UseIIS(this IWebHostBuilder hostBuilder)
3737
services.AddSingleton(new IISNativeApplication(new NativeSafeHandle(iisConfigData.pNativeApplication)));
3838
services.AddSingleton<IServer, IISHttpServer>();
3939
services.AddTransient<IISServerAuthenticationHandlerInternal>();
40-
services.AddSingleton<IStartupFilter>(new IISServerSetupFilter(iisConfigData.pwzVirtualApplicationPath));
40+
services.AddSingleton<IStartupFilter, IISServerSetupFilter>();
4141
services.AddAuthenticationCore();
4242
services.AddSingleton<IServerIntegratedAuth>(_ => new ServerIntegratedAuth()
4343
{

0 commit comments

Comments
 (0)