Skip to content

[7.0] Match strangely formatted PathBase in HttpSys & IIS #42751 #45008

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Nov 14, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 86 additions & 19 deletions src/Servers/HttpSys/src/RequestProcessing/Request.cs
Original file line number Diff line number Diff line change
Expand Up @@ -58,39 +58,106 @@ internal Request(RequestContext requestContext)

PathBase = string.Empty;
Path = originalPath;
var prefix = requestContext.Server.Options.UrlPrefixes.GetPrefix((int)requestContext.UrlContext);

// 'OPTIONS * HTTP/1.1'
if (KnownMethod == HttpApiTypes.HTTP_VERB.HttpVerbOPTIONS && string.Equals(RawUrl, "*", StringComparison.Ordinal))
{
PathBase = string.Empty;
Path = string.Empty;
}
else
// Prefix may be null if the requested has been transfered to our queue
else if (prefix is not null)
{
var prefix = requestContext.Server.Options.UrlPrefixes.GetPrefix((int)requestContext.UrlContext);
// Prefix may be null if the requested has been transfered to our queue
if (!(prefix is null))
var pathBase = prefix.PathWithoutTrailingSlash;

// url: /base/path, prefix: /base/, base: /base, path: /path
// url: /, prefix: /, base: , path: /
if (originalPath.Equals(pathBase, StringComparison.Ordinal))
{
if (originalPath.Length == prefix.PathWithoutTrailingSlash.Length)
{
// They matched exactly except for the trailing slash.
PathBase = originalPath;
Path = string.Empty;
}
else
{
// url: /base/path, prefix: /base/, base: /base, path: /path
// url: /, prefix: /, base: , path: /
PathBase = originalPath.Substring(0, prefix.PathWithoutTrailingSlash.Length); // Preserve the user input casing
Path = originalPath.Substring(prefix.PathWithoutTrailingSlash.Length);
}
// Exact match, no need to preserve the casing
PathBase = pathBase;
Path = string.Empty;
}
else if (requestContext.Server.Options.UrlPrefixes.TryMatchLongestPrefix(IsHttps, cookedUrl.GetHost()!, originalPath, out var pathBase, out var path))
else if (originalPath.Equals(pathBase, StringComparison.OrdinalIgnoreCase))
{
// Preserve the user input casing
PathBase = originalPath;
Path = string.Empty;
}
else if (originalPath.StartsWith(prefix.Path, StringComparison.Ordinal))
{
// Exact match, no need to preserve the casing
PathBase = pathBase;
Path = path;
Path = originalPath[pathBase.Length..];
}
else if (originalPath.StartsWith(prefix.Path, StringComparison.OrdinalIgnoreCase))
{
// Preserve the user input casing
PathBase = originalPath[..pathBase.Length];
Path = originalPath[pathBase.Length..];
}
else
{
// Http.Sys path base matching is based on the cooked url which applies some non-standard normalizations that we don't use
// like collapsing duplicate slashes "//", converting '\' to '/', and un-escaping "%2F" to '/'. Find the right split and
// ignore the normalizations.
var originalOffset = 0;
var baseOffset = 0;
while (originalOffset < originalPath.Length && baseOffset < pathBase.Length)
{
var baseValue = pathBase[baseOffset];
var offsetValue = originalPath[originalOffset];
if (baseValue == offsetValue
|| char.ToUpperInvariant(baseValue) == char.ToUpperInvariant(offsetValue))
{
// case-insensitive match, continue
originalOffset++;
baseOffset++;
}
else if (baseValue == '/' && offsetValue == '\\')
{
// Http.Sys considers these equivalent
originalOffset++;
baseOffset++;
}
else if (baseValue == '/' && originalPath.AsSpan(originalOffset).StartsWith("%2F", StringComparison.OrdinalIgnoreCase))
{
// Http.Sys un-escapes this
originalOffset += 3;
baseOffset++;
}
else if (baseOffset > 0 && pathBase[baseOffset - 1] == '/'
&& (offsetValue == '/' || offsetValue == '\\'))
{
// Duplicate slash, skip
originalOffset++;
}
else if (baseOffset > 0 && pathBase[baseOffset - 1] == '/'
&& originalPath.AsSpan(originalOffset).StartsWith("%2F", StringComparison.OrdinalIgnoreCase))
{
// Duplicate slash equivalent, skip
originalOffset += 3;
}
else
{
// Mismatch, fall back
// The failing test case here is "/base/call//../bat//path1//path2", reduced to "/base/call/bat//path1//path2",
// where http.sys collapses "//" before "../", but we do "../" first. We've lost the context that there were dot segments,
// or duplicate slashes, how do we figure out that "call/" can be eliminated?
originalOffset = 0;
break;
}
}
PathBase = originalPath[..originalOffset];
Path = originalPath[originalOffset..];
}
}
else if (requestContext.Server.Options.UrlPrefixes.TryMatchLongestPrefix(IsHttps, cookedUrl.GetHost()!, originalPath, out var pathBase, out var path))
{
PathBase = pathBase;
Path = path;
}

ProtocolVersion = RequestContext.GetVersion();

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Licensed to the .NET Foundation under one or more agreements.
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System;
Expand Down Expand Up @@ -138,6 +138,42 @@ public async Task Request_OverlongUTF8Path(string requestPath, string expectedPa
}
}

[ConditionalTheory]
[InlineData("/", "/", "", "/")]
[InlineData("/base", "/base", "/base", "")]
[InlineData("/base", "/baSe", "/baSe", "")]
[InlineData("/base", "/base/path", "/base", "/path")]
[InlineData("/base", "///base/path1/path2", "///base", "/path1/path2")]
[InlineData("/base/ball", @"/baSe\ball//path1//path2", @"/baSe\ball", "//path1//path2")]
[InlineData("/base/ball", @"/base%2fball//path1//path2", @"/base%2fball", "//path1//path2")]
[InlineData("/base/ball", @"/base%2Fball//path1//path2", @"/base%2Fball", "//path1//path2")]
[InlineData("/base/ball", @"/base%5cball//path1//path2", @"/base\ball", "//path1//path2")]
[InlineData("/base/ball", @"/base%5Cball//path1//path2", @"/base\ball", "//path1//path2")]
[InlineData("/base/ball", "///baSe//ball//path1//path2", "///baSe//ball", "//path1//path2")]
[InlineData("/base/ball", @"/base/\ball//path1//path2", @"/base/\ball", "//path1//path2")]
[InlineData("/base/ball", @"/base/%2fball//path1//path2", @"/base/%2fball", "//path1//path2")]
[InlineData("/base/ball", @"/base/%2Fball//path1//path2", @"/base/%2Fball", "//path1//path2")]
[InlineData("/base/ball", @"/base/%5cball//path1//path2", @"/base/\ball", "//path1//path2")]
[InlineData("/base/ball", @"/base/%5Cball//path1//path2", @"/base/\ball", "//path1//path2")]
[InlineData("/base/ball", @"/base/call/../ball//path1//path2", @"/base/ball", "//path1//path2")]
// The results should be "/base/ball", "//path1//path2", but Http.Sys collapses the "//" before the "../"
// and we don't have a good way of emulating that.
[InlineData("/base/ball", @"/base/call//../ball//path1//path2", @"", "/base/call/ball//path1//path2")]
[InlineData("/base/ball", @"/base/call/.%2e/ball//path1//path2", @"/base/ball", "//path1//path2")]
[InlineData("/base/ball", @"/base/call/.%2E/ball//path1//path2", @"/base/ball", "//path1//path2")]
public async Task Request_WithPathBase(string pathBase, string requestPath, string expectedPathBase, string expectedPath)
{
using var server = Utilities.CreateHttpServerReturnRoot(pathBase, out var root);
var responseTask = SendSocketRequestAsync(root, requestPath);
var context = await server.AcceptAsync(Utilities.DefaultTimeout).Before(responseTask);
Assert.Equal(expectedPathBase, context.Request.PathBase);
Assert.Equal(expectedPath, context.Request.Path);
context.Dispose();

var response = await responseTask;
Assert.Equal("200", response.Substring(9));
}

private async Task<string> SendSocketRequestAsync(string address, string path, string method = "GET")
{
var uri = new Uri(address);
Expand Down
1 change: 1 addition & 0 deletions src/Servers/HttpSys/test/FunctionalTests/RequestTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,7 @@ public async Task Request_FieldsCanBeSetToNull_Set()
[InlineData("/base path/", "/base%20path/sub%20path", "/base path", "/sub path")]
[InlineData("/base葉path/", "/base%E8%91%89path/sub%E8%91%89path", "/base葉path", "/sub葉path")]
[InlineData("/basepath/", "/basepath/sub%2Fpath", "/basepath", "/sub%2Fpath")]
[InlineData("/base", "///base/path1/path2", "///base", "/path1/path2")]
public async Task Request_PathSplitting(string pathBase, string requestPath, string expectedPathBase, string expectedPath)
{
string root;
Expand Down
96 changes: 91 additions & 5 deletions src/Servers/IIS/IIS/src/Core/IISHttpContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -143,19 +143,105 @@ protected void InitializeContext()
KnownMethod = VerbId;
StatusCode = 200;

var originalPath = GetOriginalPath();
var originalPath = GetOriginalPath() ?? string.Empty;
var pathBase = _server.VirtualPath ?? string.Empty;
if (pathBase.Length > 1 && pathBase[^1] == '/')
{
pathBase = pathBase[..^1];
}

if (KnownMethod == HttpApiTypes.HTTP_VERB.HttpVerbOPTIONS && string.Equals(RawTarget, "*", StringComparison.Ordinal))
{
PathBase = string.Empty;
Path = string.Empty;
}
else
else if (string.IsNullOrEmpty(pathBase) || pathBase == "/")
{
// Path and pathbase are unescaped by RequestUriBuilder
// The UsePathBase middleware will modify the pathbase and path correctly
PathBase = string.Empty;
Path = originalPath ?? string.Empty;
Path = originalPath;
}
else if (originalPath.Equals(pathBase, StringComparison.Ordinal))
{
// Exact match, no need to preserve the casing
PathBase = pathBase;
Path = string.Empty;
}
else if (originalPath.Equals(pathBase, StringComparison.OrdinalIgnoreCase))
{
// Preserve the user input casing
PathBase = originalPath;
Path = string.Empty;
}
else if (originalPath.Length == pathBase.Length + 1
&& originalPath[^1] == '/'
&& originalPath.StartsWith(pathBase, StringComparison.Ordinal))
{
// Exact match, no need to preserve the casing
PathBase = pathBase;
Path = "/";
}
else if (originalPath.Length == pathBase.Length + 1
&& originalPath[^1] == '/'
&& originalPath.StartsWith(pathBase, StringComparison.OrdinalIgnoreCase))
{
// Preserve the user input casing
PathBase = originalPath[..pathBase.Length];
Path = "/";
}
else
{
// Http.Sys path base matching is based on the cooked url which applies some non-standard normalizations that we don't use
// like collapsing duplicate slashes "//", converting '\' to '/', and un-escaping "%2F" to '/'. Find the right split and
// ignore the normalizations.
var originalOffset = 0;
var baseOffset = 0;
while (originalOffset < originalPath.Length && baseOffset < pathBase.Length)
{
var baseValue = pathBase[baseOffset];
var offsetValue = originalPath[originalOffset];
if (baseValue == offsetValue
|| char.ToUpperInvariant(baseValue) == char.ToUpperInvariant(offsetValue))
{
// case-insensitive match, continue
originalOffset++;
baseOffset++;
}
else if (baseValue == '/' && offsetValue == '\\')
{
// Http.Sys considers these equivalent
originalOffset++;
baseOffset++;
}
else if (baseValue == '/' && originalPath.AsSpan(originalOffset).StartsWith("%2F", StringComparison.OrdinalIgnoreCase))
{
// Http.Sys un-escapes this
originalOffset += 3;
baseOffset++;
}
else if (baseOffset > 0 && pathBase[baseOffset - 1] == '/'
&& (offsetValue == '/' || offsetValue == '\\'))
{
// Duplicate slash, skip
originalOffset++;
}
else if (baseOffset > 0 && pathBase[baseOffset - 1] == '/'
&& originalPath.AsSpan(originalOffset).StartsWith("%2F", StringComparison.OrdinalIgnoreCase))
{
// Duplicate slash equivalent, skip
originalOffset += 3;
}
else
{
// Mismatch, fall back
// The failing test case here is "/base/call//../bat//path1//path2", reduced to "/base/call/bat//path1//path2",
// where http.sys collapses "//" before "../", but we do "../" first. We've lost the context that there were dot segments,
// or duplicate slashes, how do we figure out that "call/" can be eliminated?
originalOffset = 0;
break;
}
}
PathBase = originalPath[..originalOffset];
Path = originalPath[originalOffset..];
}

var cookedUrl = GetCookedUrl();
Expand Down
5 changes: 5 additions & 0 deletions src/Servers/IIS/IIS/src/Core/IISHttpServer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ internal sealed class IISHttpServer : IServer
private readonly IISServerOptions _options;
private readonly IISNativeApplication _nativeApplication;
private readonly ServerAddressesFeature _serverAddressesFeature;
private readonly string? _virtualPath;

private readonly TaskCompletionSource _shutdownSignal = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
private bool? _websocketAvailable;
Expand Down Expand Up @@ -66,6 +67,8 @@ ILogger<IISHttpServer> logger
_logger = logger;
_options = options.Value;
_serverAddressesFeature = new ServerAddressesFeature();
var iisConfigData = NativeMethods.HttpGetApplicationProperties();
_virtualPath = iisConfigData.pwzVirtualApplicationPath;

if (_options.ForwardWindowsAuthentication)
{
Expand All @@ -80,6 +83,8 @@ ILogger<IISHttpServer> logger
}
}

public string? VirtualPath => _virtualPath;

public unsafe Task StartAsync<TContext>(IHttpApplication<TContext> application, CancellationToken cancellationToken) where TContext : notnull
{
_httpServerHandle = GCHandle.Alloc(this);
Expand Down
8 changes: 0 additions & 8 deletions src/Servers/IIS/IIS/src/Core/IISServerSetupFilter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,6 @@ namespace Microsoft.AspNetCore.Server.IIS.Core;

internal sealed class IISServerSetupFilter : IStartupFilter
{
private readonly string _virtualPath;

public IISServerSetupFilter(string virtualPath)
{
_virtualPath = virtualPath;
}

public Action<IApplicationBuilder> Configure(Action<IApplicationBuilder> next)
{
return app =>
Expand All @@ -27,7 +20,6 @@ public Action<IApplicationBuilder> Configure(Action<IApplicationBuilder> next)
throw new InvalidOperationException("Application is running inside IIS process but is not configured to use IIS server.");
}

app.UsePathBase(_virtualPath);
next(app);
};
}
Expand Down
2 changes: 1 addition & 1 deletion src/Servers/IIS/IIS/src/WebHostBuilderIISExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ public static IWebHostBuilder UseIIS(this IWebHostBuilder hostBuilder)
services.AddSingleton(new IISNativeApplication(new NativeSafeHandle(iisConfigData.pNativeApplication)));
services.AddSingleton<IServer, IISHttpServer>();
services.AddTransient<IISServerAuthenticationHandlerInternal>();
services.AddSingleton<IStartupFilter>(new IISServerSetupFilter(iisConfigData.pwzVirtualApplicationPath));
services.AddSingleton<IStartupFilter, IISServerSetupFilter>();
services.AddAuthenticationCore();
services.AddSingleton<IServerIntegratedAuth>(_ => new ServerIntegratedAuth()
{
Expand Down
Loading