Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<ILogger, string, Exception?> s_reservedHeaderIgnored =
LoggerMessage.Define<string>(
LogLevel.Warning,
DownstreamApiLoggingEventId.ReservedHeaderIgnored,
"[MsIdWeb] Header '{HeaderName}' supplied through ExtraHeaderParameters was ignored because the name is reserved for the library.");

private static readonly Action<ILogger, string, Exception?> s_duplicateHeaderIgnored =
LoggerMessage.Define<string>(
LogLevel.Warning,
DownstreamApiLoggingEventId.DuplicateHeaderIgnored,
"[MsIdWeb] Header '{HeaderName}' supplied through ExtraHeaderParameters was ignored because the request already carries a value for it.");

/// <summary>
/// Logger for handling options exceptions in DownstreamApi.
/// </summary>
Expand Down Expand Up @@ -57,6 +69,25 @@ public static void HttpRequestError(
public static void UnauthenticatedApiCall(
ILogger logger,
Exception? ex) => s_unauthenticatedApiCall(logger, ex);

/// <summary>
/// Logs that an ExtraHeaderParameters entry was skipped because its name is reserved.
/// </summary>
/// <param name="logger">Logger.</param>
/// <param name="headerName">Header name that was ignored.</param>
public static void ReservedHeaderIgnored(
ILogger logger,
string headerName) => s_reservedHeaderIgnored(logger, headerName, null);

/// <summary>
/// Logs that an ExtraHeaderParameters entry was skipped because the request already
/// carries a value for the same header name.
/// </summary>
/// <param name="logger">Logger.</param>
/// <param name="headerName">Header name that was ignored.</param>
public static void DuplicateHeaderIgnored(
ILogger logger,
string headerName) => s_duplicateHeaderIgnored(logger, headerName, null);
}
}
}
16 changes: 15 additions & 1 deletion src/Microsoft.Identity.Web.DownstreamApi/DownstreamApi.cs
Original file line number Diff line number Diff line change
Expand Up @@ -784,11 +784,25 @@ public Task<HttpResponseMessage> 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);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
65 changes: 65 additions & 0 deletions src/Microsoft.Identity.Web.DownstreamApi/ReservedHeaderNames.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// Reserved header names that callers must not provide through
/// <see cref="Microsoft.Identity.Abstractions.DownstreamApiOptions.ExtraHeaderParameters"/>.
/// The library either sets these itself, or they have host-level meaning that
/// should not be controlled by per-request configuration.
/// </summary>
internal static class ReservedHeaderNames
{
// Exact-match names (case-insensitive).
private static readonly HashSet<string> 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-",
};

/// <summary>
/// Returns <see langword="true"/> when <paramref name="headerName"/> matches any
/// reserved exact name or reserved prefix.
/// </summary>
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;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>
{
{ "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<string, string>
{
{ 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<string, string>
{
{ 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<string, string>
{
{ "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<string, string>
{
{ "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()
{
Expand Down
Loading