diff --git a/src/Microsoft.Identity.Web.DownstreamApi/DownstreamApi.Logger.cs b/src/Microsoft.Identity.Web.DownstreamApi/DownstreamApi.Logger.cs index 2de470fd6..65ab157d7 100644 --- a/src/Microsoft.Identity.Web.DownstreamApi/DownstreamApi.Logger.cs +++ b/src/Microsoft.Identity.Web.DownstreamApi/DownstreamApi.Logger.cs @@ -30,6 +30,18 @@ internal static class Logger DownstreamApiLoggingEventId.UnauthenticatedApiCall, "[MsIdWeb] An unauthenticated call was made to the Api with null Scopes"); + private static readonly Action s_reservedHeaderIgnored = + LoggerMessage.Define( + LogLevel.Warning, + DownstreamApiLoggingEventId.ReservedHeaderIgnored, + "[MsIdWeb] Header '{HeaderName}' supplied through ExtraHeaderParameters was ignored because the name is reserved for the library."); + + private static readonly Action s_duplicateHeaderIgnored = + LoggerMessage.Define( + LogLevel.Warning, + DownstreamApiLoggingEventId.DuplicateHeaderIgnored, + "[MsIdWeb] Header '{HeaderName}' supplied through ExtraHeaderParameters was ignored because the request already carries a value for it."); + /// /// Logger for handling options exceptions in DownstreamApi. /// @@ -57,6 +69,25 @@ public static void HttpRequestError( public static void UnauthenticatedApiCall( ILogger logger, Exception? ex) => s_unauthenticatedApiCall(logger, ex); + + /// + /// Logs that an ExtraHeaderParameters entry was skipped because its name is reserved. + /// + /// Logger. + /// Header name that was ignored. + public static void ReservedHeaderIgnored( + ILogger logger, + string headerName) => s_reservedHeaderIgnored(logger, headerName, null); + + /// + /// Logs that an ExtraHeaderParameters entry was skipped because the request already + /// carries a value for the same header name. + /// + /// Logger. + /// Header name that was ignored. + public static void DuplicateHeaderIgnored( + ILogger logger, + string headerName) => s_duplicateHeaderIgnored(logger, headerName, null); } } } diff --git a/src/Microsoft.Identity.Web.DownstreamApi/DownstreamApi.cs b/src/Microsoft.Identity.Web.DownstreamApi/DownstreamApi.cs index cac8b46a4..8d6e97b2f 100644 --- a/src/Microsoft.Identity.Web.DownstreamApi/DownstreamApi.cs +++ b/src/Microsoft.Identity.Web.DownstreamApi/DownstreamApi.cs @@ -784,11 +784,25 @@ public Task CallApiForAppAsync( httpRequestMessage.Headers.Accept.ParseAdd(effectiveOptions.AcceptHeader); } - // Add extra headers if specified directly on DownstreamApiOptions + // Add extra headers if specified directly on DownstreamApiOptions. + // Skip names that are reserved for the library or already present on + // the outgoing request to keep the library-set values authoritative. if (effectiveOptions.ExtraHeaderParameters != null) { foreach (var header in effectiveOptions.ExtraHeaderParameters) { + if (ReservedHeaderNames.IsReserved(header.Key)) + { + Logger.ReservedHeaderIgnored(_logger, header.Key); + continue; + } + + if (httpRequestMessage.Headers.Contains(header.Key)) + { + Logger.DuplicateHeaderIgnored(_logger, header.Key); + continue; + } + httpRequestMessage.Headers.TryAddWithoutValidation(header.Key, header.Value); } } diff --git a/src/Microsoft.Identity.Web.DownstreamApi/DownstreamApiLoggingEventId.cs b/src/Microsoft.Identity.Web.DownstreamApi/DownstreamApiLoggingEventId.cs index a6865557d..d0a4b17e1 100644 --- a/src/Microsoft.Identity.Web.DownstreamApi/DownstreamApiLoggingEventId.cs +++ b/src/Microsoft.Identity.Web.DownstreamApi/DownstreamApiLoggingEventId.cs @@ -11,6 +11,8 @@ internal static class DownstreamApiLoggingEventId // DownstreamApi EventIds 100+ public static readonly EventId HttpRequestError = new(100, "HttpRequestError"); public static readonly EventId UnauthenticatedApiCall = new(101, "UnauthenticatedApiCall"); + public static readonly EventId ReservedHeaderIgnored = new(102, "ReservedHeaderIgnored"); + public static readonly EventId DuplicateHeaderIgnored = new(103, "DuplicateHeaderIgnored"); #pragma warning restore IDE1006 // Naming styles } } diff --git a/src/Microsoft.Identity.Web.DownstreamApi/ReservedHeaderNames.cs b/src/Microsoft.Identity.Web.DownstreamApi/ReservedHeaderNames.cs new file mode 100644 index 000000000..f830f2fe7 --- /dev/null +++ b/src/Microsoft.Identity.Web.DownstreamApi/ReservedHeaderNames.cs @@ -0,0 +1,65 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; + +namespace Microsoft.Identity.Web +{ + /// + /// Reserved header names that callers must not provide through + /// . + /// The library either sets these itself, or they have host-level meaning that + /// should not be controlled by per-request configuration. + /// + internal static class ReservedHeaderNames + { + // Exact-match names (case-insensitive). + private static readonly HashSet s_exactNames = new(StringComparer.OrdinalIgnoreCase) + { + "Authorization", + "Cookie", + "Host", + "X-Original-URL", + "X-MS-CLIENT-PRINCIPAL", + "X-MS-CLIENT-PRINCIPAL-ID", + "X-MS-CLIENT-PRINCIPAL-NAME", + "X-MS-CLIENT-PRINCIPAL-IDP", + }; + + // Prefix-match names (case-insensitive). Any header name starting with one of + // these prefixes is treated as reserved. + private static readonly string[] s_prefixes = new[] + { + "X-Forwarded-", + "X-MS-TOKEN-AAD-", + }; + + /// + /// Returns when matches any + /// reserved exact name or reserved prefix. + /// + public static bool IsReserved(string headerName) + { + if (string.IsNullOrEmpty(headerName)) + { + return false; + } + + if (s_exactNames.Contains(headerName)) + { + return true; + } + + for (int i = 0; i < s_prefixes.Length; i++) + { + if (headerName.StartsWith(s_prefixes[i], StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } + } +} diff --git a/tests/Microsoft.Identity.Web.Test/DownstreamWebApiSupport/DownstreamApiTests.cs b/tests/Microsoft.Identity.Web.Test/DownstreamWebApiSupport/DownstreamApiTests.cs index 59b44b3a9..ee95f5694 100644 --- a/tests/Microsoft.Identity.Web.Test/DownstreamWebApiSupport/DownstreamApiTests.cs +++ b/tests/Microsoft.Identity.Web.Test/DownstreamWebApiSupport/DownstreamApiTests.cs @@ -171,6 +171,134 @@ public async Task UpdateRequestAsync_WithScopes_AddsSamlAuthorizationHeaderToReq Assert.Equal(options.AcquireTokenOptions.ExtraQueryParameters, DownstreamApi.CallerSDKDetails); } + [Fact] + public async Task UpdateRequestAsync_ExtraHeaderParameters_CallerProvidedAuthorization_IsIgnored() + { + // Arrange + var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "https://example.com"); + var content = new StringContent("test content"); + var options = new DownstreamApiOptions + { + Scopes = ["scope1"], + BaseUrl = "https://localhost:44321/WeatherForecast", + ExtraHeaderParameters = new Dictionary + { + { "Authorization", "Bearer caller-supplied" } + } + }; + + // Act + await _input.UpdateRequestWithCertificateAsync(httpRequestMessage, content, options, false, new ClaimsPrincipal(), CancellationToken.None); + + // Assert + Assert.Single(httpRequestMessage.Headers.GetValues("Authorization")); + Assert.Equal("ey", httpRequestMessage.Headers.Authorization?.Parameter); + Assert.Equal("Bearer", httpRequestMessage.Headers.Authorization?.Scheme); + } + + [Theory] + [InlineData("Authorization")] + [InlineData("authorization")] + [InlineData("Cookie")] + [InlineData("Host")] + [InlineData("X-Original-URL")] + [InlineData("X-MS-CLIENT-PRINCIPAL")] + [InlineData("X-MS-CLIENT-PRINCIPAL-ID")] + [InlineData("X-MS-CLIENT-PRINCIPAL-NAME")] + [InlineData("X-MS-CLIENT-PRINCIPAL-IDP")] + public async Task UpdateRequestAsync_ExtraHeaderParameters_ReservedNames_AreIgnored(string headerName) + { + // Arrange + var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "https://example.com"); + var content = new StringContent("test content"); + var options = new DownstreamApiOptions + { + ExtraHeaderParameters = new Dictionary + { + { headerName, "caller-supplied-value" } + } + }; + + // Act + await _input.UpdateRequestWithCertificateAsync(httpRequestMessage, content, options, false, null, CancellationToken.None); + + // Assert + Assert.False(httpRequestMessage.Headers.Contains(headerName)); + } + + [Theory] + [InlineData("X-Forwarded-For")] + [InlineData("X-Forwarded-Host")] + [InlineData("X-Forwarded-Proto")] + [InlineData("x-forwarded-for")] + [InlineData("X-MS-TOKEN-AAD-ID-TOKEN")] + [InlineData("X-MS-TOKEN-AAD-ACCESS-TOKEN")] + [InlineData("x-ms-token-aad-refresh-token")] + public async Task UpdateRequestAsync_ExtraHeaderParameters_ReservedPrefixes_AreIgnored(string headerName) + { + // Arrange + var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "https://example.com"); + var content = new StringContent("test content"); + var options = new DownstreamApiOptions + { + ExtraHeaderParameters = new Dictionary + { + { headerName, "caller-supplied-value" } + } + }; + + // Act + await _input.UpdateRequestWithCertificateAsync(httpRequestMessage, content, options, false, null, CancellationToken.None); + + // Assert + Assert.False(httpRequestMessage.Headers.Contains(headerName)); + } + + [Fact] + public async Task UpdateRequestAsync_ExtraHeaderParameters_DuplicateOfLibrarySetHeader_IsIgnored() + { + // Arrange + var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "https://example.com"); + var content = new StringContent("test content"); + var options = new DownstreamApiOptions + { + AcceptHeader = "application/json", + ExtraHeaderParameters = new Dictionary + { + { "Accept", "text/xml" } + } + }; + + // Act + await _input.UpdateRequestWithCertificateAsync(httpRequestMessage, content, options, false, null, CancellationToken.None); + + // Assert + Assert.Single(httpRequestMessage.Headers.Accept); + Assert.Equal("application/json", httpRequestMessage.Headers.Accept.Single().MediaType); + } + + [Fact] + public async Task UpdateRequestAsync_ExtraHeaderParameters_AllowedHeader_IsForwarded() + { + // Arrange + var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "https://example.com"); + var content = new StringContent("test content"); + var options = new DownstreamApiOptions + { + ExtraHeaderParameters = new Dictionary + { + { "X-Custom-Tracking", "trace-id-123" } + } + }; + + // Act + await _input.UpdateRequestWithCertificateAsync(httpRequestMessage, content, options, false, null, CancellationToken.None); + + // Assert + Assert.True(httpRequestMessage.Headers.Contains("X-Custom-Tracking")); + Assert.Equal("trace-id-123", httpRequestMessage.Headers.GetValues("X-Custom-Tracking").Single()); + } + [Fact] public void SerializeInput_ReturnsCorrectHttpContent() {