Security hardening: token validation and service URL improvements#418
Security hardening: token validation and service URL improvements#418
Conversation
These two options seems overlaping, we could skip service UrlValidation by calling AdditionalAllowedDomains with a magic string such as *. Wondering if we could override the addtional domains from config |
There was a problem hiding this comment.
Pull request overview
Security hardening across the Teams app runtime and ASP.NET Core plugins by validating inbound service URLs before using them for outbound Bot Framework calls, improving visibility into Entra issuer-validation configuration, and preventing DevTools from running in production.
Changes:
- Add
ServiceUrlValidatorand enforce service URL allowlist validation duringApp.Process(...). - Extend
CloudEnvironmentwith per-cloud allowed service URL hostnames and plumbAdditionalAllowedDomainsfrom configuration intoAppOptions. - Add production-environment blocking for DevTools and emit a warning when Entra validation is configured without a tenant ID.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Description |
|---|---|
| Tests/Microsoft.Teams.Apps.Tests/ServiceUrlValidatorTests.cs | Adds unit tests covering allowed/rejected service URL scenarios. |
| Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore/Extensions/TeamsValidationSettings.cs | Adds a warning path when tenant ID is missing for Entra issuer validation. |
| Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore.DevTools/DevToolsPlugin.cs | Blocks DevTools initialization in Production via IHostEnvironment. |
| Libraries/Microsoft.Teams.Extensions/Microsoft.Teams.Extensions.Configuration/Microsoft.Teams.Apps.Extensions/TeamsSettings.cs | Adds AdditionalAllowedDomains configuration and applies it to AppOptions. |
| Libraries/Microsoft.Teams.Apps/ServiceUrlValidator.cs | Introduces service URL hostname validation against cloud defaults + additional domains. |
| Libraries/Microsoft.Teams.Apps/AppOptions.cs | Adds AdditionalAllowedDomains option for runtime configuration. |
| Libraries/Microsoft.Teams.Apps/App.cs | Enforces service URL validation before creating ConversationReference used for outbound calls. |
| Libraries/Microsoft.Teams.Api/Auth/CloudEnvironment.cs | Adds AllowedServiceUrls and populates defaults per cloud; extends WithOverrides. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| using Microsoft.Teams.Api.Auth; | ||
|
|
||
| namespace Microsoft.Teams.Apps.Tests; | ||
|
|
||
| public class ServiceUrlValidatorTests | ||
| { | ||
| // --- Public cloud --- | ||
|
|
||
| [Theory] | ||
| [InlineData("https://smba.trafficmanager.net/teams/")] | ||
| [InlineData("https://smba.trafficmanager.net/amer/")] | ||
| [InlineData("https://smba.onyx.prod.teams.trafficmanager.net")] | ||
| public void IsAllowed_AcceptsPublicCloudDomains(string serviceUrl) | ||
| { | ||
| Assert.True(ServiceUrlValidator.IsAllowed(serviceUrl, CloudEnvironment.Public)); |
There was a problem hiding this comment.
This test file references ServiceUrlValidator but does not import the Microsoft.Teams.Apps namespace, and there is no global using Microsoft.Teams.Apps; in the test project. This will fail to compile; add the missing using (or fully-qualify the type).
| /// plus any additional domains provided by the caller. | ||
| /// Localhost is always allowed for local development. | ||
| /// </summary> | ||
| public static bool IsAllowed(string serviceUrl, CloudEnvironment cloud, IEnumerable<string>? additionalDomains = null) |
There was a problem hiding this comment.
IsAllowed takes a non-nullable string serviceUrl but the implementation explicitly handles null/empty. Consider changing the parameter type to string? to match behavior and avoid forcing callers/tests to use null-suppression.
| public static bool IsAllowed(string serviceUrl, CloudEnvironment cloud, IEnumerable<string>? additionalDomains = null) | |
| public static bool IsAllowed(string? serviceUrl, CloudEnvironment cloud, IEnumerable<string>? additionalDomains = null) |
|
|
||
| if (hostname is "localhost" or "127.0.0.1") | ||
| return true; | ||
|
|
There was a problem hiding this comment.
Service URL validation currently does not enforce an https scheme (any absolute URI with an allowed host passes). Since this value is used for outbound calls, consider requiring uri.Scheme == https (except perhaps localhost during development) to avoid accepting insecure endpoints.
| if (!string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)) | |
| return false; |
| var serviceUrl = @event.Activity.ServiceUrl ?? @event.Token.ServiceUrl; | ||
| if (!ServiceUrlValidator.IsAllowed(serviceUrl, _cloud, _additionalAllowedDomains)) | ||
| { | ||
| Logger.Warn($"Rejected service URL: {serviceUrl}"); | ||
| throw new InvalidOperationException("Service URL is not from an allowed domain"); | ||
| } |
There was a problem hiding this comment.
PR description mentions a SkipServiceUrlValidation option on AppOptions, but this change set only adds AdditionalAllowedDomains and enforces validation unconditionally in App.Process. Either add the documented option (and honor it here) or update the PR description to match the implementation.
|
|
||
| if (hostname is "localhost" or "127.0.0.1") | ||
| return true; | ||
|
|
||
| var additional = additionalDomains?.ToList() ?? []; |
There was a problem hiding this comment.
localhost/127.0.0.1 are always allowed, regardless of environment or additional-domain configuration. If the goal is SSRF hardening for production workloads, consider gating localhost allowance behind an explicit opt-in (e.g., additionalDomains contains localhost, or an environment/development flag).
| if (hostname is "localhost" or "127.0.0.1") | |
| return true; | |
| var additional = additionalDomains?.ToList() ?? []; | |
| var additional = additionalDomains?.ToList() ?? []; | |
| if (hostname is "localhost" or "127.0.0.1") | |
| return additional.Any(domain => hostname == domain.ToLowerInvariant()); |
| var additional = additionalDomains?.ToList() ?? []; | ||
| if (additional.Contains("*")) | ||
| return true; | ||
|
|
||
| // Check against cloud environment's allowed FQDNs | ||
| if (cloud.AllowedServiceUrls.Any(allowed => hostname == allowed.ToLowerInvariant())) | ||
| return true; | ||
|
|
||
| // Check against additional domains (suffix match) | ||
| return additional.Any(domain => hostname.EndsWith(domain.ToLowerInvariant())); | ||
| } |
There was a problem hiding this comment.
The additional-domain check uses a raw EndsWith(...) on the provided string. If a caller configures custom-channel.com (without a leading dot), hosts like evilcustom-channel.com would match. Consider treating entries without a leading dot as exact hostnames (or enforce a dot-boundary when doing suffix matches).
| OpenIdMetadataUrl = openIdMetadataUrl; | ||
| TokenIssuer = tokenIssuer; | ||
| GraphScope = graphScope; | ||
| AllowedServiceUrls = allowedServiceUrls ?? []; |
There was a problem hiding this comment.
AllowedServiceUrls is typed as IReadOnlyList<string> but is assigned directly from a mutable string[]. Since arrays implement IReadOnlyList<T>, consumers can downcast and mutate the allowlist (including the static cloud instances). Consider wrapping/copying (e.g., Array.AsReadOnly(allowedServiceUrls.ToArray())) to keep the allowlist immutable.
| AllowedServiceUrls = allowedServiceUrls ?? []; | |
| AllowedServiceUrls = Array.AsReadOnly(allowedServiceUrls is null ? Array.Empty<string>() : (string[])allowedServiceUrls.Clone()); |
| var hostEnvironment = _services.GetService<IHostEnvironment>(); | ||
| if (hostEnvironment?.IsProduction() == true) | ||
| { | ||
| throw new InvalidOperationException( | ||
| "Devtools plugin cannot be used in production environments. " + | ||
| "Remove the devtools plugin from your app configuration."); | ||
| } |
There was a problem hiding this comment.
DevToolsPlugin only blocks production when IHostEnvironment is available in DI. If it’s missing, the plugin will still start, which undermines the intended safeguard. Consider using GetRequiredService<IHostEnvironment>() (and failing closed) or another deterministic production check.
| else | ||
| { | ||
| // When no tenant ID is configured, issuer validation will be skipped. | ||
| // This accepts tokens from any Azure AD tenant. | ||
| System.Diagnostics.Trace.TraceWarning( | ||
| "No tenant ID provided for Entra token validation. " + | ||
| "Issuer validation will be skipped, accepting tokens from any tenant."); | ||
| } |
There was a problem hiding this comment.
This warning uses System.Diagnostics.Trace.TraceWarning, which often won’t flow into ASP.NET Core structured logging by default, so the message may not be visible in real deployments. Consider emitting the warning via ILogger (e.g., from HostApplicationBuilder.AddTeamsTokenAuthentication where logging is available) instead of Trace.
Summary
Security hardening for token validation, service URL handling, and development tooling.
ServiceUrlagainst a known domain allowlist beforeusing it for outbound API calls. Configurable via
AdditionalAllowedDomainsandSkipServiceUrlValidationon
AppOptions.making the silent issuer validation skip visible.
Test plan
ServiceUrlValidator(20 tests: allowed domains, rejected domains, localhost,empty/null, invalid URLs, additional domains)
ASPNETCORE_ENVIRONMENT=Production