Skip to content
Merged
69 changes: 69 additions & 0 deletions src/Microsoft.Identity.Web.Sidecar/LocalCallerRestriction.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

using System.Net;

namespace Microsoft.Identity.Web.Sidecar;

/// <summary>
/// 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.
/// </summary>
public static class LocalCallerRestriction
{
/// <summary>
/// Path of the health endpoint. It remains reachable from non-loopback
/// callers (for example, orchestrator liveness/readiness probes).
/// </summary>
public const string HealthEndpointPath = "/healthz";

private static readonly PathString s_healthEndpoint = new(HealthEndpointPath);

/// <summary>
/// Adds middleware that rejects, with <c>403 Forbidden</c>, any request
/// whose connection does not originate from the loopback interface, except
/// requests to the health endpoint.
/// </summary>
/// <param name="app">The application to configure.</param>
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;
});
}

/// <summary>
/// Determines whether a connection originates from the local host.
/// A <see langword="null"/> address (for example, in-process hosting that
/// has no transport peer) is treated as local.
/// </summary>
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;
Comment thread
iNinja marked this conversation as resolved.
}

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());
}
}
28 changes: 11 additions & 17 deletions src/Microsoft.Identity.Web.Sidecar/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
Expand All @@ -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();
Expand Down
58 changes: 0 additions & 58 deletions tests/E2E Tests/Sidecar.Tests/HostFilteringTests.cs

This file was deleted.

158 changes: 158 additions & 0 deletions tests/E2E Tests/Sidecar.Tests/LocalCallerRestrictionTests.cs
Original file line number Diff line number Diff line change
@@ -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<SidecarApiFactory>
{
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<IStartupFilter>(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;

/// <summary>
/// Prepends a middleware that sets the connection's remote address, so the
/// in-memory test server can exercise the loopback boundary.
/// </summary>
sealed class RemoteIpStartupFilter(IPAddress remoteIpAddress) : IStartupFilter
{
readonly IPAddress _remoteIpAddress = remoteIpAddress;

public Action<IApplicationBuilder> Configure(Action<IApplicationBuilder> next) =>
app =>
{
app.Use(async (context, requestDelegate) =>
{
context.Connection.RemoteIpAddress = _remoteIpAddress;
await requestDelegate(context);
});

next(app);
};
}
}
Loading