From b146898b748e11490a4b51483ed38f4e0ffe7817 Mon Sep 17 00:00:00 2001
From: westey <164392973+westey-m@users.noreply.github.com>
Date: Wed, 21 Oct 2026 11:14:49 +0000
Subject: [PATCH 1/2] DevUI improve local request checks
---
.../DevUI/DevUI_Step01_BasicUsage/Program.cs | 9 +-
.../DevUI/DevUI_Step01_BasicUsage/README.md | 7 +-
dotnet/samples/02-agents/DevUI/README.md | 5 +
.../DevUIAggregatorHostedService.cs | 72 +++++
.../README.md | 7 +
.../DevUIAccessControlMiddleware.cs | 48 ++++
.../DevUIAccessControlStartupFilter.cs | 21 ++
.../DevUIAuthFilter.cs | 90 +------
.../Microsoft.Agents.AI.DevUI/DevUIOptions.cs | 36 ++-
.../DevUIRequestValidator.cs | 197 ++++++++++++++
.../src/Microsoft.Agents.AI.DevUI/README.md | 26 +-
.../ServiceCollectionsExtensions.cs | 5 +-
.../DevUIAggregatorHostedServiceTests.cs | 131 ++++++++-
.../DevUIAccessControlTests.cs | 255 +++++++++++++++++-
python/packages/devui/AGENTS.md | 2 +
python/packages/devui/README.md | 2 +
python/packages/devui/dev.md | 3 +
17 files changed, 800 insertions(+), 116 deletions(-)
create mode 100644 dotnet/src/Microsoft.Agents.AI.DevUI/DevUIAccessControlMiddleware.cs
create mode 100644 dotnet/src/Microsoft.Agents.AI.DevUI/DevUIAccessControlStartupFilter.cs
create mode 100644 dotnet/src/Microsoft.Agents.AI.DevUI/DevUIRequestValidator.cs
diff --git a/dotnet/samples/02-agents/DevUI/DevUI_Step01_BasicUsage/Program.cs b/dotnet/samples/02-agents/DevUI/DevUI_Step01_BasicUsage/Program.cs
index 7ab48835813..d5acc22e293 100644
--- a/dotnet/samples/02-agents/DevUI/DevUI_Step01_BasicUsage/Program.cs
+++ b/dotnet/samples/02-agents/DevUI/DevUI_Step01_BasicUsage/Program.cs
@@ -22,13 +22,13 @@ namespace DevUI_Step01_BasicUsage;
/// 1. Set up Azure OpenAI as the chat client
/// 2. Create function tools for agents to use
/// 3. Register agents and workflows using the hosting packages with tools
-/// 4. Map the DevUI endpoint which automatically configures the middleware
+/// 4. Register DevUI services and map the DevUI endpoint
/// 5. Map the dynamic OpenAI Responses API for Python DevUI compatibility
/// 6. Access the DevUI in a web browser
///
/// The DevUI provides an interactive web interface for testing and debugging AI agents.
/// DevUI assets are served from embedded resources within the assembly.
-/// Simply call MapDevUI() to set up everything needed.
+/// Call AddDevUI() before building the app, then MapDevUI() to expose the interface.
///
/// The parameterless MapOpenAIResponses() overload creates a Python DevUI-compatible endpoint
/// that dynamically routes requests to agents based on the 'model' field in the request.
@@ -95,6 +95,11 @@ static string GetCurrentTime()
builder.Services.AddOpenAIResponses();
builder.Services.AddOpenAIConversations();
+ if (builder.Environment.IsDevelopment())
+ {
+ builder.AddDevUI();
+ }
+
var app = builder.Build();
app.MapOpenAIResponses();
diff --git a/dotnet/samples/02-agents/DevUI/DevUI_Step01_BasicUsage/README.md b/dotnet/samples/02-agents/DevUI/DevUI_Step01_BasicUsage/README.md
index d46b1a3905d..be38e938d29 100644
--- a/dotnet/samples/02-agents/DevUI/DevUI_Step01_BasicUsage/README.md
+++ b/dotnet/samples/02-agents/DevUI/DevUI_Step01_BasicUsage/README.md
@@ -70,12 +70,17 @@ To add DevUI to your ASP.NET Core application:
agent directly from a `Workflow`, opt in with
`workflow.AsAIAgent(includeWorkflowOutputsInResponse: true)`.
-3. Add OpenAI services and map the endpoints for OpenAI and DevUI:
+3. Register OpenAI and DevUI services before building the app, then map their endpoints:
```csharp
// Register services for OpenAI responses and conversations (also required for DevUI)
builder.Services.AddOpenAIResponses();
builder.Services.AddOpenAIConversations();
+ if (builder.Environment.IsDevelopment())
+ {
+ builder.AddDevUI();
+ }
+
var app = builder.Build();
// Map endpoints for OpenAI responses and conversations (also required for DevUI)
diff --git a/dotnet/samples/02-agents/DevUI/README.md b/dotnet/samples/02-agents/DevUI/README.md
index 45b2f6f63b4..d9e64c14152 100644
--- a/dotnet/samples/02-agents/DevUI/README.md
+++ b/dotnet/samples/02-agents/DevUI/README.md
@@ -42,6 +42,11 @@ builder.AddAIAgent("my-agent", "You are a helpful assistant.");
builder.Services.AddOpenAIResponses();
builder.Services.AddOpenAIConversations();
+if (builder.Environment.IsDevelopment())
+{
+ builder.AddDevUI();
+}
+
var app = builder.Build();
// Map endpoints for OpenAI responses and conversations (also required for DevUI)
diff --git a/dotnet/src/Aspire.Hosting.AgentFramework.DevUI/DevUIAggregatorHostedService.cs b/dotnet/src/Aspire.Hosting.AgentFramework.DevUI/DevUIAggregatorHostedService.cs
index ae182609614..4230a398a80 100644
--- a/dotnet/src/Aspire.Hosting.AgentFramework.DevUI/DevUIAggregatorHostedService.cs
+++ b/dotnet/src/Aspire.Hosting.AgentFramework.DevUI/DevUIAggregatorHostedService.cs
@@ -6,6 +6,7 @@
using System.Diagnostics;
using System.IO;
using System.Linq;
+using System.Net;
using System.Net.Http;
using System.Reflection;
using System.Text.Json;
@@ -89,6 +90,7 @@ public async Task StartAsync(CancellationToken cancellationToken)
// Bind to a fixed port if one was specified on the DevUI resource; otherwise use 0 for dynamic allocation.
var port = this._resource.Port ?? 0;
this._app.Urls.Add($"http://127.0.0.1:{port}");
+ this._app.Use(ValidateLocalRequestAsync);
this.MapRoutes(this._app);
await this._app.StartAsync(cancellationToken).ConfigureAwait(false);
@@ -129,6 +131,76 @@ public async ValueTask DisposeAsync()
this._tracingProbeLock.Dispose();
}
+ private static async Task ValidateLocalRequestAsync(HttpContext context, RequestDelegate next)
+ {
+ if (!IsLoopbackHost(context.Request.Host.Host) ||
+ !IsAllowedBrowserRequest(context.Request))
+ {
+ context.Response.StatusCode = StatusCodes.Status403Forbidden;
+ return;
+ }
+
+ await next(context).ConfigureAwait(false);
+ }
+
+ private static bool IsLoopbackHost(string host)
+ {
+ if (string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+
+ return IPAddress.TryParse(host, out var address) && IPAddress.IsLoopback(address);
+ }
+
+ private static bool IsAllowedBrowserRequest(HttpRequest request)
+ {
+ if (HttpMethods.IsGet(request.Method) ||
+ HttpMethods.IsHead(request.Method) ||
+ HttpMethods.IsOptions(request.Method) ||
+ HttpMethods.IsTrace(request.Method))
+ {
+ return true;
+ }
+
+ if (request.Headers.TryGetValue("Sec-Fetch-Site", out var fetchSites))
+ {
+ foreach (var fetchSite in fetchSites)
+ {
+ if (fetchSite?.Split(',').Any(static value =>
+ string.Equals(value.Trim(), "cross-site", StringComparison.OrdinalIgnoreCase)) == true)
+ {
+ return false;
+ }
+ }
+ }
+
+ if (!request.Headers.TryGetValue("Origin", out var origins))
+ {
+ return true;
+ }
+
+ if (origins.Count != 1 ||
+ !Uri.TryCreate(origins[0], UriKind.Absolute, out var origin) ||
+ (!string.Equals(origin.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) &&
+ !string.Equals(origin.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)) ||
+ !string.IsNullOrEmpty(origin.UserInfo) ||
+ !string.Equals(origin.AbsolutePath, "/", StringComparison.Ordinal) ||
+ !string.IsNullOrEmpty(origin.Query) ||
+ !string.IsNullOrEmpty(origin.Fragment))
+ {
+ return false;
+ }
+
+ var requestPort = request.Host.Port ?? GetDefaultPort(request.Scheme);
+ return string.Equals(origin.Scheme, request.Scheme, StringComparison.OrdinalIgnoreCase) &&
+ string.Equals(origin.Host, request.Host.Host, StringComparison.OrdinalIgnoreCase) &&
+ origin.Port == requestPort;
+ }
+
+ private static int GetDefaultPort(string scheme)
+ => string.Equals(scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) ? 443 : 80;
+
///
/// Loads the DevUI frontend resources from the Microsoft.Agents.AI.DevUI assembly.
/// The assembly embeds the Vite SPA build output as manifest resources.
diff --git a/dotnet/src/Aspire.Hosting.AgentFramework.DevUI/README.md b/dotnet/src/Aspire.Hosting.AgentFramework.DevUI/README.md
index 01e9afbafe7..2ff3447fe18 100644
--- a/dotnet/src/Aspire.Hosting.AgentFramework.DevUI/README.md
+++ b/dotnet/src/Aspire.Hosting.AgentFramework.DevUI/README.md
@@ -5,6 +5,13 @@ Provides extension methods and resource definitions for an Aspire AppHost to con
> [!WARNING]
> DevUI and its aggregator are for trusted development environments, not production authentication gateways. Protect backend agent endpoints independently; protecting the Aspire dashboard or UI does not automatically protect those routes. Follow the [shared hosting guide](../../samples/04-hosting/README.md#development-tools) for backend authorization, caller isolation, and client/proxy credential requirements.
+The aggregator binds only to loopback and includes limited browser-request protections: every request must use
+`localhost` or a loopback IP address in its `Host` header, and state-changing requests with a mismatched or invalid
+`Origin` or `Sec-Fetch-Site: cross-site` are rejected. Non-browser clients that send neither header continue to work.
+These checks reduce local DNS-rebinding and cross-site request risks, but they do not provide authentication,
+authorization, multi-user isolation, or protection for the backend services. Use the aggregator only for local,
+trusted development.
+
## Getting started
### Prerequisites
diff --git a/dotnet/src/Microsoft.Agents.AI.DevUI/DevUIAccessControlMiddleware.cs b/dotnet/src/Microsoft.Agents.AI.DevUI/DevUIAccessControlMiddleware.cs
new file mode 100644
index 00000000000..0442a123dd8
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.DevUI/DevUIAccessControlMiddleware.cs
@@ -0,0 +1,48 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+namespace Microsoft.Agents.AI.DevUI;
+
+///
+/// Applies DevUI access validation to the standard OpenAI Responses and Conversations routes.
+///
+internal sealed class DevUIAccessControlMiddleware
+{
+ private static readonly PathString ResponsesPath = new("/v1/responses");
+ private static readonly PathString ConversationsPath = new("/v1/conversations");
+
+ private readonly RequestDelegate _next;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The next middleware in the request pipeline.
+ public DevUIAccessControlMiddleware(RequestDelegate next)
+ {
+ ArgumentNullException.ThrowIfNull(next);
+ this._next = next;
+ }
+
+ ///
+ /// Validates protected DevUI backend routes and continues allowed requests.
+ ///
+ /// The current HTTP context.
+ /// The shared DevUI request validator.
+ /// A task that represents processing the request.
+ public async Task InvokeAsync(HttpContext context, DevUIRequestValidator validator)
+ {
+ if (IsProtectedPath(context.Request.Path))
+ {
+ var failure = validator.Validate(context);
+ if (failure is not null)
+ {
+ await failure.ExecuteAsync(context).ConfigureAwait(false);
+ return;
+ }
+ }
+
+ await this._next(context).ConfigureAwait(false);
+ }
+
+ private static bool IsProtectedPath(PathString path)
+ => path.StartsWithSegments(ResponsesPath) || path.StartsWithSegments(ConversationsPath);
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.DevUI/DevUIAccessControlStartupFilter.cs b/dotnet/src/Microsoft.Agents.AI.DevUI/DevUIAccessControlStartupFilter.cs
new file mode 100644
index 00000000000..29def4b895b
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.DevUI/DevUIAccessControlStartupFilter.cs
@@ -0,0 +1,21 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+namespace Microsoft.Agents.AI.DevUI;
+
+///
+/// Adds to the application request pipeline.
+///
+internal sealed class DevUIAccessControlStartupFilter : IStartupFilter
+{
+ ///
+ public Action Configure(Action next)
+ {
+ ArgumentNullException.ThrowIfNull(next);
+
+ return app =>
+ {
+ app.UseMiddleware();
+ next(app);
+ };
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.DevUI/DevUIAuthFilter.cs b/dotnet/src/Microsoft.Agents.AI.DevUI/DevUIAuthFilter.cs
index 7f238ab3a00..a3e081598e6 100644
--- a/dotnet/src/Microsoft.Agents.AI.DevUI/DevUIAuthFilter.cs
+++ b/dotnet/src/Microsoft.Agents.AI.DevUI/DevUIAuthFilter.cs
@@ -1,104 +1,30 @@
// Copyright (c) Microsoft. All rights reserved.
-using System.Net;
-using System.Security.Cryptography;
-using System.Text;
-using Microsoft.Extensions.Options;
-using Microsoft.Net.Http.Headers;
-
namespace Microsoft.Agents.AI.DevUI;
///
-/// Endpoint filter that enforces the DevUI security posture: loopback-only
-/// access by default, plus optional bearer-token authentication.
+/// Endpoint filter that applies the shared DevUI request validation policy.
///
internal sealed class DevUIAuthFilter : IEndpointFilter
{
- private const string BearerScheme = "Bearer";
-
- private readonly DevUIOptions _options;
- private readonly byte[]? _expectedTokenBytes;
- private readonly ILogger _logger;
+ private readonly DevUIRequestValidator _validator;
///
/// Gets a value indicating whether a bearer token is required by this filter
/// (either via or the
/// DEVUI_AUTH_TOKEN environment variable).
///
- public bool TokenRequired => this._expectedTokenBytes is { Length: > 0 };
+ public bool TokenRequired => this._validator.TokenRequired;
- public DevUIAuthFilter(IOptions options, ILogger logger)
+ public DevUIAuthFilter(DevUIRequestValidator validator)
{
- ArgumentNullException.ThrowIfNull(options);
- ArgumentNullException.ThrowIfNull(logger);
- this._options = options.Value;
- this._logger = logger;
-
- var configuredToken = !string.IsNullOrEmpty(this._options.AuthToken)
- ? this._options.AuthToken
- : Environment.GetEnvironmentVariable(DevUIOptions.AuthTokenEnvironmentVariable);
-
- this._expectedTokenBytes = !string.IsNullOrEmpty(configuredToken)
- ? Encoding.UTF8.GetBytes(configuredToken)
- : null;
+ ArgumentNullException.ThrowIfNull(validator);
+ this._validator = validator;
}
public async ValueTask
///
+/// DevUI is intended for trusted development environments and is not
+/// recommended as a production hosting surface.
+///
/// DevUI exposes agent metadata that is sensitive in production contexts:
/// system instructions, tool definitions, model identifiers, and workflow
-/// structure. By default, DevUI rejects any request whose remote endpoint
-/// is not a loopback address. Hosts that intentionally expose DevUI on a
-/// non-loopback interface must opt in via
-/// and should also configure or
-/// to attach an authorization policy.
+/// structure. By default, DevUI requires a loopback peer and loopback Host,
+/// and rejects cross-site state-changing browser requests. Hosts that
+/// intentionally expose DevUI on a non-loopback interface must opt in via
+/// and should also configure
+/// or to attach an
+/// authorization policy.
+///
///
public sealed class DevUIOptions
{
@@ -27,16 +32,20 @@ public sealed class DevUIOptions
/// non-loopback callers. Defaults to .
///
///
- /// When , any request whose
- /// is
- /// not a loopback address (or is missing) is rejected with HTTP 403 before
- /// reaching the DevUI handlers. Enable only when the host is responsible
- /// for fronting DevUI with its own authentication, network policy, or both.
+ /// When , requests require a loopback
+ /// and a localhost or loopback
+ /// IP Host header. State-changing requests with a mismatched or invalid
+ /// Origin, or with Sec-Fetch-Site: cross-site, are also rejected.
+ /// These checks apply to the DevUI endpoints and the standard
+ /// /v1/responses and /v1/conversations paths. Enable only
+ /// when the host is responsible for its own network and browser-request
+ /// protections.
///
public bool AllowRemoteAccess { get; set; }
///
- /// Gets or sets a shared bearer token required on every DevUI request.
+ /// Gets or sets a shared bearer token required on every DevUI request and
+ /// the standard /v1/responses and /v1/conversations paths.
/// When or empty, the value of the
/// DEVUI_AUTH_TOKEN environment variable is used instead.
///
@@ -55,5 +64,10 @@ public sealed class DevUIOptions
/// conventions (for example
/// group.RequireAuthorization("DevUIPolicy")).
///
+ ///
+ /// This callback does not apply to separately mapped OpenAI endpoints.
+ /// Hosts that need custom authorization on those endpoints must attach it
+ /// when mapping them.
+ ///
public Action? ConfigureEndpoints { get; set; }
}
diff --git a/dotnet/src/Microsoft.Agents.AI.DevUI/DevUIRequestValidator.cs b/dotnet/src/Microsoft.Agents.AI.DevUI/DevUIRequestValidator.cs
new file mode 100644
index 00000000000..0f4a81f14f1
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.DevUI/DevUIRequestValidator.cs
@@ -0,0 +1,197 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Net;
+using System.Security.Cryptography;
+using System.Text;
+using Microsoft.Extensions.Options;
+using Microsoft.Net.Http.Headers;
+
+namespace Microsoft.Agents.AI.DevUI;
+
+///
+/// Validates the network origin, browser origin, and optional bearer token for DevUI requests.
+///
+internal sealed class DevUIRequestValidator
+{
+ private const string BearerScheme = "Bearer";
+ private const string SecFetchSiteHeader = "Sec-Fetch-Site";
+
+ private readonly DevUIOptions _options;
+ private readonly byte[]? _expectedTokenBytes;
+ private readonly ILogger _logger;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The configured DevUI access options.
+ /// The logger used for rejected requests.
+ public DevUIRequestValidator(IOptions options, ILogger logger)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+ ArgumentNullException.ThrowIfNull(logger);
+
+ this._options = options.Value;
+ this._logger = logger;
+
+ var configuredToken = !string.IsNullOrEmpty(this._options.AuthToken)
+ ? this._options.AuthToken
+ : Environment.GetEnvironmentVariable(DevUIOptions.AuthTokenEnvironmentVariable);
+
+ this._expectedTokenBytes = !string.IsNullOrEmpty(configuredToken)
+ ? Encoding.UTF8.GetBytes(configuredToken)
+ : null;
+ }
+
+ ///
+ /// Gets a value indicating whether requests must include the configured bearer token.
+ ///
+ public bool TokenRequired => this._expectedTokenBytes is { Length: > 0 };
+
+ ///
+ /// Validates a DevUI request against the configured access policy.
+ ///
+ /// The HTTP context to validate.
+ /// An error result when validation fails; otherwise, .
+ public IResult? Validate(HttpContext context)
+ {
+ ArgumentNullException.ThrowIfNull(context);
+
+ if (!this._options.AllowRemoteAccess)
+ {
+ var remoteIp = context.Connection.RemoteIpAddress;
+ if (remoteIp is null || !IPAddress.IsLoopback(remoteIp))
+ {
+ DevUILog.RejectedNonLoopbackRequest(this._logger, remoteIp);
+ return AccessDenied("DevUI is restricted to loopback callers by default. Enable AllowRemoteAccess to permit remote access.");
+ }
+
+ if (!HostIsLoopback(context.Request.Host))
+ {
+ return AccessDenied("DevUI requires a localhost or loopback IP Host header by default.");
+ }
+
+ if (IsStateChanging(context.Request.Method) && !BrowserRequestIsAllowed(context.Request))
+ {
+ return AccessDenied("DevUI rejected a cross-site browser request.");
+ }
+ }
+
+ if (this._expectedTokenBytes is { Length: > 0 } expected && !TokenIsValid(context.Request, expected))
+ {
+ context.Response.Headers[HeaderNames.WWWAuthenticate] = BearerScheme;
+ return Results.Problem(
+ statusCode: StatusCodes.Status401Unauthorized,
+ title: "DevUI authentication required",
+ detail: "Provide a valid bearer token via the Authorization header.");
+ }
+
+ return null;
+ }
+
+ private static IResult AccessDenied(string detail)
+ => Results.Problem(
+ statusCode: StatusCodes.Status403Forbidden,
+ title: "DevUI access denied",
+ detail: detail);
+
+ private static bool BrowserRequestIsAllowed(HttpRequest request)
+ {
+ if (request.Headers.TryGetValue(SecFetchSiteHeader, out var fetchSiteValues))
+ {
+ foreach (var value in fetchSiteValues)
+ {
+ if (string.Equals(value?.Trim(), "cross-site", StringComparison.OrdinalIgnoreCase))
+ {
+ return false;
+ }
+ }
+ }
+
+ if (!request.Headers.TryGetValue(HeaderNames.Origin, out var originValues))
+ {
+ return true;
+ }
+
+ if (originValues.Count != 1 ||
+ !Uri.TryCreate(originValues[0], UriKind.Absolute, out var origin) ||
+ !string.IsNullOrEmpty(origin.UserInfo) ||
+ !string.Equals(origin.AbsolutePath, "/", StringComparison.Ordinal) ||
+ !string.IsNullOrEmpty(origin.Query) ||
+ !string.IsNullOrEmpty(origin.Fragment))
+ {
+ return false;
+ }
+
+ var requestPort = request.Host.Port ?? DefaultPort(request.Scheme);
+ return string.Equals(origin.Scheme, request.Scheme, StringComparison.OrdinalIgnoreCase) &&
+ HostsMatch(origin.Host, request.Host.Host) &&
+ origin.Port == requestPort;
+ }
+
+ private static int DefaultPort(string scheme)
+ => string.Equals(scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) ? 443 : 80;
+
+ private static bool HostIsLoopback(HostString host)
+ {
+ var hostName = RemoveIpv6Brackets(host.Host);
+ return string.Equals(hostName, "localhost", StringComparison.OrdinalIgnoreCase) ||
+ (IPAddress.TryParse(hostName, out var address) && IPAddress.IsLoopback(address));
+ }
+
+ private static bool HostsMatch(string first, string second)
+ {
+ first = RemoveIpv6Brackets(first);
+ second = RemoveIpv6Brackets(second);
+
+ if (IPAddress.TryParse(first, out var firstAddress) &&
+ IPAddress.TryParse(second, out var secondAddress))
+ {
+ return firstAddress.Equals(secondAddress);
+ }
+
+ return string.Equals(first, second, StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static string RemoveIpv6Brackets(string host)
+ => host.Length >= 2 && host[0] == '[' && host[^1] == ']'
+ ? host[1..^1]
+ : host;
+
+ private static bool IsStateChanging(string method)
+ => !HttpMethods.IsGet(method) &&
+ !HttpMethods.IsHead(method) &&
+ !HttpMethods.IsOptions(method) &&
+ !HttpMethods.IsTrace(method);
+
+ private static bool TokenIsValid(HttpRequest request, byte[] expected)
+ {
+ if (!request.Headers.TryGetValue(HeaderNames.Authorization, out var headerValues))
+ {
+ return false;
+ }
+
+ foreach (var header in headerValues)
+ {
+ if (string.IsNullOrEmpty(header))
+ {
+ continue;
+ }
+
+ const int PrefixLength = 7; // "Bearer "
+ if (header.Length <= PrefixLength ||
+ !header.StartsWith(BearerScheme, StringComparison.OrdinalIgnoreCase) ||
+ header[BearerScheme.Length] != ' ')
+ {
+ continue;
+ }
+
+ var presented = Encoding.UTF8.GetBytes(header.AsSpan(PrefixLength).Trim().ToString());
+ if (CryptographicOperations.FixedTimeEquals(presented, expected))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.DevUI/README.md b/dotnet/src/Microsoft.Agents.AI.DevUI/README.md
index 7eedb0d1b3b..829775d9567 100644
--- a/dotnet/src/Microsoft.Agents.AI.DevUI/README.md
+++ b/dotnet/src/Microsoft.Agents.AI.DevUI/README.md
@@ -3,7 +3,7 @@
This package provides a web interface for testing and debugging AI agents during development.
> [!WARNING]
-> DevUI is intended for development only. Its endpoints surface agent system instructions, tool definitions, model identifiers, and workflow structure. Do not expose DevUI to untrusted callers. By default, DevUI rejects any request whose remote endpoint is not a loopback address; see [Security](#security) below for the available options.
+> DevUI is intended for development only. Its endpoints surface agent system instructions, tool definitions, model identifiers, and workflow structure. Do not expose DevUI to untrusted callers. By default, DevUI requires a loopback network peer and loopback Host, and rejects cross-site state-changing browser requests; see [Security](#security) below for the available options.
## Installation
@@ -90,21 +90,29 @@ available for continuation.
## Security
-DevUI exposes `/v1/entities` and `/v1/entities/{id}/info`, which return agent metadata including the system prompt (`ChatClientAgent.Instructions`). To prevent accidental disclosure, the DevUI route group is wrapped in a small endpoint filter that:
+DevUI exposes `/v1/entities` and `/v1/entities/{id}/info`, which return agent metadata including the system prompt (`ChatClientAgent.Instructions`). `AddDevUI()` also automatically applies the same access checks to the standard `/v1/responses` and `/v1/conversations` paths and their descendants, regardless of where those endpoints are mapped in the application.
-- Rejects requests from any non-loopback `RemoteIpAddress` with HTTP 403 by default.
-- Optionally requires a shared bearer token on every request.
+In the default local mode, DevUI:
+
+- Requires a loopback `RemoteIpAddress`.
+- Requires the `Host` header to be `localhost` or a loopback IPv4 or IPv6 address.
+- Rejects state-changing requests when `Origin` is invalid or does not match the request origin.
+- Rejects state-changing requests with `Sec-Fetch-Site: cross-site`.
+- Allows non-browser clients that send neither `Origin` nor `Sec-Fetch-Site`.
+- Optionally requires a shared bearer token.
+
+Local-access and browser-request failures return HTTP 403. Missing or invalid configured tokens return HTTP 401 with `WWW-Authenticate: Bearer`. The `/meta` endpoint remains unauthenticated so the frontend can discover whether a token is required.
Configure via `DevUIOptions`:
```csharp
builder.AddDevUI(options =>
{
- // Allow non-loopback callers. Set this only when the host fronts DevUI with
- // its own authentication or network policy.
+ // Skip the built-in peer, Host, and browser-origin restrictions. Set this
+ // only when the host supplies its own network and browser-request policy.
options.AllowRemoteAccess = true;
- // Optional: require Authorization: Bearer on every request.
+ // Optional: require a bearer token on DevUI and standard backend requests.
// Falls back to the DEVUI_AUTH_TOKEN environment variable when null.
options.AuthToken = builder.Configuration["DevUI:AuthToken"];
@@ -113,6 +121,6 @@ builder.AddDevUI(options =>
});
```
-The bundled bearer-token check uses constant-time comparison and is intended as a convenience for development scenarios. Production hosts should prefer a real ASP.NET Core authentication scheme via `ConfigureEndpoints`.
+`AllowRemoteAccess=true` skips the built-in peer, Host, and browser-origin restrictions, but it does not skip a configured bearer token. The bundled token check uses constant-time comparison and is intended as a convenience for development scenarios.
-The DevUI route filter is not a substitute for authorization and caller isolation on separately mapped OpenAI Responses/Conversations endpoints. Protect each backend route group independently, and supply credentials compatible with that configuration. A shared DevUI token is not a per-user identity. See the [shared hosting guide](../../samples/04-hosting/README.md#development-tools).
+Automatic backend protection is intentionally limited to the standard `/v1/responses` and `/v1/conversations` paths and their descendants. Custom OpenAI endpoint paths are not detected or protected. `ConfigureEndpoints` applies only to endpoints mapped by `MapDevUI()`; attach host-supplied authorization directly to separately mapped backend endpoints when needed. A shared DevUI token is not a per-user identity. See the [shared hosting guide](../../samples/04-hosting/README.md#development-tools).
diff --git a/dotnet/src/Microsoft.Agents.AI.DevUI/ServiceCollectionsExtensions.cs b/dotnet/src/Microsoft.Agents.AI.DevUI/ServiceCollectionsExtensions.cs
index 0a434d73c32..844863ec93b 100644
--- a/dotnet/src/Microsoft.Agents.AI.DevUI/ServiceCollectionsExtensions.cs
+++ b/dotnet/src/Microsoft.Agents.AI.DevUI/ServiceCollectionsExtensions.cs
@@ -3,6 +3,7 @@
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.DevUI;
using Microsoft.Agents.AI.Workflows;
+using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Extensions.DependencyInjection;
@@ -36,7 +37,9 @@ public static IServiceCollection AddDevUI(this IServiceCollection services, Acti
optionsBuilder.Configure(configure);
}
- services.AddSingleton();
+ services.TryAddSingleton();
+ services.TryAddSingleton();
+ services.TryAddEnumerable(ServiceDescriptor.Singleton());
// a factory that tries to construct an AIAgent from Workflow,
// even if workflow was not explicitly registered as an AIAgent.
diff --git a/dotnet/tests/Aspire.Hosting.AgentFramework.DevUI.UnitTests/DevUIAggregatorHostedServiceTests.cs b/dotnet/tests/Aspire.Hosting.AgentFramework.DevUI.UnitTests/DevUIAggregatorHostedServiceTests.cs
index 9af48926dce..76b1f3b3146 100644
--- a/dotnet/tests/Aspire.Hosting.AgentFramework.DevUI.UnitTests/DevUIAggregatorHostedServiceTests.cs
+++ b/dotnet/tests/Aspire.Hosting.AgentFramework.DevUI.UnitTests/DevUIAggregatorHostedServiceTests.cs
@@ -489,6 +489,88 @@ public async Task ProxyRequest_NormalizedPath_ForwardsToConfiguredBackendAsync(s
Assert.Single(proxy.BackendRequests);
}
+ [Fact]
+ public async Task ProxyRequest_ForeignHost_IsRejectedBeforeBackendAsync()
+ {
+ // Arrange
+ await using var proxy = await ProxyTestContext.StartAsync();
+
+ // Act
+ var response = await proxy.SendAsync("/v1/conversations", host: "attacker.example");
+
+ // Assert
+ Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
+ Assert.Empty(proxy.BackendRequests);
+ }
+
+ [Fact]
+ public async Task ProxyRequest_MismatchedOriginPost_IsRejectedBeforeBackendAsync()
+ {
+ // Arrange
+ await using var proxy = await ProxyTestContext.StartAsync();
+
+ // Act
+ var response = await proxy.SendAsync(
+ "/v1/conversations",
+ HttpMethod.Post,
+ origin: "https://attacker.example");
+
+ // Assert
+ Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
+ Assert.Empty(proxy.BackendRequests);
+ }
+
+ [Fact]
+ public async Task ProxyRequest_CrossSiteFetchPost_IsRejectedBeforeBackendAsync()
+ {
+ // Arrange
+ await using var proxy = await ProxyTestContext.StartAsync();
+
+ // Act
+ var response = await proxy.SendAsync(
+ "/v1/conversations",
+ HttpMethod.Post,
+ secFetchSite: "cross-site");
+
+ // Assert
+ Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
+ Assert.Empty(proxy.BackendRequests);
+ }
+
+ [Fact]
+ public async Task ProxyRequest_SameOriginPost_IsForwardedAsync()
+ {
+ // Arrange
+ await using var proxy = await ProxyTestContext.StartAsync();
+
+ // Act
+ var response = await proxy.SendAsync(
+ "/v1/conversations",
+ HttpMethod.Post,
+ origin: proxy.Origin,
+ secFetchSite: "same-origin");
+
+ // Assert
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ var forwarded = Assert.Single(proxy.BackendRequests);
+ Assert.Equal(HttpMethod.Post.Method, forwarded.Method);
+ }
+
+ [Fact]
+ public async Task ProxyRequest_NonBrowserPost_IsForwardedAsync()
+ {
+ // Arrange
+ await using var proxy = await ProxyTestContext.StartAsync();
+
+ // Act
+ var response = await proxy.SendAsync("/v1/conversations", HttpMethod.Post);
+
+ // Assert
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ var forwarded = Assert.Single(proxy.BackendRequests);
+ Assert.Equal(HttpMethod.Post.Method, forwarded.Method);
+ }
+
#region Proxy Test Helpers
///
@@ -501,13 +583,13 @@ private sealed class ProxyTestContext : IAsyncDisposable
private readonly WebApplication _backend;
private readonly DevUIAggregatorHostedService _aggregator;
private readonly HttpClient _client;
- private readonly List<(string Path, string QueryString)> _backendRequests;
+ private readonly List<(string Method, string Path, string QueryString)> _backendRequests;
private ProxyTestContext(
WebApplication backend,
DevUIAggregatorHostedService aggregator,
HttpClient client,
- List<(string Path, string QueryString)> backendRequests)
+ List<(string Method, string Path, string QueryString)> backendRequests)
{
this._backend = backend;
this._aggregator = aggregator;
@@ -516,11 +598,14 @@ private ProxyTestContext(
}
/// Gets the requests received by the stub backend, in arrival order.
- public IReadOnlyList<(string Path, string QueryString)> BackendRequests => this._backendRequests;
+ public IReadOnlyList<(string Method, string Path, string QueryString)> BackendRequests => this._backendRequests;
+
+ /// Gets the aggregator origin used by the test client.
+ public string Origin => this._client.BaseAddress!.GetLeftPart(UriPartial.Authority);
public static async Task StartAsync()
{
- var backendRequests = new List<(string Path, string QueryString)>();
+ var backendRequests = new List<(string Method, string Path, string QueryString)>();
var backend = await StartStubBackendAsync(backendRequests).ConfigureAwait(false);
var aggregator = await StartAggregatorAsync(GetBaseAddress(backend)).ConfigureAwait(false);
@@ -529,9 +614,33 @@ public static async Task StartAsync()
return new ProxyTestContext(backend, aggregator, client, backendRequests);
}
- /// Sends a GET request to the aggregator using the given relative path.
- public Task SendAsync(string relativePath)
- => this._client.GetAsync(new Uri(relativePath, UriKind.Relative));
+ /// Sends a request to the aggregator using the given method and browser request headers.
+ public async Task SendAsync(
+ string relativePath,
+ HttpMethod? method = null,
+ string? host = null,
+ string? origin = null,
+ string? secFetchSite = null)
+ {
+ using var request = new HttpRequestMessage(method ?? HttpMethod.Get, new Uri(relativePath, UriKind.Relative));
+
+ if (host is not null)
+ {
+ request.Headers.Host = host;
+ }
+
+ if (origin is not null)
+ {
+ request.Headers.TryAddWithoutValidation("Origin", origin);
+ }
+
+ if (secFetchSite is not null)
+ {
+ request.Headers.TryAddWithoutValidation("Sec-Fetch-Site", secFetchSite);
+ }
+
+ return await this._client.SendAsync(request).ConfigureAwait(false);
+ }
public async ValueTask DisposeAsync()
{
@@ -545,7 +654,8 @@ public async ValueTask DisposeAsync()
///
/// Starts a minimal backend that records the path and query string of every request it receives.
///
- private static async Task StartStubBackendAsync(List<(string Path, string QueryString)> requests)
+ private static async Task StartStubBackendAsync(
+ List<(string Method, string Path, string QueryString)> requests)
{
var builder = WebApplication.CreateSlimBuilder();
builder.Logging.ClearProviders();
@@ -554,7 +664,10 @@ private static async Task StartStubBackendAsync(List<(string Pat
app.Urls.Add("http://127.0.0.1:0");
app.Map("{**path}", (HttpContext context) =>
{
- requests.Add((context.Request.Path.Value ?? string.Empty, context.Request.QueryString.Value ?? string.Empty));
+ requests.Add((
+ context.Request.Method,
+ context.Request.Path.Value ?? string.Empty,
+ context.Request.QueryString.Value ?? string.Empty));
return Results.Json(new { ok = true });
});
diff --git a/dotnet/tests/Microsoft.Agents.AI.DevUI.UnitTests/DevUIAccessControlTests.cs b/dotnet/tests/Microsoft.Agents.AI.DevUI.UnitTests/DevUIAccessControlTests.cs
index 8f6266cc59b..f62ec1a4d9e 100644
--- a/dotnet/tests/Microsoft.Agents.AI.DevUI.UnitTests/DevUIAccessControlTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.DevUI.UnitTests/DevUIAccessControlTests.cs
@@ -6,6 +6,7 @@
using System.Net.Http.Headers;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.AI;
@@ -18,11 +19,16 @@ namespace Microsoft.Agents.AI.DevUI.UnitTests;
public class DevUIAccessControlTests
{
- private static WebApplicationBuilder NewBuilder()
+ private static WebApplicationBuilder NewBuilder(IPAddress? startupRemoteIp = null)
{
var builder = WebApplication.CreateBuilder();
builder.WebHost.UseTestServer();
+ if (startupRemoteIp is not null)
+ {
+ builder.Services.AddSingleton(new RemoteIpStartupFilter(startupRemoteIp));
+ }
+
var mockChatClient = new Mock();
var agent = new ChatClientAgent(mockChatClient.Object, "Test", "agent-name");
builder.Services.AddKeyedSingleton("agent-name", agent);
@@ -182,4 +188,251 @@ public async Task LoopbackRequest_WithWrongBearerToken_ReturnsUnauthorizedAsync(
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
+
+ [Fact]
+ public async Task StandardResponsesRoute_WithForeignHost_ReturnsForbiddenAsync()
+ {
+ // Arrange
+ var builder = NewBuilder(IPAddress.Loopback);
+ builder.Services.AddDevUI();
+ using var app = builder.Build();
+ app.MapPost("/v1/responses", () => Results.Ok());
+ await app.StartAsync();
+ using var request = new HttpRequestMessage(HttpMethod.Post, new Uri("/v1/responses", UriKind.Relative));
+ request.Headers.Host = "attacker.example";
+
+ // Act
+ var response = await app.GetTestClient().SendAsync(request);
+
+ // Assert
+ Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
+ }
+
+ [Theory]
+ [InlineData("localhost")]
+ [InlineData("127.0.0.1")]
+ [InlineData("[::1]")]
+ public async Task StandardResponsesRoute_WithLoopbackHost_SucceedsAsync(string host)
+ {
+ // Arrange
+ var builder = NewBuilder(IPAddress.Loopback);
+ builder.Services.AddDevUI();
+ using var app = builder.Build();
+ app.MapPost("/v1/responses", () => Results.Ok());
+ await app.StartAsync();
+ using var request = new HttpRequestMessage(HttpMethod.Post, new Uri("/v1/responses", UriKind.Relative));
+ request.Headers.Host = host;
+
+ // Act
+ var response = await app.GetTestClient().SendAsync(request);
+
+ // Assert
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task StandardResponsesRoute_WithForeignOrigin_ReturnsForbiddenBeforeHandlerAsync()
+ {
+ // Arrange
+ var handlerInvoked = false;
+ var builder = NewBuilder(IPAddress.Loopback);
+ builder.Services.AddDevUI();
+ using var app = builder.Build();
+ app.MapPost("/v1/responses", () =>
+ {
+ handlerInvoked = true;
+ return Results.Ok();
+ });
+ await app.StartAsync();
+ using var request = new HttpRequestMessage(HttpMethod.Post, new Uri("/v1/responses", UriKind.Relative));
+ request.Headers.TryAddWithoutValidation("Origin", "https://attacker.example");
+
+ // Act
+ var response = await app.GetTestClient().SendAsync(request);
+
+ // Assert
+ Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
+ Assert.False(handlerInvoked);
+ }
+
+ [Fact]
+ public async Task StandardResponsesRoute_WithCrossSiteFetchMetadata_ReturnsForbiddenAsync()
+ {
+ // Arrange
+ var builder = NewBuilder(IPAddress.Loopback);
+ builder.Services.AddDevUI();
+ using var app = builder.Build();
+ app.MapPost("/v1/responses", () => Results.Ok());
+ await app.StartAsync();
+ using var request = new HttpRequestMessage(HttpMethod.Post, new Uri("/v1/responses", UriKind.Relative));
+ request.Headers.TryAddWithoutValidation("Sec-Fetch-Site", "cross-site");
+
+ // Act
+ var response = await app.GetTestClient().SendAsync(request);
+
+ // Assert
+ Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task StandardResponsesRoute_WithMatchingOrigin_SucceedsAsync()
+ {
+ // Arrange
+ var builder = NewBuilder(IPAddress.Loopback);
+ builder.Services.AddDevUI();
+ using var app = builder.Build();
+ app.MapPost("/v1/responses", () => Results.Ok());
+ await app.StartAsync();
+ using var request = new HttpRequestMessage(HttpMethod.Post, new Uri("/v1/responses", UriKind.Relative));
+ request.Headers.TryAddWithoutValidation("Origin", "http://localhost");
+
+ // Act
+ var response = await app.GetTestClient().SendAsync(request);
+
+ // Assert
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task StandardResponsesRoute_WithoutBrowserHeaders_SucceedsAsync()
+ {
+ // Arrange
+ var builder = NewBuilder(IPAddress.Loopback);
+ builder.Services.AddDevUI();
+ using var app = builder.Build();
+ app.MapPost("/v1/responses", () => Results.Ok());
+ await app.StartAsync();
+
+ // Act
+ var response = await app.GetTestClient().PostAsync(
+ new Uri("/v1/responses", UriKind.Relative),
+ content: null);
+
+ // Assert
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task StandardConversationsDescendantRoute_WithForeignHost_ReturnsForbiddenAsync()
+ {
+ // Arrange
+ var builder = NewBuilder(IPAddress.Loopback);
+ builder.Services.AddDevUI();
+ using var app = builder.Build();
+ app.MapGet("/v1/conversations/conversation-id", () => Results.Ok());
+ await app.StartAsync();
+ using var request = new HttpRequestMessage(
+ HttpMethod.Get,
+ new Uri("/v1/conversations/conversation-id", UriKind.Relative));
+ request.Headers.Host = "attacker.example";
+
+ // Act
+ var response = await app.GetTestClient().SendAsync(request);
+
+ // Assert
+ Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
+ }
+
+ [Theory]
+ [InlineData("/v1/responses")]
+ [InlineData("/v1/conversations")]
+ public async Task StandardBackendRoute_WithConfiguredToken_RequiresBearerHeaderAsync(string path)
+ {
+ // Arrange
+ var builder = NewBuilder();
+ builder.Services.AddDevUI(options =>
+ {
+ options.AllowRemoteAccess = true;
+ options.AuthToken = "secret-token";
+ });
+ using var app = builder.Build();
+ app.MapPost(path, () => Results.Ok());
+ await app.StartAsync();
+
+ // Act
+ var response = await app.GetTestClient().PostAsync(
+ new Uri(path, UriKind.Relative),
+ content: null);
+
+ // Assert
+ Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
+ Assert.Equal("Bearer", response.Headers.WwwAuthenticate.ToString());
+ }
+
+ [Fact]
+ public async Task StandardResponsesRoute_WithRemoteAccess_SkipsLocalChecksButEnforcesTokenAsync()
+ {
+ // Arrange
+ var builder = NewBuilder(IPAddress.Parse("192.0.2.1"));
+ builder.Services.AddDevUI(options =>
+ {
+ options.AllowRemoteAccess = true;
+ options.AuthToken = "secret-token";
+ });
+ using var app = builder.Build();
+ app.MapPost("/v1/responses", () => Results.Ok());
+ await app.StartAsync();
+ using var request = new HttpRequestMessage(HttpMethod.Post, new Uri("/v1/responses", UriKind.Relative));
+ request.Headers.Host = "remote.example";
+ request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "secret-token");
+ request.Headers.TryAddWithoutValidation("Origin", "https://attacker.example");
+ request.Headers.TryAddWithoutValidation("Sec-Fetch-Site", "cross-site");
+
+ // Act
+ var response = await app.GetTestClient().SendAsync(request);
+
+ // Assert
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task UnrelatedApplicationRoute_IsNotProtectedAsync()
+ {
+ // Arrange
+ var builder = NewBuilder(IPAddress.Parse("192.0.2.1"));
+ builder.Services.AddDevUI();
+ using var app = builder.Build();
+ app.MapPost("/application", () => Results.Ok());
+ await app.StartAsync();
+ using var request = new HttpRequestMessage(HttpMethod.Post, new Uri("/application", UriKind.Relative));
+ request.Headers.Host = "attacker.example";
+ request.Headers.TryAddWithoutValidation("Origin", "https://attacker.example");
+ request.Headers.TryAddWithoutValidation("Sec-Fetch-Site", "cross-site");
+
+ // Act
+ var response = await app.GetTestClient().SendAsync(request);
+
+ // Assert
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ }
+
+ [Fact]
+ public void AddDevUI_WhenCalledRepeatedly_RegistersOneStartupFilter()
+ {
+ // Arrange
+ var services = new ServiceCollection();
+
+ // Act
+ services.AddDevUI();
+ services.AddDevUI();
+ using var provider = services.BuildServiceProvider();
+ var startupFilters = provider.GetServices();
+
+ // Assert
+ Assert.Single(startupFilters, filter => filter is DevUIAccessControlStartupFilter);
+ }
+
+ private sealed class RemoteIpStartupFilter(IPAddress remoteIp) : IStartupFilter
+ {
+ public Action Configure(Action next)
+ => app =>
+ {
+ app.Use(async (context, following) =>
+ {
+ context.Connection.RemoteIpAddress = remoteIp;
+ await following(context);
+ });
+ next(app);
+ };
+ }
}
diff --git a/python/packages/devui/AGENTS.md b/python/packages/devui/AGENTS.md
index 566dcfee0ae..a5866e436c3 100644
--- a/python/packages/devui/AGENTS.md
+++ b/python/packages/devui/AGENTS.md
@@ -44,6 +44,8 @@ devui --entities my_agent.py
DevUI is a development-only sample app, not a production hosting surface. Authentication is enabled by default.
Unauthenticated mode is allowed only on `localhost` / `127.0.0.1`; `0.0.0.0`, LAN IPs, and hostnames require
`DEVUI_AUTH_TOKEN` or `--auth-token`.
+Even on loopback, `--no-auth` removes protection against HTTP requests sent by hostile websites to local DevUI.
+Use it only in an isolated loopback development environment.
## Import Path
diff --git a/python/packages/devui/README.md b/python/packages/devui/README.md
index f5acf2dad6f..a3dc795e13b 100644
--- a/python/packages/devui/README.md
+++ b/python/packages/devui/README.md
@@ -360,6 +360,8 @@ devui ./agents --mode user --host 0.0.0.0 --auth-token ""
```
Do not use `--no-auth` with `0.0.0.0`, LAN IPs, or hostnames. That configuration fails closed before startup.
+Even on loopback, `--no-auth` removes protection against HTTP requests sent by hostile websites to local DevUI.
+Use it only in an isolated loopback development environment.
**Security features:**
diff --git a/python/packages/devui/dev.md b/python/packages/devui/dev.md
index 0f7441b9bd1..a1e2fb0c839 100644
--- a/python/packages/devui/dev.md
+++ b/python/packages/devui/dev.md
@@ -63,6 +63,9 @@ This launches the UI with all example agents/workflows at http://localhost:8080
DevUI is auth-enabled by default. Copy the generated token from startup logs and pass it as
`Authorization: Bearer ` for direct API calls. Use `--no-auth` only for loopback-only local testing.
+Even on loopback, `--no-auth` removes protection against HTTP requests sent by hostile websites to local DevUI.
+Use it only in an isolated loopback development environment.
+
## 5. What You'll See
- A web interface for testing agents interactively
From d659e874e7a199af1ad81d58dc03cf6fa84636e2 Mon Sep 17 00:00:00 2001
From: westey <164392973+westey-m@users.noreply.github.com>
Date: Wed, 21 Oct 2026 13:13:32 +0000
Subject: [PATCH 2/2] Address PR comments and fix build error
---
.../DevUIAggregatorHostedService.cs | 11 +-
.../README.md | 4 +-
.../DevUIAccessControlMiddleware.cs | 35 ++++-
.../DevUIAuthFilter.cs | 3 +-
.../Microsoft.Agents.AI.DevUI/DevUIOptions.cs | 25 ++--
.../DevUIRequestValidator.cs | 16 +-
.../src/Microsoft.Agents.AI.DevUI/README.md | 13 +-
...ting.AgentFramework.DevUI.UnitTests.csproj | 1 +
.../DevUIAggregatorHostedServiceTests.cs | 77 ++++++++--
.../DevUIAccessControlTests.cs | 138 +++++++++++++++++-
10 files changed, 281 insertions(+), 42 deletions(-)
diff --git a/dotnet/src/Aspire.Hosting.AgentFramework.DevUI/DevUIAggregatorHostedService.cs b/dotnet/src/Aspire.Hosting.AgentFramework.DevUI/DevUIAggregatorHostedService.cs
index 4230a398a80..7bc28bcc27c 100644
--- a/dotnet/src/Aspire.Hosting.AgentFramework.DevUI/DevUIAggregatorHostedService.cs
+++ b/dotnet/src/Aspire.Hosting.AgentFramework.DevUI/DevUIAggregatorHostedService.cs
@@ -60,13 +60,14 @@ public DevUIAggregatorHostedService(
DevUIResource resource,
ILogger logger,
IConfiguration? configuration = null,
- AspireDashboardConnection? dashboardConnectionOverride = null)
+ AspireDashboardConnection? dashboardConnectionOverride = null,
+ bool loadFrontendResources = true)
{
this._resource = resource;
this._logger = logger;
this._configuration = configuration;
this._dashboardConnectionOverride = dashboardConnectionOverride;
- this._frontendResources = LoadFrontendResources(logger);
+ this._frontendResources = loadFrontendResources ? LoadFrontendResources(logger) : null;
}
///
@@ -955,7 +956,7 @@ private async Task ProxyAndRecordConversationAsync(
foreach (var header in context.Request.Headers)
{
- if (IsHopByHopHeader(header.Key))
+ if (IsHopByHopHeader(header.Key) || IsBrowserProvenanceHeader(header.Key))
{
continue;
}
@@ -1186,6 +1187,10 @@ private static bool IsHopByHopHeader(string headerName)
|| headerName.Equals("Host", StringComparison.OrdinalIgnoreCase);
}
+ private static bool IsBrowserProvenanceHeader(string headerName)
+ => headerName.Equals("Origin", StringComparison.OrdinalIgnoreCase)
+ || headerName.StartsWith("Sec-Fetch-", StringComparison.OrdinalIgnoreCase);
+
///
/// Validates that constructing a proxy target URI from and
/// does not redirect the request to an unintended host.
diff --git a/dotnet/src/Aspire.Hosting.AgentFramework.DevUI/README.md b/dotnet/src/Aspire.Hosting.AgentFramework.DevUI/README.md
index 2ff3447fe18..a06f6c917b4 100644
--- a/dotnet/src/Aspire.Hosting.AgentFramework.DevUI/README.md
+++ b/dotnet/src/Aspire.Hosting.AgentFramework.DevUI/README.md
@@ -10,7 +10,9 @@ The aggregator binds only to loopback and includes limited browser-request prote
`Origin` or `Sec-Fetch-Site: cross-site` are rejected. Non-browser clients that send neither header continue to work.
These checks reduce local DNS-rebinding and cross-site request risks, but they do not provide authentication,
authorization, multi-user isolation, or protection for the backend services. Use the aggregator only for local,
-trusted development.
+trusted development. After validating the incoming browser request, the aggregator does not forward browser
+provenance headers such as `Origin` or `Sec-Fetch-*` on its server-to-server backend hop; application credentials
+such as `Authorization` are forwarded.
## Getting started
diff --git a/dotnet/src/Microsoft.Agents.AI.DevUI/DevUIAccessControlMiddleware.cs b/dotnet/src/Microsoft.Agents.AI.DevUI/DevUIAccessControlMiddleware.cs
index 0442a123dd8..a4851c98417 100644
--- a/dotnet/src/Microsoft.Agents.AI.DevUI/DevUIAccessControlMiddleware.cs
+++ b/dotnet/src/Microsoft.Agents.AI.DevUI/DevUIAccessControlMiddleware.cs
@@ -7,8 +7,8 @@ namespace Microsoft.Agents.AI.DevUI;
///
internal sealed class DevUIAccessControlMiddleware
{
- private static readonly PathString ResponsesPath = new("/v1/responses");
- private static readonly PathString ConversationsPath = new("/v1/conversations");
+ private static readonly PathString s_responsesPath = new("/v1/responses");
+ private static readonly PathString s_conversationsPath = new("/v1/conversations");
private readonly RequestDelegate _next;
@@ -30,9 +30,10 @@ public DevUIAccessControlMiddleware(RequestDelegate next)
/// A task that represents processing the request.
public async Task InvokeAsync(HttpContext context, DevUIRequestValidator validator)
{
- if (IsProtectedPath(context.Request.Path))
+ var requestPath = context.Request.PathBase.Add(context.Request.Path);
+ if (IsProtectedPath(requestPath))
{
- var failure = validator.Validate(context);
+ var failure = validator.ValidateLocalRequest(context);
if (failure is not null)
{
await failure.ExecuteAsync(context).ConfigureAwait(false);
@@ -44,5 +45,29 @@ public async Task InvokeAsync(HttpContext context, DevUIRequestValidator validat
}
private static bool IsProtectedPath(PathString path)
- => path.StartsWithSegments(ResponsesPath) || path.StartsWithSegments(ConversationsPath);
+ => ContainsPathSegment(path, s_responsesPath) || ContainsPathSegment(path, s_conversationsPath);
+
+ private static bool ContainsPathSegment(PathString path, PathString routePath)
+ {
+ var pathValue = path.Value;
+ var routeValue = routePath.Value;
+ if (pathValue is null || routeValue is null)
+ {
+ return false;
+ }
+
+ var matchIndex = pathValue.IndexOf(routeValue, StringComparison.OrdinalIgnoreCase);
+ while (matchIndex >= 0)
+ {
+ var matchEnd = matchIndex + routeValue.Length;
+ if (matchEnd == pathValue.Length || pathValue[matchEnd] == '/')
+ {
+ return true;
+ }
+
+ matchIndex = pathValue.IndexOf(routeValue, matchEnd, StringComparison.OrdinalIgnoreCase);
+ }
+
+ return false;
+ }
}
diff --git a/dotnet/src/Microsoft.Agents.AI.DevUI/DevUIAuthFilter.cs b/dotnet/src/Microsoft.Agents.AI.DevUI/DevUIAuthFilter.cs
index a3e081598e6..9736f6e58db 100644
--- a/dotnet/src/Microsoft.Agents.AI.DevUI/DevUIAuthFilter.cs
+++ b/dotnet/src/Microsoft.Agents.AI.DevUI/DevUIAuthFilter.cs
@@ -24,7 +24,8 @@ public DevUIAuthFilter(DevUIRequestValidator validator)
public async ValueTask
+ /// The HTTP context to validate.
+ /// An error result when validation fails; otherwise, .
+ public IResult? ValidateToken(HttpContext context)
+ {
+ ArgumentNullException.ThrowIfNull(context);
+
if (this._expectedTokenBytes is { Length: > 0 } expected && !TokenIsValid(context.Request, expected))
{
context.Response.Headers[HeaderNames.WWWAuthenticate] = BearerScheme;
diff --git a/dotnet/src/Microsoft.Agents.AI.DevUI/README.md b/dotnet/src/Microsoft.Agents.AI.DevUI/README.md
index 829775d9567..fbf88edb942 100644
--- a/dotnet/src/Microsoft.Agents.AI.DevUI/README.md
+++ b/dotnet/src/Microsoft.Agents.AI.DevUI/README.md
@@ -90,7 +90,7 @@ available for continuation.
## Security
-DevUI exposes `/v1/entities` and `/v1/entities/{id}/info`, which return agent metadata including the system prompt (`ChatClientAgent.Instructions`). `AddDevUI()` also automatically applies the same access checks to the standard `/v1/responses` and `/v1/conversations` paths and their descendants, regardless of where those endpoints are mapped in the application.
+DevUI exposes `/v1/entities` and `/v1/entities/{id}/info`, which return agent metadata including the system prompt (`ChatClientAgent.Instructions`). `AddDevUI()` also automatically applies the local peer, Host, and browser-origin checks to the standard `/v1/responses` and `/v1/conversations` paths and their descendants, regardless of where those endpoints are mapped in the application. A configured DevUI token protects the UI and entity endpoints mapped by `MapDevUI()`; it is not automatically imposed on those separately mapped standard backend routes.
In the default local mode, DevUI:
@@ -99,7 +99,7 @@ In the default local mode, DevUI:
- Rejects state-changing requests when `Origin` is invalid or does not match the request origin.
- Rejects state-changing requests with `Sec-Fetch-Site: cross-site`.
- Allows non-browser clients that send neither `Origin` nor `Sec-Fetch-Site`.
-- Optionally requires a shared bearer token.
+- Optionally requires a shared bearer token on the endpoints mapped by `MapDevUI()`.
Local-access and browser-request failures return HTTP 403. Missing or invalid configured tokens return HTTP 401 with `WWW-Authenticate: Bearer`. The `/meta` endpoint remains unauthenticated so the frontend can discover whether a token is required.
@@ -112,7 +112,8 @@ builder.AddDevUI(options =>
// only when the host supplies its own network and browser-request policy.
options.AllowRemoteAccess = true;
- // Optional: require a bearer token on DevUI and standard backend requests.
+ // Optional: require a bearer token on the UI and entity endpoints mapped
+ // by MapDevUI(). Separately mapped standard backend routes do not inherit it.
// Falls back to the DEVUI_AUTH_TOKEN environment variable when null.
options.AuthToken = builder.Configuration["DevUI:AuthToken"];
@@ -121,6 +122,8 @@ builder.AddDevUI(options =>
});
```
-`AllowRemoteAccess=true` skips the built-in peer, Host, and browser-origin restrictions, but it does not skip a configured bearer token. The bundled token check uses constant-time comparison and is intended as a convenience for development scenarios.
+`AllowRemoteAccess=true` skips the built-in peer, Host, and browser-origin restrictions, but it does not skip a configured bearer token on the endpoints mapped by `MapDevUI()`. The bundled token check uses constant-time comparison and is intended as a convenience for development scenarios.
-Automatic backend protection is intentionally limited to the standard `/v1/responses` and `/v1/conversations` paths and their descendants. Custom OpenAI endpoint paths are not detected or protected. `ConfigureEndpoints` applies only to endpoints mapped by `MapDevUI()`; attach host-supplied authorization directly to separately mapped backend endpoints when needed. A shared DevUI token is not a per-user identity. See the [shared hosting guide](../../samples/04-hosting/README.md#development-tools).
+Automatic local backend checks are intentionally limited to the standard `/v1/responses` and `/v1/conversations` paths and their descendants. Custom OpenAI endpoint paths are not detected or protected. Neither `AuthToken` nor `ConfigureEndpoints` is automatically applied to separately mapped backend endpoints; attach host-supplied authorization directly when needed. A shared DevUI token is not a per-user identity.
+
+The built-in local checks reduce direct local-development risks; they are not a production authentication boundary and cannot authenticate the original caller when a public reverse proxy makes requests appear local. Reverse-proxied exposure requires host-owned authentication and network policy, normally with `AllowRemoteAccess=true` so the proxy traffic is evaluated by those host controls rather than DevUI's direct-loopback assumptions. See the [shared hosting guide](../../samples/04-hosting/README.md#development-tools).
diff --git a/dotnet/tests/Aspire.Hosting.AgentFramework.DevUI.UnitTests/Aspire.Hosting.AgentFramework.DevUI.UnitTests.csproj b/dotnet/tests/Aspire.Hosting.AgentFramework.DevUI.UnitTests/Aspire.Hosting.AgentFramework.DevUI.UnitTests.csproj
index 9c1f22aca3d..7b6f202ebe6 100644
--- a/dotnet/tests/Aspire.Hosting.AgentFramework.DevUI.UnitTests/Aspire.Hosting.AgentFramework.DevUI.UnitTests.csproj
+++ b/dotnet/tests/Aspire.Hosting.AgentFramework.DevUI.UnitTests/Aspire.Hosting.AgentFramework.DevUI.UnitTests.csproj
@@ -14,6 +14,7 @@
+
diff --git a/dotnet/tests/Aspire.Hosting.AgentFramework.DevUI.UnitTests/DevUIAggregatorHostedServiceTests.cs b/dotnet/tests/Aspire.Hosting.AgentFramework.DevUI.UnitTests/DevUIAggregatorHostedServiceTests.cs
index 76b1f3b3146..576228f1bf5 100644
--- a/dotnet/tests/Aspire.Hosting.AgentFramework.DevUI.UnitTests/DevUIAggregatorHostedServiceTests.cs
+++ b/dotnet/tests/Aspire.Hosting.AgentFramework.DevUI.UnitTests/DevUIAggregatorHostedServiceTests.cs
@@ -556,6 +556,30 @@ public async Task ProxyRequest_SameOriginPost_IsForwardedAsync()
Assert.Equal(HttpMethod.Post.Method, forwarded.Method);
}
+ [Fact]
+ public async Task ProxyRequest_SameOriginPost_ToDevUIBackend_PreservesApplicationHeadersOnlyAsync()
+ {
+ // Arrange
+ await using var proxy = await ProxyTestContext.StartAsync(useDevUIRequestValidation: true);
+
+ // Act
+ var response = await proxy.SendAsync(
+ "/v1/conversations",
+ HttpMethod.Post,
+ origin: proxy.Origin,
+ secFetchSite: "same-origin",
+ authorization: "Bearer backend-token",
+ secFetchMode: "cors");
+
+ // Assert
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ var forwarded = Assert.Single(proxy.BackendRequests);
+ Assert.Equal("Bearer backend-token", forwarded.Authorization);
+ Assert.Null(forwarded.Origin);
+ Assert.Null(forwarded.SecFetchSite);
+ Assert.Null(forwarded.SecFetchMode);
+ }
+
[Fact]
public async Task ProxyRequest_NonBrowserPost_IsForwardedAsync()
{
@@ -583,13 +607,13 @@ private sealed class ProxyTestContext : IAsyncDisposable
private readonly WebApplication _backend;
private readonly DevUIAggregatorHostedService _aggregator;
private readonly HttpClient _client;
- private readonly List<(string Method, string Path, string QueryString)> _backendRequests;
+ private readonly List _backendRequests;
private ProxyTestContext(
WebApplication backend,
DevUIAggregatorHostedService aggregator,
HttpClient client,
- List<(string Method, string Path, string QueryString)> backendRequests)
+ List backendRequests)
{
this._backend = backend;
this._aggregator = aggregator;
@@ -598,15 +622,15 @@ private ProxyTestContext(
}
/// Gets the requests received by the stub backend, in arrival order.
- public IReadOnlyList<(string Method, string Path, string QueryString)> BackendRequests => this._backendRequests;
+ public IReadOnlyList BackendRequests => this._backendRequests;
/// Gets the aggregator origin used by the test client.
public string Origin => this._client.BaseAddress!.GetLeftPart(UriPartial.Authority);
- public static async Task StartAsync()
+ public static async Task StartAsync(bool useDevUIRequestValidation = false)
{
- var backendRequests = new List<(string Method, string Path, string QueryString)>();
- var backend = await StartStubBackendAsync(backendRequests).ConfigureAwait(false);
+ var backendRequests = new List();
+ var backend = await StartStubBackendAsync(backendRequests, useDevUIRequestValidation).ConfigureAwait(false);
var aggregator = await StartAggregatorAsync(GetBaseAddress(backend)).ConfigureAwait(false);
var client = new HttpClient { BaseAddress = new Uri($"http://127.0.0.1:{aggregator.AllocatedPort}") };
@@ -620,7 +644,9 @@ public async Task SendAsync(
HttpMethod? method = null,
string? host = null,
string? origin = null,
- string? secFetchSite = null)
+ string? secFetchSite = null,
+ string? authorization = null,
+ string? secFetchMode = null)
{
using var request = new HttpRequestMessage(method ?? HttpMethod.Get, new Uri(relativePath, UriKind.Relative));
@@ -639,6 +665,16 @@ public async Task SendAsync(
request.Headers.TryAddWithoutValidation("Sec-Fetch-Site", secFetchSite);
}
+ if (authorization is not null)
+ {
+ request.Headers.TryAddWithoutValidation("Authorization", authorization);
+ }
+
+ if (secFetchMode is not null)
+ {
+ request.Headers.TryAddWithoutValidation("Sec-Fetch-Mode", secFetchMode);
+ }
+
return await this._client.SendAsync(request).ConfigureAwait(false);
}
@@ -655,19 +691,28 @@ public async ValueTask DisposeAsync()
/// Starts a minimal backend that records the path and query string of every request it receives.
///
private static async Task StartStubBackendAsync(
- List<(string Method, string Path, string QueryString)> requests)
+ List requests,
+ bool useDevUIRequestValidation)
{
var builder = WebApplication.CreateSlimBuilder();
builder.Logging.ClearProviders();
+ if (useDevUIRequestValidation)
+ {
+ builder.Services.AddDevUI();
+ }
var app = builder.Build();
app.Urls.Add("http://127.0.0.1:0");
app.Map("{**path}", (HttpContext context) =>
{
- requests.Add((
+ requests.Add(new BackendRequest(
context.Request.Method,
context.Request.Path.Value ?? string.Empty,
- context.Request.QueryString.Value ?? string.Empty));
+ context.Request.QueryString.Value ?? string.Empty,
+ context.Request.Headers.Authorization.FirstOrDefault(),
+ context.Request.Headers.Origin.FirstOrDefault(),
+ context.Request.Headers["Sec-Fetch-Site"].FirstOrDefault(),
+ context.Request.Headers["Sec-Fetch-Mode"].FirstOrDefault()));
return Results.Json(new { ok = true });
});
@@ -686,7 +731,8 @@ private static async Task StartAggregatorAsync(str
using var loggerFactory = LoggerFactory.Create(_ => { });
var aggregator = new DevUIAggregatorHostedService(
resource,
- loggerFactory.CreateLogger());
+ loggerFactory.CreateLogger(),
+ loadFrontendResources: false);
await aggregator.StartAsync(CancellationToken.None).ConfigureAwait(false);
return aggregator;
@@ -718,6 +764,15 @@ private static TestBackendResource CreateBackendResource(string backendUrl)
private static string GetBaseAddress(WebApplication app)
=> app.Services.GetRequiredService().Features.Get()!.Addresses.First();
+ private sealed record BackendRequest(
+ string Method,
+ string Path,
+ string QueryString,
+ string? Authorization,
+ string? Origin,
+ string? SecFetchSite,
+ string? SecFetchMode);
+
private sealed class TestBackendResource(string name) : Resource(name), IResourceWithEndpoints;
#endregion
diff --git a/dotnet/tests/Microsoft.Agents.AI.DevUI.UnitTests/DevUIAccessControlTests.cs b/dotnet/tests/Microsoft.Agents.AI.DevUI.UnitTests/DevUIAccessControlTests.cs
index f62ec1a4d9e..847555e09b7 100644
--- a/dotnet/tests/Microsoft.Agents.AI.DevUI.UnitTests/DevUIAccessControlTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.DevUI.UnitTests/DevUIAccessControlTests.cs
@@ -333,10 +333,34 @@ public async Task StandardConversationsDescendantRoute_WithForeignHost_ReturnsFo
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
+ [Theory]
+ [InlineData("/devui/")]
+ [InlineData("/v1/entities")]
+ public async Task DevUIRoute_WithConfiguredToken_RequiresBearerHeaderAsync(string path)
+ {
+ // Arrange
+ var builder = NewBuilder();
+ builder.Services.AddDevUI(options =>
+ {
+ options.AllowRemoteAccess = true;
+ options.AuthToken = "secret-token";
+ });
+ using var app = builder.Build();
+ app.MapDevUI();
+ await app.StartAsync();
+
+ // Act
+ var response = await app.GetTestClient().GetAsync(new Uri(path, UriKind.Relative));
+
+ // Assert
+ Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
+ Assert.Equal("Bearer", response.Headers.WwwAuthenticate.ToString());
+ }
+
[Theory]
[InlineData("/v1/responses")]
[InlineData("/v1/conversations")]
- public async Task StandardBackendRoute_WithConfiguredToken_RequiresBearerHeaderAsync(string path)
+ public async Task StandardBackendRoute_WithConfiguredToken_DoesNotRequireBearerHeaderAsync(string path)
{
// Arrange
var builder = NewBuilder();
@@ -355,12 +379,12 @@ public async Task StandardBackendRoute_WithConfiguredToken_RequiresBearerHeaderA
content: null);
// Assert
- Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
- Assert.Equal("Bearer", response.Headers.WwwAuthenticate.ToString());
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ Assert.Empty(response.Headers.WwwAuthenticate);
}
[Fact]
- public async Task StandardResponsesRoute_WithRemoteAccess_SkipsLocalChecksButEnforcesTokenAsync()
+ public async Task StandardResponsesRoute_WithRemoteAccess_SkipsDevUIAccessChecksAsync()
{
// Arrange
var builder = NewBuilder(IPAddress.Parse("192.0.2.1"));
@@ -374,7 +398,6 @@ public async Task StandardResponsesRoute_WithRemoteAccess_SkipsLocalChecksButEnf
await app.StartAsync();
using var request = new HttpRequestMessage(HttpMethod.Post, new Uri("/v1/responses", UriKind.Relative));
request.Headers.Host = "remote.example";
- request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "secret-token");
request.Headers.TryAddWithoutValidation("Origin", "https://attacker.example");
request.Headers.TryAddWithoutValidation("Sec-Fetch-Site", "cross-site");
@@ -385,6 +408,111 @@ public async Task StandardResponsesRoute_WithRemoteAccess_SkipsLocalChecksButEnf
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
+ [Fact]
+ public async Task StandardResponsesPreflight_WithConfiguredToken_ReachesHandlerAsync()
+ {
+ // Arrange
+ var builder = NewBuilder(IPAddress.Loopback);
+ builder.Services.AddDevUI(options => options.AuthToken = "secret-token");
+ using var app = builder.Build();
+ app.MapMethods("/v1/responses", [HttpMethods.Options], () => Results.NoContent());
+ await app.StartAsync();
+ using var request = new HttpRequestMessage(HttpMethod.Options, new Uri("/v1/responses", UriKind.Relative));
+ request.Headers.TryAddWithoutValidation("Origin", "https://frontend.example");
+ request.Headers.TryAddWithoutValidation("Access-Control-Request-Method", "POST");
+
+ // Act
+ var response = await app.GetTestClient().SendAsync(request);
+
+ // Assert
+ Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
+ Assert.Empty(response.Headers.WwwAuthenticate);
+ }
+
+ [Fact]
+ public async Task PathBaseStandardResponsesDescendantRoute_WithForeignHost_ReturnsForbiddenBeforeHandlerAsync()
+ {
+ // Arrange
+ var handlerInvoked = false;
+ var builder = NewBuilder(IPAddress.Loopback);
+ builder.Services.AddDevUI();
+ using var app = builder.Build();
+ app.UsePathBase("/agent");
+ app.MapPost("/v1/responses/response-id", () =>
+ {
+ handlerInvoked = true;
+ return Results.Ok();
+ });
+ await app.StartAsync();
+ using var request = new HttpRequestMessage(
+ HttpMethod.Post,
+ new Uri("/agent/v1/responses/response-id", UriKind.Relative));
+ request.Headers.Host = "attacker.example";
+
+ // Act
+ var response = await app.GetTestClient().SendAsync(request);
+
+ // Assert
+ Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
+ Assert.False(handlerInvoked);
+ }
+
+ [Fact]
+ public async Task PathBaseStandardConversationsRoute_WithLoopbackHost_ReachesHandlerAsync()
+ {
+ // Arrange
+ var handlerInvoked = false;
+ var builder = NewBuilder(IPAddress.Loopback);
+ builder.Services.AddDevUI();
+ using var app = builder.Build();
+ app.UsePathBase("/agent");
+ app.MapGet("/v1/conversations", () =>
+ {
+ handlerInvoked = true;
+ return Results.Ok();
+ });
+ await app.StartAsync();
+ using var request = new HttpRequestMessage(
+ HttpMethod.Get,
+ new Uri("/agent/v1/conversations", UriKind.Relative));
+ request.Headers.Host = "localhost";
+
+ // Act
+ var response = await app.GetTestClient().SendAsync(request);
+
+ // Assert
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ Assert.True(handlerInvoked);
+ }
+
+ [Fact]
+ public async Task PathBaseSimilarResponsesRoute_WithForeignHost_ReachesHandlerAsync()
+ {
+ // Arrange
+ var handlerInvoked = false;
+ var builder = NewBuilder(IPAddress.Loopback);
+ builder.Services.AddDevUI();
+ using var app = builder.Build();
+ app.UsePathBase("/agent");
+ app.MapPost("/v1/responses-other", () =>
+ {
+ handlerInvoked = true;
+ return Results.Ok();
+ });
+ await app.StartAsync();
+ using var request = new HttpRequestMessage(
+ HttpMethod.Post,
+ new Uri("/agent/v1/responses-other", UriKind.Relative));
+ request.Headers.Host = "attacker.example";
+
+ // Act
+ var response = await app.GetTestClient().SendAsync(request);
+
+ // Assert
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ Assert.True(handlerInvoked);
+ }
+
[Fact]
public async Task UnrelatedApplicationRoute_IsNotProtectedAsync()
{