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..7bc28bcc27c 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;
@@ -59,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;
}
///
@@ -89,6 +91,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 +132,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.
@@ -883,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;
}
@@ -1114,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 01e9afbafe7..a06f6c917b4 100644
--- a/dotnet/src/Aspire.Hosting.AgentFramework.DevUI/README.md
+++ b/dotnet/src/Aspire.Hosting.AgentFramework.DevUI/README.md
@@ -5,6 +5,15 @@ 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. 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
### 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..a4851c98417
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.DevUI/DevUIAccessControlMiddleware.cs
@@ -0,0 +1,73 @@
+// 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 s_responsesPath = new("/v1/responses");
+ private static readonly PathString s_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)
+ {
+ var requestPath = context.Request.PathBase.Add(context.Request.Path);
+ if (IsProtectedPath(requestPath))
+ {
+ var failure = validator.ValidateLocalRequest(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)
+ => 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/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..9736f6e58db 100644
--- a/dotnet/src/Microsoft.Agents.AI.DevUI/DevUIAuthFilter.cs
+++ b/dotnet/src/Microsoft.Agents.AI.DevUI/DevUIAuthFilter.cs
@@ -1,104 +1,31 @@
// 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 directly on a non-loopback interface must opt in
+/// via . The built-in local checks are not a
+/// production or public reverse-proxy authentication boundary. Reverse-proxied
+/// exposure requires host-owned authentication and network policy, normally
+/// together with set to .
+///
///
public sealed class DevUIOptions
{
@@ -27,18 +33,28 @@ 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. In particular, these checks do not authenticate the
+ /// original caller when a reverse proxy makes the connection appear local.
+ /// Public or production reverse-proxy exposure requires host-owned
+ /// authentication and network policy, normally with this option set to
+ /// .
///
public bool AllowRemoteAccess { get; set; }
///
- /// Gets or sets a shared bearer token required on every DevUI request.
- /// When or empty, the value of the
- /// DEVUI_AUTH_TOKEN environment variable is used instead.
+ /// Gets or sets a shared bearer token required on the UI and entity
+ /// endpoints mapped by MapDevUI(). The token is not automatically
+ /// imposed on separately mapped standard /v1/responses or
+ /// /v1/conversations routes. When or empty,
+ /// the value of the DEVUI_AUTH_TOKEN environment variable is used
+ /// instead.
///
///
/// When a token is configured, requests must include the header
@@ -55,5 +71,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..0ea3b1a79d7
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.DevUI/DevUIRequestValidator.cs
@@ -0,0 +1,209 @@
+// 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 that a request satisfies the configured local access policy.
+ ///
+ /// The HTTP context to validate.
+ /// An error result when validation fails; otherwise, .
+ public IResult? ValidateLocalRequest(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.");
+ }
+ }
+
+ return null;
+ }
+
+ ///
+ /// Validates that a request includes the optional configured DevUI bearer token.
+ ///
+ /// 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;
+ 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..fbf88edb942 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,30 @@ 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 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.
-- 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 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.
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 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"];
@@ -113,6 +122,8 @@ 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 on the endpoints mapped by `MapDevUI()`. The bundled token check uses constant-time comparison and is intended as a convenience for development scenarios.
+
+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 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).
+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/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/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 9af48926dce..576228f1bf5 100644
--- a/dotnet/tests/Aspire.Hosting.AgentFramework.DevUI.UnitTests/DevUIAggregatorHostedServiceTests.cs
+++ b/dotnet/tests/Aspire.Hosting.AgentFramework.DevUI.UnitTests/DevUIAggregatorHostedServiceTests.cs
@@ -489,6 +489,112 @@ 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_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()
+ {
+ // 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 +607,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 _backendRequests;
private ProxyTestContext(
WebApplication backend,
DevUIAggregatorHostedService aggregator,
HttpClient client,
- List<(string Path, string QueryString)> backendRequests)
+ List backendRequests)
{
this._backend = backend;
this._aggregator = aggregator;
@@ -516,12 +622,15 @@ private ProxyTestContext(
}
/// Gets the requests received by the stub backend, in arrival order.
- public IReadOnlyList<(string Path, string QueryString)> BackendRequests => this._backendRequests;
+ public IReadOnlyList BackendRequests => this._backendRequests;
- public static async Task StartAsync()
+ /// Gets the aggregator origin used by the test client.
+ public string Origin => this._client.BaseAddress!.GetLeftPart(UriPartial.Authority);
+
+ public static async Task StartAsync(bool useDevUIRequestValidation = false)
{
- var backendRequests = new List<(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}") };
@@ -529,9 +638,45 @@ 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,
+ string? authorization = null,
+ string? secFetchMode = 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);
+ }
+
+ 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);
+ }
public async ValueTask DisposeAsync()
{
@@ -545,16 +690,29 @@ 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 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((context.Request.Path.Value ?? string.Empty, context.Request.QueryString.Value ?? string.Empty));
+ requests.Add(new BackendRequest(
+ context.Request.Method,
+ context.Request.Path.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 });
});
@@ -573,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;
@@ -605,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 8f6266cc59b..847555e09b7 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,379 @@ 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("/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_DoesNotRequireBearerHeaderAsync(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.OK, response.StatusCode);
+ Assert.Empty(response.Headers.WwwAuthenticate);
+ }
+
+ [Fact]
+ public async Task StandardResponsesRoute_WithRemoteAccess_SkipsDevUIAccessChecksAsync()
+ {
+ // 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.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 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()
+ {
+ // 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