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
106 changes: 106 additions & 0 deletions dotnet/src/Microsoft.Agents.AI.DevUI/DevUIAuthFilter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
// 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;

/// <summary>
/// Endpoint filter that enforces the DevUI security posture: loopback-only
/// access by default, plus optional bearer-token authentication.
/// </summary>
internal sealed class DevUIAuthFilter : IEndpointFilter
{
private const string BearerScheme = "Bearer";

private readonly DevUIOptions _options;
private readonly byte[]? _expectedTokenBytes;
private readonly ILogger<DevUIAuthFilter> _logger;

/// <summary>
/// Gets a value indicating whether a bearer token is required by this filter
/// (either via <see cref="DevUIOptions.AuthToken"/> or the
/// <c>DEVUI_AUTH_TOKEN</c> environment variable).
/// </summary>
public bool TokenRequired => this._expectedTokenBytes is { Length: > 0 };

public DevUIAuthFilter(IOptions<DevUIOptions> options, ILogger<DevUIAuthFilter> 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);

Comment thread
moonbox3 marked this conversation as resolved.
this._expectedTokenBytes = !string.IsNullOrEmpty(configuredToken)
? Encoding.UTF8.GetBytes(configuredToken)
: null;
Comment thread
moonbox3 marked this conversation as resolved.
}

public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
{
var httpContext = context.HttpContext;
var remoteIp = httpContext.Connection.RemoteIpAddress;
var isLoopback = remoteIp is not null && IPAddress.IsLoopback(remoteIp);

if (!isLoopback && !this._options.AllowRemoteAccess)
{
this._logger.LogWarning(
"Rejected non-loopback DevUI request from {RemoteIp}. Set DevUIOptions.AllowRemoteAccess to permit remote callers.",
remoteIp);
return Results.Problem(
statusCode: StatusCodes.Status403Forbidden,
title: "DevUI access denied",
detail: "DevUI is restricted to loopback callers by default. Enable AllowRemoteAccess to permit remote access.");
}

if (this._expectedTokenBytes is { Length: > 0 } expected && !TokenIsValid(httpContext.Request, expected))
{
httpContext.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 await next(context).ConfigureAwait(false);
}

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;
}
}
50 changes: 45 additions & 5 deletions dotnet/src/Microsoft.Agents.AI.DevUI/DevUIExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.

using System.Diagnostics.CodeAnalysis;
using Microsoft.Extensions.Options;

namespace Microsoft.Agents.AI.DevUI;

Expand All @@ -13,12 +14,19 @@ public static class DevUIExtensions
/// Maps an endpoint that serves the DevUI from the '/devui' path.
/// </summary>
/// <remarks>
/// <para>
/// DevUI requires the OpenAI Responses and Conversations services to be registered with
/// <see cref="MicrosoftAgentAIHostingOpenAIServiceCollectionExtensions.AddOpenAIResponses(IServiceCollection)"/> and
/// <see cref="MicrosoftAgentAIHostingOpenAIServiceCollectionExtensions.AddOpenAIConversations(IServiceCollection)"/>,
/// and the corresponding endpoints to be mapped using
/// <see cref="MicrosoftAgentAIHostingOpenAIEndpointRouteBuilderExtensions.MapOpenAIResponses(IEndpointRouteBuilder)"/> and
/// <see cref="MicrosoftAgentAIHostingOpenAIEndpointRouteBuilderExtensions.MapOpenAIConversations(IEndpointRouteBuilder)"/>.
/// </para>
/// <para>
/// DevUI is restricted to loopback callers unless
/// <see cref="DevUIOptions.AllowRemoteAccess"/> is set. See <see cref="DevUIOptions"/>
/// for the available authentication and authorization hooks.
/// </para>
/// </remarks>
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the endpoint to.</param>
/// <returns>A <see cref="IEndpointConventionBuilder"/> that can be used to add authorization or other endpoint configuration.</returns>
Expand All @@ -30,11 +38,29 @@ public static class DevUIExtensions
public static IEndpointConventionBuilder MapDevUI(
this IEndpointRouteBuilder endpoints)
{
var group = endpoints.MapGroup("");
group.MapDevUI(pattern: "/devui");
group.MapMeta();
group.MapEntities();
return group;
ArgumentNullException.ThrowIfNull(endpoints);

var authFilter = endpoints.ServiceProvider.GetRequiredService<DevUIAuthFilter>();
var options = endpoints.ServiceProvider.GetRequiredService<IOptions<DevUIOptions>>().Value;
var startupLogger = endpoints.ServiceProvider.GetRequiredService<ILogger<DevUIAuthFilter>>();

WarnIfInsecurelyExposed(startupLogger, options);

// /meta must remain reachable without authentication so the frontend can
// discover whether a bearer token is required before prompting for one.
endpoints.MapMeta(authRequired: authFilter.TokenRequired);

var protectedGroup = endpoints.MapGroup("");

// Conventions must be applied before endpoints are added to the group so
// they reliably attach to every protected DevUI endpoint.
options.ConfigureEndpoints?.Invoke(protectedGroup);
protectedGroup.AddEndpointFilter(authFilter);

Comment thread
moonbox3 marked this conversation as resolved.
protectedGroup.MapDevUI(pattern: "/devui");
protectedGroup.MapEntities();

return protectedGroup;
}

/// <summary>
Expand Down Expand Up @@ -66,4 +92,18 @@ internal static IEndpointConventionBuilder MapDevUI(
.WithName($"DevUI at {cleanPattern}")
.WithDescription("Interactive developer interface for Microsoft Agent Framework");
}

private static void WarnIfInsecurelyExposed(ILogger logger, DevUIOptions options)
{
var tokenConfigured = !string.IsNullOrEmpty(options.AuthToken)
|| !string.IsNullOrEmpty(Environment.GetEnvironmentVariable(DevUIOptions.AuthTokenEnvironmentVariable));

if (options.AllowRemoteAccess && !tokenConfigured && options.ConfigureEndpoints is null)
{
logger.LogWarning(
"DevUI is configured with AllowRemoteAccess=true and no authentication. " +
"Set DevUIOptions.AuthToken, the {EnvVar} environment variable, or attach an authorization policy via ConfigureEndpoints.",
DevUIOptions.AuthTokenEnvironmentVariable);
}
}
}
59 changes: 59 additions & 0 deletions dotnet/src/Microsoft.Agents.AI.DevUI/DevUIOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// Copyright (c) Microsoft. All rights reserved.

namespace Microsoft.Agents.AI.DevUI;

/// <summary>
/// Options that control the security posture of the DevUI HTTP surface.
/// </summary>
/// <remarks>
/// 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 <see cref="AllowRemoteAccess"/>
/// and should also configure <see cref="AuthToken"/> or
/// <see cref="ConfigureEndpoints"/> to attach an authorization policy.
/// </remarks>
public sealed class DevUIOptions
{
/// <summary>
/// Environment variable inspected for a default bearer token when
/// <see cref="AuthToken"/> is not explicitly set.
/// </summary>
public const string AuthTokenEnvironmentVariable = "DEVUI_AUTH_TOKEN";

/// <summary>
/// Gets or sets a value indicating whether DevUI may be served to
/// non-loopback callers. Defaults to <see langword="false"/>.
/// </summary>
/// <remarks>
/// When <see langword="false"/>, any request whose
/// <see cref="ConnectionInfo.RemoteIpAddress"/> 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.
/// </remarks>
public bool AllowRemoteAccess { get; set; }

/// <summary>
/// Gets or sets a shared bearer token required on every DevUI request.
/// When <see langword="null"/> or empty, the value of the
/// <c>DEVUI_AUTH_TOKEN</c> environment variable is used instead.
/// </summary>
/// <remarks>
/// When a token is configured, requests must include the header
/// <c>Authorization: Bearer &lt;token&gt;</c>. Comparison is performed
/// in constant time. This is a convenience for development scenarios.
/// Production hosts should prefer a real ASP.NET Core authentication
/// scheme attached via <see cref="ConfigureEndpoints"/>.
/// </remarks>
public string? AuthToken { get; set; }

/// <summary>
/// Gets or sets a callback invoked with the DevUI endpoint group so the
/// host can attach authorization, rate limiting, or other endpoint
/// conventions (for example
/// <c>group.RequireAuthorization("DevUIPolicy")</c>).
/// </summary>
public Action<IEndpointConventionBuilder>? ConfigureEndpoints { get; set; }
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.

using Microsoft.Agents.AI.DevUI;

namespace Microsoft.Extensions.Hosting;

/// <summary>
Expand All @@ -13,10 +15,19 @@ public static class MicrosoftAgentAIDevUIHostApplicationBuilderExtensions
/// <param name="builder">The <see cref="IHostApplicationBuilder"/> to configure.</param>
/// <returns>The <see cref="IHostApplicationBuilder"/> for method chaining.</returns>
public static IHostApplicationBuilder AddDevUI(this IHostApplicationBuilder builder)
=> AddDevUI(builder, configure: null);

/// <summary>
/// Adds DevUI services to the host application builder.
/// </summary>
/// <param name="builder">The <see cref="IHostApplicationBuilder"/> to configure.</param>
/// <param name="configure">Optional callback used to configure <see cref="DevUIOptions"/>.</param>
/// <returns>The <see cref="IHostApplicationBuilder"/> for method chaining.</returns>
public static IHostApplicationBuilder AddDevUI(this IHostApplicationBuilder builder, Action<DevUIOptions>? configure)
{
Comment thread
moonbox3 marked this conversation as resolved.
ArgumentNullException.ThrowIfNull(builder);

builder.Services.AddDevUI();
builder.Services.AddDevUI(configure);

return builder;
}
Expand Down
9 changes: 5 additions & 4 deletions dotnet/src/Microsoft.Agents.AI.DevUI/MetaApiExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ internal static class MetaApiExtensions
/// Maps the HTTP API endpoint for retrieving server metadata.
/// </summary>
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the route to.</param>
/// <param name="authRequired">Value reported via <c>auth_required</c> in the meta response so the frontend can decide whether to prompt for a bearer token.</param>
/// <returns>The <see cref="IEndpointConventionBuilder"/> for method chaining.</returns>
/// <remarks>
/// This extension method registers the following endpoint:
Expand All @@ -22,16 +23,16 @@ internal static class MetaApiExtensions
/// The endpoint is compatible with the Python DevUI frontend and provides essential
/// configuration information needed for proper frontend initialization.
/// </remarks>
public static IEndpointConventionBuilder MapMeta(this IEndpointRouteBuilder endpoints)
public static IEndpointConventionBuilder MapMeta(this IEndpointRouteBuilder endpoints, bool authRequired = false)
{
return endpoints.MapGet("/meta", GetMeta)
return endpoints.MapGet("/meta", () => GetMeta(authRequired))
.WithName("GetMeta")
.WithSummary("Get server metadata and configuration")
.WithDescription("Returns server metadata including UI mode, version, framework identifier, capabilities, and authentication requirements. Used by the frontend for initialization and feature detection.")
.Produces<MetaResponse>(StatusCodes.Status200OK, contentType: "application/json");
}

private static IResult GetMeta()
private static IResult GetMeta(bool authRequired)
{
// TODO: Consider making these configurable via IOptions<DevUIOptions>
// For now, using sensible defaults that match Python DevUI behavior
Expand All @@ -53,7 +54,7 @@ private static IResult GetMeta()
// Deployment capability - not currently supported in .NET DevUI
["deployment"] = false
},
AuthRequired = false // Could be made configurable based on authentication middleware
AuthRequired = authRequired
};

return Results.Json(meta, EntitiesJsonContext.Default.MetaResponse);
Expand Down
30 changes: 30 additions & 0 deletions dotnet/src/Microsoft.Agents.AI.DevUI/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

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.

## Installation

```bash
Expand Down Expand Up @@ -48,3 +51,30 @@ if (builder.Environment.IsDevelopment())

app.Run();
```

## 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:

- Rejects requests from any non-loopback `RemoteIpAddress` with HTTP 403 by default.
- Optionally requires a shared bearer token on every request.

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.
options.AllowRemoteAccess = true;

// Optional: require Authorization: Bearer <token> on every request.
// Falls back to the DEVUI_AUTH_TOKEN environment variable when null.
options.AuthToken = builder.Configuration["DevUI:AuthToken"];

// Optional: attach a real authorization policy or rate limiting.
options.ConfigureEndpoints = group => group.RequireAuthorization("DevUIPolicy");
});
```

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`.
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.

using Microsoft.Agents.AI;
using Microsoft.Agents.AI.DevUI;
using Microsoft.Agents.AI.Workflows;
using Microsoft.Shared.Diagnostics;

Expand All @@ -17,9 +18,26 @@ public static class MicrosoftAgentAIDevUIServiceCollectionsExtensions
/// <param name="services">The <see cref="IServiceCollection"/> to configure.</param>
/// <returns>The <see cref="IServiceCollection"/> for method chaining.</returns>
public static IServiceCollection AddDevUI(this IServiceCollection services)
=> AddDevUI(services, configure: null);

/// <summary>
/// Adds services required for DevUI integration.
/// </summary>
/// <param name="services">The <see cref="IServiceCollection"/> to configure.</param>
/// <param name="configure">Optional callback used to configure <see cref="DevUIOptions"/>.</param>
/// <returns>The <see cref="IServiceCollection"/> for method chaining.</returns>
public static IServiceCollection AddDevUI(this IServiceCollection services, Action<DevUIOptions>? configure)
{
Comment thread
moonbox3 marked this conversation as resolved.
ArgumentNullException.ThrowIfNull(services);

var optionsBuilder = services.AddOptions<DevUIOptions>();
if (configure is not null)
{
optionsBuilder.Configure(configure);
}

services.AddSingleton<DevUIAuthFilter>();

// a factory that tries to construct an AIAgent from Workflow,
// even if workflow was not explicitly registered as an AIAgent.

Expand Down
Loading
Loading