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);
+ };
+ }
+}