From 7d82b2f94daab5aaf2a2de1d2cb0c29a9f879a54 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:30:05 +0100 Subject: [PATCH 1/3] fix(dotnet): validate header delimiters Reject control delimiters at identity binding and transport boundaries.`n`nShare the validation with declarative workflow headers to keep enforcement consistent. --- dotnet/eng/MSBuild/Shared.props | 3 + .../ClientHeaderValidation.cs | 19 +- .../FoundryAgentSessionExtensions.cs | 4 + .../Microsoft.Agents.AI.Foundry.csproj | 1 + .../UserIdentityPolicy.cs | 7 + .../DefaultHttpRequestHandler.cs | 20 +- ...oft.Agents.AI.Workflows.Declarative.csproj | 1 + .../src/Shared/Http/HttpHeaderValidation.cs | 17 ++ .../FoundryHostedRequestTests.cs | 190 ++++++++++++++++++ 9 files changed, 240 insertions(+), 22 deletions(-) create mode 100644 dotnet/src/Shared/Http/HttpHeaderValidation.cs diff --git a/dotnet/eng/MSBuild/Shared.props b/dotnet/eng/MSBuild/Shared.props index 4e126e4be64..10db0a47ef4 100644 --- a/dotnet/eng/MSBuild/Shared.props +++ b/dotnet/eng/MSBuild/Shared.props @@ -38,4 +38,7 @@ + + + diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/ClientHeaderValidation.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/ClientHeaderValidation.cs index 0dfd116eb39..10a7315cebd 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry/ClientHeaderValidation.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/ClientHeaderValidation.cs @@ -21,20 +21,20 @@ public static void Validate(string name, string value) } // Reject transport delimiters before using the name in exception text or a transport API. - if (ContainsProhibitedCharacter(name)) - { - throw new ArgumentException("Header name must not contain NUL, carriage-return, or line-feed characters.", nameof(name)); - } + HttpHeaderValidation.ValidateNoProhibitedCharacters( + name, + nameof(name), + "Header name must not contain NUL, carriage-return, or line-feed characters."); if (value.Length == 0) { throw new ArgumentException("Header value must not be empty.", nameof(value)); } - if (ContainsProhibitedCharacter(value)) - { - throw new ArgumentException("Header value must not contain NUL, carriage-return, or line-feed characters.", nameof(value)); - } + HttpHeaderValidation.ValidateNoProhibitedCharacters( + value, + nameof(value), + "Header value must not contain NUL, carriage-return, or line-feed characters."); if (!name.StartsWith(ClientHeaderPrefix, StringComparison.OrdinalIgnoreCase)) { @@ -43,7 +43,4 @@ public static void Validate(string name, string value) nameof(name)); } } - - private static bool ContainsProhibitedCharacter(string value) => - value.IndexOf('\0') >= 0 || value.IndexOf('\r') >= 0 || value.IndexOf('\n') >= 0; } diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryAgentSessionExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryAgentSessionExtensions.cs index 3383001f484..a00aa146f8c 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryAgentSessionExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryAgentSessionExtensions.cs @@ -108,6 +108,10 @@ internal set { _ = Throw.IfNull(session); _ = Throw.IfNullOrWhitespace(value); + HttpHeaderValidation.ValidateNoProhibitedCharacters( + value, + "userIdentity", + "User identity must not contain NUL, carriage-return, or line-feed characters."); session.StateBag.SetValue(FoundryHostedAgentUserIdentityKey, value); } } diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/Microsoft.Agents.AI.Foundry.csproj b/dotnet/src/Microsoft.Agents.AI.Foundry/Microsoft.Agents.AI.Foundry.csproj index 5959b69eecf..981245c48f6 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry/Microsoft.Agents.AI.Foundry.csproj +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/Microsoft.Agents.AI.Foundry.csproj @@ -6,6 +6,7 @@ related per-agent endpoint surface). Flip back to IsReleased=true once Azure.AI.Projects ships a stable 3.0.0. --> true + true true $(NoWarn);AAIP001;AAIP002;OPENAI001;SCME0001 diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/UserIdentityPolicy.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/UserIdentityPolicy.cs index 4fa86673df4..ad4a736526e 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry/UserIdentityPolicy.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/UserIdentityPolicy.cs @@ -3,6 +3,7 @@ using System.ClientModel.Primitives; using System.Collections.Generic; using System.Threading.Tasks; +using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.AI.Foundry; @@ -38,6 +39,12 @@ private static void Stamp(PipelineMessage message) return; } + // Session state can be restored without using the binding API, so validate again at the + // final transport boundary before the value reaches the header collection. + HttpHeaderValidation.ValidateNoProhibitedCharacters( + identity, + "userIdentity", + "User identity must not contain NUL, carriage-return, or line-feed characters."); message.Request.Headers.Set("x-ms-user-identity", identity); } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DefaultHttpRequestHandler.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DefaultHttpRequestHandler.cs index 090f23a2c09..c89e9e76343 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DefaultHttpRequestHandler.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DefaultHttpRequestHandler.cs @@ -9,6 +9,7 @@ using System.Text; using System.Threading; using System.Threading.Tasks; +using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.AI.Workflows.Declarative; @@ -264,25 +265,22 @@ private static HttpRequestMessage BuildHttpRequestMessage(HttpRequestInfo reques private static void ValidateHeader(string name, string value) { - if (ContainsHttpHeaderDelimiter(name)) - { - throw new ArgumentException("HTTP header name contains invalid characters.", nameof(name)); - } + HttpHeaderValidation.ValidateNoProhibitedCharacters( + name, + nameof(name), + "HTTP header name contains invalid characters."); ValidateHeaderValue(name, value); } private static void ValidateHeaderValue(string name, string value) { - if (ContainsHttpHeaderDelimiter(value)) - { - throw new ArgumentException($"HTTP header '{name}' contains invalid characters.", nameof(value)); - } + HttpHeaderValidation.ValidateNoProhibitedCharacters( + value, + nameof(value), + $"HTTP header '{name}' contains invalid characters."); } - private static bool ContainsHttpHeaderDelimiter(string value) => - value.IndexOfAny(['\r', '\n', '\0']) >= 0; - private static HttpClient CreateOwnedHttpClient() { HttpClientHandler handler = new() diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Microsoft.Agents.AI.Workflows.Declarative.csproj b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Microsoft.Agents.AI.Workflows.Declarative.csproj index 27cfe95f29e..e0264efc14d 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Microsoft.Agents.AI.Workflows.Declarative.csproj +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Microsoft.Agents.AI.Workflows.Declarative.csproj @@ -6,6 +6,7 @@ + true true true true diff --git a/dotnet/src/Shared/Http/HttpHeaderValidation.cs b/dotnet/src/Shared/Http/HttpHeaderValidation.cs new file mode 100644 index 00000000000..c319deb5cb4 --- /dev/null +++ b/dotnet/src/Shared/Http/HttpHeaderValidation.cs @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; + +namespace Microsoft.Shared.Diagnostics; + +/// Validates values before they reach HTTP header transport APIs. +internal static class HttpHeaderValidation +{ + public static void ValidateNoProhibitedCharacters(string value, string parameterName, string message) + { + if (value.IndexOfAny(['\r', '\n', '\0']) >= 0) + { + throw new ArgumentException(message, parameterName); + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/FoundryHostedRequestTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/FoundryHostedRequestTests.cs index aee7a5000be..807e8cb9e89 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/FoundryHostedRequestTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/FoundryHostedRequestTests.cs @@ -101,6 +101,148 @@ await Assert.ThrowsAsync( () => agent.CreateFoundryHostedAgentSessionAsync(userIdentity: " ")); } + [Theory] + [InlineData("\0")] + [InlineData("\r")] + [InlineData("\n")] + public async Task CreateFoundryHostedAgentSessionAsync_ProhibitedUserIdentityCharacter_ThrowsAsync(string prohibitedCharacter) + { + // Arrange + FoundryAgent agent = CreateFoundryAgent(); + + // Act + ArgumentException exception = await Assert.ThrowsAsync( + () => agent.CreateFoundryHostedAgentSessionAsync(userIdentity: $"before{prohibitedCharacter}after")); + + // Assert + Assert.Equal("userIdentity", exception.ParamName); + } + + [Theory] + [InlineData("\0")] + [InlineData("\r")] + [InlineData("\n")] + public void UserIdentityPolicy_Process_ProhibitedUserIdentityCharacter_Throws(string prohibitedCharacter) + { + // Arrange + using var handler = new RecordingHandler(MinimalResponseJson()); +#pragma warning disable CA5399 + using var http = new HttpClient(handler, disposeHandler: false); +#pragma warning restore CA5399 + ClientPipeline pipeline = ClientPipeline.Create( + new ClientPipelineOptions { Transport = new HttpClientPipelineTransport(http) }, + perCallPolicies: default, + perTryPolicies: default, + beforeTransportPolicies: default); + PipelineMessage message = CreatePipelineMessage(pipeline); + UserIdentityScope.Current = $"before{prohibitedCharacter}after"; + + try + { + // Act + ArgumentException exception = Assert.Throws( + () => UserIdentityPolicy.Instance.Process( + message, + [UserIdentityPolicy.Instance, TerminalPolicy.Instance], + 0)); + + // Assert + Assert.Equal("userIdentity", exception.ParamName); + } + finally + { + UserIdentityScope.Current = null; + } + } + + [Theory] + [InlineData("\0")] + [InlineData("\r")] + [InlineData("\n")] + public async Task UserIdentityPolicy_ProcessAsync_ProhibitedUserIdentityCharacter_ThrowsAsync(string prohibitedCharacter) + { + // Arrange + using var handler = new RecordingHandler(MinimalResponseJson()); +#pragma warning disable CA5399 + using var http = new HttpClient(handler, disposeHandler: false); +#pragma warning restore CA5399 + ClientPipeline pipeline = ClientPipeline.Create( + new ClientPipelineOptions { Transport = new HttpClientPipelineTransport(http) }, + perCallPolicies: default, + perTryPolicies: default, + beforeTransportPolicies: default); + PipelineMessage message = CreatePipelineMessage(pipeline); + UserIdentityScope.Current = $"before{prohibitedCharacter}after"; + + try + { + // Act + ArgumentException exception = await Assert.ThrowsAsync( + async () => await UserIdentityPolicy.Instance.ProcessAsync( + message, + [UserIdentityPolicy.Instance, TerminalPolicy.Instance], + 0)); + + // Assert + Assert.Equal("userIdentity", exception.ParamName); + } + finally + { + UserIdentityScope.Current = null; + } + } + + [Theory] + [InlineData("\0")] + [InlineData("\r")] + [InlineData("\n")] + public async Task RunAsync_RestoredProhibitedUserIdentity_DoesNotReachTransportAsync(string prohibitedCharacter) + { + // Arrange + using var handler = new RecordingHandler(MinimalResponseJson()); + (FoundryHostedRequestAgent agent, AgentSession session, HttpClient http) = await CreateEndToEndAgentAsync(handler); + using (http) + { + SetRawUserIdentity(session, $"before{prohibitedCharacter}after"); + + // Act + ArgumentException exception = await Assert.ThrowsAsync( + () => agent.RunAsync("hi", session)); + + // Assert + Assert.Equal("userIdentity", exception.ParamName); + Assert.Empty(handler.Requests); + } + } + + [Theory] + [InlineData("\0")] + [InlineData("\r")] + [InlineData("\n")] + public async Task RunStreamingAsync_RestoredProhibitedUserIdentity_DoesNotReachTransportAsync(string prohibitedCharacter) + { + // Arrange + using var handler = new RecordingHandler(MinimalResponseJson()); + (FoundryHostedRequestAgent agent, AgentSession session, HttpClient http) = await CreateEndToEndAgentAsync(handler); + using (http) + { + SetRawUserIdentity(session, $"before{prohibitedCharacter}after"); + + // Act + ArgumentException exception = await Assert.ThrowsAsync( + async () => + { + await foreach (AgentResponseUpdate _ in agent.RunStreamingAsync("hi", session)) + { + } + }); + + // Assert + Assert.Equal("userIdentity", exception.ParamName); + Assert.Empty(handler.Requests); + } + } + [Fact] public async Task Conflict_SessionAndOptionsHostedIdsDiffer_ThrowsAsync() { @@ -382,6 +524,42 @@ private static FoundryAgent CreateFoundryAgent() => model: "gpt-4o-mini", instructions: "Test"); + private static PipelineMessage CreatePipelineMessage(ClientPipeline pipeline) + { + PipelineMessage message = pipeline.CreateMessage(); + message.Request.Method = "POST"; + message.Request.Uri = new Uri("https://example.test/"); + return message; + } + + private static async Task<(FoundryHostedRequestAgent Agent, AgentSession Session, HttpClient HttpClient)> CreateEndToEndAgentAsync( + RecordingHandler handler) + { +#pragma warning disable CA5399 + var http = new HttpClient(handler, disposeHandler: false); +#pragma warning restore CA5399 + var openAIClient = new OpenAIClient( + new ApiKeyCredential("fake"), + new OpenAIClientOptions { Transport = new HttpClientPipelineTransport(http) }); + IChatClient chatClient = openAIClient.GetResponsesClient().AsIChatClient(); + +#pragma warning disable MEAI001 + OpenAIRequestPolicies policies = chatClient.GetService()!; + OpenAIRequestPoliciesReflection.AddPolicyIfMissing(policies, UserIdentityPolicy.Instance); +#pragma warning restore MEAI001 + + var chatAgent = new ChatClientAgent(chatClient); + AgentSession session = await chatAgent.CreateSessionAsync(); + return (new FoundryHostedRequestAgent(chatAgent), session, http); + } + + private static void SetRawUserIdentity(AgentSession session, string userIdentity) + { + // Session deserialization writes state directly, so the final transport boundary must + // reject invalid restored values even when the public session factory was not used. + session.StateBag.SetValue("Microsoft.Agents.AI.Foundry.UserIdentity", userIdentity); + } + private static string MinimalResponseJson() => """ { "id":"resp_1","object":"response","created_at":1700000000,"status":"completed", @@ -391,6 +569,18 @@ private static string MinimalResponseJson() => """ private sealed class TestSession : AgentSession; + private sealed class TerminalPolicy : PipelinePolicy + { + public static TerminalPolicy Instance { get; } = new(); + + public override void Process(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) + { + } + + public override ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) => + default; + } + private sealed class ProbeAgent : AIAgent { private readonly Action? _onRun; From aadb0233d6e3721450d6ae239920eed648075032 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:38:55 +0100 Subject: [PATCH 2/3] fix(dotnet): reject restored identity delimiters Validate restored non-null identities before whitespace handling so delimiter-only values cannot bypass transport checks. --- .../UserIdentityPolicy.cs | 8 ++++- .../FoundryHostedRequestTests.cs | 36 ++++++++++++++----- 2 files changed, 35 insertions(+), 9 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/UserIdentityPolicy.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/UserIdentityPolicy.cs index ad4a736526e..ca801911dec 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry/UserIdentityPolicy.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/UserIdentityPolicy.cs @@ -34,7 +34,7 @@ public override ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList( @@ -219,14 +234,19 @@ public async Task RunAsync_RestoredProhibitedUserIdentity_DoesNotReachTransportA [InlineData("\0")] [InlineData("\r")] [InlineData("\n")] - public async Task RunStreamingAsync_RestoredProhibitedUserIdentity_DoesNotReachTransportAsync(string prohibitedCharacter) + [InlineData("before\0after")] + [InlineData("before\rafter")] + [InlineData("before\nafter")] + [InlineData(" \r ")] + [InlineData(" \n ")] + public async Task RunStreamingAsync_RestoredProhibitedUserIdentity_DoesNotReachTransportAsync(string invalidIdentity) { // Arrange using var handler = new RecordingHandler(MinimalResponseJson()); (FoundryHostedRequestAgent agent, AgentSession session, HttpClient http) = await CreateEndToEndAgentAsync(handler); using (http) { - SetRawUserIdentity(session, $"before{prohibitedCharacter}after"); + SetRawUserIdentity(session, invalidIdentity); // Act ArgumentException exception = await Assert.ThrowsAsync( From 32dcc0373f942cab225c72c167ac409be1edf7a7 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:57:15 +0100 Subject: [PATCH 3/3] test(dotnet): document stream drain --- .../FoundryHostedRequestTests.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/FoundryHostedRequestTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/FoundryHostedRequestTests.cs index adffbdfe8cf..db955a69b15 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/FoundryHostedRequestTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/FoundryHostedRequestTests.cs @@ -254,6 +254,7 @@ public async Task RunStreamingAsync_RestoredProhibitedUserIdentity_DoesNotReachT { await foreach (AgentResponseUpdate _ in agent.RunStreamingAsync("hi", session)) { + // Drain the stream so transport validation executes. } });