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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
5 changes: 5 additions & 0 deletions dotnet/samples/02-agents/DevUI/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}

/// <summary>
Expand All @@ -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);
Expand Down Expand Up @@ -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;

/// <summary>
/// Loads the DevUI frontend resources from the <c>Microsoft.Agents.AI.DevUI</c> assembly.
/// The assembly embeds the Vite SPA build output as manifest resources.
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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);

/// <summary>
/// Validates that constructing a proxy target URI from <paramref name="backendUrl"/> and
/// <paramref name="path"/> does not redirect the request to an unintended host.
Expand Down
9 changes: 9 additions & 0 deletions dotnet/src/Aspire.Hosting.AgentFramework.DevUI/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
// Copyright (c) Microsoft. All rights reserved.

namespace Microsoft.Agents.AI.DevUI;

/// <summary>
/// Applies DevUI access validation to the standard OpenAI Responses and Conversations routes.
/// </summary>
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;

/// <summary>
/// Initializes a new instance of the <see cref="DevUIAccessControlMiddleware"/> class.
/// </summary>
/// <param name="next">The next middleware in the request pipeline.</param>
public DevUIAccessControlMiddleware(RequestDelegate next)
{
ArgumentNullException.ThrowIfNull(next);
this._next = next;
}

/// <summary>
/// Validates protected DevUI backend routes and continues allowed requests.
/// </summary>
/// <param name="context">The current HTTP context.</param>
/// <param name="validator">The shared DevUI request validator.</param>
/// <returns>A task that represents processing the request.</returns>
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;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// Copyright (c) Microsoft. All rights reserved.

namespace Microsoft.Agents.AI.DevUI;

/// <summary>
/// Adds <see cref="DevUIAccessControlMiddleware"/> to the application request pipeline.
/// </summary>
internal sealed class DevUIAccessControlStartupFilter : IStartupFilter
{
/// <inheritdoc />
public Action<IApplicationBuilder> Configure(Action<IApplicationBuilder> next)
{
ArgumentNullException.ThrowIfNull(next);

return app =>
{
app.UseMiddleware<DevUIAccessControlMiddleware>();
Comment thread
westey-m marked this conversation as resolved.
next(app);
};
}
}
Loading
Loading