diff --git a/AGENTS.md b/AGENTS.md index 29cd8da703..eb97c2d73a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,6 +48,7 @@ tests/ # C# tests and HTTP samples ## Testing and Safety +- **C# test conventions:** Use explicit `// Arrange`, `// Act`, and `// Assert` sections; keep assertions out of the action. Inherit the appropriate existing test base (`TestWithLoggingBase` for isolated tests, `TestWithServices` for DI, or `IntegrationTestsBase` with `AppWebHostFactory` for application integration). Reuse repository fixtures and HTTP helpers rather than duplicating application startup or service registration. Read `.agents/skills/backend-testing/SKILL.md` before adding or changing backend tests. - **Test value and runtime:** Before adding a test, identify the distinct failure it catches and check existing coverage. Extend an existing test or use parameterized cases when the setup and behavior are the same. Combine related assertions in one scenario when they share expensive setup; keep independent behaviors separately diagnosable. Do not add tests for trivial accessors, framework behavior, implementation details, or duplicate coverage merely to increase test counts. - Use the cheapest reliable layer: pure logic in unit tests, service/API contracts in integration tests, and browser tests for user journeys or behavior that requires a real browser. Keep a representative browser integration case when moving a data matrix to unit/component tests. - Keep fixtures and generated data minimal, but cross the actual pagination/batch boundary when that is the behavior under test. Use controlled time and observable conditions instead of fixed sleeps. Keep benchmarks with no correctness assertions out of the normal test suite. diff --git a/src/Exceptionless.Web/ClientApp/src/hooks.client.ts b/src/Exceptionless.Web/ClientApp/src/hooks.client.ts index ab9b6d7df2..8e7e8c4c9f 100644 --- a/src/Exceptionless.Web/ClientApp/src/hooks.client.ts +++ b/src/Exceptionless.Web/ClientApp/src/hooks.client.ts @@ -10,6 +10,12 @@ import { normalizePath, normalizeRouteId } from '$lib/telemetry'; import { installSvelteEffectDepthDiagnostics } from '$lib/telemetry/svelte-effect-depth-diagnostics'; import { Exceptionless, guid, toError } from '@exceptionless/browser'; import { useMiddleware } from '@foundatiofx/fetchclient'; +import { config } from 'zod'; + +// Zod's object-validator JIT uses Function(), which strict CSP blocks without unsafe-eval. +// Use its interpreted validator instead of weakening script-src (validation rules are unchanged). +// https://github.com/colinhacks/zod/blob/main/packages/zod/src/v4/core/util.ts +config({ jitless: true }); installSvelteEffectDepthDiagnostics(); diff --git a/src/Exceptionless.Web/ClientApp/svelte.config.js b/src/Exceptionless.Web/ClientApp/svelte.config.js index 82019d1368..a4e6ec5a55 100644 --- a/src/Exceptionless.Web/ClientApp/svelte.config.js +++ b/src/Exceptionless.Web/ClientApp/svelte.config.js @@ -18,6 +18,37 @@ const config = { $generated: 'src/lib/generated', $lib: 'src/lib', $shared: 'src/lib/features/shared' + }, + // Native CSP covers Vite responses and hashes the static SPA bootstrap at build time. + // The published build must be served through ASP.NET: its header restricts these + // runtime/Vite connection targets and supplies frame-ancestors, which meta cannot. + csp: { + directives: { + 'base-uri': ['none'], + // ws: also permits wss:; * alone covers HTTP(S), not WebSockets. + 'connect-src': ['*', 'ws:'], + 'default-src': ['none'], + 'font-src': ['self', 'https://*.intercomcdn.com'], + 'form-action': ['self'], + 'frame-src': ['https://*.stripe.com', 'https://link.com', 'https://*.link.com'], + 'img-src': [ + 'self', + 'blob:', + 'data:', + 'https://*.link.com', + 'https://js.intercomcdn.com', + 'https://static.intercomassets.com', + 'https://www.gravatar.com' + ], + 'media-src': ['https://js.intercomcdn.com'], + 'object-src': ['none'], + 'script-src': ['self', 'strict-dynamic', 'https://*.stripe.com', 'https://*.intercom.io', 'https://js.intercomcdn.com'], + 'style-src': ['self', 'unsafe-inline'], + 'upgrade-insecure-requests': process.env.NODE_ENV === 'production', + // Explicitly deny workers; without this, worker-src falls back to script-src. + 'worker-src': ['none'] + }, + mode: 'auto' } }, preprocess: vitePreprocess() diff --git a/src/Exceptionless.Web/Program.cs b/src/Exceptionless.Web/Program.cs index b64a9adeef..2db260ac1a 100644 --- a/src/Exceptionless.Web/Program.cs +++ b/src/Exceptionless.Web/Program.cs @@ -9,8 +9,8 @@ using Exceptionless.Insulation.Configuration; using Exceptionless.Insulation.Security; using Exceptionless.Web.Api; -using Exceptionless.Web.Assistant; using Exceptionless.Web.Api.Results; +using Exceptionless.Web.Assistant; using Exceptionless.Web.Extensions; using Exceptionless.Web.Hubs; using Exceptionless.Web.Mcp; @@ -123,6 +123,7 @@ public static async Task Main(string[] args) builder.Services.AddSingleton(apmConfig); builder.Services.AddAppOptions(options); builder.Services.AddHttpContextAccessor(); + builder.Services.AddCsp(nonceByteAmount: 32); builder.Services.AddCors(b => b.AddPolicy("AllowAny", p => p .AllowAnyHeader() @@ -266,59 +267,8 @@ ApplicationException applicationException when applicationException.Message.Cont if (ssl) app.UseHttpsRedirection(); - app.UseCsp(csp => - { - csp.AllowFonts.FromSelf() - .From("https://fonts.gstatic.com") - .From("https://www.gravatar.com") - .From("https://fonts.intercomcdn.com") - .From("https://cdn.jsdelivr.net"); - csp.AllowImages.FromSelf() - .From("data:") - .From("https://q.stripe.com") - .From("https://js.intercomcdn.com") - .From("https://downloads.intercomcdn.com") - .From("https://uploads.intercomcdn.com") - .From("https://static.intercomassets.com") - .From("https://user-images.githubusercontent.com") - .From("https://www.gravatar.com") - .From("http://www.gravatar.com"); - csp.AllowScripts.FromSelf() - .AllowUnsafeInline() - .AllowUnsafeEval() - .From("https://js.stripe.com") - .From("https://widget.intercom.io") - .From("https://js.intercomcdn.com") - .From("https://cdn.jsdelivr.net"); - csp.AllowStyles.FromSelf() - .AllowUnsafeInline() - .From("https://fonts.googleapis.com") - .From("https://cdn.jsdelivr.net"); - csp.AllowConnections.ToSelf() - .To("https://collector.exceptionless.io") - .To("https://config.exceptionless.io") - .To("https://heartbeat.exceptionless.io") - .To("https://via.intercom.io") - .To("https://api.intercom.io") - .To("https://api-iam.intercom.io/") - .To("https://api-ping.intercom.io") - .To("https://*.intercom-messenger.com") - .To("wss://*.intercom-messenger.com") - .To("https://nexus-websocket-a.intercom.io") - .To("wss://nexus-websocket-a.intercom.io") - .To("https://nexus-websocket-b.intercom.io") - .To("wss://nexus-websocket-b.intercom.io") - .To("https://uploads.intercomcdn.com") - .To("https://uploads.intercomusercontent.com"); - - ApiContentSecurityPolicy.AllowConfiguredOrigins(csp, configuration.GetValue("ApiUrl")); - - csp.OnSendingHeader = new Func(context => - { - context.ShouldNotSend = context.HttpContext.Request.Path.StartsWithSegments("/api"); - return Task.CompletedTask; - }); - }); + app.UseCsp(csp => FrontendContentSecurityPolicy.Configure(csp, app.Environment.WebRootFileProvider, + options.AppMode != AppMode.Development, configuration.GetValue("BaseURL"), configuration.GetValue("ApiUrl"))); app.UseSerilogRequestLogging(o => { @@ -370,12 +320,7 @@ ApplicationException applicationException when applicationException.Message.Cont } app.MapOpenApi("/docs/v2/openapi.json"); - app.MapScalarApiReference("/docs", o => - { - o.WithOpenApiRoutePattern("/docs/{documentName}/openapi.json") - .AddDocument("v2", "Exceptionless API", "/docs/{documentName}/openapi.json", true) - .AddPreferredSecuritySchemes("Bearer"); - }); + app.MapScalarApiReference("/docs", ConfigureScalar); app.MapApiEndpoints(); app.MapGet("/mcp", () => Results.StatusCode(StatusCodes.Status405MethodNotAllowed)) .RequireAuthorization(AuthorizationRoles.McpPolicy) @@ -429,6 +374,20 @@ internal static Task WriteProblemDetailsStatusCodeResponseAsync(StatusCodeContex .ExecuteAsync(statusCodeContext.HttpContext); } + internal static void ConfigureScalar(ScalarOptions options, HttpContext context) + { + // Resolve per request so the document and CSP header share a fresh nonce. + var nonceService = context.RequestServices.GetRequiredService(); + string nonce = nonceService.GetNonce(); + options.WithNonce(nonce) + .DisableDefaultFonts() + // The optional AI agent queries Scalar services even without a key on localhost. + .DisableAgent() + .WithOpenApiRoutePattern("/docs/{documentName}/openapi.json") + .AddDocument("v2", "Exceptionless API", "/docs/{documentName}/openapi.json", true) + .AddPreferredSecuritySchemes("Bearer"); + } + private static RequestDelegate CreateRequestDelegate(IEndpointRouteBuilder endpoints, string filePath) { var app = endpoints.CreateApplicationBuilder(); diff --git a/src/Exceptionless.Web/Security/FrontendContentSecurityPolicy.cs b/src/Exceptionless.Web/Security/FrontendContentSecurityPolicy.cs new file mode 100644 index 0000000000..32db5564ea --- /dev/null +++ b/src/Exceptionless.Web/Security/FrontendContentSecurityPolicy.cs @@ -0,0 +1,112 @@ +using System.Text.RegularExpressions; +using Joonasw.AspNetCore.SecurityHeaders.Csp.Builder; +using Microsoft.Extensions.FileProviders; + +namespace Exceptionless.Web.Security; + +internal static partial class FrontendContentSecurityPolicy +{ + // CSP directives and source syntax: + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy/default-src + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy/script-src + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy/connect-src + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy/style-src + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy/img-src + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy/font-src + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy/frame-src + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy/media-src + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy/worker-src + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy/form-action + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy/object-src + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy/base-uri + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy/frame-ancestors + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy/upgrade-insecure-requests + // Build-generated hashes and provider requirements: + // https://svelte.dev/docs/kit/configuration#csp + // https://docs.stripe.com/security/guide#content-security-policy + // https://www.intercom.com/help/en/articles/3894-using-intercom-with-content-security-policy + // https://scalar.com/products/api-references/integrations/aspnetcore/integration#assets + // https://scalar.com/products/api-references/configuration#agent + // https://docs.gravatar.com/sdk/images/ + // https://exceptionless.com/docs/clients/javascript/ + public static void Configure(CspBuilder csp, IFileProvider files, bool upgradeInsecureRequests, string? siteBaseUrl = null, string? apiUrl = null) + { + // This header is the published SPA's security boundary. The static build's meta policy + // permits runtime/Vite connection targets and cannot enforce frame-ancestors. + // Deny resource types unless their directive explicitly allows them. + csp.ByDefaultAllow.FromNowhere(); + + // Nonces/hashes authorize our bootstrap; strict-dynamic trusts scripts it loads. + // Provider host wildcards consolidate Stripe.js and Intercom's script host families. + csp.AllowScripts.FromSelf().AddNonce().WithStrictDynamic() + .From("https://*.stripe.com") + .From("https://*.intercom.io") + .From("https://js.intercomcdn.com"); + + // Read once when UseCsp configures the pipeline at startup, never per request. + // Trust only hashes from the published SPA, never request or response HTML. + // SvelteKit generates these for its bootstrap; static responses need no nonce rewriting. + IFileInfo index = files.GetFileInfo("index.html"); + if (index.Exists) + { + using var reader = new StreamReader(index.CreateReadStream()); + foreach (Match hash in ScriptHashRegex().Matches(reader.ReadToEnd())) + csp.AllowScripts.From(hash.Value); + } + + // UI style attributes and Scalar/Intercom's injected styles still need inline CSS. + csp.AllowStyles.FromSelf().AllowUnsafeInline(); + + // Local previews, Stripe Link assets, core Intercom assets and user Gravatar images. + csp.AllowImages.FromSelf().From("blob:").From("data:") + .From("https://*.link.com") + .From("https://js.intercomcdn.com") + .From("https://static.intercomassets.com") + .From("https://www.gravatar.com"); + // Bundled app fonts and Intercom's js/fonts CDN hosts. + csp.AllowFonts.FromSelf().From("https://*.intercomcdn.com"); + + // The backend serves a fixed policy; only Vite allows arbitrary environment connections. + csp.AllowConnections.ToSelf() + // Browser telemetry to Exceptionless collectors (hooks.client.ts). + .To("https://*.exceptionless.io") + // Payment Element uses Stripe.js; Link is enabled by its default payment options. + .To("https://api.stripe.com") + .To("https://link.com").To("https://*.link.com") + // Messenger API/ping and realtime connections; no upload or attachment hosts. + .To("https://*.intercom.io").To("wss://*.intercom.io") + .To("https://*.intercom-messenger.com").To("wss://*.intercom-messenger.com"); + // Explicit configured WebSocket origins cover browsers where 'self' does not match WSS. + // BaseURL/ApiUrl must match the externally served origins, including behind reverse proxies. + // Invalid or missing origins add no sources; request/forwarded headers are never trusted. + ApiContentSecurityPolicy.AllowConfiguredOrigins(csp, siteBaseUrl); + ApiContentSecurityPolicy.AllowConfiguredOrigins(csp, apiUrl); + + // Stripe Payment Element, 3DS and Link frames. + csp.AllowFrames.From("https://*.stripe.com") + .From("https://link.com").From("https://*.link.com"); + + // Intercom's core messenger sounds; optional video/attachment sources are excluded. + csp.AllowAudioAndVideo.From("https://js.intercomcdn.com"); + + // The SPA does not register workers; keep worker-src explicit because it otherwise + // falls back through script-src rather than directly to default-src. + csp.AllowWorkers.FromNowhere(); + csp.AllowFormActions.ToSelf(); + csp.AllowPlugins.FromNowhere(); + csp.AllowBaseUri.FromNowhere(); + csp.AllowFraming.FromNowhere(); + if (upgradeInsecureRequests) + csp.SetUpgradeInsecureRequests(); + + csp.OnSendingHeader = context => + { + context.ShouldNotSend = context.HttpContext.Request.Path.StartsWithSegments("/api"); + return Task.CompletedTask; + }; + } + + [GeneratedRegex("'sha256-[A-Za-z0-9+/]{43}='", RegexOptions.CultureInvariant)] + private static partial Regex ScriptHashRegex(); +} diff --git a/tests/Exceptionless.Tests/Api/ApiContentSecurityPolicyTests.cs b/tests/Exceptionless.Tests/Api/ApiContentSecurityPolicyTests.cs index eb1cf01b9f..1df1cbcc68 100644 --- a/tests/Exceptionless.Tests/Api/ApiContentSecurityPolicyTests.cs +++ b/tests/Exceptionless.Tests/Api/ApiContentSecurityPolicyTests.cs @@ -1,9 +1,10 @@ using Exceptionless.Web.Security; +using Foundatio.Xunit; using Xunit; namespace Exceptionless.Tests.Api; -public sealed class ApiContentSecurityPolicyTests +public sealed class ApiContentSecurityPolicyTests(ITestOutputHelper output) : TestWithLoggingBase(output) { [Theory] [InlineData(" https://api.localhost:9443/backend?ignored=true#ignored ", "https://api.localhost:9443", "wss://api.localhost:9443")] diff --git a/tests/Exceptionless.Tests/Api/SpaHostingTests.cs b/tests/Exceptionless.Tests/Api/SpaHostingTests.cs index e8ab812fbe..aa4cc95d5c 100644 --- a/tests/Exceptionless.Tests/Api/SpaHostingTests.cs +++ b/tests/Exceptionless.Tests/Api/SpaHostingTests.cs @@ -1,16 +1,17 @@ using System.Net; using Exceptionless.Tests.Extensions; +using Foundatio.Xunit; using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.Configuration; using Xunit; namespace Exceptionless.Tests.Api; -public sealed class SpaHostingTests : IClassFixture +public sealed class SpaHostingTests : TestWithLoggingBase, IClassFixture { private readonly AppWebHostFactory _factory; - public SpaHostingTests(AppWebHostFactory factory) => _factory = factory; + public SpaHostingTests(ITestOutputHelper output, AppWebHostFactory factory) : base(output) => _factory = factory; [Theory] [InlineData("/login")] @@ -72,7 +73,7 @@ public async Task PostAsync_ApplicationRoute_DoesNotReturnShell() } [Fact] - public async Task GetAsync_ConfiguredApiOrigin_AllowsApiAndWebSocketConnections() + public async Task GetAsync_ConfiguredApiOrigin_UsesRestrictedConnectionsAndStrictScripts() { // Arrange const string apiUrl = "https://localhost:9443/backend?ignored=true"; @@ -89,18 +90,36 @@ public async Task GetAsync_ConfiguredApiOrigin_AllowsApiAndWebSocketConnections( string policy = Assert.Single(response.Headers.GetValues("Content-Security-Policy")); string connections = Assert.Single(policy.Split(';'), directive => directive.StartsWith("connect-src ", StringComparison.Ordinal)); string[] sources = connections.Split(' ', StringSplitOptions.RemoveEmptyEntries); - Assert.Contains("'self'", sources); + Assert.DoesNotContain("*", sources); + Assert.DoesNotContain("ws:", sources); + Assert.DoesNotContain("wss:", sources); Assert.Contains("https://localhost:9443", sources); Assert.Contains("wss://localhost:9443", sources); - Assert.DoesNotContain("*", sources); Assert.DoesNotContain(apiUrl, sources); - // API-origin validation must preserve the existing script compatibility policy. + // The published static shell relies on this response header for deny-by-default and + // anti-framing protection; frame-ancestors cannot be enforced by its meta policy. + string defaults = Assert.Single(policy.Split(';'), directive => directive.StartsWith("default-src ", StringComparison.Ordinal)); + Assert.Equal(["default-src", "'none'"], defaults.Split(' ', StringSplitOptions.RemoveEmptyEntries)); + string ancestors = Assert.Single(policy.Split(';'), directive => directive.StartsWith("frame-ancestors ", StringComparison.Ordinal)); + Assert.Equal(["frame-ancestors", "'none'"], ancestors.Split(' ', StringSplitOptions.RemoveEmptyEntries)); + Assert.Equal("DENY", Assert.Single(response.Headers.GetValues("X-Frame-Options"))); + + string frames = Assert.Single(policy.Split(';'), directive => directive.StartsWith("frame-src ", StringComparison.Ordinal)); + Assert.DoesNotContain("'self'", frames.Split(' ', StringSplitOptions.RemoveEmptyEntries)); + string media = Assert.Single(policy.Split(';'), directive => directive.StartsWith("media-src ", StringComparison.Ordinal)); + Assert.DoesNotContain("'self'", media.Split(' ', StringSplitOptions.RemoveEmptyEntries)); + string workers = Assert.Single(policy.Split(';'), directive => directive.StartsWith("worker-src ", StringComparison.Ordinal)); + Assert.Equal(["worker-src", "'none'"], workers.Split(' ', StringSplitOptions.RemoveEmptyEntries)); + Assert.DoesNotContain(policy.Split(';'), directive => directive.StartsWith("manifest-src ", StringComparison.Ordinal)); + + // Connection configuration must not weaken script execution restrictions. string scripts = Assert.Single(policy.Split(';'), directive => directive.StartsWith("script-src ", StringComparison.Ordinal)); string[] scriptSources = scripts.Split(' ', StringSplitOptions.RemoveEmptyEntries); - Assert.Contains("'unsafe-inline'", scriptSources); - Assert.Contains("'unsafe-eval'", scriptSources); - Assert.Contains("https://js.stripe.com", scriptSources); - Assert.Contains("https://widget.intercom.io", scriptSources); + Assert.DoesNotContain("'unsafe-inline'", scriptSources); + Assert.DoesNotContain("'unsafe-eval'", scriptSources); + Assert.Contains("'strict-dynamic'", scriptSources); + Assert.Contains("https://*.stripe.com", scriptSources); + Assert.Contains("https://*.intercom.io", scriptSources); } } diff --git a/tests/Exceptionless.Tests/Utility/Handlers/CspResponseTests.cs b/tests/Exceptionless.Tests/Utility/Handlers/CspResponseTests.cs new file mode 100644 index 0000000000..24c3746b7e --- /dev/null +++ b/tests/Exceptionless.Tests/Utility/Handlers/CspResponseTests.cs @@ -0,0 +1,148 @@ +using System.Net; +using System.Security.Cryptography; +using System.Text; +using System.Text.RegularExpressions; +using Exceptionless.Web.Security; +using Foundatio.Xunit; +using Joonasw.AspNetCore.SecurityHeaders; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.FileProviders; +using Scalar.AspNetCore; +using Xunit; + +namespace Exceptionless.Tests.Utility.Handlers; + +public sealed class CspResponseTests(ITestOutputHelper output) : TestWithLoggingBase(output) +{ + [Fact] + public async Task Configure_PublishedSpa_TrustsStartupHashWithoutRewritingResponses() + { + // Arrange + string webRoot = Path.Combine(Path.GetTempPath(), $"exceptionless-csp-{Guid.NewGuid():N}"); + Directory.CreateDirectory(webRoot); + string hash = Convert.ToBase64String(SHA256.HashData(Encoding.UTF8.GetBytes("start()"))); + string html = $""""""; + string injectedHash = Convert.ToBase64String(SHA256.HashData(Encoding.UTF8.GetBytes("injected()"))); + string changedHtml = html + ""; + + try + { + await File.WriteAllTextAsync(Path.Combine(webRoot, "index.html"), html, TestContext.Current.CancellationToken); + using IHost host = await CreateMiddlewareHostAsync(webRoot); + using HttpClient client = host.GetTestClient(); + await File.WriteAllTextAsync(Path.Combine(webRoot, "index.html"), changedHtml, TestContext.Current.CancellationToken); + + // Act + using HttpResponseMessage response = await client.GetAsync("/index.html", TestContext.Current.CancellationToken); + string body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + string policy = response.Headers.GetValues("Content-Security-Policy").Single(); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(changedHtml, body); + Assert.Contains("default-src 'none'", policy); + Assert.Contains($"'sha256-{hash}'", policy); + Assert.DoesNotContain(injectedHash, policy); + Assert.DoesNotContain("nonce=", body); + } + finally + { + Directory.Delete(webRoot, recursive: true); + } + } + + [Fact] + public async Task ConfigureScalar_TwoRequests_UsesFreshNoncesOnlyForScalarScripts() + { + // Arrange + using IHost host = await CreateMiddlewareHostAsync(); + using HttpClient client = host.GetTestClient(); + + // Act + using HttpResponseMessage firstResponse = await client.GetAsync("/docs/", TestContext.Current.CancellationToken); + using HttpResponseMessage secondResponse = await client.GetAsync("/docs/", TestContext.Current.CancellationToken); + string firstBody = await firstResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + string secondBody = await secondResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + MatchCollection firstNonces = Regex.Matches(firstBody, "]*\\bnonce=\"(?[^\"]+)\""); + MatchCollection secondNonces = Regex.Matches(secondBody, "]*\\bnonce=\"(?[^\"]+)\""); + // Assert + Assert.Equal(HttpStatusCode.OK, firstResponse.StatusCode); + Assert.Equal(HttpStatusCode.OK, secondResponse.StatusCode); + Assert.Equal(3, firstNonces.Count); + Assert.Equal(3, secondNonces.Count); + string firstNonce = WebUtility.HtmlDecode(firstNonces[0].Groups["nonce"].Value); + string secondNonce = WebUtility.HtmlDecode(secondNonces[0].Groups["nonce"].Value); + Assert.All(firstNonces, match => Assert.Equal(firstNonce, WebUtility.HtmlDecode(match.Groups["nonce"].Value))); + Assert.All(secondNonces, match => Assert.Equal(secondNonce, WebUtility.HtmlDecode(match.Groups["nonce"].Value))); + Assert.Equal(32, Convert.FromBase64String(firstNonce).Length); + Assert.Equal(32, Convert.FromBase64String(secondNonce).Length); + Assert.NotEqual(firstNonce, secondNonce); + Assert.Contains($"'nonce-{firstNonce}'", Assert.Single(firstResponse.Headers.GetValues("Content-Security-Policy"))); + Assert.Contains($"'nonce-{secondNonce}'", Assert.Single(secondResponse.Headers.GetValues("Content-Security-Policy"))); + Assert.Equal("no-store", firstResponse.Headers.CacheControl?.ToString()); + Assert.Equal("no-store", secondResponse.Headers.CacheControl?.ToString()); + Assert.Contains("", firstBody); + Assert.Contains("", secondBody); + Assert.Contains("\"withDefaultFonts\":false", firstBody); + Assert.Contains("\"agent\":{\"disabled\":true}", firstBody); + Assert.DoesNotContain("cdn.jsdelivr.net", firstBody); + } + + [Fact] + public async Task Configure_ConfiguredOrigins_RestrictsConnectionsWithoutTrustingRequestHeaders() + { + // Arrange + using IHost host = await CreateMiddlewareHostAsync(siteBaseUrl: "https://site.localhost:8111", apiUrl: "https://api.localhost:9443/backend?ignored=true"); + using HttpClient client = host.GetTestClient(); + client.DefaultRequestHeaders.Host = "poisoned.localhost:4444"; + client.DefaultRequestHeaders.Add("X-Forwarded-Host", "forwarded.localhost:5555"); + client.DefaultRequestHeaders.Add("Forwarded", "host=forwarded.localhost:5555;proto=http"); + + // Act + using HttpResponseMessage response = await client.GetAsync("/docs/", TestContext.Current.CancellationToken); + string policy = response.Headers.GetValues("Content-Security-Policy").Single(); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + string connections = Assert.Single(policy.Split(';').Select(directive => directive.Trim()), directive => directive.StartsWith("connect-src ", StringComparison.Ordinal)); + Assert.Equal([ + "connect-src", "'self'", "https://*.exceptionless.io", "https://api.stripe.com", + "https://link.com", "https://*.link.com", "https://*.intercom.io", "wss://*.intercom.io", + "https://*.intercom-messenger.com", "wss://*.intercom-messenger.com", + "https://site.localhost:8111", "wss://site.localhost:8111", "https://api.localhost:9443", "wss://api.localhost:9443" + ], connections.Split(' ', StringSplitOptions.RemoveEmptyEntries)); + } + + // Exercise the middleware boundary without starting unrelated databases or copying app routing. + private async Task CreateMiddlewareHostAsync(string? webRoot = null, string? siteBaseUrl = null, string? apiUrl = null) + { + IHost host = Host.CreateDefaultBuilder() + .ConfigureServices(services => services.AddSingleton(Log)) + .ConfigureWebHost(builder => + { + if (webRoot is not null) + builder.UseContentRoot(webRoot).UseWebRoot(webRoot); + builder.UseTestServer() + .ConfigureServices(services => + { + services.AddCsp(nonceByteAmount: 32); + services.AddRouting(); + }) + .Configure(app => + { + var environment = app.ApplicationServices.GetRequiredService(); + app.UseCsp(csp => FrontendContentSecurityPolicy.Configure(csp, environment.WebRootFileProvider, + upgradeInsecureRequests: true, siteBaseUrl, apiUrl)); + app.UseStaticFiles(); + app.UseRouting(); + app.UseEndpoints(endpoints => endpoints.MapScalarApiReference("/docs", (options, context) => + { + Exceptionless.Web.Program.ConfigureScalar(options, context); + options.AddHeaderContent(""); + })); + }); + }).Build(); + await host.StartAsync(TestContext.Current.CancellationToken); + return host; + } +}