diff --git a/Agents/Implementation Agents/Trace Parser/TraceParserWeb/README.md b/Agents/Implementation Agents/Trace Parser/TraceParserWeb/README.md index acd7c789..95228c29 100644 --- a/Agents/Implementation Agents/Trace Parser/TraceParserWeb/README.md +++ b/Agents/Implementation Agents/Trace Parser/TraceParserWeb/README.md @@ -90,6 +90,18 @@ The chat page's **Switch Agent** dialog changes only the agent name, environment Configure credentials on the server, not in the browser. Saved agent profiles contain only agent identifiers. Older saved profiles remain readable, but their tenant/client fields are ignored and omitted when profiles are saved again. +### Agent destination protection + +Environment IDs are validated on the server before profiles are saved or clients are used. Accepted identifiers are a hyphenated GUID or `Default-` (case-insensitive, with surrounding whitespace trimmed); saved identifiers are canonicalized. URLs, encoded escapes and arbitrary environment names are rejected with form feedback. Valid legacy profiles still work; invalid legacy entries remain visible for correction or deletion but cannot connect. + +Startup configuration and runtime agent switching use the same validation. Switching retains the server's cloud and app registration, never browser-supplied authentication settings. The default cloud is `Prod`. The named hosted clouds from **Microsoft.Agents.CopilotStudio.Client 1.3.176** are supported; `Local`, `Other`, `Unknown`, undefined cloud values and any `CustomPowerPlatformCloud` are rejected. `UseExperimentalEndpoint=true` is explicitly unsupported. These restrictions fail closed at startup rather than acquiring a token for an unverified endpoint. + +The authenticated HTTP handler independently checks **every request before token acquisition or attachment**, including requests that already have Authorization and SDK response-derived activity/stream URLs. Only HTTPS on port 443, without userinfo or fragments, is allowed. Hosts must have the exact SDK environment-ID DNS-label shape under the **configured cloud's** Power Platform API domain; a matching substring or arbitrary subdomain is not enough. A server `DirectConnectUrl` must satisfy this same policy (specify `Cloud` for a non-commercial URL). A trusted direct-only configuration can omit EnvironmentId/SchemaName; identifiers supplied alongside it are still validated. Switching clears the default direct URL so it does not override the selected agent. + +Automatic HTTP redirects are disabled and all 3xx responses are rejected, even redirects to another trusted host. Transport-level redirects would otherwise bypass the authenticated handler. No arbitrary redirect or experimental island endpoint is followed. If the service begins requiring redirects or another hostname family, update and test this explicit policy before enabling that behavior; do not broaden it to suffix/substring matching or an allow-all override. + +**Deployment:** publish/restart only the Blazor web application for this fix; no database, importer, DAB, token-scope, consent or tenant changes are required. Review the server's CopilotStudio cloud/direct/experimental settings before rollout, because unsupported configurations now fail startup. Normal commercial GUID/Default-GUID agent switching is preserved. Offline regression coverage checks the actual pinned SDK's generated URLs and synthetic returned endpoints; it is not a live service compatibility test or a claim of complete application security coverage. + For local dev, add to `appsettings.Development.json` or user secrets: ```json { @@ -122,7 +134,7 @@ Deploy the updated **read-only** `TraceParserMCP/dab-config.json` as part of thi ### Deletion regression checks -The dependency-free console harness checks deletion authorization/completion, read-only DAB permissions, import-status query/error handling, visible deletion feedback, and non-overlapping status polls. It uses synthetic identities and fake stores/HTTP responses, with no database or network calls: +The dependency-free console harness checks deletion authorization/completion, read-only DAB permissions, import-status query/error handling, visible deletion feedback, non-overlapping status polls, agent identifier/profile validation, startup/runtime configuration, authenticated destination checks, redirect policy, and SDK-generated/response-derived URLs. It uses synthetic identities/tokens and fake stores/HTTP responses, with no database or network calls: ```powershell dotnet run --project .\tests\TraceParserWeb.RegressionTests -c Release diff --git a/Agents/Implementation Agents/Trace Parser/TraceParserWeb/TraceParserWeb/Components/Pages/Chat/Chat.razor b/Agents/Implementation Agents/Trace Parser/TraceParserWeb/TraceParserWeb/Components/Pages/Chat/Chat.razor index 3b38c1ea..ccee5c2a 100644 --- a/Agents/Implementation Agents/Trace Parser/TraceParserWeb/TraceParserWeb/Components/Pages/Chat/Chat.razor +++ b/Agents/Implementation Agents/Trace Parser/TraceParserWeb/TraceParserWeb/Components/Pages/Chat/Chat.razor @@ -126,9 +126,12 @@
- +
+ @if (agentValidationError is not null) + { +

@agentValidationError

+ } } @@ -150,6 +153,7 @@ private string editAgentName = ""; private string editEnvironmentId = ""; private string editSchemaName = ""; + private string? agentValidationError; private List savedAgentProfiles = new(); protected override void OnInitialized() @@ -157,10 +161,6 @@ activeAgentName = CopilotStudioClient.ActiveAgentName; } - private bool IsAgentFormValid => - !string.IsNullOrWhiteSpace(editEnvironmentId) && - !string.IsNullOrWhiteSpace(editSchemaName); - private void ToggleInformativeMessages() { showInformativeMessages = !showInformativeMessages; @@ -214,6 +214,7 @@ editAgentName = ""; editEnvironmentId = ""; editSchemaName = ""; + agentValidationError = null; } private void SelectAgentProfile(AgentProfile profile) @@ -221,6 +222,7 @@ editAgentName = profile.Name; editEnvironmentId = profile.EnvironmentId; editSchemaName = profile.SchemaName; + agentValidationError = null; } private async Task DeleteAgentProfile(AgentProfile profile) @@ -244,12 +246,28 @@ private async Task ConnectToAgent() { - if (!IsAgentFormValid) return; + agentValidationError = null; + if (!CopilotDestinationPolicy.TryNormalizeEnvironmentId(editEnvironmentId, out var environmentId)) + { + agentValidationError = CopilotDestinationPolicy.EnvironmentIdError; + return; + } + if (string.IsNullOrWhiteSpace(editSchemaName)) + { + agentValidationError = "Enter an agent Schema Name."; + return; + } + + var newSettings = CopilotStudioConnectionSettings.ForAgent( + Configuration, environmentId, editSchemaName); + var newScope = CopilotClient.ScopeFromSettings(newSettings); + var logger = LoggerFactory.CreateLogger(); + var newClient = new CopilotClient(newSettings, HttpClientFactory, logger, "mcs"); var profile = new AgentProfile { Name = string.IsNullOrWhiteSpace(editAgentName) ? editSchemaName : editAgentName.Trim(), - EnvironmentId = editEnvironmentId.Trim(), + EnvironmentId = environmentId, SchemaName = editSchemaName.Trim() }; @@ -258,24 +276,6 @@ savedAgentProfiles.Insert(0, profile); await SaveAgentProfiles(); - // Build in-memory configuration from form values - var configData = new Dictionary - { - ["CopilotStudio:EnvironmentId"] = profile.EnvironmentId, - ["CopilotStudio:SchemaName"] = profile.SchemaName, - }; - var memConfig = new ConfigurationBuilder() - .AddInMemoryCollection(configData) - .Build(); - - var newSettings = new CopilotStudioConnectionSettings( - memConfig.GetSection("CopilotStudio"), - Configuration.GetSection("AzureAd")); - - var newScope = CopilotClient.ScopeFromSettings(newSettings); - var logger = LoggerFactory.CreateLogger(); - var newClient = new CopilotClient(newSettings, HttpClientFactory, logger, "mcs"); - CopilotStudioClient.SwitchAgent(newClient, newScope); // Cancel any in-progress streaming and reset conversation diff --git a/Agents/Implementation Agents/Trace Parser/TraceParserWeb/TraceParserWeb/Program.cs b/Agents/Implementation Agents/Trace Parser/TraceParserWeb/TraceParserWeb/Program.cs index a4d88c17..9b3311ae 100644 --- a/Agents/Implementation Agents/Trace Parser/TraceParserWeb/TraceParserWeb/Program.cs +++ b/Agents/Implementation Agents/Trace Parser/TraceParserWeb/TraceParserWeb/Program.cs @@ -92,13 +92,12 @@ // Register settings and scope builder.Services.AddSingleton(copilotSettings); +builder.Services.AddSingleton(new CopilotDestinationPolicy(copilotSettings.Cloud)); builder.Services.AddSingleton(new CopilotScope(copilotScope)); builder.Services.AddSingleton(); // Register HttpClient for Copilot Studio with token handler -builder.Services.AddScoped(); -builder.Services.AddHttpClient("mcs") - .AddHttpMessageHandler(); +builder.Services.AddCopilotStudioHttpClient(); // Register CopilotClient builder.Services.AddScoped(sp => diff --git a/Agents/Implementation Agents/Trace Parser/TraceParserWeb/TraceParserWeb/Services/Authentication/AuthTokenHandler.cs b/Agents/Implementation Agents/Trace Parser/TraceParserWeb/TraceParserWeb/Services/Authentication/AuthTokenHandler.cs index 1b19a9ac..ce547c3a 100644 --- a/Agents/Implementation Agents/Trace Parser/TraceParserWeb/TraceParserWeb/Services/Authentication/AuthTokenHandler.cs +++ b/Agents/Implementation Agents/Trace Parser/TraceParserWeb/TraceParserWeb/Services/Authentication/AuthTokenHandler.cs @@ -7,6 +7,7 @@ internal class AuthTokenHandler( IHttpContextAccessor httpContextAccessor, ITokenAcquisition tokenAcquisition, CopilotScope copilotScope, + CopilotDestinationPolicy destinations, ILogger logger) : DelegatingHandler { @@ -21,6 +22,9 @@ internal class AuthTokenHandler( protected override async Task SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) { + // This also covers pre-authorized requests and SDK response-derived URLs. + destinations.ValidateDestination(request.RequestUri); + if (request.Headers.Authorization is null) { var context = httpContextAccessor.HttpContext @@ -47,7 +51,27 @@ protected override async Task SendAsync( } } - return await base.SendAsync(request, cancellationToken); + var response = await base.SendAsync(request, cancellationToken); + if ((int)response.StatusCode is >= 300 and < 400) + { + var status = response.StatusCode; + response.Dispose(); + throw new HttpRequestException("Copilot Studio redirects are not supported.", null, status); + } + return response; + } + } + + internal static class CopilotStudioHttpClientRegistration + { + internal static IServiceCollection AddCopilotStudioHttpClient(this IServiceCollection services) + { + services.AddScoped(); + services.AddHttpClient("mcs") + // Redirects in the transport do not re-enter AuthTokenHandler. + .ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler { AllowAutoRedirect = false }) + .AddHttpMessageHandler(); + return services; } } } \ No newline at end of file diff --git a/Agents/Implementation Agents/Trace Parser/TraceParserWeb/TraceParserWeb/Services/Authentication/CopilotDestinationPolicy.cs b/Agents/Implementation Agents/Trace Parser/TraceParserWeb/TraceParserWeb/Services/Authentication/CopilotDestinationPolicy.cs new file mode 100644 index 00000000..a599abb0 --- /dev/null +++ b/Agents/Implementation Agents/Trace Parser/TraceParserWeb/TraceParserWeb/Services/Authentication/CopilotDestinationPolicy.cs @@ -0,0 +1,92 @@ +using System.Text.RegularExpressions; +using Microsoft.Agents.CopilotStudio.Client; +using Microsoft.Agents.CopilotStudio.Client.Discovery; + +namespace TraceParserWeb.Services.Authentication; + +internal sealed class CopilotDestinationPolicy +{ + internal const string EnvironmentIdError = + "Enter an Environment ID in GUID or Default-GUID format, not a URL."; + + private readonly Regex trustedHost; + + internal CopilotDestinationPolicy(PowerPlatformCloud? cloud) + { + // SDK 1.3.176: commercial environments split the final two ID characters + // into a DNS label; the other hosted clouds split the final character. + var (suffix, split) = (cloud ?? PowerPlatformCloud.Prod) switch + { + PowerPlatformCloud.Prod or PowerPlatformCloud.FirstRelease => ("api.powerplatform.com", 2), + PowerPlatformCloud.Exp => ("api.exp.powerplatform.com", 1), + PowerPlatformCloud.Dev => ("api.dev.powerplatform.com", 1), + PowerPlatformCloud.Test => ("api.test.powerplatform.com", 1), + PowerPlatformCloud.Preprod => ("api.preprod.powerplatform.com", 1), + PowerPlatformCloud.Prv => ("api.prv.powerplatform.com", 1), + PowerPlatformCloud.Gov or PowerPlatformCloud.GovFR => ("api.gov.powerplatform.microsoft.us", 1), + PowerPlatformCloud.High => ("api.high.powerplatform.microsoft.us", 1), + PowerPlatformCloud.DoD => ("api.appsplatform.us", 1), + PowerPlatformCloud.Mooncake => ("api.powerplatform.partner.microsoftonline.cn", 1), + PowerPlatformCloud.Ex => ("api.powerplatform.eaglex.ic.gov", 1), + PowerPlatformCloud.Rx => ("api.powerplatform.microsoft.scloud", 1), + _ => throw new ArgumentException("This Power Platform cloud is not supported. Local and custom clouds are not allowed.") + }; + trustedHost = new Regex( + $@"\A(?:default)?[0-9a-f]{{{32 - split}}}\.[0-9a-f]{{{split}}}\.environment\.{Regex.Escape(suffix)}\z", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.NonBacktracking); + } + + internal static bool TryNormalizeEnvironmentId(string? value, out string normalized) + { + normalized = ""; + var candidate = value?.Trim(); + if (candidate is null) return false; + var isDefault = candidate.StartsWith("Default-", StringComparison.OrdinalIgnoreCase); + var id = isDefault ? candidate["Default-".Length..] : candidate; + if (id.Length != 36 || !Guid.TryParseExact(id, "D", out var guid)) return false; + normalized = (isDefault ? "Default-" : "") + guid.ToString("D"); + return true; + } + + internal static void ValidateSettings(ConnectionSettings settings) + { + var direct = !string.IsNullOrEmpty(settings.DirectConnectUrl); + if (settings.EnvironmentId is not null || !direct) + { + if (!TryNormalizeEnvironmentId(settings.EnvironmentId, out var environmentId)) + throw new ArgumentException(EnvironmentIdError, nameof(settings.EnvironmentId)); + settings.EnvironmentId = environmentId; + } + + if (settings.SchemaName is not null || !direct) + { + if (string.IsNullOrWhiteSpace(settings.SchemaName)) + throw new ArgumentException("Enter an agent Schema Name.", nameof(settings.SchemaName)); + settings.SchemaName = settings.SchemaName.Trim(); + } + + var policy = new CopilotDestinationPolicy(settings.Cloud); + if (!string.IsNullOrEmpty(settings.CustomPowerPlatformCloud)) + throw new ArgumentException("Custom Power Platform endpoints are not supported."); + if (settings.UseExperimentalEndpoint) + throw new ArgumentException("Experimental island endpoints are not supported."); + if (direct) + { + if (!Uri.TryCreate(settings.DirectConnectUrl, UriKind.Absolute, out var uri)) + throw new ArgumentException("DirectConnectUrl must be an absolute trusted Power Platform URL."); + policy.ValidateDestination(uri); + } + } + + internal void ValidateDestination(Uri? uri) + { + if (uri is null || !uri.IsAbsoluteUri || + !uri.IsWellFormedOriginalString() || uri.Scheme != Uri.UriSchemeHttps || uri.Port != 443 || + uri.UserInfo.Length != 0 || uri.Fragment.Length != 0 || + uri.HostNameType != UriHostNameType.Dns || !trustedHost.IsMatch(uri.IdnHost)) + { + // Do not echo untrusted URLs, query strings, or credentials in errors. + throw new InvalidOperationException("The agent destination is not a trusted HTTPS Power Platform environment endpoint."); + } + } +} diff --git a/Agents/Implementation Agents/Trace Parser/TraceParserWeb/TraceParserWeb/Services/Authentication/CopilotStudioConnectionSettings.cs b/Agents/Implementation Agents/Trace Parser/TraceParserWeb/TraceParserWeb/Services/Authentication/CopilotStudioConnectionSettings.cs index 0287f69d..b5a2dfc4 100644 --- a/Agents/Implementation Agents/Trace Parser/TraceParserWeb/TraceParserWeb/Services/Authentication/CopilotStudioConnectionSettings.cs +++ b/Agents/Implementation Agents/Trace Parser/TraceParserWeb/TraceParserWeb/Services/Authentication/CopilotStudioConnectionSettings.cs @@ -1,19 +1,50 @@ using Microsoft.Agents.CopilotStudio.Client; +using Microsoft.Agents.CopilotStudio.Client.Discovery; namespace TraceParserWeb.Services.Authentication { - internal class CopilotStudioConnectionSettings( - IConfigurationSection copilotConfig, - IConfigurationSection azureAdConfig) - : ConnectionSettings(copilotConfig) + internal class CopilotStudioConnectionSettings : ConnectionSettings { - public string TenantId { get; } = azureAdConfig["TenantId"] - ?? throw new ArgumentException("TenantId not found in AzureAd config"); + internal CopilotStudioConnectionSettings( + IConfigurationSection copilotConfig, + IConfigurationSection azureAdConfig) : base(copilotConfig) + { + // The SDK skips these configuration values when DirectConnectUrl is set. + // They must still pass the same server policy, including the cloud boundary. + EnvironmentId = copilotConfig["EnvironmentId"]; + SchemaName = copilotConfig["SchemaName"]; + Cloud = copilotConfig.GetValue("Cloud", PowerPlatformCloud.Prod); + CustomPowerPlatformCloud = copilotConfig["CustomPowerPlatformCloud"]; + UseExperimentalEndpoint = copilotConfig.GetValue("UseExperimentalEndpoint"); + CopilotDestinationPolicy.ValidateSettings(this); + TenantId = azureAdConfig["TenantId"] + ?? throw new ArgumentException("TenantId not found in AzureAd config"); + AppClientId = azureAdConfig["ClientId"] + ?? throw new ArgumentException("ClientId not found in AzureAd config"); + AppClientSecret = azureAdConfig["ClientSecret"]; + UseS2SConnection = copilotConfig.GetValue("UseS2SConnection", false); + } - public string AppClientId { get; } = azureAdConfig["ClientId"] - ?? throw new ArgumentException("ClientId not found in AzureAd config"); + internal static CopilotStudioConnectionSettings ForAgent( + IConfiguration configuration, string environmentId, string schemaName) + { + using var agentConfiguration = new ConfigurationManager(); + agentConfiguration.AddConfiguration(configuration); + agentConfiguration.AddInMemoryCollection(new Dictionary + { + ["CopilotStudio:EnvironmentId"] = environmentId, + ["CopilotStudio:SchemaName"] = schemaName, + // A server default direct URL must not override the selected agent. + ["CopilotStudio:DirectConnectUrl"] = null + }); + return new CopilotStudioConnectionSettings( + agentConfiguration.GetSection("CopilotStudio"), + configuration.GetSection("AzureAd")); + } - public string? AppClientSecret { get; } = azureAdConfig["ClientSecret"]; - public bool UseS2SConnection { get; } = copilotConfig.GetValue("UseS2SConnection", false); + public string TenantId { get; } + public string AppClientId { get; } + public string? AppClientSecret { get; } + public bool UseS2SConnection { get; } } } diff --git a/Agents/Implementation Agents/Trace Parser/TraceParserWeb/TraceParserWeb/TraceParserWeb.csproj b/Agents/Implementation Agents/Trace Parser/TraceParserWeb/TraceParserWeb/TraceParserWeb.csproj index b2aac387..77a94ce8 100644 --- a/Agents/Implementation Agents/Trace Parser/TraceParserWeb/TraceParserWeb/TraceParserWeb.csproj +++ b/Agents/Implementation Agents/Trace Parser/TraceParserWeb/TraceParserWeb/TraceParserWeb.csproj @@ -8,6 +8,7 @@ + diff --git a/Agents/Implementation Agents/Trace Parser/TraceParserWeb/tests/TraceParserWeb.RegressionTests/AgentDestinationChecks.cs b/Agents/Implementation Agents/Trace Parser/TraceParserWeb/tests/TraceParserWeb.RegressionTests/AgentDestinationChecks.cs new file mode 100644 index 00000000..784e8663 --- /dev/null +++ b/Agents/Implementation Agents/Trace Parser/TraceParserWeb/tests/TraceParserWeb.RegressionTests/AgentDestinationChecks.cs @@ -0,0 +1,453 @@ +#pragma warning disable BL0006 +using System.Net; +using System.Net.Http.Headers; +using System.Reflection; +using System.Security.Claims; +using System.Text.Json; +using Microsoft.Agents.CopilotStudio.Client; +using Microsoft.Agents.CopilotStudio.Client.Discovery; +using Microsoft.Agents.Core.Models; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Rendering; +using Microsoft.AspNetCore.Components.RenderTree; +using Microsoft.AspNetCore.Components.Web; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Http; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Identity.Web; +using Microsoft.JSInterop; +using TraceParserWeb.Components.Pages.Chat; +using TraceParserWeb.Services; +using TraceParserWeb.Services.Authentication; + +static class AgentDestinationChecks +{ + const string EnvironmentId = "11111111-2222-3333-4444-555555555555"; + const string Host = "111111112222333344445555555555.55.environment.api.powerplatform.com"; + const string Endpoint = "https://" + Host + "/copilotstudio/dataverse-backed/authenticated/bots/synthetic/conversations"; + const string Scope = "https://api.powerplatform.com/.default"; + + public static async Task RunAsync() + { + var passed = 0; + foreach (var invalid in new[] { "", " ", "abc", "attacker.example/abc", "//attacker.example/abc", + "https://attacker.example/abc", "attacker.example\\abc", "attacker.example?abc", "attacker.example#abc", + "attacker.example:443/abc", "user@attacker.example/abc", "%2f%2fattacker.example", "%252f", + EnvironmentId + "/x", EnvironmentId + ".attacker.example", EnvironmentId + "@attacker.example", + EnvironmentId.Insert(12, "\r\n"), "{" + EnvironmentId + "}", EnvironmentId.Replace("-", ""), + "Default-attacker.example/abc", "Default-Default-" + EnvironmentId, "Default-" + EnvironmentId + "%2f" }) + { + Check(!CopilotDestinationPolicy.TryNormalizeEnvironmentId(invalid, out _), "Invalid environment ID accepted"); + var config = Configuration(invalid); + Throws(() => Startup(config)); + Throws(() => CopilotStudioConnectionSettings.ForAgent(Configuration(), invalid, "synthetic")); + passed++; + } + foreach (var input in new[] { EnvironmentId, " " + EnvironmentId + " ", "DEFAULT-" + EnvironmentId, + " ABCDEFAB-CDEF-ABCD-EFAB-CDEFABCDEFAB " }) + { + Check(CopilotDestinationPolicy.TryNormalizeEnvironmentId(input, out var canonical), "Valid environment rejected"); + var startup = Startup(Configuration(input)); + var runtime = CopilotStudioConnectionSettings.ForAgent(Configuration(), input, " synthetic "); + Check(startup.EnvironmentId == canonical && runtime.EnvironmentId == canonical && + runtime.SchemaName == "synthetic", "Startup/runtime normalization differs"); + Check(runtime.TenantId == startup.TenantId && runtime.AppClientId == startup.AppClientId && + runtime.AppClientSecret == startup.AppClientSecret, "Switching changed the server identity boundary"); + Check(CopilotClient.ScopeFromSettings(startup) == Scope && CopilotClient.ScopeFromSettings(runtime) == Scope, + "Default token audience changed"); + passed++; + } + + foreach (var option in new[] { + ("Cloud", "Local"), ("Cloud", "Other"), ("Cloud", "Unknown"), + ("Cloud", "999"), ("CustomPowerPlatformCloud", "https://attacker.example"), + ("UseExperimentalEndpoint", "true") }) + { + var config = Configuration(extra: new() { [option.Item1] = option.Item2 }); + Throws(() => Startup(config)); + Throws(() => CopilotStudioConnectionSettings.ForAgent(config, EnvironmentId, "synthetic")); + Throws(() => Startup(Configuration(extra: new() { + [option.Item1] = option.Item2, ["DirectConnectUrl"] = Endpoint }))); + passed++; + } + { + var directOnly = Configuration(extra: new() { + ["EnvironmentId"] = null, ["SchemaName"] = null, ["DirectConnectUrl"] = Endpoint }); + Check(Startup(directOnly).DirectConnectUrl == Endpoint, "Trusted direct-only configuration rejected"); + Throws(() => Startup(Configuration(extra: new() { + ["DirectConnectUrl"] = Endpoint, ["Cloud"] = "High" }))); + passed++; + } + foreach (var direct in new[] { "https://attacker.example", "http://" + Host, "https://" + Host + ":444/", + "https://user@" + Host, "https://" + Host + ".attacker.example", "/relative", + "https://api.powerplatform.com/", "https://anything.environment.api.powerplatform.com/" }) + { + if (direct == "/relative") + Throws(() => Startup(Configuration(extra: new() { ["DirectConnectUrl"] = direct }))); + else + Throws(() => Startup(Configuration(extra: new() { ["DirectConnectUrl"] = direct }))); + passed++; + } + { + var config = Configuration(extra: new() { ["DirectConnectUrl"] = Endpoint }); + Check(Startup(config).DirectConnectUrl == Endpoint, "Trusted direct URL rejected"); + var switched = CopilotStudioConnectionSettings.ForAgent(config, "Default-" + EnvironmentId, "new_agent"); + Check(string.IsNullOrEmpty(switched.DirectConnectUrl) && switched.SchemaName == "new_agent", + "Server default direct URL overrode agent switching"); + passed++; + } + + var blockedUris = new Uri?[] { + null, new("/relative", UriKind.Relative), new("https://attacker.example/abc"), + new("http://" + Host), new("https://" + Host + ":444/"), + new("https://user:synthetic@" + Host), new("https://" + Host + "@attacker.example"), + new("https://" + Host + ".attacker.example"), new("https://evil" + Host), + new("https://" + Host + "./"), new("https://" + Host + "/#fragment"), + new("https://localhost"), new("https://127.0.0.1"), new("https://[::1]"), + new("https://2130706433"), new("https://169.254.169.254/metadata/identity/oauth2/token"), + new("https://environment.api.powerplatform.com"), new("https://api.powerplatform.com"), + new("https://fake.environment.api.powerplatform.com"), new("https://powerplatform.com.attacker.example"), + new("https://111111112222333344445555555555.55.environment.api.powerplatform.com.evil"), + new("https://1111111122223333444455555555555.5.environment.api.high.powerplatform.microsoft.us"), + new("https://111111112222333344445555555555.55.environment.api.powerplatform.cоm") + }; + foreach (var malformed in new[] { "https://" + Host + "\\@attacker.example", + "https://%61ttacker.example/", "https://" + Host + "%2f@attacker.example", + "https://" + Host + "%00.attacker.example/" }) + { + if (Uri.TryCreate(malformed, UriKind.Absolute, out var uri)) + Throws(() => new CopilotDestinationPolicy(null).ValidateDestination(uri)); + passed++; + } + foreach (var uri in blockedUris) + foreach (var preauthorized in new[] { false, true }) + { + using var transport = new AgentTransport(); + var tokens = Tokens(); + using var handler = Handler(tokens, transport); + using var http = new HttpMessageInvoker(handler); + using var request = new HttpRequestMessage(HttpMethod.Get, uri); + if (preauthorized) request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "synthetic-existing"); + await ThrowsAsync(() => http.SendAsync(request, default)); + Check(tokens.Calls == 0 && transport.Requests.Count == 0, "Blocked destination reached token acquisition or transport"); + Check(preauthorized || request.Headers.Authorization is null, "Blocked destination acquired an authorization header"); + passed++; + } + foreach (var preauthorized in new[] { false, true }) + { + using var transport = new AgentTransport(); + var tokens = Tokens(); + using var handler = Handler(tokens, transport); + using var http = new HttpMessageInvoker(handler); + using var request = new HttpRequestMessage(HttpMethod.Post, "https://" + Host.ToUpperInvariant() + ":443/activities"); + if (preauthorized) request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "synthetic-existing"); + using var response = await http.SendAsync(request, default); + Check(transport.Requests.Count == 1 && transport.Authorized && + tokens.Calls == (preauthorized ? 0 : 1), "Valid request was not sent with authorization"); + if (!preauthorized) Check(tokens.Scopes.SequenceEqual(new[] { Scope }), "Default token scope changed"); + passed++; + } + { + using var transport = new AgentTransport(); + var tokens = Tokens(); + using var handler = Handler(tokens, transport, authenticated: false); + using var http = new HttpMessageInvoker(handler); + using var request = new HttpRequestMessage(HttpMethod.Post, Endpoint); + await ThrowsAsync(() => http.SendAsync(request, default)); + Check(tokens.Calls == 0 && transport.Requests.Count == 0, "Unauthenticated request was sent"); + passed++; + } + { + using var transport = new AgentTransport(); + var tokens = Tokens(); + using var handler = Handler(tokens, transport); + using var http = new HttpMessageInvoker(handler); + AuthTokenHandler.ScopeOverride.Value = "synthetic-circuit-scope"; + try + { + using var request = new HttpRequestMessage(HttpMethod.Post, Endpoint); + using var response = await http.SendAsync(request, default); + Check(tokens.Scopes.Single() == "synthetic-circuit-scope", "Circuit scope override changed"); + } + finally { AuthTokenHandler.ScopeOverride.Value = null; } + passed++; + } + + // Exercise the real named registration. Inspect redirect policy before replacing + // ONLY its transport with an offline responder; no DNS/socket calls can occur. + foreach (var status in new[] { 301, 302, 303, 307, 308 }) + foreach (var target in new[] { Endpoint + "/next", "https://attacker.example/redirect" }) + { + using var transport = new AgentTransport { Response = () => + { + var response = new HttpResponseMessage((HttpStatusCode)status); + response.Headers.Location = new Uri(target); + return response; + }}; + var tokens = Tokens(); + var filter = new OfflineAgentTransport(transport); + using var services = Services(tokens, filter); + using var http = services.GetRequiredService().CreateClient("mcs"); + await ThrowsAsync(() => http.GetAsync(Endpoint)); + Check(filter.RedirectsDisabled && transport.Requests.Count == 1 && tokens.Calls == 1, + "Redirect followed or bypassed named pipeline policy"); + passed++; + } + + // Contract tests use the actual pinned SDK, not a copy of its URL builder. + foreach (var cloud in Enum.GetValues() + .Where(c => c is not (PowerPlatformCloud.Local or PowerPlatformCloud.Other or PowerPlatformCloud.Unknown))) + foreach (var id in new[] { EnvironmentId, "Default-" + EnvironmentId }) + { + var config = Configuration(id, new() { ["Cloud"] = cloud.ToString() }); + var settings = Startup(config); + var switched = CopilotStudioConnectionSettings.ForAgent(config, id, "switched_agent"); + Check(switched.Cloud == cloud && CopilotClient.ScopeFromSettings(switched) == CopilotClient.ScopeFromSettings(settings), + "Switching lost configured cloud or changed audience"); + using var transport = new AgentTransport(); + var tokens = Tokens(); + var scope = CopilotClient.ScopeFromSettings(settings); + using var handler = Handler(tokens, transport, cloud: cloud, scope: scope); + var sdk = new CopilotClient(settings, new AgentHttpFactory(handler), NullLogger.Instance, "mcs"); + await Drain(sdk.StartConversationAsync()); + await Drain(sdk.AskQuestionAsync("offline", "synthetic-conversation", default)); + await Drain(sdk.SendActivityAsync(new Activity { Type = "message", Text = "offline", + Conversation = new ConversationAccount { Id = "synthetic-conversation" } }, default)); + Check(transport.Requests.Count == 3 && tokens.Calls == 3 && + transport.Requests.All(uri => uri.AbsolutePath.StartsWith("/copilotstudio/dataverse-backed/authenticated/bots/")), + "SDK start/activity/stream requests did not pass destination policy"); + Check(tokens.Scopes.Single() == scope, "Configured cloud token scope changed"); + var directSettings = Startup(Configuration(id, new() { + ["Cloud"] = cloud.ToString(), ["DirectConnectUrl"] = transport.Requests[0].AbsoluteUri })); + Check(CopilotClient.ScopeFromSettings(directSettings) == scope, "Direct endpoint changed configured cloud audience"); + var directClient = new CopilotClient(directSettings, new AgentHttpFactory(handler), NullLogger.Instance, "mcs"); + await Drain(directClient.StartConversationAsync()); + Check(transport.Requests.Count == 4, "Trusted SDK direct URL was not sent"); + passed++; + } + foreach (var returned in new[] { Endpoint, "https://attacker.example/returned-stream" }) + { + var allowed = returned == Endpoint; + using var transport = new AgentTransport { ReturnedEndpoint = returned }; + var tokens = Tokens(); + using var handler = Handler(tokens, transport); + var sdkSettings = Startup(Configuration()); + // Bypass the first layer deliberately to exercise SDK-returned URL handling. + sdkSettings.UseExperimentalEndpoint = true; + var sdk = new CopilotClient(sdkSettings, new AgentHttpFactory(handler), NullLogger.Instance, "mcs"); + await Drain(sdk.StartConversationAsync()); + if (allowed) await Drain(sdk.AskQuestionAsync("offline", "synthetic-conversation", default)); + else await ThrowsAsync(() => Drain(sdk.AskQuestionAsync("offline", "synthetic-conversation", default))); + Check(tokens.Calls == (allowed ? 2 : 1) && transport.Requests.Count == tokens.Calls, + "SDK response-derived URL bypassed guard or blocked trusted activity URL"); + passed++; + } + { + using var transport = new AgentTransport(); + var tokens = Tokens(); + using var handler = Handler(tokens, transport); + var unvalidated = new ConnectionSettings { EnvironmentId = "attacker.example/abc", SchemaName = "synthetic" }; + var sdk = new CopilotClient(unvalidated, new AgentHttpFactory(handler), NullLogger.Instance, "mcs"); + await ThrowsAsync(() => Drain(sdk.StartConversationAsync())); + Check(tokens.Calls == 0 && transport.Requests.Count == 0, "Original SDK injection escaped the independent guard"); + passed++; + } + passed += await ProfileChecks(); + return passed; + } + + static async Task ProfileChecks() + { + var passed = 0; + foreach (var id in new[] { "attacker.example/abc", EnvironmentId, "DEFAULT-" + EnvironmentId }) + { + var js = new AgentJs { Profiles = JsonSerializer.Serialize(new[] { + new { Name = "legacy", EnvironmentId = id, SchemaName = "synthetic", + TenantId = "untrusted-legacy-tenant", ClientId = "untrusted-legacy-client", + ClientSecret = "synthetic-legacy-secret" } }) }; + using var transport = new AgentTransport(); + var tokens = Tokens(); + using var handler = Handler(tokens, transport); + var factory = new AgentHttpFactory(handler); + var chatClient = new CopilotStudioIChatClient(new CopilotClient( + Startup(Configuration()), factory, NullLogger.Instance, "mcs")); + using var page = new Chat(); + Set(page, "JS", js); + Set(page, "Configuration", Configuration()); + Set(page, "LoggerFactory", NullLoggerFactory.Instance); + Set(page, "HttpClientFactory", factory); + Set(page, "CopilotStudioClient", chatClient); + var input = new ChatInput(); + Set(input, "textArea", new ElementReference("offline", new WebElementReferenceContext(js))); + Set(page, "chatInput", input); + await (Task)Invoke(page, "OpenAgentSelector")!; + var profiles = (List)Get(page, "savedAgentProfiles")!; + Check(profiles.Single().EnvironmentId == id, "Legacy profile did not load"); + Invoke(page, "SelectAgentProfile", profiles.Single()); + await (Task)Invoke(page, "ConnectToAgent")!; + if (id.StartsWith("attacker", StringComparison.Ordinal)) + { + Check(js.Saves == 0 && chatClient.ActiveAgentName is null && tokens.Calls == 0 && + transport.Requests.Count == 0, "Malicious saved profile saved or connected"); + using var builder = new RenderTreeBuilder(); + Invoke(page, "BuildRenderTree", builder); + var frames = builder.GetFrames(); + Check(frames.Array.Take(frames.Count).Any(frame => frame.FrameType == RenderTreeFrameType.Text && + frame.TextContent.Contains("GUID", StringComparison.Ordinal)), "Validation feedback was not rendered"); + } + else + { + Check(js.Saves == 1 && chatClient.ActiveAgentName == "legacy", "Valid legacy agent could not switch"); + using var saved = JsonDocument.Parse(js.Profiles!); + var profile = saved.RootElement[0]; + Check(profile.EnumerateObject().Count() == 3 && !profile.TryGetProperty("ClientSecret", out _), + "Legacy authentication fields survived saving"); + Check(profile.GetProperty("EnvironmentId").GetString() == + (id.StartsWith("DEFAULT", StringComparison.Ordinal) ? "Default-" : "") + EnvironmentId, + "Saved environment was not canonical"); + } + passed++; + } + { + using var page = new Chat(); + Set(page, "editEnvironmentId", EnvironmentId); + Set(page, "editSchemaName", " "); + await (Task)Invoke(page, "ConnectToAgent")!; + Check((string?)Get(page, "agentValidationError") == "Enter an agent Schema Name.", + "Missing schema silently rejected"); + passed++; + } + return passed; + } + + static IConfiguration Configuration(string id = EnvironmentId, Dictionary? extra = null) + { + var values = new Dictionary { + ["CopilotStudio:EnvironmentId"] = id, ["CopilotStudio:SchemaName"] = "synthetic", + ["AzureAd:TenantId"] = "22222222-2222-2222-2222-222222222222", + ["AzureAd:ClientId"] = "33333333-3333-3333-3333-333333333333", + ["AzureAd:ClientSecret"] = "synthetic-server-credential" + }; + foreach (var pair in extra ?? []) values["CopilotStudio:" + pair.Key] = pair.Value; + return new ConfigurationBuilder().AddInMemoryCollection(values).Build(); + } + + static CopilotStudioConnectionSettings Startup(IConfiguration config) => + new(config.GetSection("CopilotStudio"), config.GetSection("AzureAd")); + static SyntheticAgentTokens Tokens() => (SyntheticAgentTokens)DispatchProxy.Create(); + static IHttpContextAccessor Context(bool authenticated = true) => new HttpContextAccessor { + HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal(new ClaimsIdentity( + [new Claim("tid", "22222222-2222-2222-2222-222222222222")], authenticated ? "synthetic" : null)) } + }; + static AuthTokenHandler Handler(SyntheticAgentTokens tokens, AgentTransport transport, + bool authenticated = true, PowerPlatformCloud? cloud = null, string scope = Scope) => + new(Context(authenticated), (ITokenAcquisition)tokens, new CopilotScope(scope), + new CopilotDestinationPolicy(cloud), NullLogger.Instance) { InnerHandler = transport }; + static ServiceProvider Services(SyntheticAgentTokens tokens, OfflineAgentTransport filter) => + new ServiceCollection().AddLogging() + .AddSingleton(Context()).AddSingleton((ITokenAcquisition)tokens) + .AddSingleton(new CopilotScope(Scope)).AddSingleton(new CopilotDestinationPolicy(null)) + .AddSingleton(filter).AddCopilotStudioHttpClient().BuildServiceProvider(); + static async Task Drain(IAsyncEnumerable activities) + { + await foreach (var _ in activities) { } + } + static void Check(bool condition, string message) + { + if (!condition) throw new Exception(message); + } + static void Throws(Action action) where T : Exception + { + try { action(); } + catch (T) { return; } + throw new Exception($"Expected {typeof(T).Name}"); + } + static async Task ThrowsAsync(Func action) where T : Exception + { + try { await action(); } + catch (T) { return; } + throw new Exception($"Expected {typeof(T).Name}"); + } + const BindingFlags Members = BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public; + static void Set(object instance, string name, object value) + { + if (instance.GetType().GetField(name, Members) is { } field) field.SetValue(instance, value); + else instance.GetType().GetProperty(name, Members)!.SetValue(instance, value); + } + static object? Get(object instance, string name) => instance.GetType().GetField(name, Members)!.GetValue(instance); + static object? Invoke(object instance, string name, params object[] args) => + instance.GetType().GetMethod(name, Members)!.Invoke(instance, args); +} + +public class SyntheticAgentTokens : DispatchProxy +{ + public int Calls { get; private set; } + public string[] Scopes { get; private set; } = []; + protected override object? Invoke(MethodInfo? targetMethod, object?[]? args) + { + if (targetMethod?.Name != nameof(ITokenAcquisition.GetAccessTokenForUserAsync)) + throw new NotSupportedException("Unexpected token method"); + Calls++; + Scopes = ((IEnumerable)args![0]!).ToArray(); + return Task.FromResult("offline-synthetic-token"); + } +} + +sealed class AgentTransport : HttpMessageHandler +{ + public List Requests { get; } = []; + public bool Authorized { get; private set; } + public string? ReturnedEndpoint { get; init; } + public Func? Response { get; init; } + protected override Task SendAsync(HttpRequestMessage request, CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + Requests.Add(request.RequestUri!); + Authorized = request.Headers.Authorization is not null; + var response = Response?.Invoke() ?? new HttpResponseMessage(HttpStatusCode.OK) { + Content = new StringContent("event: activity\ndata: {\"type\":\"message\",\"text\":\"offline\",\"conversation\":{\"id\":\"synthetic-conversation\"}}\n\n", + System.Text.Encoding.UTF8, "text/event-stream") + }; + response.Headers.TryAddWithoutValidation("x-ms-conversationid", "synthetic-conversation"); + if (ReturnedEndpoint is not null) + response.Headers.TryAddWithoutValidation("x-ms-d2e-experimental", ReturnedEndpoint); + return Task.FromResult(response); + } +} + +sealed class AgentHttpFactory(HttpMessageHandler handler) : IHttpClientFactory +{ + public HttpClient CreateClient(string name) => name == "mcs" + ? new HttpClient(handler, disposeHandler: false) + : throw new InvalidOperationException("Unexpected SDK client name"); +} + +sealed class OfflineAgentTransport(AgentTransport transport) : IHttpMessageHandlerBuilderFilter +{ + public bool RedirectsDisabled { get; private set; } + public Action Configure(Action next) => builder => + { + next(builder); + RedirectsDisabled = builder.PrimaryHandler is HttpClientHandler { AllowAutoRedirect: false }; + builder.PrimaryHandler.Dispose(); + builder.PrimaryHandler = transport; + }; +} + +sealed class AgentJs : IJSRuntime +{ + public string? Profiles { get; set; } + public int Saves { get; private set; } + public ValueTask InvokeAsync(string identifier, object?[]? args) => + InvokeAsync(identifier, default, args); + public ValueTask InvokeAsync(string identifier, CancellationToken cancellationToken, object?[]? args) + { + if (identifier == "localStorage.getItem") return ValueTask.FromResult((TValue)(object)Profiles!); + if (identifier == "localStorage.setItem") { Saves++; Profiles = (string)args![1]!; } + else if (identifier != "Blazor._internal.domWrapper.focus") + throw new NotSupportedException("Unexpected JS call"); + return ValueTask.FromResult(default(TValue)!); + } +} diff --git a/Agents/Implementation Agents/Trace Parser/TraceParserWeb/tests/TraceParserWeb.RegressionTests/Program.cs b/Agents/Implementation Agents/Trace Parser/TraceParserWeb/tests/TraceParserWeb.RegressionTests/Program.cs index 81879bdb..d8b1ffd1 100644 --- a/Agents/Implementation Agents/Trace Parser/TraceParserWeb/tests/TraceParserWeb.RegressionTests/Program.cs +++ b/Agents/Implementation Agents/Trace Parser/TraceParserWeb/tests/TraceParserWeb.RegressionTests/Program.cs @@ -121,6 +121,7 @@ await Reject(Service(store, User(), configuredTenan passed++; } passed += await ImportStatusChecks.RunAsync(); +passed += await AgentDestinationChecks.RunAsync(); Console.WriteLine($"{passed} regression checks passed."); sealed class ProbeAuthentication(ClaimsPrincipal user) : AuthenticationStateProvider