diff --git a/src/Microsoft.Identity.Web.Sidecar/LocalCallerRestriction.cs b/src/Microsoft.Identity.Web.Sidecar/LocalCallerRestriction.cs new file mode 100644 index 000000000..5aef7747f --- /dev/null +++ b/src/Microsoft.Identity.Web.Sidecar/LocalCallerRestriction.cs @@ -0,0 +1,69 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Net; + +namespace Microsoft.Identity.Web.Sidecar; + +/// +/// Restricts the sidecar to callers connecting over the loopback interface +/// (the co-located application), responding with 403 to other callers. The +/// health endpoint is exempt so liveness/readiness probes, which target the +/// pod's routable address, continue to work. +/// +public static class LocalCallerRestriction +{ + /// + /// Path of the health endpoint. It remains reachable from non-loopback + /// callers (for example, orchestrator liveness/readiness probes). + /// + public const string HealthEndpointPath = "/healthz"; + + private static readonly PathString s_healthEndpoint = new(HealthEndpointPath); + + /// + /// Adds middleware that rejects, with 403 Forbidden, any request + /// whose connection does not originate from the loopback interface, except + /// requests to the health endpoint. + /// + /// The application to configure. + public static void UseLocalCallerRestriction(this WebApplication app) + { + app.Use(static (context, next) => + { + if (IsLocal(context.Connection.RemoteIpAddress) || + context.Request.Path.Equals(s_healthEndpoint, StringComparison.OrdinalIgnoreCase)) + { + return next(context); + } + + context.Response.StatusCode = StatusCodes.Status403Forbidden; + return Task.CompletedTask; + }); + } + + /// + /// Determines whether a connection originates from the local host. + /// A address (for example, in-process hosting that + /// has no transport peer) is treated as local. + /// + internal static bool IsLocal(IPAddress? remoteIpAddress) + { + if (remoteIpAddress is null) + { + // Allow a null address for the local IPC transport (Unix socket / + // named pipe) use case; never null over TCP, so remote callers stay blocked. + return true; + } + + if (IPAddress.IsLoopback(remoteIpAddress)) + { + return true; + } + + // A loopback connection can be surfaced in its IPv4-mapped IPv6 form + // (for example, ::ffff:127.0.0.1); normalize before re-checking. + return remoteIpAddress.IsIPv4MappedToIPv6 && + IPAddress.IsLoopback(remoteIpAddress.MapToIPv4()); + } +} diff --git a/src/Microsoft.Identity.Web.Sidecar/Program.cs b/src/Microsoft.Identity.Web.Sidecar/Program.cs index 90fae47a4..8787654a4 100644 --- a/src/Microsoft.Identity.Web.Sidecar/Program.cs +++ b/src/Microsoft.Identity.Web.Sidecar/Program.cs @@ -40,18 +40,6 @@ public static void Main(string[] args) options.AllowWebApiToBeAuthorizedByACL = true; }); - if (!builder.Environment.IsDevelopment()) - { - // When not in a development environment, only allow connecting over - // localhost. - // AddHostFiltering is needed because this is using SlimBuilder which doesn't include - // that middleware by default. - builder.Services.AddHostFiltering(options => - { - options.AllowedHosts = ["localhost"]; - }); - } - // Add the agent identities and downstream APIs builder.Services.AddAgentIdentities() .AddDownstreamApis(builder.Configuration.GetSection("DownstreamApis")); @@ -71,18 +59,24 @@ public static void Main(string[] args) var app = builder.Build(); + if (!app.Environment.IsDevelopment()) + { + // Loopback-only outside development; health endpoint excepted for probes. + app.UseLocalCallerRestriction(); + } + + // Register auth explicitly so it runs after the loopback check. + app.UseAuthentication(); + app.UseAuthorization(); + // Single endpoint for both liveness and readiness // as no checks are performed as part of startup. - app.MapHealthChecks("/healthz"); + app.MapHealthChecks(LocalCallerRestriction.HealthEndpointPath); if (app.Environment.IsDevelopment()) { app.MapOpenApi(); } - else - { - app.UseHostFiltering(); - } app.AddValidateRequestEndpoints(); app.AddAuthorizationHeaderRequestEndpoints(); diff --git a/tests/E2E Tests/Sidecar.Tests/HostFilteringTests.cs b/tests/E2E Tests/Sidecar.Tests/HostFilteringTests.cs deleted file mode 100644 index 352f0548b..000000000 --- a/tests/E2E Tests/Sidecar.Tests/HostFilteringTests.cs +++ /dev/null @@ -1,58 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -using System.Net; -using Microsoft.Extensions.Hosting; -using Xunit; - -namespace Sidecar.Tests; - -public class HostFilteringTests(SidecarApiFactory factory) : IClassFixture -{ - readonly SidecarApiFactory _factory = factory; - const string EnvironmentKey = "environment"; // WebHostDefaults.EnvironmentKey constant value - - [Fact] - public async Task HostFiltering_NotApplied_In_Development() - { - // Development environment (default for factory) -> no host filtering. - var devFactory = _factory.WithWebHostBuilder(b => { /* Development by default */ }); - var client = devFactory.CreateClient(); - - var request = new HttpRequestMessage(HttpMethod.Get, "/healthz") - { - Headers = { Host = "notlocalhost" } - }; - var response = await client.SendAsync(request); - - Assert.True(response.IsSuccessStatusCode, "Expected success without host filtering in Development environment."); - } - - [Fact] - public async Task HostFiltering_Blocks_Disallowed_Host_In_Production() - { - // Force Production by setting environment variable before building client. - var prodFactory = _factory.WithWebHostBuilder(b => - { - b.UseSetting(EnvironmentKey, Environments.Production); - }); - var client = prodFactory.CreateClient(); - - // Allowed host (localhost) should succeed. - var okResponse = await client.GetAsync("/healthz"); - Assert.True(okResponse.IsSuccessStatusCode, "Expected success for localhost host."); - - // Disallowed host should be rejected. - var badRequest = new HttpRequestMessage(HttpMethod.Get, "/healthz") - { - Headers = { Host = "notlocalhost" } - }; - var badResponse = await client.SendAsync(badRequest); - - var content = await badResponse.Content.ReadAsStringAsync(); - - Assert.Contains("Invalid Hostname", content, StringComparison.OrdinalIgnoreCase); - - Assert.Equal(HttpStatusCode.BadRequest, badResponse.StatusCode); - } -} diff --git a/tests/E2E Tests/Sidecar.Tests/LocalCallerRestrictionTests.cs b/tests/E2E Tests/Sidecar.Tests/LocalCallerRestrictionTests.cs new file mode 100644 index 000000000..e303b4549 --- /dev/null +++ b/tests/E2E Tests/Sidecar.Tests/LocalCallerRestrictionTests.cs @@ -0,0 +1,158 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Net; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Xunit; + +namespace Sidecar.Tests; + +public class LocalCallerRestrictionTests(SidecarApiFactory factory) : IClassFixture +{ + readonly SidecarApiFactory _factory = factory; + + // A routable, non-loopback address standing in for a remote caller. + static readonly IPAddress s_remoteAddress = IPAddress.Parse("203.0.113.10"); + + const string SensitiveEndpoint = "/AuthorizationHeaderUnauthenticated/MsGraph"; + + HttpClient CreateClient(string? environment, IPAddress? remoteIpAddress) + { + var configured = _factory.WithWebHostBuilder(builder => + { + if (environment is not null) + { + builder.UseSetting(WebHostDefaults.EnvironmentKey, environment); + } + + if (remoteIpAddress is not null) + { + builder.ConfigureServices(services => + { + services.AddSingleton(new RemoteIpStartupFilter(remoteIpAddress)); + }); + } + }); + + return configured.CreateClient(); + } + + [Fact] + public async Task Production_NonLoopbackCaller_SensitiveEndpoint_IsForbidden() + { + var client = CreateClient(Environments.Production, s_remoteAddress); + + var response = await client.GetAsync(SensitiveEndpoint); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + [Fact] + public async Task Production_NonLoopbackCaller_UnauthenticatedDownstreamApi_IsForbidden() + { + var client = CreateClient(Environments.Production, s_remoteAddress); + + using var content = new StringContent(string.Empty); + var response = await client.PostAsync("/DownstreamApiUnauthenticated/MsGraph", content); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + [Fact] + public async Task Production_NonLoopbackCaller_AuthenticatedEndpoint_IsForbidden() + { + var client = CreateClient(Environments.Production, s_remoteAddress); + + // A non-local caller is rejected even on an authenticated endpoint. + var response = await client.GetAsync("/Validate"); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + [Fact] + public async Task Production_NonLoopbackCaller_HealthEndpoint_Succeeds() + { + var client = CreateClient(Environments.Production, s_remoteAddress); + + // The health endpoint must remain reachable from non-loopback callers + // (for example, orchestrator probes that target the routable address). + var response = await client.GetAsync(LocalCallerRestrictionPath()); + + Assert.True(response.IsSuccessStatusCode, "Expected the health endpoint to be reachable from a non-loopback caller."); + Assert.Contains("Healthy", await response.Content.ReadAsStringAsync(), StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task Production_LoopbackCaller_SensitiveEndpoint_IsNotForbidden() + { + var client = CreateClient(Environments.Production, IPAddress.Loopback); + + var response = await client.GetAsync(SensitiveEndpoint); + + // The local caller is allowed through; the request may still fail later + // for other reasons, but it must not be Forbidden. + Assert.NotEqual(HttpStatusCode.Forbidden, response.StatusCode); + } + + [Fact] + public async Task Production_IPv4MappedLoopbackCaller_SensitiveEndpoint_IsNotForbidden() + { + var client = CreateClient(Environments.Production, IPAddress.Parse("::ffff:127.0.0.1")); + + var response = await client.GetAsync(SensitiveEndpoint); + + Assert.NotEqual(HttpStatusCode.Forbidden, response.StatusCode); + } + + [Fact] + public async Task Production_NoConnectionAddress_SensitiveEndpoint_IsNotForbidden() + { + // In-process hosting exposes no transport peer (null remote address), + // which is treated as local. + var client = CreateClient(Environments.Production, remoteIpAddress: null); + + var response = await client.GetAsync(SensitiveEndpoint); + + Assert.NotEqual(HttpStatusCode.Forbidden, response.StatusCode); + } + + [Fact] + public async Task Development_NonLoopbackCaller_SensitiveEndpoint_IsNotForbidden() + { + // The restriction is not applied in development, so the inner-loop + // experience is unchanged. + var client = CreateClient(Environments.Development, s_remoteAddress); + + var response = await client.GetAsync(SensitiveEndpoint); + + Assert.NotEqual(HttpStatusCode.Forbidden, response.StatusCode); + } + + static string LocalCallerRestrictionPath() => + Microsoft.Identity.Web.Sidecar.LocalCallerRestriction.HealthEndpointPath; + + /// + /// Prepends a middleware that sets the connection's remote address, so the + /// in-memory test server can exercise the loopback boundary. + /// + sealed class RemoteIpStartupFilter(IPAddress remoteIpAddress) : IStartupFilter + { + readonly IPAddress _remoteIpAddress = remoteIpAddress; + + public Action Configure(Action next) => + app => + { + app.Use(async (context, requestDelegate) => + { + context.Connection.RemoteIpAddress = _remoteIpAddress; + await requestDelegate(context); + }); + + next(app); + }; + } +}