From 0b82361b7af05dc470a8787aba8d2a686b4a8e91 Mon Sep 17 00:00:00 2001 From: TANUJ SOOD Date: Wed, 1 Jul 2026 15:54:04 +0100 Subject: [PATCH] Suppress outbound HTTP redirects in the sidecar by default Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Configuration/SidecarOptions.cs | 7 + src/Microsoft.Identity.Web.Sidecar/Program.cs | 9 + src/Microsoft.Identity.Web.Sidecar/README.md | 18 ++ .../Sidecar.Tests/OutboundRedirectTests.cs | 237 ++++++++++++++++++ 4 files changed, 271 insertions(+) create mode 100644 tests/E2E Tests/Sidecar.Tests/OutboundRedirectTests.cs diff --git a/src/Microsoft.Identity.Web.Sidecar/Configuration/SidecarOptions.cs b/src/Microsoft.Identity.Web.Sidecar/Configuration/SidecarOptions.cs index 98d35646d..dd9cda737 100644 --- a/src/Microsoft.Identity.Web.Sidecar/Configuration/SidecarOptions.cs +++ b/src/Microsoft.Identity.Web.Sidecar/Configuration/SidecarOptions.cs @@ -16,6 +16,13 @@ public class SidecarOptions /// ignored on that route and a warning is logged. /// public AllowOverridesOptions AllowOverrides { get; set; } = new(); + + /// + /// Gets or sets a value indicating whether the sidecar's outbound + /// follows HTTP redirect (3xx) responses + /// from downstream APIs. Defaults to false. + /// + public bool AllowOutboundRedirects { get; set; } } /// diff --git a/src/Microsoft.Identity.Web.Sidecar/Program.cs b/src/Microsoft.Identity.Web.Sidecar/Program.cs index 90fae47a4..a694b90b0 100644 --- a/src/Microsoft.Identity.Web.Sidecar/Program.cs +++ b/src/Microsoft.Identity.Web.Sidecar/Program.cs @@ -4,6 +4,7 @@ using System.Diagnostics.CodeAnalysis; using System.IdentityModel.Tokens.Jwt; using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.Extensions.Options; using Microsoft.Identity.Web.Sidecar.Configuration; using Microsoft.Identity.Web.Sidecar.Endpoints; using Microsoft.IdentityModel.JsonWebTokens; @@ -58,6 +59,14 @@ public static void Main(string[] args) builder.Services.Configure(builder.Configuration.GetSection("Sidecar")); + builder.Services.ConfigureHttpClientDefaults(http => + http.ConfigurePrimaryHttpMessageHandler(serviceProvider => + new SocketsHttpHandler + { + AllowAutoRedirect = serviceProvider.GetRequiredService>() + .Value.AllowOutboundRedirects, + })); + builder.Services.AddHealthChecks(); ConfigureAuthN(builder); diff --git a/src/Microsoft.Identity.Web.Sidecar/README.md b/src/Microsoft.Identity.Web.Sidecar/README.md index 4b621911b..b1996f31c 100644 --- a/src/Microsoft.Identity.Web.Sidecar/README.md +++ b/src/Microsoft.Identity.Web.Sidecar/README.md @@ -48,6 +48,24 @@ Settings are supplied via `appsettings.json`, environment variables, or any stan - **AzureAd**: Standard Microsoft.Identity.Web web API registration; client credentials are optional if only delegated flows are required. - **DownstreamApis**: Named profiles for endpoints resolved via `{apiName}`. +### Outbound redirects + +`Sidecar:AllowOutboundRedirects` controls whether the sidecar's outbound `HttpClient` +follows HTTP redirect (3xx) responses returned by a downstream API. It defaults to +`false`, so a redirect response is returned to the caller as-is rather than followed. +Set it to `true` to opt in to following redirects. + +> **Note:** Outbound redirects are not followed by default (`AllowOutboundRedirects` +> defaults to `false`). To follow redirects, set it to `true`: +> +> ```jsonc +> { +> "Sidecar": { +> "AllowOutboundRedirects": true +> } +> } +> ``` + ## Running the sidecar ### Prerequisites diff --git a/tests/E2E Tests/Sidecar.Tests/OutboundRedirectTests.cs b/tests/E2E Tests/Sidecar.Tests/OutboundRedirectTests.cs new file mode 100644 index 000000000..580eb2be4 --- /dev/null +++ b/tests/E2E Tests/Sidecar.Tests/OutboundRedirectTests.cs @@ -0,0 +1,237 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Net; +using System.Net.Http.Json; +using System.Net.Sockets; +using System.Security.Claims; +using System.Text; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Microsoft.Identity.Abstractions; +using Microsoft.Identity.Web.Sidecar.Models; +using Moq; +using Xunit; + +namespace Sidecar.Tests; + +public class OutboundRedirectTests +{ + [Fact] + public async Task OutboundRedirect_NotFollowedByDefault_ReturnsRedirectAndTargetNotContacted() + { + // Arrange + const string apiName = "redirect-api"; + + using var redirectTarget = new RawHttpServer(_ => + "HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nOK"); + string redirectTargetUrl = $"http://127.0.0.1:{redirectTarget.Port}/next"; + + using var configured = new RawHttpServer(_ => + $"HTTP/1.1 302 Found\r\nLocation: {redirectTargetUrl}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"); + + var headerProvider = CreateHeaderProviderMock(); + + using var factory = new SidecarApiFactory(); + var client = factory.WithWebHostBuilder(builder => + { + builder.ConfigureServices(services => + { + services.AddSingleton(headerProvider.Object); + services.Configure(apiName, o => + { + o.BaseUrl = $"http://127.0.0.1:{configured.Port}"; + o.RelativePath = "/start"; + o.HttpMethod = HttpMethod.Get.Method; + o.RequestAppToken = true; + o.Scopes = ["api.default"]; + }); + }); + }).CreateClient(); + + // Act + var response = await client.PostAsync($"/DownstreamApiUnauthenticated/{apiName}", content: null); + var body = await response.Content.ReadFromJsonAsync(); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotNull(body); + Assert.Equal(302, body!.StatusCode); + Assert.True(configured.HitCount >= 1); + Assert.Equal(0, redirectTarget.HitCount); + } + + [Fact] + public async Task OutboundRedirect_FollowedWhenOptedIn_TargetContacted() + { + // Arrange + const string apiName = "redirect-api"; + + using var redirectTarget = new RawHttpServer(_ => + "HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nOK"); + string redirectTargetUrl = $"http://127.0.0.1:{redirectTarget.Port}/next"; + + using var configured = new RawHttpServer(_ => + $"HTTP/1.1 302 Found\r\nLocation: {redirectTargetUrl}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"); + + var headerProvider = CreateHeaderProviderMock(); + + using var factory = new SidecarApiFactory(); + var client = factory.WithWebHostBuilder(builder => + { + builder.ConfigureAppConfiguration((_, config) => + { + config.AddInMemoryCollection(new Dictionary + { + { "Sidecar:AllowOutboundRedirects", "true" }, + }); + }); + + builder.ConfigureServices(services => + { + services.AddSingleton(headerProvider.Object); + services.Configure(apiName, o => + { + o.BaseUrl = $"http://127.0.0.1:{configured.Port}"; + o.RelativePath = "/start"; + o.HttpMethod = HttpMethod.Get.Method; + o.RequestAppToken = true; + o.Scopes = ["api.default"]; + }); + }); + }).CreateClient(); + + // Act + var response = await client.PostAsync($"/DownstreamApiUnauthenticated/{apiName}", content: null); + var body = await response.Content.ReadFromJsonAsync(); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotNull(body); + Assert.Equal(200, body!.StatusCode); + Assert.True(configured.HitCount >= 1); + Assert.True(redirectTarget.HitCount >= 1); + } + + // Sidecar supports bearer tokens only. To add mTLS, also set AllowAutoRedirect = false + // on the mTLS client. + [Fact] + public async Task Sidecar_DownstreamCalls_UseDefaultHttpClientFactoryPath() + { + // Arrange + const string apiName = "guard-api"; + + using var factory = new SidecarApiFactory(); + using var host = factory.WithWebHostBuilder(builder => + { + builder.ConfigureServices(services => + { + services.Configure(apiName, o => + { + o.BaseUrl = "https://downstream.example"; + o.Scopes = ["api.default"]; + }); + }); + }); + + using var scope = host.Services.CreateScope(); + var provider = scope.ServiceProvider; + + // Act + var schemes = (await provider.GetRequiredService() + .GetAllSchemesAsync()).ToList(); + var defaultScheme = await provider.GetRequiredService() + .GetDefaultAuthenticateSchemeAsync(); + var options = provider.GetRequiredService>().Get(apiName); + + // Assert + Assert.All(schemes, s => Assert.Equal(JwtBearerDefaults.AuthenticationScheme, s.Name)); + Assert.Equal(JwtBearerDefaults.AuthenticationScheme, defaultScheme?.Name); + Assert.NotEqual("MTLS", options.ProtocolScheme, StringComparer.OrdinalIgnoreCase); + Assert.NotEqual("MTLS_POP", options.ProtocolScheme, StringComparer.OrdinalIgnoreCase); + } + + private static Mock CreateHeaderProviderMock() + { + var mock = new Mock(); + mock.Setup(p => p.CreateAuthorizationHeaderAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync("Bearer test-token"); + return mock; + } + + private sealed class RawHttpServer : IDisposable + { + private readonly TcpListener _listener; + private readonly Func _responder; + private readonly CancellationTokenSource _cts = new(); + private int _hitCount; + + public RawHttpServer(Func responder) + { + _responder = responder; + _listener = new TcpListener(IPAddress.Loopback, 0); + _listener.Start(); + Port = ((IPEndPoint)_listener.LocalEndpoint).Port; + _ = Task.Run(AcceptLoopAsync); + } + + public int Port { get; } + + public int HitCount => Volatile.Read(ref _hitCount); + + private async Task AcceptLoopAsync() + { + while (!_cts.IsCancellationRequested) + { + TcpClient tcp; + try + { + tcp = await _listener.AcceptTcpClientAsync(_cts.Token); + } + catch + { + break; + } + + _ = Task.Run(() => HandleAsync(tcp)); + } + } + + private async Task HandleAsync(TcpClient tcp) + { + try + { + using (tcp) + await using (var stream = tcp.GetStream()) + { + var buffer = new byte[8192]; + int read = await stream.ReadAsync(buffer); + string requestText = Encoding.ASCII.GetString(buffer, 0, read); + Interlocked.Increment(ref _hitCount); + + byte[] responseBytes = Encoding.ASCII.GetBytes(_responder(requestText)); + await stream.WriteAsync(responseBytes); + await stream.FlushAsync(); + } + } + catch + { + // Best-effort test listener. + } + } + + public void Dispose() + { + _cts.Cancel(); + _listener.Stop(); + _cts.Dispose(); + } + } +}