dotnet / aspnetcore

ASP.NET Core is a cross-platform .NET framework for building modern cloud-based web applications on Windows, Mac, or Linux.
https://asp.net
MIT License
35.48k stars 10.04k forks source link

Inconsistent PathBase when running as IIS sub-application #17024

Open bachratyg opened 5 years ago

bachratyg commented 5 years ago

Describe the bug

Response.OnStarting does not observe the same PathBase as the rest of the pipeline on empty-body responses.

To Reproduce

launchSettings.json

{
  "iisSettings": {
    "windowsAuthentication": false, 
    "anonymousAuthentication": true, 
    "iisExpress": {
      "applicationUrl": "http://localhost:37035/NFE",
      "sslPort": 44359
    }
  },
  "profiles": {
    "IIS Express": {
      "commandName": "IISExpress",
      "launchBrowser": true,
      "environmentVariables": {
        "ASPNETCORE_ENVIRONMENT": "Development"
      }
    }
  }
}

Program.cs

public class Program
{
    public static void Main(string[] args)
    {
        WebHost.CreateDefaultBuilder(args)
            .UseStartup<Startup>()
            .Build()
            .Run();
    }
}

Startup.cs

public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
    }
    public void Configure(IApplicationBuilder app)
    {
        app.Use((context, next) =>
        {
            context.Response.Headers.Append("X-PATHBASE1", ">>>" + context.Request.PathBase.Value);
            context.Response.OnStarting(() =>
            {
                context.Response.Headers.Append("X-PATHBASE2", ">>>" + context.Request.PathBase.Value);
                return Task.CompletedTask;
            });
            return next();
        });
        app.Run(context => Task.CompletedTask);
    }
}

Project.csproj (2.1)

<Project Sdk="Microsoft.NET.Sdk.Web">
  <PropertyGroup>
    <TargetFramework>net48</TargetFramework>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Microsoft.AspNetCore" Version="2.1.7" />
  </ItemGroup>
</Project>

Project.csproj (3.0)

<Project Sdk="Microsoft.NET.Sdk.Web">
  <PropertyGroup>
    <TargetFramework>netcoreapp3.0</TargetFramework>
  </PropertyGroup>
</Project>

Run the app, run any request, then observe the response headers. The PathBase observed in the OnStarting callback is empty.

expected headers

X-PATHBASE1: >>>/NFE X-PATHBASE2: >>>/NFE

actual headers

X-PATHBASE1: >>>/NFE X-PATHBASE2: >>>

actual headers when using response body e.g. app.Run(context => context.Response.WriteAsync(""));

X-PATHBASE1: >>>/NFE X-PATHBASE2: >>>/NFE

Additional remarks

The root of the problem seems to be how IIS integration is imlemented. IISSetupFilter/IISServerSetupFilter injects the UsePathBase middleware early in the pipeline which sets the proper PathBase from IIS metadata. However if there is no response body then OnStarting executes after all middlewares have completed and at this point the UsePathBaseMiddleware have reverted the PathBase to its original empty value.

Possibly related: #5898

Further technical details

both 2.1 and 3.0 seems to be affected

.NET Core SDK (reflecting any global.json): Version: 3.0.100 Commit: 04339c3a26

Runtime Environment: OS Name: Windows OS Version: 10.0.18362 OS Platform: Windows RID: win10-x64 Base Path: C:\Program Files\dotnet\sdk\3.0.100\

Host (useful for support): Version: 3.0.0 Commit: 7d57652f33

.NET Core SDKs installed: 2.1.802 [C:\Program Files\dotnet\sdk] 2.2.402 [C:\Program Files\dotnet\sdk] 3.0.100 [C:\Program Files\dotnet\sdk]

.NET Core runtimes installed: Microsoft.AspNetCore.All 2.1.13 [C:\Program Files\dotnet\shared\Microsoft.AspNetCore.All] Microsoft.AspNetCore.All 2.2.7 [C:\Program Files\dotnet\shared\Microsoft.AspNetCore.All] Microsoft.AspNetCore.App 2.1.13 [C:\Program Files\dotnet\shared\Microsoft.AspNetCore.App] Microsoft.AspNetCore.App 2.2.7 [C:\Program Files\dotnet\shared\Microsoft.AspNetCore.App] Microsoft.AspNetCore.App 3.0.0 [C:\Program Files\dotnet\shared\Microsoft.AspNetCore.App] Microsoft.NETCore.App 2.1.13 [C:\Program Files\dotnet\shared\Microsoft.NETCore.App] Microsoft.NETCore.App 2.2.7 [C:\Program Files\dotnet\shared\Microsoft.NETCore.App] Microsoft.NETCore.App 3.0.0 [C:\Program Files\dotnet\shared\Microsoft.NETCore.App] Microsoft.WindowsDesktop.App 3.0.0 [C:\Program Files\dotnet\shared\Microsoft.WindowsDesktop.App]

Visual Studio Community 2019 (16.3.8)

Tratcher commented 5 years ago

While your observations make sense, I will challenge the expectations a little.

A) PathBase is not a stable value, it can change several times during the processing of a request. Components like Map, UsePathBase, and Localization can all alter it. B) OnStarting is not guaranteed a consistent view of the request since it can fire at very different points in the lifecycle as you observed. OnStarting's guarantees focus only on the response.

Yes, you'd get more consistent OnStarting behavior for IIS in-process if the path base was maintained in the server, but those other components would still adjust it. There's not much that could be done for IIS out-of-process since the integration only happens in middleware.

Recommendation: Consider handling IIS in-process PathBase directly in the server like HttpSys does. It would be more efficient and provide a more consistent PathBase experience across the lifespan of the request. Priority: low.

bachratyg commented 5 years ago

PathBase being stable or not is not the issue here. There is deliberately no middleware in the pipeline on my part that could affect PathBase. Since IIS integration is configured on the host and the application base is dictated by IIS I would expect that the context is fully initialized by the time it enters the pipeline and is not "de-initialized" before leaving it. Note: I consider Response.OnStarting and Response.OnCompleted as part of the pipeline since callbacks are usually registered from middlewares.

As a workaround would it be reasonable to query the IIS environment directly like this instead of relying on HttpContext.PathBase?

var pathBase = config.GetSetting("APPL_PATH")
            ?? Environment.GetEnvironmentVariable($"ASPNETCORE_APPL_PATH");

as in src/Microsoft.AspNetCore.Server.IISIntegration/WebHostBuilderIISExtensions.cs?

Maybe also consider exposing hosting-related properties on IWebHostEnvironment?

Tratcher commented 5 years ago

Couldn't you capture PathBase at the same time as you register OnStarting?

Maybe also consider exposing hosting-related properties on IWebHostEnvironment?

We don't consider PathBase a hosting config, but rather a server config. Also note that the support for PathBase is different across all four supported servers. Kestrel has no support, HttpSys supports multiple, IIS out-of-proc supports one via middleware, and IIS in-proc supports one (via middleware?).

bachratyg commented 5 years ago

My repro is somewhat simplified, but the following should possibly work:

private static readonly object ApplicationBaseMarker = "ApplicationBase";
// Call this early in the pipeline/from IStartupFilter
public static IApplicationBuilder MarkApplicationBase(this IApplicationBuilder builder)
{
    return builder.Use(next => context =>
    {
        context.Items[ApplicationBaseMarker] = context.Request.PathBase;
        return next(context);
    });
}
// Use this instead of PathBase
public static PathString GetApplicationBase(this HttpContext context)
{
    return (PathString)context.Items[ApplicationBaseMarker];
}

as long as it's only my code and no external deps are triggered in OnStarting that would use the PathBase. Edit: this would not work when using things like IUrlHelper.Link.

We don't consider PathBase a hosting config, but rather a server config.

Then maybe expose it on a feature interface similar to IServerAddressesFeature/IServerVariablesFeature?

Also note that the support for PathBase is different across all four supported servers. Kestrel has no support, HttpSys supports multiple, IIS out-of-proc supports one via middleware, and IIS in-proc supports one (via middleware?).

When the server supports multiple path bases, one is the prefix of the other and both are valid prefixes for a request then which would be chosen?

Tratcher commented 5 years ago

When the server supports multiple path bases, one is the prefix of the other and both are valid prefixes for a request then which would be chosen?

In HttpSys you can register multiple unrelated prefixes and the most specific match wins. E.g. http://localhost:80/a https://localhost:443/a/b/c http://myhost:80/a/b https://myhost:443/a/b/c/d

Tratcher commented 5 years ago

My recommendation is still the same as above and should address this for you for in-process:

Recommendation: Consider handling IIS in-process PathBase directly in the server like HttpSys does. It would be more efficient and provide a more consistent PathBase experience across the lifespan of the request. Priority: low.

davidfowl commented 5 years ago

This is worth fixing, but I'm not sure it's worth patching.