-
Notifications
You must be signed in to change notification settings - Fork 271
Sidecar: add loopback-only access #3897
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
iNinja
merged 8 commits into
AzureAD:master
from
soodt:tanujsood/sidecar-connection-scope
Jul 2, 2026
+238
−75
Merged
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
eb62bc4
Sidecar: add loopback-only access outside development
fc59073
Merge branch 'master' into tanujsood/sidecar-connection-scope
iNinja 9dfdb09
Sidecar: document null-address handling in loopback check
0896649
Merge branch 'master' into tanujsood/sidecar-connection-scope
iNinja 9696e77
Merge branch 'master' into tanujsood/sidecar-connection-scope
iNinja 7b3b0d7
Merge remote-tracking branch 'origin/master' into tanujsood/sidecar-c…
2402690
Merge branch 'master' into tanujsood/sidecar-connection-scope
iNinja 62c9466
Merge branch 'master' into tanujsood/sidecar-connection-scope
iNinja File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
69 changes: 69 additions & 0 deletions
69
src/Microsoft.Identity.Web.Sidecar/LocalCallerRestriction.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
| } | ||
|
|
||
| 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()); | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file was deleted.
Oops, something went wrong.
158 changes: 158 additions & 0 deletions
158
tests/E2E Tests/Sidecar.Tests/LocalCallerRestrictionTests.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| }; | ||
| } | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.