From b45baf8375271f80630214be2f442937a9ba13a2 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Thu, 30 Jul 2026 13:18:10 -0500 Subject: [PATCH 01/26] Simplify CSP nonce injection --- src/Exceptionless.Web/Program.cs | 72 +++++++++- .../Exceptionless.Tests/Api/CspNonceTests.cs | 129 ++++++++++++++++++ 2 files changed, 197 insertions(+), 4 deletions(-) create mode 100644 tests/Exceptionless.Tests/Api/CspNonceTests.cs diff --git a/src/Exceptionless.Web/Program.cs b/src/Exceptionless.Web/Program.cs index b64a9adeef..5ef985faee 100644 --- a/src/Exceptionless.Web/Program.cs +++ b/src/Exceptionless.Web/Program.cs @@ -1,5 +1,7 @@ using System.Diagnostics; using System.Security.Claims; +using System.Text; +using System.Text.RegularExpressions; using Exceptionless.Core; using Exceptionless.Core.Authorization; using Exceptionless.Core.Configuration; @@ -23,6 +25,7 @@ using Foundatio.Repositories.Exceptions; using Joonasw.AspNetCore.SecurityHeaders; using Joonasw.AspNetCore.SecurityHeaders.Csp; +using Joonasw.AspNetCore.SecurityHeaders.Csp.Builder; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Diagnostics; using Microsoft.AspNetCore.Diagnostics.HealthChecks; @@ -123,6 +126,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() @@ -284,8 +288,8 @@ ApplicationException applicationException when applicationException.Message.Cont .From("https://www.gravatar.com") .From("http://www.gravatar.com"); csp.AllowScripts.FromSelf() - .AllowUnsafeInline() - .AllowUnsafeEval() + .AddNonce() + .WithStrictDynamic() .From("https://js.stripe.com") .From("https://widget.intercom.io") .From("https://js.intercomcdn.com") @@ -343,9 +347,9 @@ ApplicationException applicationException when applicationException.Message.Cont }; }); - app.UseStaticFiles(); app.UseDefaultFiles(); - app.UseFileServer(); + app.Use(InjectCspNonceAsync); + app.UseStaticFiles(); app.UseRouting(); app.UseMiddleware(); app.UseCors("AllowAny"); @@ -429,6 +433,66 @@ internal static Task WriteProblemDetailsStatusCodeResponseAsync(StatusCodeContex .ExecuteAsync(statusCodeContext.HttpContext); } + internal static async Task InjectCspNonceAsync(HttpContext context, RequestDelegate next) + { + if (!HttpMethods.IsGet(context.Request.Method) + || !context.Request.Headers.Accept.ToString().Contains("text/html", StringComparison.OrdinalIgnoreCase)) + { + await next(context); + return; + } + + Stream responseBody = context.Response.Body; + await using var buffer = new MemoryStream(); + context.Response.Body = buffer; + + try + { + await next(context); + + buffer.Position = 0; + if (context.Response.StatusCode != StatusCodes.Status200OK + || context.Response.ContentType?.StartsWith("text/html", StringComparison.OrdinalIgnoreCase) is not true) + { + context.Response.Body = responseBody; + await buffer.CopyToAsync(context.Response.Body, context.RequestAborted); + return; + } + + using var reader = new StreamReader(buffer, Encoding.UTF8, detectEncodingFromByteOrderMarks: true, leaveOpen: true); + string html = await reader.ReadToEndAsync(context.RequestAborted); + string responseHtml = AddScriptNonce(html, context.RequestServices.GetRequiredService().GetNonce()); + byte[] responseBytes = Encoding.UTF8.GetBytes(responseHtml); + + context.Response.ContentLength = responseBytes.Length; + context.Response.Headers.CacheControl = "no-store"; + context.Response.Headers.Remove(HeaderNames.ETag); + context.Response.Headers.Remove(HeaderNames.LastModified); + + context.Response.Body = responseBody; + await context.Response.Body.WriteAsync(responseBytes, context.RequestAborted); + } + finally + { + context.Response.Body = responseBody; + } + } + + internal static string AddScriptNonce(string html, string nonce) + { + return ScriptElementRegex().Replace(html, match => + { + string attributes = NonceAttributeRegex().Replace(match.Groups["attributes"].Value, String.Empty); + return $"""", + TestContext.Current.CancellationToken); + + using var fileProvider = new PhysicalFileProvider(webRoot); + var services = new ServiceCollection(); + services.AddLogging(); + services.AddCsp(nonceByteAmount: 32); + services.AddSingleton(new TestWebHostEnvironment(webRoot, fileProvider)); + await using var serviceProvider = services.BuildServiceProvider(); + + var app = new ApplicationBuilder(serviceProvider); + app.UseCsp(csp => csp.AllowScripts.FromSelf().AddNonce().WithStrictDynamic()); + app.Use(Exceptionless.Web.Program.InjectCspNonceAsync); + app.UseStaticFiles(new StaticFileOptions { FileProvider = fileProvider }); + RequestDelegate pipeline = app.Build(); + + var responses = new List<(string Html, string Policy)>(); + for (int index = 0; index < 2; index++) + { + await using AsyncServiceScope scope = serviceProvider.CreateAsyncScope(); + var context = new DefaultHttpContext + { + RequestServices = scope.ServiceProvider + }; + context.Request.Method = HttpMethods.Get; + context.Request.Path = "/index.html"; + context.Request.Headers.Accept = "text/html"; + context.Response.Body = new MemoryStream(); + + await pipeline(context); + + context.Response.Body.Position = 0; + using var reader = new StreamReader(context.Response.Body, Encoding.UTF8); + responses.Add((await reader.ReadToEndAsync(TestContext.Current.CancellationToken), context.Response.Headers.ContentSecurityPolicy.ToString())); + } + + var nonces = responses + .Select(response => Regex.Match(response.Policy, "'nonce-(?[^']+)'").Groups["nonce"].Value) + .ToArray(); + + Assert.All(nonces, nonce => Assert.NotEmpty(nonce)); + Assert.NotEqual(nonces[0], nonces[1]); + + for (int index = 0; index < responses.Count; index++) + { + Assert.Equal(2, Regex.Matches(responses[index].Html, $"nonce=\"{Regex.Escape(nonces[index])}\"").Count); + Assert.DoesNotContain("stale", responses[index].Html); + Assert.Contains("""const marker = "'; + + expect(addNonceToScripts(html, nonce)).toBe(``); + }); + + it('replaces existing quoted, unquoted, and boolean nonce attributes', () => { + const nonce = createNonce(); + const html = ``; + + expect(addNonceToScripts(html, nonce)).toBe( + `` + ); + }); +}); + +describe('createContentSecurityPolicy', () => { + it('uses a strict nonce policy with compatibility sources', () => { + const nonce = createNonce(); + const policy = createContentSecurityPolicy(nonce); + const scriptDirective = getDirective(policy, 'script-src'); + const connectDirective = getDirective(policy, 'connect-src'); + + expect(scriptDirective).toContain(`'nonce-${nonce}'`); + expect(scriptDirective).toContain("'strict-dynamic'"); + expect(scriptDirective).toContain("'self'"); + expect(scriptDirective).toContain('https://js.stripe.com'); + expect(scriptDirective).toContain('https://*.js.stripe.com'); + expect(scriptDirective).toContain('https://widget.intercom.io'); + expect(scriptDirective).not.toContain("'unsafe-inline'"); + expect(scriptDirective).not.toContain("'unsafe-eval'"); + expect(getDirective(policy, 'script-src-attr')).toEqual(["'none'"]); + + expect(connectDirective).toContain("'self'"); + expect(connectDirective).toContain('https://api.stripe.com'); + expect(connectDirective).toContain('wss://*.intercom-messenger.com'); + expect(connectDirective).not.toContain('ws:'); + expect(connectDirective).not.toContain('wss:'); + + expect(getDirective(policy, 'img-src')).not.toContain('http://www.gravatar.com'); + + expect(getDirective(policy, 'base-uri')).toEqual(["'none'"]); + expect(getDirective(policy, 'object-src')).toEqual(["'none'"]); + expect(getDirective(policy, 'frame-ancestors')).toEqual(["'none'"]); + }); + + it('allows broad WebSocket schemes only when development connections are requested', () => { + const policy = createContentSecurityPolicy(createNonce(), { allowDevelopmentConnections: true }); + const connectDirective = getDirective(policy, 'connect-src'); + + expect(connectDirective).toContain('ws:'); + expect(connectDirective).toContain('wss:'); + }); +}); + +describe('secureHtmlResponse', () => { + it('buffers chunked HTML, nonces every script, and prevents nonce/body caching', async () => { + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode('start()')); + controller.close(); + } + }); + const originalResponse = new Response(stream, { + headers: { + 'content-length': '123', + 'content-type': 'text/html; charset=utf-8', + etag: 'stale-after-transformation' + } + }); + + const response = await secureHtmlResponse(originalResponse, { allowDevelopmentConnections: true }); + const html = await response.text(); + const nonce = html.match(/"; + bool nextCalled = false; + var middleware = CreateMiddleware( + new Dictionary { [filePath] = html }, + context => + { + nextCalled = true; + return Task.CompletedTask; + }); + var context = CreateContext(requestPath); + context.Response.Headers.ETag = "\"cached\""; + context.Response.Headers.LastModified = DateTimeOffset.UtcNow.ToString("R"); + + await middleware.InvokeAsync(context, new TestNonceService("fresh-nonce")); + + string responseBody = ReadResponseBody(context); + Assert.False(nextCalled); + Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode); + Assert.Equal("text/html; charset=utf-8", context.Response.ContentType); + Assert.Equal("no-store", context.Response.Headers.CacheControl); + Assert.False(context.Response.Headers.ContainsKey(HeaderNames.ETag)); + Assert.False(context.Response.Headers.ContainsKey(HeaderNames.LastModified)); + Assert.Equal(2, CountOccurrences(responseBody, "")] + [InlineData("")] + [InlineData("")] + [InlineData("")] + public void AddScriptNonce_ScriptWithExistingNonce_ReplacesNonce(string html) + { + string result = SpaIndexHtmlMiddleware.AddScriptNonce(html, "new"); + + Assert.Equal(1, CountOccurrences(result, "nonce=\"new\"")); + Assert.DoesNotContain("old", result, StringComparison.Ordinal); + } + + [Fact] + public void AddScriptNonce_QuotedGreaterThanInAttribute_PreservesOpeningTag() + { + const string html = ""; + + string result = SpaIndexHtmlMiddleware.AddScriptNonce(html, "new"); + + Assert.Equal("", result); + } + + [Fact] + public void AddScriptNonce_SimilarlyNamedAttributes_PreservesAttributes() + { + const string html = ""; + + string result = SpaIndexHtmlMiddleware.AddScriptNonce(html, "new"); + + Assert.Contains("data-nonce=\"keep\"", result, StringComparison.Ordinal); + Assert.Contains("noncevalue=\"keep\"", result, StringComparison.Ordinal); + Assert.Contains("nonce-value=\"keep\"", result, StringComparison.Ordinal); + Assert.Equal(1, CountOccurrences(result, "nonce=\"new\"")); + } + + [Fact] + public async Task InvokeAsync_HeadIndexRequest_WritesHeadersWithoutBody() + { + const string html = ""; + var middleware = CreateMiddleware(new Dictionary { ["index.html"] = html }); + var context = CreateContext("/index.html", HttpMethods.Head); + + await middleware.InvokeAsync(context, new TestNonceService("head-nonce")); + + string expectedResponse = ""; + Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode); + Assert.Equal("text/html; charset=utf-8", context.Response.ContentType); + Assert.Equal("no-store", context.Response.Headers.CacheControl); + Assert.Equal(Encoding.UTF8.GetByteCount(expectedResponse), context.Response.ContentLength); + Assert.Equal(String.Empty, ReadResponseBody(context)); + } + + [Fact] + public async Task InvokeAsync_NonIndexRequest_CallsNext() + { + bool nextCalled = false; + var middleware = CreateMiddleware( + new Dictionary { ["app.js"] = "console.log('ok');" }, + context => + { + nextCalled = true; + return Task.CompletedTask; + }); + var context = CreateContext("/app.js"); + + await middleware.InvokeAsync(context, new TestNonceService("unused")); + + Assert.True(nextCalled); + Assert.Equal(String.Empty, ReadResponseBody(context)); + Assert.False(context.Response.Headers.ContainsKey(HeaderNames.CacheControl)); + } + + [Fact] + public async Task InvokeAsync_MissingIndexFile_CallsNext() + { + bool nextCalled = false; + var middleware = CreateMiddleware( + new Dictionary(), + context => + { + nextCalled = true; + context.Response.StatusCode = StatusCodes.Status404NotFound; + return Task.CompletedTask; + }); + var context = CreateContext("/next/index.html"); + + await middleware.InvokeAsync(context, new TestNonceService("unused")); + + Assert.True(nextCalled); + Assert.Equal(StatusCodes.Status404NotFound, context.Response.StatusCode); + Assert.False(context.Response.Headers.ContainsKey(HeaderNames.CacheControl)); + } + + [Fact] + public async Task InvokeAsync_PostIndexRequest_CallsNext() + { + bool nextCalled = false; + var middleware = CreateMiddleware( + new Dictionary { ["index.html"] = "" }, + context => + { + nextCalled = true; + return Task.CompletedTask; + }); + var context = CreateContext("/index.html", HttpMethods.Post); + + await middleware.InvokeAsync(context, new TestNonceService("unused")); + + Assert.True(nextCalled); + Assert.Equal(String.Empty, ReadResponseBody(context)); + } + + [Fact] + public async Task Configure_IndexAndFallbackRoutes_ServeFreshNoncedHtml() + { + string webRoot = Path.Combine(Path.GetTempPath(), "Exceptionless-SpaIndexHtmlMiddlewareTests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(Path.Combine(webRoot, "next")); + await File.WriteAllTextAsync(Path.Combine(webRoot, "index.html"), "root", TestContext.Current.CancellationToken); + await File.WriteAllTextAsync(Path.Combine(webRoot, "next", "index.html"), "next", TestContext.Current.CancellationToken); + await File.WriteAllTextAsync(Path.Combine(webRoot, "app.js"), "console.log('static');", TestContext.Current.CancellationToken); + + try + { + using IHost host = await CreatePipelineHostAsync(webRoot); + using HttpClient client = host.GetTestClient(); + string? previousNonce = null; + (string Path, string Marker)[] routes = + [ + ("/", "root"), + ("/index.html", "root"), + ("/legacy/deep-route", "root"), + ("/next/", "next"), + ("/next/index.html", "next"), + ("/next/deep-route", "next") + ]; + + foreach ((string path, string marker) in routes) + { + using HttpResponseMessage response = await client.GetAsync(path, TestContext.Current.CancellationToken); + string body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + string policy = response.Headers.GetValues("Content-Security-Policy").Single(); + string nonce = GetScriptNonce(body); + + Assert.Equal(StatusCodes.Status200OK, (int)response.StatusCode); + Assert.Contains(marker, body, StringComparison.Ordinal); + Assert.Equal("no-store", response.Headers.CacheControl?.ToString()); + Assert.Contains($"'nonce-{nonce}'", policy, StringComparison.Ordinal); + Assert.Contains("'strict-dynamic'", policy, StringComparison.Ordinal); + Assert.NotEqual(previousNonce, nonce); + previousNonce = nonce; + } + + using HttpResponseMessage staticResponse = await client.GetAsync("/app.js", TestContext.Current.CancellationToken); + Assert.Equal("console.log('static');", await staticResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); + Assert.NotEqual("no-store", staticResponse.Headers.CacheControl?.ToString()); + + using HttpResponseMessage scalarResponse = await client.GetAsync("/docs/", TestContext.Current.CancellationToken); + string scalarBody = await scalarResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + string scalarNonce = GetNonceAttribute(scalarBody); + string scalarPolicy = scalarResponse.Headers.GetValues("Content-Security-Policy").Single(); + Assert.Equal("no-store", scalarResponse.Headers.CacheControl?.ToString()); + Assert.Contains($"'nonce-{scalarNonce}'", scalarPolicy, StringComparison.Ordinal); + } + finally + { + Directory.Delete(webRoot, recursive: true); + } + } + + [Fact] + public async Task ConfigureContentSecurityPolicy_DefaultAndScriptDirectives_AreLockedDown() + { + var builder = new CspBuilder(); + FrontendContentSecurityPolicy.Configure(builder); + var options = builder.BuildCspOptions(); + + (_, string policy) = options.ToString(new TestNonceService("policy-nonce")); + string scriptDirective = GetDirective(policy, "script-src"); + string imageDirective = GetDirective(policy, "img-src"); + + Assert.Contains("default-src 'self'", policy, StringComparison.Ordinal); + Assert.Contains("object-src 'none'", policy, StringComparison.Ordinal); + Assert.Contains("base-uri 'none'", policy, StringComparison.Ordinal); + Assert.Contains("frame-ancestors 'none'", policy, StringComparison.Ordinal); + Assert.Contains("form-action 'self'", policy, StringComparison.Ordinal); + Assert.Contains("manifest-src 'self'", policy, StringComparison.Ordinal); + Assert.Contains("worker-src 'self' blob:", policy, StringComparison.Ordinal); + Assert.Contains("frame-src 'self' https://*.js.stripe.com", policy, StringComparison.Ordinal); + Assert.Contains("connect-src 'self'", policy, StringComparison.Ordinal); + Assert.Contains("https://api.stripe.com", policy, StringComparison.Ordinal); + Assert.Contains("img-src 'self' data: blob: https://*.stripe.com https://*.link.com", policy, StringComparison.Ordinal); + Assert.Contains("https://uploads.intercomcdn.com", imageDirective, StringComparison.Ordinal); + Assert.Contains("wss://*.intercom-messenger.com", policy, StringComparison.Ordinal); + Assert.Contains("'nonce-policy-nonce'", scriptDirective, StringComparison.Ordinal); + Assert.Contains("'strict-dynamic'", scriptDirective, StringComparison.Ordinal); + Assert.DoesNotContain("'unsafe-inline'", scriptDirective, StringComparison.Ordinal); + Assert.DoesNotContain("'unsafe-eval'", scriptDirective, StringComparison.Ordinal); + Assert.DoesNotContain("http://", policy, StringComparison.Ordinal); + + var apiContext = new DefaultHttpContext(); + apiContext.Request.Path = "/api/v2/about"; + var sendingHeaderContext = new CspSendingHeaderContext(apiContext); + await options.OnSendingHeader(sendingHeaderContext); + Assert.True(sendingHeaderContext.ShouldNotSend); + } + + [Fact] + public void AddCsp_SeparateScopes_ProvidesDistinct32ByteNonces() + { + var services = new ServiceCollection(); + services.AddCsp(nonceByteAmount: 32); + using ServiceProvider provider = services.BuildServiceProvider(); + string firstNonce; + + using (IServiceScope firstScope = provider.CreateScope()) + { + var nonceService = firstScope.ServiceProvider.GetRequiredService(); + firstNonce = nonceService.GetNonce(); + Assert.Equal(firstNonce, nonceService.GetNonce()); + Assert.Equal(32, Convert.FromBase64String(firstNonce).Length); + } + + using IServiceScope secondScope = provider.CreateScope(); + string secondNonce = secondScope.ServiceProvider.GetRequiredService().GetNonce(); + Assert.Equal(32, Convert.FromBase64String(secondNonce).Length); + Assert.NotEqual(firstNonce, secondNonce); + } + + private static SpaIndexHtmlMiddleware CreateMiddleware(IReadOnlyDictionary files, RequestDelegate? next = null) + { + return new SpaIndexHtmlMiddleware( + next ?? (_ => Task.CompletedTask), + new InMemoryFileProvider(files)); + } + + private static async Task CreatePipelineHostAsync(string webRoot) + { + IHost host = Host.CreateDefaultBuilder() + .ConfigureWebHost(webBuilder => webBuilder + .UseContentRoot(webRoot) + .UseWebRoot(webRoot) + .UseTestServer() + .ConfigureServices(services => + { + services.AddCsp(nonceByteAmount: 32); + services.AddRouting(); + }) + .Configure(app => + { + app.UseCsp(FrontendContentSecurityPolicy.Configure); + app.UseDefaultFiles(); + app.UseMiddleware(); + app.UseStaticFiles(); + app.UseRouting(); + app.UseEndpoints(endpoints => + { + endpoints.MapScalarApiReference("/docs", (options, context) => + options.WithNonce(context.RequestServices.GetRequiredService().GetNonce())); + endpoints.MapFallback("{**slug:nonfile}", Startup.CreateSpaFallbackRequestDelegate(endpoints)); + }); + })) + .Build(); + + await host.StartAsync(TestContext.Current.CancellationToken); + return host; + } + + private static DefaultHttpContext CreateContext(string path, string method = "GET") + { + var context = new DefaultHttpContext(); + context.Request.Method = method; + context.Request.Path = path; + context.Response.Body = new MemoryStream(); + return context; + } + + private static string ReadResponseBody(DefaultHttpContext context) + { + context.Response.Body.Position = 0; + using var reader = new StreamReader(context.Response.Body, leaveOpen: true); + return reader.ReadToEnd(); + } + + private static int CountOccurrences(string value, string search) + { + return value.Split(search, StringSplitOptions.None).Length - 1; + } + + private static string GetScriptNonce(string html) + { + Assert.Contains("= 0); + nonceStart += noncePrefix.Length; + int nonceEnd = html.IndexOf('"', nonceStart); + Assert.True(nonceEnd > nonceStart); + return System.Net.WebUtility.HtmlDecode(html[nonceStart..nonceEnd]); + } + + private static string GetDirective(string policy, string directiveName) + { + return policy.Split(';').Single(directive => directive.StartsWith(directiveName + " ", StringComparison.Ordinal)); + } + + private sealed class TestNonceService(string nonce) : ICspNonceService + { + public string GetNonce() => nonce; + } + + private sealed class InMemoryFileProvider(IReadOnlyDictionary files) : IFileProvider + { + public IDirectoryContents GetDirectoryContents(string subpath) => NotFoundDirectoryContents.Singleton; + + public IFileInfo GetFileInfo(string subpath) + { + return files.TryGetValue(subpath.TrimStart('/'), out string? content) + ? new InMemoryFileInfo(subpath, content) + : new NotFoundFileInfo(subpath); + } + + public IChangeToken Watch(string filter) => NullChangeToken.Singleton; + } + + private sealed class InMemoryFileInfo(string name, string content) : IFileInfo + { + public bool Exists => true; + public long Length => Encoding.UTF8.GetByteCount(content); + public string? PhysicalPath => null; + public string Name => name; + public DateTimeOffset LastModified => DateTimeOffset.MinValue; + public bool IsDirectory => false; + + public Stream CreateReadStream() => new MemoryStream(Encoding.UTF8.GetBytes(content), writable: false); + } +} From bfee14b66aafd837e06182c63b269d2f8564c932 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Fri, 10 Jul 2026 10:19:51 -0500 Subject: [PATCH 04/26] Scope Intercom CSP sources to US --- .../server/content-security-policy.test.ts | 7 +++++ .../src/lib/server/content-security-policy.ts | 29 +++-------------- .../Security/FrontendContentSecurityPolicy.cs | 31 ++----------------- .../Handlers/SpaIndexHtmlMiddlewareTests.cs | 7 +++++ 4 files changed, 22 insertions(+), 52 deletions(-) diff --git a/src/Exceptionless.Web/ClientApp/src/lib/server/content-security-policy.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/server/content-security-policy.test.ts index 8038af8520..ab7eda0852 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/server/content-security-policy.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/server/content-security-policy.test.ts @@ -56,6 +56,13 @@ describe('createContentSecurityPolicy', () => { expect(connectDirective).not.toContain('wss:'); expect(getDirective(policy, 'img-src')).not.toContain('http://www.gravatar.com'); + expect(policy).not.toContain('intercomcdn.eu'); + expect(policy).not.toContain('.eu.intercom.io'); + expect(policy).not.toContain('.au.intercom.io'); + expect(policy).not.toContain('au.intercomcdn.com'); + expect(policy).not.toContain('static.au.intercomassets.com'); + expect(policy).not.toContain('intercom-attachments.eu'); + expect(policy).not.toContain('au.intercom-attachments.com'); expect(getDirective(policy, 'base-uri')).toEqual(["'none'"]); expect(getDirective(policy, 'object-src')).toEqual(["'none'"]); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/server/content-security-policy.ts b/src/Exceptionless.Web/ClientApp/src/lib/server/content-security-policy.ts index b210ea1e88..0bc5535e99 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/server/content-security-policy.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/server/content-security-policy.ts @@ -5,6 +5,7 @@ const NONCE_PATTERN = /^[A-Za-z\d+/]{43}=$/; const NONCE_ATTRIBUTE_PATTERN = /\s+nonce(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+))?/gi; const SCRIPT_OPENING_TAG_PATTERN = /])*)>/gi; +// Exceptionless uses Intercom's US endpoints. Keep region-specific sources scoped to that workspace. const intercomChildSources = [ 'https://intercom-sheets.com', 'https://www.intercom-reporting.com', @@ -13,15 +14,9 @@ const intercomChildSources = [ 'https://fast.wistia.net' ] as const; -const intercomDownloadSources = ['https://downloads.intercomcdn.com', 'https://downloads.intercomcdn.eu', 'https://downloads.au.intercomcdn.com'] as const; +const intercomDownloadSources = ['https://downloads.intercomcdn.com'] as const; -const intercomUploadSources = [ - 'https://uploads.intercomcdn.com', - 'https://uploads.intercomcdn.eu', - 'https://uploads.au.intercomcdn.com', - 'https://uploads.eu.intercomcdn.com', - 'https://uploads.intercomusercontent.com' -] as const; +const intercomUploadSources = ['https://uploads.intercomcdn.com', 'https://uploads.intercomusercontent.com'] as const; const intercomAttachmentSources = [ 'https://*.intercom-attachments-1.com', @@ -32,9 +27,7 @@ const intercomAttachmentSources = [ 'https://*.intercom-attachments-6.com', 'https://*.intercom-attachments-7.com', 'https://*.intercom-attachments-8.com', - 'https://*.intercom-attachments-9.com', - 'https://*.intercom-attachments.eu', - 'https://*.au.intercom-attachments.com' + 'https://*.intercom-attachments-9.com' ] as const; const contentSecurityPolicyDirectives: ReadonlyArray = [ @@ -65,13 +58,9 @@ const contentSecurityPolicyDirectives: ReadonlyArray Date: Fri, 10 Jul 2026 11:35:25 -0500 Subject: [PATCH 05/26] fix: harden strict CSP response handling --- .../server/content-security-policy.test.ts | 19 +++++++++++++++++++ .../src/lib/server/content-security-policy.ts | 9 ++++----- .../Handlers/SpaIndexHtmlMiddleware.cs | 8 ++++---- .../Handlers/SpaIndexHtmlMiddlewareTests.cs | 10 ++++++++++ 4 files changed, 37 insertions(+), 9 deletions(-) diff --git a/src/Exceptionless.Web/ClientApp/src/lib/server/content-security-policy.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/server/content-security-policy.test.ts index ab7eda0852..7669af1deb 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/server/content-security-policy.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/server/content-security-policy.test.ts @@ -30,6 +30,13 @@ describe('addNonceToScripts', () => { `` ); }); + + it('preserves script-like text inside inline scripts', () => { + const nonce = createNonce(); + const html = ''; + + expect(addNonceToScripts(html, nonce)).toBe(``); + }); }); describe('createContentSecurityPolicy', () => { @@ -119,6 +126,18 @@ describe('secureHtmlResponse', () => { expect(response.headers.has('content-security-policy')).toBe(false); expect(response.headers.has('cache-control')).toBe(false); }); + + it.each([204, 205, 304])('leaves bodyless HTML responses untouched for status %i', async (status) => { + const originalResponse = new Response(null, { + headers: { 'content-type': 'text/html; charset=utf-8' }, + status + }); + + const response = await secureHtmlResponse(originalResponse, { allowDevelopmentConnections: true }); + + expect(response).toBe(originalResponse); + expect(response.headers.has('content-security-policy')).toBe(false); + }); }); function getDirective(policy: string, name: string): string[] { diff --git a/src/Exceptionless.Web/ClientApp/src/lib/server/content-security-policy.ts b/src/Exceptionless.Web/ClientApp/src/lib/server/content-security-policy.ts index 0bc5535e99..3e0d782846 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/server/content-security-policy.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/server/content-security-policy.ts @@ -3,7 +3,7 @@ import { randomBytes } from 'node:crypto'; const NONCE_BYTE_LENGTH = 32; const NONCE_PATTERN = /^[A-Za-z\d+/]{43}=$/; const NONCE_ATTRIBUTE_PATTERN = /\s+nonce(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+))?/gi; -const SCRIPT_OPENING_TAG_PATTERN = /])*)>/gi; +const SCRIPT_ELEMENT_PATTERN = /(])*)>([\s\S]*?)(<\/script\s*>)/gi; // Exceptionless uses Intercom's US endpoints. Keep region-specific sources scoped to that workspace. const intercomChildSources = [ @@ -121,11 +121,10 @@ interface ContentSecurityPolicyOptions { export function addNonceToScripts(html: string, nonce: string): string { validateNonce(nonce); - return html.replace(SCRIPT_OPENING_TAG_PATTERN, (openingTag, attributes: string) => { + return html.replace(SCRIPT_ELEMENT_PATTERN, (_scriptElement, scriptTagName: string, attributes: string, content: string, closingTag: string) => { const attributesWithoutNonce = attributes.replace(NONCE_ATTRIBUTE_PATTERN, ''); - const scriptTagName = openingTag.slice(0, '`; + return `${scriptTagName} nonce="${nonce}"${attributesWithoutNonce}>${content}${closingTag}`; }); } @@ -151,7 +150,7 @@ export function createNonce(): string { } export async function secureHtmlResponse(response: Response, options: ContentSecurityPolicyOptions = {}): Promise { - if (!response.headers.get('content-type')?.startsWith('text/html')) { + if (!response.headers.get('content-type')?.startsWith('text/html') || response.body === null) { return response; } diff --git a/src/Exceptionless.Web/Utility/Handlers/SpaIndexHtmlMiddleware.cs b/src/Exceptionless.Web/Utility/Handlers/SpaIndexHtmlMiddleware.cs index d8a6db567d..45154ca118 100644 --- a/src/Exceptionless.Web/Utility/Handlers/SpaIndexHtmlMiddleware.cs +++ b/src/Exceptionless.Web/Utility/Handlers/SpaIndexHtmlMiddleware.cs @@ -76,15 +76,15 @@ public async Task InvokeAsync(HttpContext context, ICspNonceService nonceService internal static string AddScriptNonce(string html, string nonce) { - return ScriptTagRegex().Replace(html, match => + return ScriptElementRegex().Replace(html, match => { string attributes = NonceAttributeRegex().Replace(match.Groups["attributes"].Value, String.Empty); - return $"", result); } + [Fact] + public void AddScriptNonce_InlineScriptContainsScriptLikeText_PreservesContent() + { + const string html = ""; + + string result = SpaIndexHtmlMiddleware.AddScriptNonce(html, "new"); + + Assert.Equal("", result); + } + [Fact] public void AddScriptNonce_SimilarlyNamedAttributes_PreservesAttributes() { From 93ca952d7dcaafe0b943fd7a299421ca91a48baf Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Sun, 12 Jul 2026 13:34:19 -0500 Subject: [PATCH 06/26] Harden and synchronize CSP policies --- .../server/content-security-policy.test.ts | 28 +++++- .../src/lib/server/content-security-policy.ts | 4 +- .../Security/FrontendContentSecurityPolicy.cs | 3 +- ...tend-content-security-policy.contract.json | 97 +++++++++++++++++++ .../Exceptionless.Tests.csproj | 3 + .../Handlers/SpaIndexHtmlMiddlewareTests.cs | 38 ++++++++ 6 files changed, 167 insertions(+), 6 deletions(-) create mode 100644 src/Exceptionless.Web/Security/frontend-content-security-policy.contract.json diff --git a/src/Exceptionless.Web/ClientApp/src/lib/server/content-security-policy.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/server/content-security-policy.test.ts index 7669af1deb..b8c7dbb183 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/server/content-security-policy.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/server/content-security-policy.test.ts @@ -1,3 +1,6 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + import { describe, expect, it } from 'vitest'; import { addNonceToScripts, createContentSecurityPolicy, createNonce, secureHtmlResponse } from './content-security-policy'; @@ -40,6 +43,13 @@ describe('addNonceToScripts', () => { }); describe('createContentSecurityPolicy', () => { + it('matches the canonical cross-runtime policy contract', () => { + const nonce = createNonce(); + const policy = normalizePolicy(createContentSecurityPolicy(nonce)); + + expect(policy).toEqual(readPolicyContract()); + }); + it('uses a strict nonce policy with compatibility sources', () => { const nonce = createNonce(); const policy = createContentSecurityPolicy(nonce); @@ -54,7 +64,7 @@ describe('createContentSecurityPolicy', () => { expect(scriptDirective).toContain('https://widget.intercom.io'); expect(scriptDirective).not.toContain("'unsafe-inline'"); expect(scriptDirective).not.toContain("'unsafe-eval'"); - expect(getDirective(policy, 'script-src-attr')).toEqual(["'none'"]); + expect(scriptDirective).not.toContain('https://cdn.jsdelivr.net'); expect(connectDirective).toContain("'self'"); expect(connectDirective).toContain('https://api.stripe.com'); @@ -149,3 +159,19 @@ function getDirective(policy: string, name: string): string[] { return directive.slice(name.length + 1).split(' '); } + +function normalizePolicy(policy: string): Record { + return Object.fromEntries( + policy.split('; ').map((directive) => { + const [name, ...sources] = directive.split(' '); + return [name, sources.filter((source) => !source.startsWith("'nonce-")).sort()]; + }) + ); +} + +function readPolicyContract(): Record { + const contractPath = resolve(process.cwd(), '../Security/frontend-content-security-policy.contract.json'); + const contract = JSON.parse(readFileSync(contractPath, 'utf8')) as Record; + + return Object.fromEntries(Object.entries(contract).map(([name, sources]) => [name, [...sources].sort()])); +} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/server/content-security-policy.ts b/src/Exceptionless.Web/ClientApp/src/lib/server/content-security-policy.ts index 3e0d782846..a448dcbf7e 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/server/content-security-policy.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/server/content-security-policy.ts @@ -42,11 +42,9 @@ const contentSecurityPolicyDirectives: ReadonlyArray + diff --git a/tests/Exceptionless.Tests/Utility/Handlers/SpaIndexHtmlMiddlewareTests.cs b/tests/Exceptionless.Tests/Utility/Handlers/SpaIndexHtmlMiddlewareTests.cs index 1c0c77d510..26a74cfbaa 100644 --- a/tests/Exceptionless.Tests/Utility/Handlers/SpaIndexHtmlMiddlewareTests.cs +++ b/tests/Exceptionless.Tests/Utility/Handlers/SpaIndexHtmlMiddlewareTests.cs @@ -1,4 +1,5 @@ using System.Text; +using System.Text.Json; using Exceptionless.Web; using Exceptionless.Web.Security; using Exceptionless.Web.Utility.Handlers; @@ -256,6 +257,7 @@ public async Task ConfigureContentSecurityPolicy_DefaultAndScriptDirectives_AreL Assert.Contains("'strict-dynamic'", scriptDirective, StringComparison.Ordinal); Assert.DoesNotContain("'unsafe-inline'", scriptDirective, StringComparison.Ordinal); Assert.DoesNotContain("'unsafe-eval'", scriptDirective, StringComparison.Ordinal); + Assert.DoesNotContain("https://cdn.jsdelivr.net", scriptDirective, StringComparison.Ordinal); Assert.DoesNotContain("http://", policy, StringComparison.Ordinal); Assert.DoesNotContain("intercomcdn.eu", policy, StringComparison.Ordinal); Assert.DoesNotContain(".eu.intercom.io", policy, StringComparison.Ordinal); @@ -272,6 +274,21 @@ public async Task ConfigureContentSecurityPolicy_DefaultAndScriptDirectives_AreL Assert.True(sendingHeaderContext.ShouldNotSend); } + [Fact] + public void ConfigureContentSecurityPolicy_DefaultPolicy_MatchesCanonicalCrossRuntimeContract() + { + var builder = new CspBuilder(); + FrontendContentSecurityPolicy.Configure(builder); + (_, string policy) = builder.BuildCspOptions().ToString(new TestNonceService("contract-nonce")); + + IReadOnlyDictionary expected = ReadPolicyContract(); + IReadOnlyDictionary actual = NormalizePolicy(policy); + + Assert.Equal(expected.Keys.Order(), actual.Keys.Order()); + foreach ((string directive, string[] expectedSources) in expected) + Assert.Equal(expectedSources, actual[directive]); + } + [Fact] public void AddCsp_SeparateScopes_ProvidesDistinct32ByteNonces() { @@ -376,6 +393,27 @@ private static string GetDirective(string policy, string directiveName) return policy.Split(';').Single(directive => directive.StartsWith(directiveName + " ", StringComparison.Ordinal)); } + private static IReadOnlyDictionary NormalizePolicy(string policy) + { + return policy.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(directive => directive.Split(' ', StringSplitOptions.RemoveEmptyEntries)) + .ToDictionary( + parts => parts[0], + parts => parts.Skip(1) + .Where(source => !source.StartsWith("'nonce-", StringComparison.Ordinal)) + .Order() + .ToArray()); + } + + private static IReadOnlyDictionary ReadPolicyContract() + { + string contractPath = Path.Combine(AppContext.BaseDirectory, "Security", "frontend-content-security-policy.contract.json"); + var contract = JsonSerializer.Deserialize>(File.ReadAllText(contractPath)); + Assert.NotNull(contract); + + return contract.ToDictionary(entry => entry.Key, entry => entry.Value.Order().ToArray()); + } + private sealed class TestNonceService(string nonce) : ICspNonceService { public string GetNonce() => nonce; From f2ae0d36af344a69c2dd0dab8f011bd63d125e41 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Sun, 12 Jul 2026 13:40:19 -0500 Subject: [PATCH 07/26] Apply Svelte formatting --- .../ClientApp/src/lib/server/content-security-policy.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Exceptionless.Web/ClientApp/src/lib/server/content-security-policy.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/server/content-security-policy.test.ts index b8c7dbb183..c5ee879733 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/server/content-security-policy.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/server/content-security-policy.test.ts @@ -1,6 +1,5 @@ import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; - import { describe, expect, it } from 'vitest'; import { addNonceToScripts, createContentSecurityPolicy, createNonce, secureHtmlResponse } from './content-security-policy'; From 126a9655792fc3dfcf3aade127b602b7945b4ab3 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Sun, 12 Jul 2026 13:39:43 -0500 Subject: [PATCH 08/26] Verify development CSP policy parity --- .../src/lib/server/content-security-policy.test.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/Exceptionless.Web/ClientApp/src/lib/server/content-security-policy.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/server/content-security-policy.test.ts index c5ee879733..2a03d66e39 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/server/content-security-policy.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/server/content-security-policy.test.ts @@ -44,7 +44,7 @@ describe('addNonceToScripts', () => { describe('createContentSecurityPolicy', () => { it('matches the canonical cross-runtime policy contract', () => { const nonce = createNonce(); - const policy = normalizePolicy(createContentSecurityPolicy(nonce)); + const policy = normalizeDevelopmentPolicy(createContentSecurityPolicy(nonce, { allowDevelopmentConnections: true })); expect(policy).toEqual(readPolicyContract()); }); @@ -159,11 +159,19 @@ function getDirective(policy: string, name: string): string[] { return directive.slice(name.length + 1).split(' '); } -function normalizePolicy(policy: string): Record { +function normalizeDevelopmentPolicy(policy: string): Record { return Object.fromEntries( policy.split('; ').map((directive) => { const [name, ...sources] = directive.split(' '); - return [name, sources.filter((source) => !source.startsWith("'nonce-")).sort()]; + const developmentSources = sources.filter((source) => source === 'ws:' || source === 'wss:'); + + if (name === 'connect-src') { + expect(developmentSources).toEqual(['ws:', 'wss:']); + } else { + expect(developmentSources).toEqual([]); + } + + return [name, sources.filter((source) => !source.startsWith("'nonce-") && source !== 'ws:' && source !== 'wss:').sort()]; }) ); } From 5a0cb3ece87ab228fe1c34651ce492d059e0d33f Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Sat, 8 Aug 2026 11:50:44 -0500 Subject: [PATCH 09/26] Update CSP test fallback to MapFallbackToFile --- .../Utility/Handlers/SpaIndexHtmlMiddlewareTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Exceptionless.Tests/Utility/Handlers/SpaIndexHtmlMiddlewareTests.cs b/tests/Exceptionless.Tests/Utility/Handlers/SpaIndexHtmlMiddlewareTests.cs index 26a74cfbaa..21df38432e 100644 --- a/tests/Exceptionless.Tests/Utility/Handlers/SpaIndexHtmlMiddlewareTests.cs +++ b/tests/Exceptionless.Tests/Utility/Handlers/SpaIndexHtmlMiddlewareTests.cs @@ -341,7 +341,7 @@ private static async Task CreatePipelineHostAsync(string webRoot) { endpoints.MapScalarApiReference("/docs", (options, context) => options.WithNonce(context.RequestServices.GetRequiredService().GetNonce())); - endpoints.MapFallback("{**slug:nonfile}", Startup.CreateSpaFallbackRequestDelegate(endpoints)); + endpoints.MapFallbackToFile("index.html"); }); })) .Build(); From 36f8ed4c84b750d7da51e26dbd3c235ed2298b0f Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Wed, 12 Aug 2026 12:36:40 -0500 Subject: [PATCH 10/26] Fix CSP fallback integration coverage --- src/Exceptionless.Web/Program.cs | 2 +- .../Utility/Handlers/SpaIndexHtmlMiddlewareTests.cs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Exceptionless.Web/Program.cs b/src/Exceptionless.Web/Program.cs index 48286aa34b..e10f59114c 100644 --- a/src/Exceptionless.Web/Program.cs +++ b/src/Exceptionless.Web/Program.cs @@ -500,7 +500,7 @@ internal static string AddScriptNonce(string html, string nonce) [GeneratedRegex("\\snonce(?=[\\s=>/]|$)(?:\\s*=\\s*(?:\"[^\"]*\"|'[^']*'|[^\\s>]+))?", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] private static partial Regex NonceAttributeRegex(); - private static RequestDelegate CreateRequestDelegate(IEndpointRouteBuilder endpoints, string filePath) + internal static RequestDelegate CreateRequestDelegate(IEndpointRouteBuilder endpoints, string filePath) { var app = endpoints.CreateApplicationBuilder(); string[] reservedPrefixes = ["/api", "/docs", "/health", "/ready", "/mcp", "/.well-known", "/_app"]; diff --git a/tests/Exceptionless.Tests/Utility/Handlers/SpaIndexHtmlMiddlewareTests.cs b/tests/Exceptionless.Tests/Utility/Handlers/SpaIndexHtmlMiddlewareTests.cs index 21df38432e..38658969ab 100644 --- a/tests/Exceptionless.Tests/Utility/Handlers/SpaIndexHtmlMiddlewareTests.cs +++ b/tests/Exceptionless.Tests/Utility/Handlers/SpaIndexHtmlMiddlewareTests.cs @@ -334,14 +334,14 @@ private static async Task CreatePipelineHostAsync(string webRoot) { app.UseCsp(FrontendContentSecurityPolicy.Configure); app.UseDefaultFiles(); - app.UseMiddleware(); + app.Use(Exceptionless.Web.Program.InjectCspNonceAsync); app.UseStaticFiles(); app.UseRouting(); app.UseEndpoints(endpoints => { endpoints.MapScalarApiReference("/docs", (options, context) => options.WithNonce(context.RequestServices.GetRequiredService().GetNonce())); - endpoints.MapFallbackToFile("index.html"); + endpoints.MapFallback("{**slug:nonfile}", Exceptionless.Web.Program.CreateRequestDelegate(endpoints, "index.html")); }); })) .Build(); From 991b8caa7158dd1b47b73da1dbef414ec1424a58 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Tue, 15 Sep 2026 19:32:16 -0500 Subject: [PATCH 11/26] Harden CSP response handling and align production coverage --- .../ClientApp/src/hooks.client.ts | 3 + .../server/content-security-policy.test.ts | 7 + .../src/lib/server/content-security-policy.ts | 4 +- src/Exceptionless.Web/Program.cs | 88 ++----- .../Handlers/SpaIndexHtmlMiddleware.cs | 91 ------- .../Exceptionless.Tests/Api/CspNonceTests.cs | 20 +- ...MiddlewareTests.cs => CspResponseTests.cs} | 239 ++++-------------- 7 files changed, 102 insertions(+), 350 deletions(-) delete mode 100644 src/Exceptionless.Web/Utility/Handlers/SpaIndexHtmlMiddleware.cs rename tests/Exceptionless.Tests/Utility/Handlers/{SpaIndexHtmlMiddlewareTests.cs => CspResponseTests.cs} (55%) diff --git a/src/Exceptionless.Web/ClientApp/src/hooks.client.ts b/src/Exceptionless.Web/ClientApp/src/hooks.client.ts index ab9b6d7df2..2093e20c2d 100644 --- a/src/Exceptionless.Web/ClientApp/src/hooks.client.ts +++ b/src/Exceptionless.Web/ClientApp/src/hooks.client.ts @@ -10,6 +10,9 @@ 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'; + +config({ jitless: true }); installSvelteEffectDepthDiagnostics(); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/server/content-security-policy.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/server/content-security-policy.test.ts index 2a03d66e39..eb794102e1 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/server/content-security-policy.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/server/content-security-policy.test.ts @@ -17,6 +17,13 @@ describe('createNonce', () => { }); describe('addNonceToScripts', () => { + it('preserves similar attribute names and nonce text inside quoted values', () => { + const nonce = createNonce(); + const html = ``; + + expect(addNonceToScripts(html, nonce)).toBe(``); + }); + it('adds the nonce to every script opening tag', () => { const nonce = createNonce(); const html = ''; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/server/content-security-policy.ts b/src/Exceptionless.Web/ClientApp/src/lib/server/content-security-policy.ts index a448dcbf7e..2fd0c701c8 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/server/content-security-policy.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/server/content-security-policy.ts @@ -2,7 +2,7 @@ import { randomBytes } from 'node:crypto'; const NONCE_BYTE_LENGTH = 32; const NONCE_PATTERN = /^[A-Za-z\d+/]{43}=$/; -const NONCE_ATTRIBUTE_PATTERN = /\s+nonce(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+))?/gi; +const NONCE_ATTRIBUTE_PATTERN = /("[^"]*"|'[^']*')|\s+nonce(?=[\s=>/]|$)(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+))?/gi; const SCRIPT_ELEMENT_PATTERN = /(])*)>([\s\S]*?)(<\/script\s*>)/gi; // Exceptionless uses Intercom's US endpoints. Keep region-specific sources scoped to that workspace. @@ -120,7 +120,7 @@ export function addNonceToScripts(html: string, nonce: string): string { validateNonce(nonce); return html.replace(SCRIPT_ELEMENT_PATTERN, (_scriptElement, scriptTagName: string, attributes: string, content: string, closingTag: string) => { - const attributesWithoutNonce = attributes.replace(NONCE_ATTRIBUTE_PATTERN, ''); + const attributesWithoutNonce = attributes.replace(NONCE_ATTRIBUTE_PATTERN, (_attribute, quoted: string | undefined) => quoted ?? ''); return `${scriptTagName} nonce="${nonce}"${attributesWithoutNonce}>${content}${closingTag}`; }); diff --git a/src/Exceptionless.Web/Program.cs b/src/Exceptionless.Web/Program.cs index e10f59114c..1ae767de1e 100644 --- a/src/Exceptionless.Web/Program.cs +++ b/src/Exceptionless.Web/Program.cs @@ -25,7 +25,6 @@ using Foundatio.Repositories.Exceptions; using Joonasw.AspNetCore.SecurityHeaders; using Joonasw.AspNetCore.SecurityHeaders.Csp; -using Joonasw.AspNetCore.SecurityHeaders.Csp.Builder; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Diagnostics; using Microsoft.AspNetCore.Diagnostics.HealthChecks; @@ -270,59 +269,7 @@ 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() - .AddNonce() - .WithStrictDynamic() - .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(FrontendContentSecurityPolicy.Configure); app.UseSerilogRequestLogging(o => { @@ -435,20 +382,25 @@ internal static Task WriteProblemDetailsStatusCodeResponseAsync(StatusCodeContex internal static async Task InjectCspNonceAsync(HttpContext context, RequestDelegate next) { - string accept = context.Request.Headers.Accept.ToString(); - bool acceptsHtml = accept.Contains("text/html", StringComparison.OrdinalIgnoreCase); - bool acceptsAny = String.IsNullOrWhiteSpace(accept) || accept.Contains("*/*", StringComparison.Ordinal); + bool isHead = HttpMethods.IsHead(context.Request.Method); bool hasNonHtmlExtension = Path.HasExtension(context.Request.Path) && !context.Request.Path.Value!.EndsWith(".html", StringComparison.OrdinalIgnoreCase); - if (!HttpMethods.IsGet(context.Request.Method) + if ((!HttpMethods.IsGet(context.Request.Method) && !isHead) || context.Request.Path.StartsWithSegments("/api") - || (!acceptsHtml && (!acceptsAny || hasNonHtmlExtension))) + || context.Request.Path.StartsWithSegments("/mcp") + || hasNonHtmlExtension) { await next(context); return; } + // Each HTML response needs a new nonce, so cached or partial bodies cannot be reused. + context.Request.Headers.Remove(HeaderNames.IfNoneMatch); + context.Request.Headers.Remove(HeaderNames.IfModifiedSince); + context.Request.Headers.Remove(HeaderNames.Range); + context.Request.Headers.Remove(HeaderNames.IfRange); + Stream responseBody = context.Response.Body; await using var buffer = new MemoryStream(); context.Response.Body = buffer; @@ -466,15 +418,23 @@ internal static async Task InjectCspNonceAsync(HttpContext context, RequestDeleg return; } + context.Response.Headers.CacheControl = "no-store"; + context.Response.Headers.Remove(HeaderNames.ETag); + context.Response.Headers.Remove(HeaderNames.LastModified); + context.Response.Headers.Remove(HeaderNames.AcceptRanges); + + if (isHead) + { + context.Response.ContentLength = null; + return; + } + using var reader = new StreamReader(buffer, Encoding.UTF8, detectEncodingFromByteOrderMarks: true, leaveOpen: true); string html = await reader.ReadToEndAsync(context.RequestAborted); string responseHtml = AddScriptNonce(html, context.RequestServices.GetRequiredService().GetNonce()); byte[] responseBytes = Encoding.UTF8.GetBytes(responseHtml); context.Response.ContentLength = responseBytes.Length; - context.Response.Headers.CacheControl = "no-store"; - context.Response.Headers.Remove(HeaderNames.ETag); - context.Response.Headers.Remove(HeaderNames.LastModified); context.Response.Body = responseBody; await context.Response.Body.WriteAsync(responseBytes, context.RequestAborted); @@ -489,7 +449,7 @@ internal static string AddScriptNonce(string html, string nonce) { return ScriptElementRegex().Replace(html, match => { - string attributes = NonceAttributeRegex().Replace(match.Groups["attributes"].Value, String.Empty); + string attributes = NonceAttributeRegex().Replace(match.Groups["attributes"].Value, attribute => attribute.Groups["quoted"].Success ? attribute.Value : String.Empty); return $""; - bool nextCalled = false; - var middleware = CreateMiddleware( - new Dictionary { [filePath] = html }, - context => - { - nextCalled = true; - return Task.CompletedTask; - }); - var context = CreateContext(requestPath); - context.Response.Headers.ETag = "\"cached\""; - context.Response.Headers.LastModified = DateTimeOffset.UtcNow.ToString("R"); - - await middleware.InvokeAsync(context, new TestNonceService("fresh-nonce")); - - string responseBody = ReadResponseBody(context); - Assert.False(nextCalled); - Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode); - Assert.Equal("text/html; charset=utf-8", context.Response.ContentType); - Assert.Equal("no-store", context.Response.Headers.CacheControl); - Assert.False(context.Response.Headers.ContainsKey(HeaderNames.ETag)); - Assert.False(context.Response.Headers.ContainsKey(HeaderNames.LastModified)); - Assert.Equal(2, CountOccurrences(responseBody, "", TestContext.Current.CancellationToken); + File.SetLastWriteTimeUtc(Path.Combine(webRoot, "index.html"), new DateTime(2023, 1, 1, 0, 0, 0, DateTimeKind.Utc)); + + try + { + using IHost host = await CreatePipelineHostAsync(webRoot); + using HttpClient client = host.GetTestClient(); + using var request = new HttpRequestMessage(new HttpMethod(method), "/index.html"); + request.Headers.TryAddWithoutValidation(header, value); + using HttpResponseMessage response = await client.SendAsync(request, TestContext.Current.CancellationToken); + + Assert.Equal(System.Net.HttpStatusCode.OK, response.StatusCode); + Assert.Equal("no-store", response.Headers.CacheControl?.ToString()); + Assert.Null(response.Headers.ETag); + Assert.Null(response.Content.Headers.LastModified); + Assert.Empty(response.Headers.AcceptRanges); + string body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + if (method == "HEAD") + Assert.Empty(body); + else + Assert.Contains($"'nonce-{GetScriptNonce(body)}'", response.Headers.GetValues("Content-Security-Policy").Single()); + } + finally + { + Directory.Delete(webRoot, recursive: true); + } + } + + [Fact] + public void AddScriptNonce_NonceTextInsideAttribute_PreservesAttribute() + { + const string html = ""; + + Assert.Equal("", Exceptionless.Web.Program.AddScriptNonce(html, "new")); } [Theory] @@ -56,7 +64,7 @@ public async Task InvokeAsync_IndexRequest_AddsNonceAndDisablesCaching(string re [InlineData("")] public void AddScriptNonce_ScriptWithExistingNonce_ReplacesNonce(string html) { - string result = SpaIndexHtmlMiddleware.AddScriptNonce(html, "new"); + string result = Exceptionless.Web.Program.AddScriptNonce(html, "new"); Assert.Equal(1, CountOccurrences(result, "nonce=\"new\"")); Assert.DoesNotContain("old", result, StringComparison.Ordinal); @@ -67,7 +75,7 @@ public void AddScriptNonce_QuotedGreaterThanInAttribute_PreservesOpeningTag() { const string html = ""; - string result = SpaIndexHtmlMiddleware.AddScriptNonce(html, "new"); + string result = Exceptionless.Web.Program.AddScriptNonce(html, "new"); Assert.Equal("", result); } @@ -77,7 +85,7 @@ public void AddScriptNonce_InlineScriptContainsScriptLikeText_PreservesContent() { const string html = ""; - string result = SpaIndexHtmlMiddleware.AddScriptNonce(html, "new"); + string result = Exceptionless.Web.Program.AddScriptNonce(html, "new"); Assert.Equal("", result); } @@ -87,7 +95,7 @@ public void AddScriptNonce_SimilarlyNamedAttributes_PreservesAttributes() { const string html = ""; - string result = SpaIndexHtmlMiddleware.AddScriptNonce(html, "new"); + string result = Exceptionless.Web.Program.AddScriptNonce(html, "new"); Assert.Contains("data-nonce=\"keep\"", result, StringComparison.Ordinal); Assert.Contains("noncevalue=\"keep\"", result, StringComparison.Ordinal); @@ -95,87 +103,10 @@ public void AddScriptNonce_SimilarlyNamedAttributes_PreservesAttributes() Assert.Equal(1, CountOccurrences(result, "nonce=\"new\"")); } - [Fact] - public async Task InvokeAsync_HeadIndexRequest_WritesHeadersWithoutBody() - { - const string html = ""; - var middleware = CreateMiddleware(new Dictionary { ["index.html"] = html }); - var context = CreateContext("/index.html", HttpMethods.Head); - - await middleware.InvokeAsync(context, new TestNonceService("head-nonce")); - - string expectedResponse = ""; - Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode); - Assert.Equal("text/html; charset=utf-8", context.Response.ContentType); - Assert.Equal("no-store", context.Response.Headers.CacheControl); - Assert.Equal(Encoding.UTF8.GetByteCount(expectedResponse), context.Response.ContentLength); - Assert.Equal(String.Empty, ReadResponseBody(context)); - } - - [Fact] - public async Task InvokeAsync_NonIndexRequest_CallsNext() - { - bool nextCalled = false; - var middleware = CreateMiddleware( - new Dictionary { ["app.js"] = "console.log('ok');" }, - context => - { - nextCalled = true; - return Task.CompletedTask; - }); - var context = CreateContext("/app.js"); - - await middleware.InvokeAsync(context, new TestNonceService("unused")); - - Assert.True(nextCalled); - Assert.Equal(String.Empty, ReadResponseBody(context)); - Assert.False(context.Response.Headers.ContainsKey(HeaderNames.CacheControl)); - } - - [Fact] - public async Task InvokeAsync_MissingIndexFile_CallsNext() - { - bool nextCalled = false; - var middleware = CreateMiddleware( - new Dictionary(), - context => - { - nextCalled = true; - context.Response.StatusCode = StatusCodes.Status404NotFound; - return Task.CompletedTask; - }); - var context = CreateContext("/next/index.html"); - - await middleware.InvokeAsync(context, new TestNonceService("unused")); - - Assert.True(nextCalled); - Assert.Equal(StatusCodes.Status404NotFound, context.Response.StatusCode); - Assert.False(context.Response.Headers.ContainsKey(HeaderNames.CacheControl)); - } - - [Fact] - public async Task InvokeAsync_PostIndexRequest_CallsNext() - { - bool nextCalled = false; - var middleware = CreateMiddleware( - new Dictionary { ["index.html"] = "" }, - context => - { - nextCalled = true; - return Task.CompletedTask; - }); - var context = CreateContext("/index.html", HttpMethods.Post); - - await middleware.InvokeAsync(context, new TestNonceService("unused")); - - Assert.True(nextCalled); - Assert.Equal(String.Empty, ReadResponseBody(context)); - } - [Fact] public async Task Configure_IndexAndFallbackRoutes_ServeFreshNoncedHtml() { - string webRoot = Path.Combine(Path.GetTempPath(), "Exceptionless-SpaIndexHtmlMiddlewareTests", Guid.NewGuid().ToString("N")); + string webRoot = Path.Combine(Path.GetTempPath(), "Exceptionless-CspResponseTests", Guid.NewGuid().ToString("N")); Directory.CreateDirectory(Path.Combine(webRoot, "next")); await File.WriteAllTextAsync(Path.Combine(webRoot, "index.html"), "root", TestContext.Current.CancellationToken); await File.WriteAllTextAsync(Path.Combine(webRoot, "next", "index.html"), "next", TestContext.Current.CancellationToken); @@ -238,34 +169,14 @@ public async Task ConfigureContentSecurityPolicy_DefaultAndScriptDirectives_AreL (_, string policy) = options.ToString(new TestNonceService("policy-nonce")); string scriptDirective = GetDirective(policy, "script-src"); - string imageDirective = GetDirective(policy, "img-src"); - Assert.Contains("default-src 'self'", policy, StringComparison.Ordinal); Assert.Contains("object-src 'none'", policy, StringComparison.Ordinal); Assert.Contains("base-uri 'none'", policy, StringComparison.Ordinal); Assert.Contains("frame-ancestors 'none'", policy, StringComparison.Ordinal); - Assert.Contains("form-action 'self'", policy, StringComparison.Ordinal); - Assert.Contains("manifest-src 'self'", policy, StringComparison.Ordinal); - Assert.Contains("worker-src 'self' blob:", policy, StringComparison.Ordinal); - Assert.Contains("frame-src 'self' https://*.js.stripe.com", policy, StringComparison.Ordinal); - Assert.Contains("connect-src 'self'", policy, StringComparison.Ordinal); - Assert.Contains("https://api.stripe.com", policy, StringComparison.Ordinal); - Assert.Contains("img-src 'self' data: blob: https://*.stripe.com https://*.link.com", policy, StringComparison.Ordinal); - Assert.Contains("https://uploads.intercomcdn.com", imageDirective, StringComparison.Ordinal); - Assert.Contains("wss://*.intercom-messenger.com", policy, StringComparison.Ordinal); Assert.Contains("'nonce-policy-nonce'", scriptDirective, StringComparison.Ordinal); Assert.Contains("'strict-dynamic'", scriptDirective, StringComparison.Ordinal); Assert.DoesNotContain("'unsafe-inline'", scriptDirective, StringComparison.Ordinal); Assert.DoesNotContain("'unsafe-eval'", scriptDirective, StringComparison.Ordinal); - Assert.DoesNotContain("https://cdn.jsdelivr.net", scriptDirective, StringComparison.Ordinal); - Assert.DoesNotContain("http://", policy, StringComparison.Ordinal); - Assert.DoesNotContain("intercomcdn.eu", policy, StringComparison.Ordinal); - Assert.DoesNotContain(".eu.intercom.io", policy, StringComparison.Ordinal); - Assert.DoesNotContain(".au.intercom.io", policy, StringComparison.Ordinal); - Assert.DoesNotContain("au.intercomcdn.com", policy, StringComparison.Ordinal); - Assert.DoesNotContain("static.au.intercomassets.com", policy, StringComparison.Ordinal); - Assert.DoesNotContain("intercom-attachments.eu", policy, StringComparison.Ordinal); - Assert.DoesNotContain("au.intercom-attachments.com", policy, StringComparison.Ordinal); var apiContext = new DefaultHttpContext(); apiContext.Request.Path = "/api/v2/about"; @@ -311,13 +222,6 @@ public void AddCsp_SeparateScopes_ProvidesDistinct32ByteNonces() Assert.NotEqual(firstNonce, secondNonce); } - private static SpaIndexHtmlMiddleware CreateMiddleware(IReadOnlyDictionary files, RequestDelegate? next = null) - { - return new SpaIndexHtmlMiddleware( - next ?? (_ => Task.CompletedTask), - new InMemoryFileProvider(files)); - } - private static async Task CreatePipelineHostAsync(string webRoot) { IHost host = Host.CreateDefaultBuilder() @@ -339,9 +243,8 @@ private static async Task CreatePipelineHostAsync(string webRoot) app.UseRouting(); app.UseEndpoints(endpoints => { - endpoints.MapScalarApiReference("/docs", (options, context) => - options.WithNonce(context.RequestServices.GetRequiredService().GetNonce())); - endpoints.MapFallback("{**slug:nonfile}", Exceptionless.Web.Program.CreateRequestDelegate(endpoints, "index.html")); + endpoints.MapScalarApiReference("/docs"); + endpoints.MapFallback("{**slug:nonfile}", Exceptionless.Web.Program.CreateRequestDelegate(endpoints, "/index.html")); }); })) .Build(); @@ -350,22 +253,6 @@ private static async Task CreatePipelineHostAsync(string webRoot) return host; } - private static DefaultHttpContext CreateContext(string path, string method = "GET") - { - var context = new DefaultHttpContext(); - context.Request.Method = method; - context.Request.Path = path; - context.Response.Body = new MemoryStream(); - return context; - } - - private static string ReadResponseBody(DefaultHttpContext context) - { - context.Response.Body.Position = 0; - using var reader = new StreamReader(context.Response.Body, leaveOpen: true); - return reader.ReadToEnd(); - } - private static int CountOccurrences(string value, string search) { return value.Split(search, StringSplitOptions.None).Length - 1; @@ -418,30 +305,4 @@ private sealed class TestNonceService(string nonce) : ICspNonceService { public string GetNonce() => nonce; } - - private sealed class InMemoryFileProvider(IReadOnlyDictionary files) : IFileProvider - { - public IDirectoryContents GetDirectoryContents(string subpath) => NotFoundDirectoryContents.Singleton; - - public IFileInfo GetFileInfo(string subpath) - { - return files.TryGetValue(subpath.TrimStart('/'), out string? content) - ? new InMemoryFileInfo(subpath, content) - : new NotFoundFileInfo(subpath); - } - - public IChangeToken Watch(string filter) => NullChangeToken.Singleton; - } - - private sealed class InMemoryFileInfo(string name, string content) : IFileInfo - { - public bool Exists => true; - public long Length => Encoding.UTF8.GetByteCount(content); - public string? PhysicalPath => null; - public string Name => name; - public DateTimeOffset LastModified => DateTimeOffset.MinValue; - public bool IsDirectory => false; - - public Stream CreateReadStream() => new MemoryStream(Encoding.UTF8.GetBytes(content), writable: false); - } } From d4fe31c4a49fcd7d9956e1e76829b4431bb4bcae Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Tue, 29 Sep 2026 22:47:54 -0500 Subject: [PATCH 12/26] Verify Intercom messenger CSP connection sources --- .../Utility/Handlers/CspResponseTests.cs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/Exceptionless.Tests/Utility/Handlers/CspResponseTests.cs b/tests/Exceptionless.Tests/Utility/Handlers/CspResponseTests.cs index fcd015959f..855df38155 100644 --- a/tests/Exceptionless.Tests/Utility/Handlers/CspResponseTests.cs +++ b/tests/Exceptionless.Tests/Utility/Handlers/CspResponseTests.cs @@ -200,6 +200,24 @@ public void ConfigureContentSecurityPolicy_DefaultPolicy_MatchesCanonicalCrossRu Assert.Equal(expectedSources, actual[directive]); } + [Fact] + public void ConfigureContentSecurityPolicy_IntercomMessenger_AllowsHttpsAndWebSocketConnections() + { + var builder = new CspBuilder(); + FrontendContentSecurityPolicy.Configure(builder); + (_, string policy) = builder.BuildCspOptions().ToString(new TestNonceService("intercom-nonce")); + + IReadOnlyDictionary directives = NormalizePolicy(policy); + string[] messengerSources = directives["connect-src"] + .Where(source => source.Contains("intercom-messenger.com", StringComparison.Ordinal)) + .ToArray(); + + Assert.Equal(["https://*.intercom-messenger.com", "wss://*.intercom-messenger.com"], messengerSources); + Assert.Contains("'strict-dynamic'", directives["script-src"]); + Assert.DoesNotContain("'unsafe-inline'", directives["script-src"]); + Assert.DoesNotContain("'unsafe-eval'", directives["script-src"]); + } + [Fact] public void AddCsp_SeparateScopes_ProvidesDistinct32ByteNonces() { From 4bf2100174d3f516fcfb68070b62114f01e0e2d2 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Tue, 29 Sep 2026 23:36:54 -0500 Subject: [PATCH 13/26] Scope strict CSP to the modern Svelte frontend --- docs/frontend-content-security-policy.md | 78 +++++++++++++++++++ .../ClientApp/src/hooks.server.test.ts | 61 +++++++++++++++ .../ClientApp/src/hooks.server.ts | 5 +- .../server/content-security-policy.test.ts | 38 ++++++++- .../src/lib/server/content-security-policy.ts | 20 ++++- src/Exceptionless.Web/Program.cs | 2 +- .../Security/FrontendContentSecurityPolicy.cs | 26 ++++++- ...tend-content-security-policy.contract.json | 4 +- .../Utility/Handlers/CspResponseTests.cs | 60 +++++++++++++- 9 files changed, 279 insertions(+), 15 deletions(-) create mode 100644 docs/frontend-content-security-policy.md create mode 100644 src/Exceptionless.Web/ClientApp/src/hooks.server.test.ts diff --git a/docs/frontend-content-security-policy.md b/docs/frontend-content-security-policy.md new file mode 100644 index 0000000000..95c71e5643 --- /dev/null +++ b/docs/frontend-content-security-policy.md @@ -0,0 +1,78 @@ +# Modern frontend content security policy + +This policy targets the Svelte 5/SvelteKit static application mounted at `/next`. +It assumes the legacy Angular frontend is retired. This PR does not retire that +application, alter routing, or change deployment. Its Angular subtree matches +`main`; Angular-specific nonce middleware, renderer changes, and tests are not +part of this PR. + +ASP.NET serves the built SPA and stamps every HTML script with a fresh 32-byte +response nonce. The Svelte server hook protects development HTML; static builds +receive their policy from ASP.NET. Both generators are checked against +`src/Exceptionless.Web/Security/frontend-content-security-policy.contract.json`. +HTML cannot be cached or served partially with a mismatched nonce. API and MCP +streaming responses bypass HTML transformation. + +## Sources and consumers + +| Directive | Consumer and retained sources | +| --- | --- | +| `default-src` | Same-origin assets and API calls. | +| `script-src` | Nonce plus `strict-dynamic`; same-origin, Stripe.js (`js.stripe.com`, `*.js.stripe.com`, `maps.googleapis.com`) and Intercom script hosts are CSP2 compatibility sources. No `unsafe-inline` or `unsafe-eval`. Zod JIT is disabled. | +| `connect-src` | Same-origin API, streaming assistant responses, uploads, health checks, and `/api/v2/push` WebSocket; Exceptionless collector/config/heartbeat telemetry; Stripe API, Maps and Link; US Intercom API, realtime and upload endpoints. Includes exactly `https://*.intercom-messenger.com` and `wss://*.intercom-messenger.com`. | +| `style-src` | Same-origin CSS; inline styles used by Svelte components and Intercom; jsDelivr for Scalar API documentation served by the same ASP.NET host. | +| `font-src` | Same-origin fonts, Intercom font CDNs, and Scalar's jsDelivr assets. Google Fonts are not used by the modern app. | +| `img-src` | Same-origin uploaded avatars, Gravatar fallback, image previews (`blob:`/`data:`), Stripe/Link and documented US Intercom images/attachments. No direct GitHub avatar host is needed: `UserHandler` serves uploaded avatars through `/api/v2/users/...`. | +| `frame-src` | Same-origin; Stripe.js, authentication/3DS and Link frames; Intercom article, reporting and embedded-video frames. | +| `worker-src` | Same-origin, blob workers, and Intercom's documented child sources. Kept separate from `frame-src` following Intercom's CSP3 guidance. | +| `media-src` | Same-origin, blob media, Intercom media/download CDNs. | +| `form-action` | Same-origin plus Intercom help and US API forms. OAuth uses top-level browser navigation, not cross-origin form submission or fetch. | +| `manifest-src` | Same-origin. | +| `base-uri`, `object-src`, `frame-ancestors` | `none`: no base override, plugins, or embedding of this application. | + +US Intercom endpoints are retained without EU/Australia blanket allowances. +Google Maps remains because Stripe's Stripe.js CSP guide lists it, even though +the Svelte application itself does not render Google Maps. Stripe Checkout, +Connect embedded components, crypto onramp, OAuth fetch hosts, and arbitrary +third-party CDNs are not enabled merely because the provider offers them. + +Svelte development alone adds `ws:` and `wss:` for Vite HMR, including forwarded +Codespaces hosts. Production policies do not include those scheme-wide sources. +The application uses a real WebSocket, not EventSource: the authenticated layout +creates `WebSocketClient`, which opens `/api/v2/push` using `window.location`. +Because some browsers do not match WebSocket schemes against `'self'`, production +also allows one exact WebSocket origin derived from administrator configuration: +`BaseURL` for ASP.NET, `PUBLIC_BASE_URL` for the Svelte server hook. HTTPS becomes +WSS and HTTP becomes WS, preserving non-default ports and IPv6 while discarding +paths, queries and fragments. Request `Host` and forwarded headers never supply +this source. + +These configured URLs must match the externally served application origin, +including the public port, in reverse-proxy and self-hosted deployments. ASP.NET +already requires `BaseURL`; invalid HTTP(S) origin configuration now fails +explicitly rather than widening the policy. A missing `PUBLIC_BASE_URL` leaves +the Svelte hook restrictive (`'self'` and provider sources only); it never enables +production `ws:`/`wss:`. If a public URL is supplied but invalid, generation fails +explicitly. Static Svelte builds still use the ASP.NET runtime configuration. +No new API/WebSocket configuration keys are introduced. Custom external telemetry +endpoints still require a deliberate policy update. + +## Validation and limits + +Backend HTTP tests cover `/next` index/fallback HTML, nonce rotation, static/API +bypass, conditional/range handling, configured WebSocket origins, poisoned +Host/forwarded headers, and Scalar HTML. Svelte tests cover policy +parity, both Intercom messenger schemes, obsolete-source exclusion, nonce +injection and production/development connection differences. Run these tests +alongside the production build and lint checks when changing sources. + +External payment, OAuth and authenticated Intercom behavior requires a configured +staging environment; local policy/header tests do not certify those provider +flows. Do not solve an observed violation by adding unrestricted schemes or +relaxing script restrictions. Confirm the actual consumer and destination first. + +Provider references (reviewed September 30, 2026): + +- [Stripe integration security guide](https://docs.stripe.com/security/guide) +- [Intercom CSP requirements](https://www.intercom.com/help/en/articles/3894-using-intercom-with-content-security-policy) +- [MDN connect-src and WebSocket scheme compatibility](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy/connect-src) diff --git a/src/Exceptionless.Web/ClientApp/src/hooks.server.test.ts b/src/Exceptionless.Web/ClientApp/src/hooks.server.test.ts new file mode 100644 index 0000000000..683365cc80 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/hooks.server.test.ts @@ -0,0 +1,61 @@ +import type { Handle } from '@sveltejs/kit'; + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const environment = vi.hoisted(() => ({ building: false, dev: false })); +const publicEnvironment = vi.hoisted(() => ({ PUBLIC_BASE_URL: 'https://app.example.test/next' })); +vi.mock('$app/environment', () => environment); +vi.mock('$env/dynamic/public', () => ({ env: publicEnvironment })); + +import { handle } from './hooks.server'; + +describe('server CSP hook', () => { + beforeEach(() => { + environment.building = false; + environment.dev = false; + }); + + it.each([false, true])('limits scheme-wide WebSockets to development (dev=%s)', async (dev) => { + environment.dev = dev; + const original = new Response('', { headers: { 'content-type': 'text/html' } }); + + const response = await handle({ event: {} as Parameters[0]['event'], resolve: async () => original }); + const policy = response.headers.get('content-security-policy')!; + const connections = policy + .split('; ') + .find((directive) => directive.startsWith('connect-src '))! + .split(' '); + + expect(connections.includes('ws:')).toBe(dev); + expect(connections.includes('wss:')).toBe(dev); + expect(connections).toContain('wss://*.intercom-messenger.com'); + expect(connections).toContain('wss://app.example.test'); + expect(policy).toContain("'strict-dynamic'"); + expect(await response.text()).toMatch(/', { headers: { 'content-type': 'text/html' } }); + + const response = await handle({ event: {} as Parameters[0]['event'], resolve: async () => original }); + + expect(response).toBe(original); + expect(response.headers.has('content-security-policy')).toBe(false); + }); + + it('does not trust request or forwarded hosts for the production WebSocket origin', async () => { + const original = new Response('', { headers: { 'content-type': 'text/html' } }); + const event = { + request: new Request('https://untrusted.example/next', { headers: { 'x-forwarded-host': 'forwarded.example' } }), + url: new URL('https://untrusted.example/next') + } as Parameters[0]['event']; + + const response = await handle({ event, resolve: async () => original }); + const policy = response.headers.get('content-security-policy')!; + + expect(policy).toContain('wss://app.example.test'); + expect(policy).not.toContain('untrusted.example'); + expect(policy).not.toContain('forwarded.example'); + }); +}); diff --git a/src/Exceptionless.Web/ClientApp/src/hooks.server.ts b/src/Exceptionless.Web/ClientApp/src/hooks.server.ts index e0a33adb49..33427aea21 100644 --- a/src/Exceptionless.Web/ClientApp/src/hooks.server.ts +++ b/src/Exceptionless.Web/ClientApp/src/hooks.server.ts @@ -1,6 +1,7 @@ import type { Handle } from '@sveltejs/kit'; -import { building } from '$app/environment'; +import { building, dev } from '$app/environment'; +import { env } from '$env/dynamic/public'; import { secureHtmlResponse } from '$lib/server/content-security-policy'; export const handle: Handle = async ({ event, resolve }) => { @@ -10,5 +11,5 @@ export const handle: Handle = async ({ event, resolve }) => { return response; } - return secureHtmlResponse(response, { allowDevelopmentConnections: true }); + return secureHtmlResponse(response, { allowDevelopmentConnections: dev, siteBaseUrl: env.PUBLIC_BASE_URL }); }; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/server/content-security-policy.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/server/content-security-policy.test.ts index eb794102e1..002e392beb 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/server/content-security-policy.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/server/content-security-policy.test.ts @@ -2,7 +2,32 @@ import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { describe, expect, it } from 'vitest'; -import { addNonceToScripts, createContentSecurityPolicy, createNonce, secureHtmlResponse } from './content-security-policy'; +import { addNonceToScripts, createContentSecurityPolicy, createNonce, getWebSocketOrigin, secureHtmlResponse } from './content-security-policy'; + +describe('configured WebSocket origin', () => { + it.each([ + ['https://app.example.test/next/?query=value#fragment', 'wss://app.example.test'], + ['https://app.example.test:8443/next', 'wss://app.example.test:8443'], + ['http://localhost:7110', 'ws://localhost:7110'], + ['http://localhost:80', 'ws://localhost'], + ['https://[::1]:8443/next', 'wss://[::1]:8443'] + ])('allows only the configured origin for %s', (siteBaseUrl, expectedOrigin) => { + const policy = createContentSecurityPolicy(createNonce(), { siteBaseUrl }); + + expect(getWebSocketOrigin(siteBaseUrl)).toBe(expectedOrigin); + expect(getDirective(policy, 'connect-src')).toContain(expectedOrigin); + expect(getDirective(policy, 'connect-src')).not.toContain('ws:'); + expect(getDirective(policy, 'connect-src')).not.toContain('wss:'); + expect(policy).not.toContain('query=value'); + }); + + it.each(['', '/next', 'ftp://app.example.test', 'https://user:password@app.example.test', 'https://*.example.test'])( + 'rejects invalid configuration %s', + (siteBaseUrl) => { + expect(() => createContentSecurityPolicy(createNonce(), { siteBaseUrl })).toThrow(); + } + ); +}); describe('createNonce', () => { it('creates unique base64-encoded 32-byte nonces', () => { @@ -49,6 +74,17 @@ describe('addNonceToScripts', () => { }); describe('createContentSecurityPolicy', () => { + it('excludes unused legacy sources while preserving modern payment dependencies', () => { + const policy = createContentSecurityPolicy(createNonce()); + + expect(policy).not.toContain('fonts.googleapis.com'); + expect(policy).not.toContain('fonts.gstatic.com'); + expect(policy).not.toContain('user-images.githubusercontent.com'); + expect(getDirective(policy, 'connect-src')).toContain('https://maps.googleapis.com'); + expect(getDirective(policy, 'connect-src')).toContain('https://*.intercom-messenger.com'); + expect(getDirective(policy, 'connect-src')).toContain('wss://*.intercom-messenger.com'); + }); + it('matches the canonical cross-runtime policy contract', () => { const nonce = createNonce(); const policy = normalizeDevelopmentPolicy(createContentSecurityPolicy(nonce, { allowDevelopmentConnections: true })); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/server/content-security-policy.ts b/src/Exceptionless.Web/ClientApp/src/lib/server/content-security-policy.ts index 2fd0c701c8..46b94ec411 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/server/content-security-policy.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/server/content-security-policy.ts @@ -45,7 +45,7 @@ const contentSecurityPolicyDirectives: ReadonlyArray { if (!response.headers.get('content-type')?.startsWith('text/html') || response.body === null) { return response; diff --git a/src/Exceptionless.Web/Program.cs b/src/Exceptionless.Web/Program.cs index 1ae767de1e..234875cd5d 100644 --- a/src/Exceptionless.Web/Program.cs +++ b/src/Exceptionless.Web/Program.cs @@ -269,7 +269,7 @@ ApplicationException applicationException when applicationException.Message.Cont if (ssl) app.UseHttpsRedirection(); - app.UseCsp(FrontendContentSecurityPolicy.Configure); + app.UseCsp(csp => FrontendContentSecurityPolicy.Configure(csp, options.BaseURL)); app.UseSerilogRequestLogging(o => { diff --git a/src/Exceptionless.Web/Security/FrontendContentSecurityPolicy.cs b/src/Exceptionless.Web/Security/FrontendContentSecurityPolicy.cs index 6fd556bff9..7e2d38c8d6 100644 --- a/src/Exceptionless.Web/Security/FrontendContentSecurityPolicy.cs +++ b/src/Exceptionless.Web/Security/FrontendContentSecurityPolicy.cs @@ -6,6 +6,11 @@ namespace Exceptionless.Web.Security; internal static class FrontendContentSecurityPolicy { public static void Configure(CspBuilder csp) + { + Configure(csp, null); + } + + public static void Configure(CspBuilder csp, string? siteBaseUrl) { // Exceptionless uses Intercom's US endpoints. Keep region-specific sources scoped to that workspace. csp.ByDefaultAllow.FromSelf(); @@ -22,7 +27,6 @@ public static void Configure(CspBuilder csp) csp.AllowStyles.FromSelf() .AllowUnsafeInline() - .From("https://fonts.googleapis.com") .From("https://cdn.jsdelivr.net"); csp.AllowImages.FromSelf() @@ -47,11 +51,9 @@ public static void Configure(CspBuilder csp) .From("https://*.intercom-attachments-7.com") .From("https://*.intercom-attachments-8.com") .From("https://*.intercom-attachments-9.com") - .From("https://user-images.githubusercontent.com") .From("https://www.gravatar.com"); csp.AllowFonts.FromSelf() - .From("https://fonts.gstatic.com") .From("https://js.intercomcdn.com") .From("https://fonts.intercomcdn.com") .From("https://cdn.jsdelivr.net"); @@ -77,6 +79,11 @@ public static void Configure(CspBuilder csp) .To("https://uploads.intercomcdn.com") .To("https://uploads.intercomusercontent.com"); + // Use administrator configuration, never request Host or forwarded headers. + // Some browsers do not match WebSocket schemes against connect-src 'self'. + if (siteBaseUrl is not null) + csp.AllowConnections.To(GetWebSocketOrigin(siteBaseUrl)); + csp.AllowFrames.FromSelf() .From("https://*.js.stripe.com") .From("https://js.stripe.com") @@ -116,4 +123,17 @@ public static void Configure(CspBuilder csp) return Task.CompletedTask; }; } + + internal static string GetWebSocketOrigin(string siteBaseUrl) + { + if (!Uri.TryCreate(siteBaseUrl, UriKind.Absolute, out Uri? uri) + || (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps) + || !String.IsNullOrEmpty(uri.UserInfo) + || uri.HostNameType is not (UriHostNameType.Dns or UriHostNameType.IPv4 or UriHostNameType.IPv6) + || uri.Host.Contains('*')) + throw new ArgumentException("The CSP site base URL must be an absolute HTTP(S) URL without credentials or wildcard hosts.", nameof(siteBaseUrl)); + + var origin = new UriBuilder(uri.Scheme == Uri.UriSchemeHttps ? "wss" : "ws", uri.Host, uri.IsDefaultPort ? -1 : uri.Port); + return origin.Uri.GetLeftPart(UriPartial.Authority); + } } diff --git a/src/Exceptionless.Web/Security/frontend-content-security-policy.contract.json b/src/Exceptionless.Web/Security/frontend-content-security-policy.contract.json index 3fd4798f9c..e3fdf99b8b 100644 --- a/src/Exceptionless.Web/Security/frontend-content-security-policy.contract.json +++ b/src/Exceptionless.Web/Security/frontend-content-security-policy.contract.json @@ -10,7 +10,7 @@ "https://widget.intercom.io", "https://js.intercomcdn.com" ], - "style-src": ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com", "https://cdn.jsdelivr.net"], + "style-src": ["'self'", "'unsafe-inline'", "https://cdn.jsdelivr.net"], "img-src": [ "'self'", "blob:", @@ -34,12 +34,10 @@ "https://*.intercom-attachments-7.com", "https://*.intercom-attachments-8.com", "https://*.intercom-attachments-9.com", - "https://user-images.githubusercontent.com", "https://www.gravatar.com" ], "font-src": [ "'self'", - "https://fonts.gstatic.com", "https://js.intercomcdn.com", "https://fonts.intercomcdn.com", "https://cdn.jsdelivr.net" diff --git a/tests/Exceptionless.Tests/Utility/Handlers/CspResponseTests.cs b/tests/Exceptionless.Tests/Utility/Handlers/CspResponseTests.cs index 855df38155..c25edf2808 100644 --- a/tests/Exceptionless.Tests/Utility/Handlers/CspResponseTests.cs +++ b/tests/Exceptionless.Tests/Utility/Handlers/CspResponseTests.cs @@ -121,7 +121,6 @@ public async Task Configure_IndexAndFallbackRoutes_ServeFreshNoncedHtml() [ ("/", "root"), ("/index.html", "root"), - ("/legacy/deep-route", "root"), ("/next/", "next"), ("/next/index.html", "next"), ("/next/deep-route", "next") @@ -147,6 +146,16 @@ public async Task Configure_IndexAndFallbackRoutes_ServeFreshNoncedHtml() Assert.Equal("console.log('static');", await staticResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); Assert.NotEqual("no-store", staticResponse.Headers.CacheControl?.ToString()); + using var poisonedRequest = new HttpRequestMessage(HttpMethod.Get, "/next/"); + poisonedRequest.Headers.Host = "untrusted.example"; + poisonedRequest.Headers.Add("X-Forwarded-Host", "forwarded.example"); + poisonedRequest.Headers.Add("X-Forwarded-Proto", "http"); + using HttpResponseMessage poisonedResponse = await client.SendAsync(poisonedRequest, TestContext.Current.CancellationToken); + string poisonedPolicy = poisonedResponse.Headers.GetValues("Content-Security-Policy").Single(); + Assert.Contains("wss://app.example.test", poisonedPolicy, StringComparison.Ordinal); + Assert.DoesNotContain("untrusted.example", poisonedPolicy, StringComparison.Ordinal); + Assert.DoesNotContain("forwarded.example", poisonedPolicy, StringComparison.Ordinal); + using HttpResponseMessage scalarResponse = await client.GetAsync("/docs/", TestContext.Current.CancellationToken); string scalarBody = await scalarResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); string scalarNonce = GetNonceAttribute(scalarBody); @@ -218,6 +227,53 @@ public void ConfigureContentSecurityPolicy_IntercomMessenger_AllowsHttpsAndWebSo Assert.DoesNotContain("'unsafe-eval'", directives["script-src"]); } + [Fact] + public void ConfigureContentSecurityPolicy_ModernSite_ExcludesUnusedLegacySources() + { + var builder = new CspBuilder(); + FrontendContentSecurityPolicy.Configure(builder); + (_, string policy) = builder.BuildCspOptions().ToString(new TestNonceService("modern-nonce")); + + Assert.DoesNotContain("fonts.googleapis.com", policy, StringComparison.Ordinal); + Assert.DoesNotContain("fonts.gstatic.com", policy, StringComparison.Ordinal); + Assert.DoesNotContain("user-images.githubusercontent.com", policy, StringComparison.Ordinal); + IReadOnlyDictionary directives = NormalizePolicy(policy); + Assert.DoesNotContain("https:", directives["connect-src"]); + Assert.DoesNotContain("ws:", directives["connect-src"]); + Assert.DoesNotContain("wss:", directives["connect-src"]); + Assert.Contains("https://maps.googleapis.com", policy, StringComparison.Ordinal); + } + + [Theory] + [InlineData("https://app.example.test/next/?query=value#fragment", "wss://app.example.test")] + [InlineData("https://app.example.test:8443/next", "wss://app.example.test:8443")] + [InlineData("http://localhost:7110", "ws://localhost:7110")] + [InlineData("http://localhost:80", "ws://localhost")] + [InlineData("https://[::1]:8443/next", "wss://[::1]:8443")] + public void ConfigureContentSecurityPolicy_ConfiguredSite_AddsOnlyItsWebSocketOrigin(string siteBaseUrl, string expectedOrigin) + { + var builder = new CspBuilder(); + FrontendContentSecurityPolicy.Configure(builder, siteBaseUrl); + (_, string policy) = builder.BuildCspOptions().ToString(new TestNonceService("origin-nonce")); + + string[] connections = NormalizePolicy(policy)["connect-src"]; + Assert.Contains(expectedOrigin, connections); + Assert.DoesNotContain("ws:", connections); + Assert.DoesNotContain("wss:", connections); + Assert.DoesNotContain("query=value", policy, StringComparison.Ordinal); + } + + [Theory] + [InlineData("")] + [InlineData("/next")] + [InlineData("ftp://app.example.test")] + [InlineData("https://user:password@app.example.test")] + [InlineData("https://*.example.test")] + public void ConfigureContentSecurityPolicy_InvalidSite_RejectsConfiguration(string siteBaseUrl) + { + Assert.Throws(() => FrontendContentSecurityPolicy.Configure(new CspBuilder(), siteBaseUrl)); + } + [Fact] public void AddCsp_SeparateScopes_ProvidesDistinct32ByteNonces() { @@ -254,7 +310,7 @@ private static async Task CreatePipelineHostAsync(string webRoot) }) .Configure(app => { - app.UseCsp(FrontendContentSecurityPolicy.Configure); + app.UseCsp(csp => FrontendContentSecurityPolicy.Configure(csp, "https://app.example.test/next")); app.UseDefaultFiles(); app.Use(Exceptionless.Web.Program.InjectCspNonceAsync); app.UseStaticFiles(); From 5ae85aaf2803b81711bded6fbc88949e89e26bf7 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Tue, 29 Sep 2026 23:49:47 -0500 Subject: [PATCH 14/26] Preserve empty same-origin CSP configuration --- docs/frontend-content-security-policy.md | 2 +- .../ClientApp/src/hooks.server.test.ts | 26 +++++++++++++++++++ .../ClientApp/src/hooks.server.ts | 3 ++- 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/docs/frontend-content-security-policy.md b/docs/frontend-content-security-policy.md index 95c71e5643..49828ba4d6 100644 --- a/docs/frontend-content-security-policy.md +++ b/docs/frontend-content-security-policy.md @@ -50,7 +50,7 @@ this source. These configured URLs must match the externally served application origin, including the public port, in reverse-proxy and self-hosted deployments. ASP.NET already requires `BaseURL`; invalid HTTP(S) origin configuration now fails -explicitly rather than widening the policy. A missing `PUBLIC_BASE_URL` leaves +explicitly rather than widening the policy. A missing or empty `PUBLIC_BASE_URL` leaves the Svelte hook restrictive (`'self'` and provider sources only); it never enables production `ws:`/`wss:`. If a public URL is supplied but invalid, generation fails explicitly. Static Svelte builds still use the ASP.NET runtime configuration. diff --git a/src/Exceptionless.Web/ClientApp/src/hooks.server.test.ts b/src/Exceptionless.Web/ClientApp/src/hooks.server.test.ts index 683365cc80..e8ef7803bb 100644 --- a/src/Exceptionless.Web/ClientApp/src/hooks.server.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/hooks.server.test.ts @@ -13,6 +13,7 @@ describe('server CSP hook', () => { beforeEach(() => { environment.building = false; environment.dev = false; + publicEnvironment.PUBLIC_BASE_URL = 'https://app.example.test/next'; }); it.each([false, true])('limits scheme-wide WebSockets to development (dev=%s)', async (dev) => { @@ -44,6 +45,31 @@ describe('server CSP hook', () => { expect(response.headers.has('content-security-policy')).toBe(false); }); + it('serves HTML with a restrictive policy when the public URL uses the empty same-origin default', async () => { + publicEnvironment.PUBLIC_BASE_URL = ''; + const original = new Response('', { headers: { 'content-type': 'text/html' } }); + + const response = await handle({ event: {} as Parameters[0]['event'], resolve: async () => original }); + const connections = response.headers + .get('content-security-policy')! + .split('; ') + .find((directive) => directive.startsWith('connect-src '))! + .split(' '); + + expect(response.status).toBe(200); + expect(connections).toContain("'self'"); + expect(connections).not.toContain('ws:'); + expect(connections).not.toContain('wss:'); + expect(connections).not.toContain('wss://app.example.test'); + }); + + it('rejects a supplied non-HTTP public URL instead of broadening production sources', async () => { + publicEnvironment.PUBLIC_BASE_URL = 'ftp://app.example.test'; + const original = new Response('', { headers: { 'content-type': 'text/html' } }); + + await expect(handle({ event: {} as Parameters[0]['event'], resolve: async () => original })).rejects.toThrow(); + }); + it('does not trust request or forwarded hosts for the production WebSocket origin', async () => { const original = new Response('', { headers: { 'content-type': 'text/html' } }); const event = { diff --git a/src/Exceptionless.Web/ClientApp/src/hooks.server.ts b/src/Exceptionless.Web/ClientApp/src/hooks.server.ts index 33427aea21..d4c36062f6 100644 --- a/src/Exceptionless.Web/ClientApp/src/hooks.server.ts +++ b/src/Exceptionless.Web/ClientApp/src/hooks.server.ts @@ -11,5 +11,6 @@ export const handle: Handle = async ({ event, resolve }) => { return response; } - return secureHtmlResponse(response, { allowDevelopmentConnections: dev, siteBaseUrl: env.PUBLIC_BASE_URL }); + // The checked-in .env uses an empty value for the same-origin default. + return secureHtmlResponse(response, { allowDevelopmentConnections: dev, siteBaseUrl: env.PUBLIC_BASE_URL || undefined }); }; From 7bfed8679c43ad8befa864d094558c2a9ddd3f4a Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Wed, 30 Sep 2026 10:48:02 -0500 Subject: [PATCH 15/26] Limit modern frontend CSP to used vendor capabilities --- docs/frontend-content-security-policy.md | 78 ------------------- .../server/content-security-policy.test.ts | 24 +++++- .../src/lib/server/content-security-policy.ts | 58 ++------------ src/Exceptionless.Web/Program.cs | 3 +- .../Security/FrontendContentSecurityPolicy.cs | 50 ++---------- ...tend-content-security-policy.contract.json | 47 ++--------- .../Utility/Handlers/CspResponseTests.cs | 22 +++++- 7 files changed, 65 insertions(+), 217 deletions(-) delete mode 100644 docs/frontend-content-security-policy.md diff --git a/docs/frontend-content-security-policy.md b/docs/frontend-content-security-policy.md deleted file mode 100644 index 49828ba4d6..0000000000 --- a/docs/frontend-content-security-policy.md +++ /dev/null @@ -1,78 +0,0 @@ -# Modern frontend content security policy - -This policy targets the Svelte 5/SvelteKit static application mounted at `/next`. -It assumes the legacy Angular frontend is retired. This PR does not retire that -application, alter routing, or change deployment. Its Angular subtree matches -`main`; Angular-specific nonce middleware, renderer changes, and tests are not -part of this PR. - -ASP.NET serves the built SPA and stamps every HTML script with a fresh 32-byte -response nonce. The Svelte server hook protects development HTML; static builds -receive their policy from ASP.NET. Both generators are checked against -`src/Exceptionless.Web/Security/frontend-content-security-policy.contract.json`. -HTML cannot be cached or served partially with a mismatched nonce. API and MCP -streaming responses bypass HTML transformation. - -## Sources and consumers - -| Directive | Consumer and retained sources | -| --- | --- | -| `default-src` | Same-origin assets and API calls. | -| `script-src` | Nonce plus `strict-dynamic`; same-origin, Stripe.js (`js.stripe.com`, `*.js.stripe.com`, `maps.googleapis.com`) and Intercom script hosts are CSP2 compatibility sources. No `unsafe-inline` or `unsafe-eval`. Zod JIT is disabled. | -| `connect-src` | Same-origin API, streaming assistant responses, uploads, health checks, and `/api/v2/push` WebSocket; Exceptionless collector/config/heartbeat telemetry; Stripe API, Maps and Link; US Intercom API, realtime and upload endpoints. Includes exactly `https://*.intercom-messenger.com` and `wss://*.intercom-messenger.com`. | -| `style-src` | Same-origin CSS; inline styles used by Svelte components and Intercom; jsDelivr for Scalar API documentation served by the same ASP.NET host. | -| `font-src` | Same-origin fonts, Intercom font CDNs, and Scalar's jsDelivr assets. Google Fonts are not used by the modern app. | -| `img-src` | Same-origin uploaded avatars, Gravatar fallback, image previews (`blob:`/`data:`), Stripe/Link and documented US Intercom images/attachments. No direct GitHub avatar host is needed: `UserHandler` serves uploaded avatars through `/api/v2/users/...`. | -| `frame-src` | Same-origin; Stripe.js, authentication/3DS and Link frames; Intercom article, reporting and embedded-video frames. | -| `worker-src` | Same-origin, blob workers, and Intercom's documented child sources. Kept separate from `frame-src` following Intercom's CSP3 guidance. | -| `media-src` | Same-origin, blob media, Intercom media/download CDNs. | -| `form-action` | Same-origin plus Intercom help and US API forms. OAuth uses top-level browser navigation, not cross-origin form submission or fetch. | -| `manifest-src` | Same-origin. | -| `base-uri`, `object-src`, `frame-ancestors` | `none`: no base override, plugins, or embedding of this application. | - -US Intercom endpoints are retained without EU/Australia blanket allowances. -Google Maps remains because Stripe's Stripe.js CSP guide lists it, even though -the Svelte application itself does not render Google Maps. Stripe Checkout, -Connect embedded components, crypto onramp, OAuth fetch hosts, and arbitrary -third-party CDNs are not enabled merely because the provider offers them. - -Svelte development alone adds `ws:` and `wss:` for Vite HMR, including forwarded -Codespaces hosts. Production policies do not include those scheme-wide sources. -The application uses a real WebSocket, not EventSource: the authenticated layout -creates `WebSocketClient`, which opens `/api/v2/push` using `window.location`. -Because some browsers do not match WebSocket schemes against `'self'`, production -also allows one exact WebSocket origin derived from administrator configuration: -`BaseURL` for ASP.NET, `PUBLIC_BASE_URL` for the Svelte server hook. HTTPS becomes -WSS and HTTP becomes WS, preserving non-default ports and IPv6 while discarding -paths, queries and fragments. Request `Host` and forwarded headers never supply -this source. - -These configured URLs must match the externally served application origin, -including the public port, in reverse-proxy and self-hosted deployments. ASP.NET -already requires `BaseURL`; invalid HTTP(S) origin configuration now fails -explicitly rather than widening the policy. A missing or empty `PUBLIC_BASE_URL` leaves -the Svelte hook restrictive (`'self'` and provider sources only); it never enables -production `ws:`/`wss:`. If a public URL is supplied but invalid, generation fails -explicitly. Static Svelte builds still use the ASP.NET runtime configuration. -No new API/WebSocket configuration keys are introduced. Custom external telemetry -endpoints still require a deliberate policy update. - -## Validation and limits - -Backend HTTP tests cover `/next` index/fallback HTML, nonce rotation, static/API -bypass, conditional/range handling, configured WebSocket origins, poisoned -Host/forwarded headers, and Scalar HTML. Svelte tests cover policy -parity, both Intercom messenger schemes, obsolete-source exclusion, nonce -injection and production/development connection differences. Run these tests -alongside the production build and lint checks when changing sources. - -External payment, OAuth and authenticated Intercom behavior requires a configured -staging environment; local policy/header tests do not certify those provider -flows. Do not solve an observed violation by adding unrestricted schemes or -relaxing script restrictions. Confirm the actual consumer and destination first. - -Provider references (reviewed September 30, 2026): - -- [Stripe integration security guide](https://docs.stripe.com/security/guide) -- [Intercom CSP requirements](https://www.intercom.com/help/en/articles/3894-using-intercom-with-content-security-policy) -- [MDN connect-src and WebSocket scheme compatibility](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy/connect-src) diff --git a/src/Exceptionless.Web/ClientApp/src/lib/server/content-security-policy.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/server/content-security-policy.test.ts index 002e392beb..98788cf17f 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/server/content-security-policy.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/server/content-security-policy.test.ts @@ -74,13 +74,33 @@ describe('addNonceToScripts', () => { }); describe('createContentSecurityPolicy', () => { - it('excludes unused legacy sources while preserving modern payment dependencies', () => { + it('excludes unused vendor capabilities while preserving core payment and messenger dependencies', () => { const policy = createContentSecurityPolicy(createNonce()); expect(policy).not.toContain('fonts.googleapis.com'); expect(policy).not.toContain('fonts.gstatic.com'); expect(policy).not.toContain('user-images.githubusercontent.com'); - expect(getDirective(policy, 'connect-src')).toContain('https://maps.googleapis.com'); + for (const unusedSource of [ + 'maps.googleapis.com', + 'cdn.jsdelivr.net', + 'intercom-sheets.com', + 'intercom-reporting.com', + 'youtube.com', + 'vimeo.com', + 'wistia.net', + 'intercom-attachments-', + 'uploads.intercom', + 'downloads.intercom', + 'gifs.intercom', + 'video-messages.intercom', + 'messenger-apps.intercom', + 'intercom.help' + ]) { + expect(policy).not.toContain(unusedSource); + } + expect(getDirective(policy, 'connect-src')).toContain('https://api.stripe.com'); + expect(getDirective(policy, 'form-action')).toEqual(["'self'"]); + expect(getDirective(policy, 'worker-src')).toEqual(["'self'", 'blob:']); expect(getDirective(policy, 'connect-src')).toContain('https://*.intercom-messenger.com'); expect(getDirective(policy, 'connect-src')).toContain('wss://*.intercom-messenger.com'); }); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/server/content-security-policy.ts b/src/Exceptionless.Web/ClientApp/src/lib/server/content-security-policy.ts index 46b94ec411..cf6a94585c 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/server/content-security-policy.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/server/content-security-policy.ts @@ -6,30 +6,6 @@ const NONCE_ATTRIBUTE_PATTERN = /("[^"]*"|'[^']*')|\s+nonce(?=[\s=>/]|$)(?:\s*=\ const SCRIPT_ELEMENT_PATTERN = /(])*)>([\s\S]*?)(<\/script\s*>)/gi; // Exceptionless uses Intercom's US endpoints. Keep region-specific sources scoped to that workspace. -const intercomChildSources = [ - 'https://intercom-sheets.com', - 'https://www.intercom-reporting.com', - 'https://www.youtube.com', - 'https://player.vimeo.com', - 'https://fast.wistia.net' -] as const; - -const intercomDownloadSources = ['https://downloads.intercomcdn.com'] as const; - -const intercomUploadSources = ['https://uploads.intercomcdn.com', 'https://uploads.intercomusercontent.com'] as const; - -const intercomAttachmentSources = [ - 'https://*.intercom-attachments-1.com', - 'https://*.intercom-attachments-2.com', - 'https://*.intercom-attachments-3.com', - 'https://*.intercom-attachments-4.com', - 'https://*.intercom-attachments-5.com', - 'https://*.intercom-attachments-6.com', - 'https://*.intercom-attachments-7.com', - 'https://*.intercom-attachments-8.com', - 'https://*.intercom-attachments-9.com' -] as const; - const contentSecurityPolicyDirectives: ReadonlyArray = [ ['default-src', ["'self'"]], [ @@ -39,13 +15,12 @@ const contentSecurityPolicyDirectives: ReadonlyArray { - o.WithOpenApiRoutePattern("/docs/{documentName}/openapi.json") + o.DisableDefaultFonts() + .WithOpenApiRoutePattern("/docs/{documentName}/openapi.json") .AddDocument("v2", "Exceptionless API", "/docs/{documentName}/openapi.json", true) .AddPreferredSecuritySchemes("Bearer"); }); diff --git a/src/Exceptionless.Web/Security/FrontendContentSecurityPolicy.cs b/src/Exceptionless.Web/Security/FrontendContentSecurityPolicy.cs index 7e2d38c8d6..88d1f32633 100644 --- a/src/Exceptionless.Web/Security/FrontendContentSecurityPolicy.cs +++ b/src/Exceptionless.Web/Security/FrontendContentSecurityPolicy.cs @@ -20,14 +20,12 @@ public static void Configure(CspBuilder csp, string? siteBaseUrl) .WithStrictDynamic() .From("https://*.js.stripe.com") .From("https://js.stripe.com") - .From("https://maps.googleapis.com") .From("https://app.intercom.io") .From("https://widget.intercom.io") .From("https://js.intercomcdn.com"); csp.AllowStyles.FromSelf() - .AllowUnsafeInline() - .From("https://cdn.jsdelivr.net"); + .AllowUnsafeInline(); csp.AllowImages.FromSelf() .From("data:") @@ -36,34 +34,17 @@ public static void Configure(CspBuilder csp, string? siteBaseUrl) .From("https://*.link.com") .From("https://js.intercomcdn.com") .From("https://static.intercomassets.com") - .From("https://downloads.intercomcdn.com") - .From("https://uploads.intercomcdn.com") - .From("https://uploads.intercomusercontent.com") - .From("https://gifs.intercomcdn.com") - .From("https://video-messages.intercomcdn.com") - .From("https://messenger-apps.intercom.io") - .From("https://*.intercom-attachments-1.com") - .From("https://*.intercom-attachments-2.com") - .From("https://*.intercom-attachments-3.com") - .From("https://*.intercom-attachments-4.com") - .From("https://*.intercom-attachments-5.com") - .From("https://*.intercom-attachments-6.com") - .From("https://*.intercom-attachments-7.com") - .From("https://*.intercom-attachments-8.com") - .From("https://*.intercom-attachments-9.com") .From("https://www.gravatar.com"); csp.AllowFonts.FromSelf() .From("https://js.intercomcdn.com") - .From("https://fonts.intercomcdn.com") - .From("https://cdn.jsdelivr.net"); + .From("https://fonts.intercomcdn.com"); csp.AllowConnections.ToSelf() .To("https://collector.exceptionless.io") .To("https://config.exceptionless.io") .To("https://heartbeat.exceptionless.io") .To("https://api.stripe.com") - .To("https://maps.googleapis.com") .To("https://link.com") .To("https://*.link.com") .To("https://via.intercom.io") @@ -75,9 +56,7 @@ public static void Configure(CspBuilder csp, string? siteBaseUrl) .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"); + .To("wss://nexus-websocket-b.intercom.io"); // Use administrator configuration, never request Host or forwarded headers. // Some browsers do not match WebSocket schemes against connect-src 'self'. @@ -89,29 +68,16 @@ public static void Configure(CspBuilder csp, string? siteBaseUrl) .From("https://js.stripe.com") .From("https://hooks.stripe.com") .From("https://link.com") - .From("https://*.link.com") - .From("https://intercom-sheets.com") - .From("https://www.intercom-reporting.com") - .From("https://www.youtube.com") - .From("https://player.vimeo.com") - .From("https://fast.wistia.net"); + .From("https://*.link.com"); csp.AllowAudioAndVideo.FromSelf() .From("blob:") - .From("https://js.intercomcdn.com") - .From("https://downloads.intercomcdn.com"); + .From("https://js.intercomcdn.com"); csp.AllowWorkers.FromSelf() - .From("blob:") - .From("https://intercom-sheets.com") - .From("https://www.intercom-reporting.com") - .From("https://www.youtube.com") - .From("https://player.vimeo.com") - .From("https://fast.wistia.net"); - - csp.AllowFormActions.ToSelf() - .To("https://intercom.help") - .To("https://api-iam.intercom.io"); + .From("blob:"); + + csp.AllowFormActions.ToSelf(); csp.AllowManifest.FromSelf(); csp.AllowPlugins.FromNowhere(); csp.AllowBaseUri.FromNowhere(); diff --git a/src/Exceptionless.Web/Security/frontend-content-security-policy.contract.json b/src/Exceptionless.Web/Security/frontend-content-security-policy.contract.json index e3fdf99b8b..00382fab34 100644 --- a/src/Exceptionless.Web/Security/frontend-content-security-policy.contract.json +++ b/src/Exceptionless.Web/Security/frontend-content-security-policy.contract.json @@ -5,12 +5,11 @@ "'self'", "https://js.stripe.com", "https://*.js.stripe.com", - "https://maps.googleapis.com", "https://app.intercom.io", "https://widget.intercom.io", "https://js.intercomcdn.com" ], - "style-src": ["'self'", "'unsafe-inline'", "https://cdn.jsdelivr.net"], + "style-src": ["'self'", "'unsafe-inline'"], "img-src": [ "'self'", "blob:", @@ -19,28 +18,12 @@ "https://*.link.com", "https://js.intercomcdn.com", "https://static.intercomassets.com", - "https://gifs.intercomcdn.com", - "https://video-messages.intercomcdn.com", - "https://messenger-apps.intercom.io", - "https://downloads.intercomcdn.com", - "https://uploads.intercomcdn.com", - "https://uploads.intercomusercontent.com", - "https://*.intercom-attachments-1.com", - "https://*.intercom-attachments-2.com", - "https://*.intercom-attachments-3.com", - "https://*.intercom-attachments-4.com", - "https://*.intercom-attachments-5.com", - "https://*.intercom-attachments-6.com", - "https://*.intercom-attachments-7.com", - "https://*.intercom-attachments-8.com", - "https://*.intercom-attachments-9.com", "https://www.gravatar.com" ], "font-src": [ "'self'", "https://js.intercomcdn.com", - "https://fonts.intercomcdn.com", - "https://cdn.jsdelivr.net" + "https://fonts.intercomcdn.com" ], "connect-src": [ "'self'", @@ -48,7 +31,6 @@ "https://config.exceptionless.io", "https://heartbeat.exceptionless.io", "https://api.stripe.com", - "https://maps.googleapis.com", "https://link.com", "https://*.link.com", "https://via.intercom.io", @@ -60,9 +42,7 @@ "https://nexus-websocket-a.intercom.io", "wss://nexus-websocket-a.intercom.io", "https://nexus-websocket-b.intercom.io", - "wss://nexus-websocket-b.intercom.io", - "https://uploads.intercomcdn.com", - "https://uploads.intercomusercontent.com" + "wss://nexus-websocket-b.intercom.io" ], "frame-src": [ "'self'", @@ -70,24 +50,11 @@ "https://*.js.stripe.com", "https://hooks.stripe.com", "https://link.com", - "https://*.link.com", - "https://intercom-sheets.com", - "https://www.intercom-reporting.com", - "https://www.youtube.com", - "https://player.vimeo.com", - "https://fast.wistia.net" - ], - "media-src": ["'self'", "blob:", "https://js.intercomcdn.com", "https://downloads.intercomcdn.com"], - "worker-src": [ - "'self'", - "blob:", - "https://intercom-sheets.com", - "https://www.intercom-reporting.com", - "https://www.youtube.com", - "https://player.vimeo.com", - "https://fast.wistia.net" + "https://*.link.com" ], - "form-action": ["'self'", "https://intercom.help", "https://api-iam.intercom.io"], + "media-src": ["'self'", "blob:", "https://js.intercomcdn.com"], + "worker-src": ["'self'", "blob:"], + "form-action": ["'self'"], "manifest-src": ["'self'"], "base-uri": ["'none'"], "object-src": ["'none'"], diff --git a/tests/Exceptionless.Tests/Utility/Handlers/CspResponseTests.cs b/tests/Exceptionless.Tests/Utility/Handlers/CspResponseTests.cs index c25edf2808..45079add68 100644 --- a/tests/Exceptionless.Tests/Utility/Handlers/CspResponseTests.cs +++ b/tests/Exceptionless.Tests/Utility/Handlers/CspResponseTests.cs @@ -162,6 +162,13 @@ public async Task Configure_IndexAndFallbackRoutes_ServeFreshNoncedHtml() string scalarPolicy = scalarResponse.Headers.GetValues("Content-Security-Policy").Single(); Assert.Equal("no-store", scalarResponse.Headers.CacheControl?.ToString()); Assert.Contains($"'nonce-{scalarNonce}'", scalarPolicy, StringComparison.Ordinal); + Assert.Contains("scalar.js", scalarBody, StringComparison.Ordinal); + Assert.Contains("scalar.aspnetcore.js", scalarBody, StringComparison.Ordinal); + Assert.Contains("\"withDefaultFonts\":false", scalarBody, StringComparison.Ordinal); + Assert.DoesNotContain("cdn.jsdelivr.net", scalarBody, StringComparison.Ordinal); + using HttpResponseMessage scalarScriptResponse = await client.GetAsync("/docs/scalar.js", TestContext.Current.CancellationToken); + Assert.Equal(StatusCodes.Status200OK, (int)scalarScriptResponse.StatusCode); + Assert.StartsWith("text/javascript", scalarScriptResponse.Content.Headers.ContentType?.ToString()); } finally { @@ -228,7 +235,7 @@ public void ConfigureContentSecurityPolicy_IntercomMessenger_AllowsHttpsAndWebSo } [Fact] - public void ConfigureContentSecurityPolicy_ModernSite_ExcludesUnusedLegacySources() + public void ConfigureContentSecurityPolicy_ModernSite_ExcludesUnusedVendorSources() { var builder = new CspBuilder(); FrontendContentSecurityPolicy.Configure(builder); @@ -241,7 +248,16 @@ public void ConfigureContentSecurityPolicy_ModernSite_ExcludesUnusedLegacySource Assert.DoesNotContain("https:", directives["connect-src"]); Assert.DoesNotContain("ws:", directives["connect-src"]); Assert.DoesNotContain("wss:", directives["connect-src"]); - Assert.Contains("https://maps.googleapis.com", policy, StringComparison.Ordinal); + foreach (string unusedSource in new[] + { + "maps.googleapis.com", "cdn.jsdelivr.net", "intercom-sheets.com", "intercom-reporting.com", + "youtube.com", "vimeo.com", "wistia.net", "intercom-attachments-", "uploads.intercom", + "downloads.intercom", "gifs.intercom", "video-messages.intercom", "messenger-apps.intercom", "intercom.help" + }) + Assert.DoesNotContain(unusedSource, policy, StringComparison.Ordinal); + Assert.Contains("https://api.stripe.com", directives["connect-src"]); + Assert.Equal(["'self'"], directives["form-action"]); + Assert.Equal(["'self'", "blob:"], directives["worker-src"]); } [Theory] @@ -317,7 +333,7 @@ private static async Task CreatePipelineHostAsync(string webRoot) app.UseRouting(); app.UseEndpoints(endpoints => { - endpoints.MapScalarApiReference("/docs"); + endpoints.MapScalarApiReference("/docs", options => options.DisableDefaultFonts()); endpoints.MapFallback("{**slug:nonfile}", Exceptionless.Web.Program.CreateRequestDelegate(endpoints, "/index.html")); }); })) From b3086d3775713747d829aa5fa142377d3e560d43 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Wed, 30 Sep 2026 13:34:54 -0500 Subject: [PATCH 16/26] Remove unused telemetry sources and blob worker permission --- .../ClientApp/src/lib/server/content-security-policy.test.ts | 4 +++- .../ClientApp/src/lib/server/content-security-policy.ts | 4 +--- .../Security/FrontendContentSecurityPolicy.cs | 5 +---- .../Security/frontend-content-security-policy.contract.json | 4 +--- .../Exceptionless.Tests/Utility/Handlers/CspResponseTests.cs | 4 ++-- 5 files changed, 8 insertions(+), 13 deletions(-) diff --git a/src/Exceptionless.Web/ClientApp/src/lib/server/content-security-policy.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/server/content-security-policy.test.ts index 98788cf17f..9f8c2a73da 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/server/content-security-policy.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/server/content-security-policy.test.ts @@ -81,6 +81,8 @@ describe('createContentSecurityPolicy', () => { expect(policy).not.toContain('fonts.gstatic.com'); expect(policy).not.toContain('user-images.githubusercontent.com'); for (const unusedSource of [ + 'config.exceptionless.io', + 'heartbeat.exceptionless.io', 'maps.googleapis.com', 'cdn.jsdelivr.net', 'intercom-sheets.com', @@ -100,7 +102,7 @@ describe('createContentSecurityPolicy', () => { } expect(getDirective(policy, 'connect-src')).toContain('https://api.stripe.com'); expect(getDirective(policy, 'form-action')).toEqual(["'self'"]); - expect(getDirective(policy, 'worker-src')).toEqual(["'self'", 'blob:']); + expect(getDirective(policy, 'worker-src')).toEqual(["'self'"]); expect(getDirective(policy, 'connect-src')).toContain('https://*.intercom-messenger.com'); expect(getDirective(policy, 'connect-src')).toContain('wss://*.intercom-messenger.com'); }); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/server/content-security-policy.ts b/src/Exceptionless.Web/ClientApp/src/lib/server/content-security-policy.ts index cf6a94585c..a1ab045aad 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/server/content-security-policy.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/server/content-security-policy.ts @@ -40,8 +40,6 @@ const contentSecurityPolicyDirectives: ReadonlyArray Date: Wed, 30 Sep 2026 13:40:47 -0500 Subject: [PATCH 17/26] Drop unused Checkout and Connect image allowance --- .../src/lib/server/content-security-policy.test.ts | 1 + .../src/lib/server/content-security-policy.ts | 11 +---------- .../Security/FrontendContentSecurityPolicy.cs | 1 - .../frontend-content-security-policy.contract.json | 1 - .../Utility/Handlers/CspResponseTests.cs | 1 + 5 files changed, 3 insertions(+), 12 deletions(-) diff --git a/src/Exceptionless.Web/ClientApp/src/lib/server/content-security-policy.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/server/content-security-policy.test.ts index 9f8c2a73da..c0b5184b8e 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/server/content-security-policy.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/server/content-security-policy.test.ts @@ -100,6 +100,7 @@ describe('createContentSecurityPolicy', () => { ]) { expect(policy).not.toContain(unusedSource); } + expect(getDirective(policy, 'img-src')).not.toContain('https://*.stripe.com'); expect(getDirective(policy, 'connect-src')).toContain('https://api.stripe.com'); expect(getDirective(policy, 'form-action')).toEqual(["'self'"]); expect(getDirective(policy, 'worker-src')).toEqual(["'self'"]); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/server/content-security-policy.ts b/src/Exceptionless.Web/ClientApp/src/lib/server/content-security-policy.ts index a1ab045aad..877b0b3f4c 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/server/content-security-policy.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/server/content-security-policy.ts @@ -23,16 +23,7 @@ const contentSecurityPolicyDirectives: ReadonlyArray Date: Thu, 1 Oct 2026 22:36:19 -0500 Subject: [PATCH 18/26] Authorize only trusted frontend scripts and reject partial HTML --- .../ClientApp/src/hooks.server.test.ts | 58 ++++++++++-- .../ClientApp/src/hooks.server.ts | 8 ++ .../server/content-security-policy.test.ts | 90 +++++++++++++------ .../src/lib/server/content-security-policy.ts | 53 ++++++++--- .../ClientApp/svelte.config.js | 5 ++ src/Exceptionless.Web/Program.cs | 28 ++---- .../Security/FrontendScriptNonces.cs | 59 ++++++++++++ .../Exceptionless.Tests/Api/CspNonceTests.cs | 2 + .../Utility/Handlers/CspResponseTests.cs | 61 +++++++++++-- 9 files changed, 291 insertions(+), 73 deletions(-) create mode 100644 src/Exceptionless.Web/Security/FrontendScriptNonces.cs diff --git a/src/Exceptionless.Web/ClientApp/src/hooks.server.test.ts b/src/Exceptionless.Web/ClientApp/src/hooks.server.test.ts index e8ef7803bb..54edc213d1 100644 --- a/src/Exceptionless.Web/ClientApp/src/hooks.server.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/hooks.server.test.ts @@ -18,9 +18,9 @@ describe('server CSP hook', () => { it.each([false, true])('limits scheme-wide WebSockets to development (dev=%s)', async (dev) => { environment.dev = dev; - const original = new Response('', { headers: { 'content-type': 'text/html' } }); + const original = createFrameworkResponse(); - const response = await handle({ event: {} as Parameters[0]['event'], resolve: async () => original }); + const response = await handle({ event: createEvent(), resolve: async () => original }); const policy = response.headers.get('content-security-policy')!; const connections = policy .split('; ') @@ -39,7 +39,7 @@ describe('server CSP hook', () => { environment.building = true; const original = new Response('', { headers: { 'content-type': 'text/html' } }); - const response = await handle({ event: {} as Parameters[0]['event'], resolve: async () => original }); + const response = await handle({ event: createEvent(), resolve: async () => original }); expect(response).toBe(original); expect(response.headers.has('content-security-policy')).toBe(false); @@ -49,7 +49,7 @@ describe('server CSP hook', () => { publicEnvironment.PUBLIC_BASE_URL = ''; const original = new Response('', { headers: { 'content-type': 'text/html' } }); - const response = await handle({ event: {} as Parameters[0]['event'], resolve: async () => original }); + const response = await handle({ event: createEvent(), resolve: async () => original }); const connections = response.headers .get('content-security-policy')! .split('; ') @@ -67,7 +67,43 @@ describe('server CSP hook', () => { publicEnvironment.PUBLIC_BASE_URL = 'ftp://app.example.test'; const original = new Response('', { headers: { 'content-type': 'text/html' } }); - await expect(handle({ event: {} as Parameters[0]['event'], resolve: async () => original })).rejects.toThrow(); + await expect(handle({ event: createEvent(), resolve: async () => original })).rejects.toThrow(); + }); + + it.each(['GET', 'HEAD'])('removes conditional and range headers before resolving HTML (%s)', async (method) => { + const event = createEvent(method, '/next/', { + 'if-modified-since': 'Wed, 30 Sep 2026 00:00:00 GMT', + 'if-none-match': '*', + 'if-range': 'old', + range: 'bytes=0-23' + }); + const response = await handle({ + event, + resolve: async (resolvedEvent) => { + for (const header of ['range', 'if-range', 'if-none-match', 'if-modified-since']) { + expect(resolvedEvent.request.headers.has(header)).toBe(false); + } + return method === 'HEAD' ? new Response(null, { headers: { 'content-type': 'text/html' } }) : createFrameworkResponse(); + } + }); + + expect(response.status).toBe(200); + expect(response.headers.get('cache-control')).toBe('no-store'); + if (method === 'HEAD') expect(response.body).toBeNull(); + }); + + it.each([ + ['GET', '/next/app.js'], + ['POST', '/next/'] + ])('preserves conditional headers outside document requests (%s %s)', async (method, path) => { + const event = createEvent(method, path, { 'if-none-match': 'current' }); + await handle({ + event, + resolve: async (resolvedEvent) => { + expect(resolvedEvent.request.headers.get('if-none-match')).toBe('current'); + return Response.json({ ok: true }); + } + }); }); it('does not trust request or forwarded hosts for the production WebSocket origin', async () => { @@ -85,3 +121,15 @@ describe('server CSP hook', () => { expect(policy).not.toContain('forwarded.example'); }); }); + +function createEvent(method = 'GET', path = '/next/', headers: HeadersInit = {}) { + const url = new URL(path, 'https://app.example.test'); + return { request: new Request(url, { headers, method }), url } as Parameters[0]['event']; +} + +function createFrameworkResponse() { + const nonce = 'dGVzdC1mcmFtZXdvcmstbm9uY2U='; + return new Response(``, { + headers: { 'content-security-policy': `script-src 'nonce-${nonce}' 'strict-dynamic'`, 'content-type': 'text/html' } + }); +} diff --git a/src/Exceptionless.Web/ClientApp/src/hooks.server.ts b/src/Exceptionless.Web/ClientApp/src/hooks.server.ts index d4c36062f6..02a42425aa 100644 --- a/src/Exceptionless.Web/ClientApp/src/hooks.server.ts +++ b/src/Exceptionless.Web/ClientApp/src/hooks.server.ts @@ -5,6 +5,14 @@ import { env } from '$env/dynamic/public'; import { secureHtmlResponse } from '$lib/server/content-security-policy'; export const handle: Handle = async ({ event, resolve }) => { + const path = event.url.pathname; + const isDocumentRequest = ['GET', 'HEAD'].includes(event.request.method) && (!/\/[^/]*\.[^/]+$/.test(path) || path.toLowerCase().endsWith('.html')); + if (!building && isDocumentRequest) { + for (const header of ['if-none-match', 'if-modified-since', 'range', 'if-range']) { + event.request.headers.delete(header); + } + } + const response = await resolve(event); if (building) { diff --git a/src/Exceptionless.Web/ClientApp/src/lib/server/content-security-policy.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/server/content-security-policy.test.ts index c0b5184b8e..c68ed04958 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/server/content-security-policy.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/server/content-security-policy.test.ts @@ -2,7 +2,7 @@ import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { describe, expect, it } from 'vitest'; -import { addNonceToScripts, createContentSecurityPolicy, createNonce, getWebSocketOrigin, secureHtmlResponse } from './content-security-policy'; +import { createContentSecurityPolicy, createNonce, getWebSocketOrigin, replaceScriptNonce, secureHtmlResponse } from './content-security-policy'; describe('configured WebSocket origin', () => { it.each([ @@ -41,35 +41,23 @@ describe('createNonce', () => { }); }); -describe('addNonceToScripts', () => { - it('preserves similar attribute names and nonce text inside quoted values', () => { +describe('replaceScriptNonce', () => { + it('replaces only SvelteKit-authorized script nonces, preserving untrusted tags and quoted attribute text', () => { + const trustedNonce = createNonce(); const nonce = createNonce(); - const html = ``; + const html = + `` + + `` + + ``; - expect(addNonceToScripts(html, nonce)).toBe(``); - }); - - it('adds the nonce to every script opening tag', () => { - const nonce = createNonce(); - const html = ''; - - expect(addNonceToScripts(html, nonce)).toBe(``); - }); - - it('replaces existing quoted, unquoted, and boolean nonce attributes', () => { - const nonce = createNonce(); - const html = ``; + const result = replaceScriptNonce(html, trustedNonce, nonce); - expect(addNonceToScripts(html, nonce)).toBe( - `` + expect(result).toContain(``); + expect(result).toContain(``); + expect(result).toContain( + '' ); - }); - - it('preserves script-like text inside inline scripts', () => { - const nonce = createNonce(); - const html = ''; - - expect(addNonceToScripts(html, nonce)).toBe(``); + expect([...result.matchAll(new RegExp(`nonce="${nonce.replaceAll('+', '\\+')}"`, 'g'))]).toHaveLength(2); }); }); @@ -161,20 +149,28 @@ describe('createContentSecurityPolicy', () => { }); describe('secureHtmlResponse', () => { - it('buffers chunked HTML, nonces every script, and prevents nonce/body caching', async () => { + it('buffers chunked HTML, preserves framework trust, and removes stale response metadata', async () => { + const trustedNonce = createNonce(); const encoder = new TextEncoder(); const stream = new ReadableStream({ start(controller) { controller.enqueue(encoder.encode('start()')); + controller.enqueue( + encoder.encode( + `ipt nonce="${trustedNonce}" type="module">start()` + ) + ); controller.close(); } }); const originalResponse = new Response(stream, { headers: { + 'accept-ranges': 'bytes', 'content-length': '123', + 'content-security-policy': `script-src 'nonce-${trustedNonce}' 'strict-dynamic'`, 'content-type': 'text/html; charset=utf-8', - etag: 'stale-after-transformation' + etag: 'stale-after-transformation', + 'last-modified': 'Wed, 30 Sep 2026 00:00:00 GMT' } }); @@ -190,6 +186,42 @@ describe('secureHtmlResponse', () => { expect(response.headers.get('cache-control')).toBe('no-store'); expect(response.headers.has('content-length')).toBe(false); expect(response.headers.has('etag')).toBe(false); + expect(response.headers.has('last-modified')).toBe(false); + expect(response.headers.has('accept-ranges')).toBe(false); + expect(html).toContain(''); + }); + + it('does not grant a nonce when the framework has not authorized any scripts', async () => { + const html = ''; + const response = await secureHtmlResponse(new Response(html, { headers: { 'content-type': 'text/html' } })); + + expect(await response.text()).toBe(html); + expect(response.headers.get('content-security-policy')).toContain("'strict-dynamic'"); + expect(response.headers.get('cache-control')).toBe('no-store'); + }); + + it.each([200, 206])('rejects partial HTML rather than rewriting its byte range (status %i)', async (status) => { + const originalResponse = new Response('', { + headers: { 'content-range': 'bytes 0-23/100', 'content-type': 'text/html' }, + status + }); + + await expect(secureHtmlResponse(originalResponse)).rejects.toThrow('complete HTML document'); + }); + + it('secures HEAD metadata without manufacturing a response body', async () => { + const response = await secureHtmlResponse( + new Response(null, { + headers: { 'accept-ranges': 'bytes', 'content-type': 'text/html', etag: 'stale', 'last-modified': 'Wed, 30 Sep 2026 00:00:00 GMT' } + }) + ); + + expect(response.body).toBeNull(); + expect(response.headers.get('cache-control')).toBe('no-store'); + expect(response.headers.has('etag')).toBe(false); + expect(response.headers.has('last-modified')).toBe(false); + expect(response.headers.has('accept-ranges')).toBe(false); + expect(response.headers.get('content-security-policy')).toContain("'strict-dynamic'"); }); it('leaves non-HTML responses untouched', async () => { diff --git a/src/Exceptionless.Web/ClientApp/src/lib/server/content-security-policy.ts b/src/Exceptionless.Web/ClientApp/src/lib/server/content-security-policy.ts index 877b0b3f4c..666135387f 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/server/content-security-policy.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/server/content-security-policy.ts @@ -2,7 +2,7 @@ import { randomBytes } from 'node:crypto'; const NONCE_BYTE_LENGTH = 32; const NONCE_PATTERN = /^[A-Za-z\d+/]{43}=$/; -const NONCE_ATTRIBUTE_PATTERN = /("[^"]*"|'[^']*')|\s+nonce(?=[\s=>/]|$)(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+))?/gi; +const NONCE_ATTRIBUTE_PATTERN = /("[^"]*"|'[^']*')|\s+nonce\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/gi; const SCRIPT_ELEMENT_PATTERN = /(])*)>([\s\S]*?)(<\/script\s*>)/gi; // Exceptionless uses Intercom's US endpoints. Keep region-specific sources scoped to that workspace. @@ -61,16 +61,6 @@ interface ContentSecurityPolicyOptions { siteBaseUrl?: string; } -export function addNonceToScripts(html: string, nonce: string): string { - validateNonce(nonce); - - return html.replace(SCRIPT_ELEMENT_PATTERN, (_scriptElement, scriptTagName: string, attributes: string, content: string, closingTag: string) => { - const attributesWithoutNonce = attributes.replace(NONCE_ATTRIBUTE_PATTERN, (_attribute, quoted: string | undefined) => quoted ?? ''); - - return `${scriptTagName} nonce="${nonce}"${attributesWithoutNonce}>${content}${closingTag}`; - }); -} - export function createContentSecurityPolicy(nonce: string, options: ContentSecurityPolicyOptions = {}): string { validateNonce(nonce); @@ -106,17 +96,54 @@ export function getWebSocketOrigin(siteBaseUrl: string): string { return url.origin; } +export function replaceScriptNonce(html: string, trustedNonce: string, nonce: string): string { + validateNonce(nonce); + + return html.replace(SCRIPT_ELEMENT_PATTERN, (_scriptElement, scriptTagName: string, attributes: string, content: string, closingTag: string) => { + const updatedAttributes = attributes.replace( + NONCE_ATTRIBUTE_PATTERN, + (attribute, quoted: string | undefined, doubleQuoted: string | undefined, singleQuoted: string | undefined, unquoted: string | undefined) => { + if (!quoted && (doubleQuoted ?? singleQuoted ?? unquoted) === trustedNonce) { + return ` nonce="${nonce}"`; + } + + return attribute; + } + ); + + return `${scriptTagName}${updatedAttributes}>${content}${closingTag}`; + }); +} + export async function secureHtmlResponse(response: Response, options: ContentSecurityPolicyOptions = {}): Promise { - if (!response.headers.get('content-type')?.startsWith('text/html') || response.body === null) { + if (!response.headers.get('content-type')?.toLowerCase().startsWith('text/html') || [204, 205, 304].includes(response.status)) { return response; } + if (response.status === 206 || response.headers.has('content-range')) { + throw new Error('CSP requires a complete HTML document; partial HTML responses cannot be secured.'); + } + const nonce = createNonce(); - const html = addNonceToScripts(await response.text(), nonce); + // Only SvelteKit's unpredictable, per-response nonce identifies trusted scripts. + // Unmarked scripts, including injected tags, never receive authorization. + const scriptPolicy = response.headers + .get('content-security-policy') + ?.split(';') + .find((directive) => directive.trim().startsWith('script-src ')); + const trustedNonce = scriptPolicy?.match(/'nonce-([A-Za-z\d+/]+={0,2})'/)?.[1]; + let html = response.body === null ? null : await response.text(); + if (html !== null && trustedNonce) { + html = replaceScriptNonce(html, trustedNonce, nonce); + } + const headers = new Headers(response.headers); headers.delete('content-encoding'); headers.delete('content-length'); headers.delete('etag'); + headers.delete('last-modified'); + headers.delete('accept-ranges'); + headers.delete('content-range'); headers.set('Cache-Control', 'no-store'); headers.set('Content-Security-Policy', createContentSecurityPolicy(nonce, options)); diff --git a/src/Exceptionless.Web/ClientApp/svelte.config.js b/src/Exceptionless.Web/ClientApp/svelte.config.js index 82019d1368..9dee9d83d5 100644 --- a/src/Exceptionless.Web/ClientApp/svelte.config.js +++ b/src/Exceptionless.Web/ClientApp/svelte.config.js @@ -18,6 +18,11 @@ const config = { $generated: 'src/lib/generated', $lib: 'src/lib', $shared: 'src/lib/features/shared' + + }, + csp: { + directives: { 'script-src': ['self', 'strict-dynamic'] }, + mode: 'auto' } }, preprocess: vitePreprocess() diff --git a/src/Exceptionless.Web/Program.cs b/src/Exceptionless.Web/Program.cs index 907c7a9ead..3d1ae52b18 100644 --- a/src/Exceptionless.Web/Program.cs +++ b/src/Exceptionless.Web/Program.cs @@ -1,7 +1,6 @@ using System.Diagnostics; using System.Security.Claims; using System.Text; -using System.Text.RegularExpressions; using Exceptionless.Core; using Exceptionless.Core.Authorization; using Exceptionless.Core.Configuration; @@ -11,8 +10,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; @@ -126,6 +125,7 @@ public static async Task Main(string[] args) builder.Services.AddAppOptions(options); builder.Services.AddHttpContextAccessor(); builder.Services.AddCsp(nonceByteAmount: 32); + builder.Services.AddSingleton(); builder.Services.AddCors(b => b.AddPolicy("AllowAny", p => p .AllowAnyHeader() @@ -321,9 +321,10 @@ ApplicationException applicationException when applicationException.Message.Cont } app.MapOpenApi("/docs/v2/openapi.json"); - app.MapScalarApiReference("/docs", o => + app.MapScalarApiReference("/docs", (o, context) => { - o.DisableDefaultFonts() + o.WithNonce(context.RequestServices.GetRequiredService().GetNonce()) + .DisableDefaultFonts() .WithOpenApiRoutePattern("/docs/{documentName}/openapi.json") .AddDocument("v2", "Exceptionless API", "/docs/{documentName}/openapi.json", true) .AddPreferredSecuritySchemes("Bearer"); @@ -423,6 +424,7 @@ internal static async Task InjectCspNonceAsync(HttpContext context, RequestDeleg context.Response.Headers.Remove(HeaderNames.ETag); context.Response.Headers.Remove(HeaderNames.LastModified); context.Response.Headers.Remove(HeaderNames.AcceptRanges); + context.Response.Headers.Remove(HeaderNames.ContentRange); if (isHead) { @@ -432,7 +434,8 @@ internal static async Task InjectCspNonceAsync(HttpContext context, RequestDeleg using var reader = new StreamReader(buffer, Encoding.UTF8, detectEncodingFromByteOrderMarks: true, leaveOpen: true); string html = await reader.ReadToEndAsync(context.RequestAborted); - string responseHtml = AddScriptNonce(html, context.RequestServices.GetRequiredService().GetNonce()); + string responseHtml = context.RequestServices.GetRequiredService() + .AddNonce(html, context.RequestServices.GetRequiredService().GetNonce()); byte[] responseBytes = Encoding.UTF8.GetBytes(responseHtml); context.Response.ContentLength = responseBytes.Length; @@ -446,21 +449,6 @@ internal static async Task InjectCspNonceAsync(HttpContext context, RequestDeleg } } - internal static string AddScriptNonce(string html, string nonce) - { - return ScriptElementRegex().Replace(html, match => - { - string attributes = NonceAttributeRegex().Replace(match.Groups["attributes"].Value, attribute => attribute.Groups["quoted"].Success ? attribute.Value : String.Empty); - return $""; - Assert.Equal("", Exceptionless.Web.Program.AddScriptNonce(html, "new")); + Assert.Equal("", AddNonceToTrustedHtml(html, "new")); } [Theory] @@ -64,7 +66,7 @@ public void AddScriptNonce_NonceTextInsideAttribute_PreservesAttribute() [InlineData("")] public void AddScriptNonce_ScriptWithExistingNonce_ReplacesNonce(string html) { - string result = Exceptionless.Web.Program.AddScriptNonce(html, "new"); + string result = AddNonceToTrustedHtml(html, "new"); Assert.Equal(1, CountOccurrences(result, "nonce=\"new\"")); Assert.DoesNotContain("old", result, StringComparison.Ordinal); @@ -75,7 +77,7 @@ public void AddScriptNonce_QuotedGreaterThanInAttribute_PreservesOpeningTag() { const string html = ""; - string result = Exceptionless.Web.Program.AddScriptNonce(html, "new"); + string result = AddNonceToTrustedHtml(html, "new"); Assert.Equal("", result); } @@ -85,7 +87,7 @@ public void AddScriptNonce_InlineScriptContainsScriptLikeText_PreservesContent() { const string html = ""; - string result = Exceptionless.Web.Program.AddScriptNonce(html, "new"); + string result = AddNonceToTrustedHtml(html, "new"); Assert.Equal("", result); } @@ -95,7 +97,7 @@ public void AddScriptNonce_SimilarlyNamedAttributes_PreservesAttributes() { const string html = ""; - string result = Exceptionless.Web.Program.AddScriptNonce(html, "new"); + string result = AddNonceToTrustedHtml(html, "new"); Assert.Contains("data-nonce=\"keep\"", result, StringComparison.Ordinal); Assert.Contains("noncevalue=\"keep\"", result, StringComparison.Ordinal); @@ -103,6 +105,23 @@ public void AddScriptNonce_SimilarlyNamedAttributes_PreservesAttributes() Assert.Equal(1, CountOccurrences(result, "nonce=\"new\"")); } + [Fact] + public void AddNonce_UntrustedOrModifiedScripts_DoesNotAuthorizeThem() + { + const string trustedHtml = ""; + const string injectedHtml = trustedHtml + + "" + + "" + + "" + + ""; + + string result = AddNonceToTrustedHtml(injectedHtml, "fresh", trustedHtml); + + Assert.StartsWith("", result); + Assert.Equal(2, CountOccurrences(result, "nonce=\"fresh\"")); + Assert.EndsWith(injectedHtml[trustedHtml.Length..], result); + } + [Fact] public async Task Configure_IndexAndFallbackRoutes_ServeFreshNoncedHtml() { @@ -163,12 +182,22 @@ public async Task Configure_IndexAndFallbackRoutes_ServeFreshNoncedHtml() Assert.Equal("no-store", scalarResponse.Headers.CacheControl?.ToString()); Assert.Contains($"'nonce-{scalarNonce}'", scalarPolicy, StringComparison.Ordinal); Assert.Contains("scalar.js", scalarBody, StringComparison.Ordinal); + Assert.Contains("", scalarBody, StringComparison.Ordinal); + MatchCollection scalarScriptNonces = Regex.Matches(scalarBody, "]*\\bnonce=\"(?[^\"]+)\""); + Assert.Equal(3, scalarScriptNonces.Count); + Assert.All(scalarScriptNonces, script => Assert.Equal(scalarNonce, System.Net.WebUtility.HtmlDecode(script.Groups["nonce"].Value))); Assert.Contains("scalar.aspnetcore.js", scalarBody, StringComparison.Ordinal); Assert.Contains("\"withDefaultFonts\":false", scalarBody, StringComparison.Ordinal); Assert.DoesNotContain("cdn.jsdelivr.net", scalarBody, StringComparison.Ordinal); using HttpResponseMessage scalarScriptResponse = await client.GetAsync("/docs/scalar.js", TestContext.Current.CancellationToken); Assert.Equal(StatusCodes.Status200OK, (int)scalarScriptResponse.StatusCode); Assert.StartsWith("text/javascript", scalarScriptResponse.Content.Headers.ContentType?.ToString()); + + using HttpResponseMessage nextScalarResponse = await client.GetAsync("/docs/", TestContext.Current.CancellationToken); + string nextScalarBody = await nextScalarResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + string nextScalarNonce = GetNonceAttribute(nextScalarBody); + Assert.NotEqual(scalarNonce, nextScalarNonce); + Assert.Contains($"'nonce-{nextScalarNonce}'", nextScalarResponse.Headers.GetValues("Content-Security-Policy").Single()); } finally { @@ -323,6 +352,7 @@ private static async Task CreatePipelineHostAsync(string webRoot) .ConfigureServices(services => { services.AddCsp(nonceByteAmount: 32); + services.AddSingleton(); services.AddRouting(); }) .Configure(app => @@ -334,7 +364,10 @@ private static async Task CreatePipelineHostAsync(string webRoot) app.UseRouting(); app.UseEndpoints(endpoints => { - endpoints.MapScalarApiReference("/docs", options => options.DisableDefaultFonts()); + endpoints.MapScalarApiReference("/docs", (options, context) => options + .WithNonce(context.RequestServices.GetRequiredService().GetNonce()) + .DisableDefaultFonts() + .AddHeaderContent("")); endpoints.MapFallback("{**slug:nonfile}", Exceptionless.Web.Program.CreateRequestDelegate(endpoints, "/index.html")); }); })) @@ -344,6 +377,22 @@ private static async Task CreatePipelineHostAsync(string webRoot) return host; } + private static string AddNonceToTrustedHtml(string html, string nonce, string? trustedHtml = null) + { + string webRoot = Path.Combine(Path.GetTempPath(), $"exceptionless-csp-{Guid.NewGuid():N}"); + Directory.CreateDirectory(webRoot); + try + { + File.WriteAllText(Path.Combine(webRoot, "index.html"), trustedHtml ?? html); + using var files = new PhysicalFileProvider(webRoot); + return new FrontendScriptNonces(files).AddNonce(html, nonce); + } + finally + { + Directory.Delete(webRoot, recursive: true); + } + } + private static int CountOccurrences(string value, string search) { return value.Split(search, StringSplitOptions.None).Length - 1; From ebcac53cfbec02c6227158b70a2e1136274effa0 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Mon, 5 Oct 2026 21:53:47 -0500 Subject: [PATCH 19/26] Simplify CSP for the single static Svelte frontend --- .../ClientApp/src/hooks.server.test.ts | 135 ------ .../ClientApp/src/hooks.server.ts | 24 - .../server/content-security-policy.test.ts | 282 ------------ .../src/lib/server/content-security-policy.ts | 161 ------- .../ClientApp/svelte.config.js | 29 +- src/Exceptionless.Web/Program.cs | 75 +--- .../Security/ApiContentSecurityPolicy.cs | 29 -- .../Security/FrontendContentSecurityPolicy.cs | 99 ++-- .../Security/FrontendScriptNonces.cs | 59 --- ...tend-content-security-policy.contract.json | 59 --- .../Api/ApiContentSecurityPolicyTests.cs | 44 -- .../Exceptionless.Tests/Api/CspNonceTests.cs | 143 ------ .../Exceptionless.Tests.csproj | 3 - .../Utility/Handlers/CspResponseTests.cs | 425 +++--------------- 14 files changed, 108 insertions(+), 1459 deletions(-) delete mode 100644 src/Exceptionless.Web/ClientApp/src/hooks.server.test.ts delete mode 100644 src/Exceptionless.Web/ClientApp/src/hooks.server.ts delete mode 100644 src/Exceptionless.Web/ClientApp/src/lib/server/content-security-policy.test.ts delete mode 100644 src/Exceptionless.Web/ClientApp/src/lib/server/content-security-policy.ts delete mode 100644 src/Exceptionless.Web/Security/ApiContentSecurityPolicy.cs delete mode 100644 src/Exceptionless.Web/Security/FrontendScriptNonces.cs delete mode 100644 src/Exceptionless.Web/Security/frontend-content-security-policy.contract.json delete mode 100644 tests/Exceptionless.Tests/Api/ApiContentSecurityPolicyTests.cs delete mode 100644 tests/Exceptionless.Tests/Api/CspNonceTests.cs diff --git a/src/Exceptionless.Web/ClientApp/src/hooks.server.test.ts b/src/Exceptionless.Web/ClientApp/src/hooks.server.test.ts deleted file mode 100644 index 54edc213d1..0000000000 --- a/src/Exceptionless.Web/ClientApp/src/hooks.server.test.ts +++ /dev/null @@ -1,135 +0,0 @@ -import type { Handle } from '@sveltejs/kit'; - -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -const environment = vi.hoisted(() => ({ building: false, dev: false })); -const publicEnvironment = vi.hoisted(() => ({ PUBLIC_BASE_URL: 'https://app.example.test/next' })); -vi.mock('$app/environment', () => environment); -vi.mock('$env/dynamic/public', () => ({ env: publicEnvironment })); - -import { handle } from './hooks.server'; - -describe('server CSP hook', () => { - beforeEach(() => { - environment.building = false; - environment.dev = false; - publicEnvironment.PUBLIC_BASE_URL = 'https://app.example.test/next'; - }); - - it.each([false, true])('limits scheme-wide WebSockets to development (dev=%s)', async (dev) => { - environment.dev = dev; - const original = createFrameworkResponse(); - - const response = await handle({ event: createEvent(), resolve: async () => original }); - const policy = response.headers.get('content-security-policy')!; - const connections = policy - .split('; ') - .find((directive) => directive.startsWith('connect-src '))! - .split(' '); - - expect(connections.includes('ws:')).toBe(dev); - expect(connections.includes('wss:')).toBe(dev); - expect(connections).toContain('wss://*.intercom-messenger.com'); - expect(connections).toContain('wss://app.example.test'); - expect(policy).toContain("'strict-dynamic'"); - expect(await response.text()).toMatch(/', { headers: { 'content-type': 'text/html' } }); - - const response = await handle({ event: createEvent(), resolve: async () => original }); - - expect(response).toBe(original); - expect(response.headers.has('content-security-policy')).toBe(false); - }); - - it('serves HTML with a restrictive policy when the public URL uses the empty same-origin default', async () => { - publicEnvironment.PUBLIC_BASE_URL = ''; - const original = new Response('', { headers: { 'content-type': 'text/html' } }); - - const response = await handle({ event: createEvent(), resolve: async () => original }); - const connections = response.headers - .get('content-security-policy')! - .split('; ') - .find((directive) => directive.startsWith('connect-src '))! - .split(' '); - - expect(response.status).toBe(200); - expect(connections).toContain("'self'"); - expect(connections).not.toContain('ws:'); - expect(connections).not.toContain('wss:'); - expect(connections).not.toContain('wss://app.example.test'); - }); - - it('rejects a supplied non-HTTP public URL instead of broadening production sources', async () => { - publicEnvironment.PUBLIC_BASE_URL = 'ftp://app.example.test'; - const original = new Response('', { headers: { 'content-type': 'text/html' } }); - - await expect(handle({ event: createEvent(), resolve: async () => original })).rejects.toThrow(); - }); - - it.each(['GET', 'HEAD'])('removes conditional and range headers before resolving HTML (%s)', async (method) => { - const event = createEvent(method, '/next/', { - 'if-modified-since': 'Wed, 30 Sep 2026 00:00:00 GMT', - 'if-none-match': '*', - 'if-range': 'old', - range: 'bytes=0-23' - }); - const response = await handle({ - event, - resolve: async (resolvedEvent) => { - for (const header of ['range', 'if-range', 'if-none-match', 'if-modified-since']) { - expect(resolvedEvent.request.headers.has(header)).toBe(false); - } - return method === 'HEAD' ? new Response(null, { headers: { 'content-type': 'text/html' } }) : createFrameworkResponse(); - } - }); - - expect(response.status).toBe(200); - expect(response.headers.get('cache-control')).toBe('no-store'); - if (method === 'HEAD') expect(response.body).toBeNull(); - }); - - it.each([ - ['GET', '/next/app.js'], - ['POST', '/next/'] - ])('preserves conditional headers outside document requests (%s %s)', async (method, path) => { - const event = createEvent(method, path, { 'if-none-match': 'current' }); - await handle({ - event, - resolve: async (resolvedEvent) => { - expect(resolvedEvent.request.headers.get('if-none-match')).toBe('current'); - return Response.json({ ok: true }); - } - }); - }); - - it('does not trust request or forwarded hosts for the production WebSocket origin', async () => { - const original = new Response('', { headers: { 'content-type': 'text/html' } }); - const event = { - request: new Request('https://untrusted.example/next', { headers: { 'x-forwarded-host': 'forwarded.example' } }), - url: new URL('https://untrusted.example/next') - } as Parameters[0]['event']; - - const response = await handle({ event, resolve: async () => original }); - const policy = response.headers.get('content-security-policy')!; - - expect(policy).toContain('wss://app.example.test'); - expect(policy).not.toContain('untrusted.example'); - expect(policy).not.toContain('forwarded.example'); - }); -}); - -function createEvent(method = 'GET', path = '/next/', headers: HeadersInit = {}) { - const url = new URL(path, 'https://app.example.test'); - return { request: new Request(url, { headers, method }), url } as Parameters[0]['event']; -} - -function createFrameworkResponse() { - const nonce = 'dGVzdC1mcmFtZXdvcmstbm9uY2U='; - return new Response(``, { - headers: { 'content-security-policy': `script-src 'nonce-${nonce}' 'strict-dynamic'`, 'content-type': 'text/html' } - }); -} diff --git a/src/Exceptionless.Web/ClientApp/src/hooks.server.ts b/src/Exceptionless.Web/ClientApp/src/hooks.server.ts deleted file mode 100644 index 02a42425aa..0000000000 --- a/src/Exceptionless.Web/ClientApp/src/hooks.server.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { Handle } from '@sveltejs/kit'; - -import { building, dev } from '$app/environment'; -import { env } from '$env/dynamic/public'; -import { secureHtmlResponse } from '$lib/server/content-security-policy'; - -export const handle: Handle = async ({ event, resolve }) => { - const path = event.url.pathname; - const isDocumentRequest = ['GET', 'HEAD'].includes(event.request.method) && (!/\/[^/]*\.[^/]+$/.test(path) || path.toLowerCase().endsWith('.html')); - if (!building && isDocumentRequest) { - for (const header of ['if-none-match', 'if-modified-since', 'range', 'if-range']) { - event.request.headers.delete(header); - } - } - - const response = await resolve(event); - - if (building) { - return response; - } - - // The checked-in .env uses an empty value for the same-origin default. - return secureHtmlResponse(response, { allowDevelopmentConnections: dev, siteBaseUrl: env.PUBLIC_BASE_URL || undefined }); -}; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/server/content-security-policy.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/server/content-security-policy.test.ts deleted file mode 100644 index c68ed04958..0000000000 --- a/src/Exceptionless.Web/ClientApp/src/lib/server/content-security-policy.test.ts +++ /dev/null @@ -1,282 +0,0 @@ -import { readFileSync } from 'node:fs'; -import { resolve } from 'node:path'; -import { describe, expect, it } from 'vitest'; - -import { createContentSecurityPolicy, createNonce, getWebSocketOrigin, replaceScriptNonce, secureHtmlResponse } from './content-security-policy'; - -describe('configured WebSocket origin', () => { - it.each([ - ['https://app.example.test/next/?query=value#fragment', 'wss://app.example.test'], - ['https://app.example.test:8443/next', 'wss://app.example.test:8443'], - ['http://localhost:7110', 'ws://localhost:7110'], - ['http://localhost:80', 'ws://localhost'], - ['https://[::1]:8443/next', 'wss://[::1]:8443'] - ])('allows only the configured origin for %s', (siteBaseUrl, expectedOrigin) => { - const policy = createContentSecurityPolicy(createNonce(), { siteBaseUrl }); - - expect(getWebSocketOrigin(siteBaseUrl)).toBe(expectedOrigin); - expect(getDirective(policy, 'connect-src')).toContain(expectedOrigin); - expect(getDirective(policy, 'connect-src')).not.toContain('ws:'); - expect(getDirective(policy, 'connect-src')).not.toContain('wss:'); - expect(policy).not.toContain('query=value'); - }); - - it.each(['', '/next', 'ftp://app.example.test', 'https://user:password@app.example.test', 'https://*.example.test'])( - 'rejects invalid configuration %s', - (siteBaseUrl) => { - expect(() => createContentSecurityPolicy(createNonce(), { siteBaseUrl })).toThrow(); - } - ); -}); - -describe('createNonce', () => { - it('creates unique base64-encoded 32-byte nonces', () => { - const nonces = Array.from({ length: 32 }, () => createNonce()); - - expect(new Set(nonces)).toHaveLength(nonces.length); - for (const nonce of nonces) { - expect(nonce).toMatch(/^[A-Za-z\d+/]{43}=$/); - expect(Buffer.from(nonce, 'base64')).toHaveLength(32); - } - }); -}); - -describe('replaceScriptNonce', () => { - it('replaces only SvelteKit-authorized script nonces, preserving untrusted tags and quoted attribute text', () => { - const trustedNonce = createNonce(); - const nonce = createNonce(); - const html = - `` + - `` + - ``; - - const result = replaceScriptNonce(html, trustedNonce, nonce); - - expect(result).toContain(``); - expect(result).toContain(``); - expect(result).toContain( - '' - ); - expect([...result.matchAll(new RegExp(`nonce="${nonce.replaceAll('+', '\\+')}"`, 'g'))]).toHaveLength(2); - }); -}); - -describe('createContentSecurityPolicy', () => { - it('excludes unused vendor capabilities while preserving core payment and messenger dependencies', () => { - const policy = createContentSecurityPolicy(createNonce()); - - expect(policy).not.toContain('fonts.googleapis.com'); - expect(policy).not.toContain('fonts.gstatic.com'); - expect(policy).not.toContain('user-images.githubusercontent.com'); - for (const unusedSource of [ - 'config.exceptionless.io', - 'heartbeat.exceptionless.io', - 'maps.googleapis.com', - 'cdn.jsdelivr.net', - 'intercom-sheets.com', - 'intercom-reporting.com', - 'youtube.com', - 'vimeo.com', - 'wistia.net', - 'intercom-attachments-', - 'uploads.intercom', - 'downloads.intercom', - 'gifs.intercom', - 'video-messages.intercom', - 'messenger-apps.intercom', - 'intercom.help' - ]) { - expect(policy).not.toContain(unusedSource); - } - expect(getDirective(policy, 'img-src')).not.toContain('https://*.stripe.com'); - expect(getDirective(policy, 'connect-src')).toContain('https://api.stripe.com'); - expect(getDirective(policy, 'form-action')).toEqual(["'self'"]); - expect(getDirective(policy, 'worker-src')).toEqual(["'self'"]); - expect(getDirective(policy, 'connect-src')).toContain('https://*.intercom-messenger.com'); - expect(getDirective(policy, 'connect-src')).toContain('wss://*.intercom-messenger.com'); - }); - - it('matches the canonical cross-runtime policy contract', () => { - const nonce = createNonce(); - const policy = normalizeDevelopmentPolicy(createContentSecurityPolicy(nonce, { allowDevelopmentConnections: true })); - - expect(policy).toEqual(readPolicyContract()); - }); - - it('uses a strict nonce policy with compatibility sources', () => { - const nonce = createNonce(); - const policy = createContentSecurityPolicy(nonce); - const scriptDirective = getDirective(policy, 'script-src'); - const connectDirective = getDirective(policy, 'connect-src'); - - expect(scriptDirective).toContain(`'nonce-${nonce}'`); - expect(scriptDirective).toContain("'strict-dynamic'"); - expect(scriptDirective).toContain("'self'"); - expect(scriptDirective).toContain('https://js.stripe.com'); - expect(scriptDirective).toContain('https://*.js.stripe.com'); - expect(scriptDirective).toContain('https://widget.intercom.io'); - expect(scriptDirective).not.toContain("'unsafe-inline'"); - expect(scriptDirective).not.toContain("'unsafe-eval'"); - expect(scriptDirective).not.toContain('https://cdn.jsdelivr.net'); - - expect(connectDirective).toContain("'self'"); - expect(connectDirective).toContain('https://api.stripe.com'); - expect(connectDirective).toContain('wss://*.intercom-messenger.com'); - expect(connectDirective).not.toContain('ws:'); - expect(connectDirective).not.toContain('wss:'); - - expect(getDirective(policy, 'img-src')).not.toContain('http://www.gravatar.com'); - expect(policy).not.toContain('intercomcdn.eu'); - expect(policy).not.toContain('.eu.intercom.io'); - expect(policy).not.toContain('.au.intercom.io'); - expect(policy).not.toContain('au.intercomcdn.com'); - expect(policy).not.toContain('static.au.intercomassets.com'); - expect(policy).not.toContain('intercom-attachments.eu'); - expect(policy).not.toContain('au.intercom-attachments.com'); - - expect(getDirective(policy, 'base-uri')).toEqual(["'none'"]); - expect(getDirective(policy, 'object-src')).toEqual(["'none'"]); - expect(getDirective(policy, 'frame-ancestors')).toEqual(["'none'"]); - }); - - it('allows broad WebSocket schemes only when development connections are requested', () => { - const policy = createContentSecurityPolicy(createNonce(), { allowDevelopmentConnections: true }); - const connectDirective = getDirective(policy, 'connect-src'); - - expect(connectDirective).toContain('ws:'); - expect(connectDirective).toContain('wss:'); - }); -}); - -describe('secureHtmlResponse', () => { - it('buffers chunked HTML, preserves framework trust, and removes stale response metadata', async () => { - const trustedNonce = createNonce(); - const encoder = new TextEncoder(); - const stream = new ReadableStream({ - start(controller) { - controller.enqueue(encoder.encode('start()` - ) - ); - controller.close(); - } - }); - const originalResponse = new Response(stream, { - headers: { - 'accept-ranges': 'bytes', - 'content-length': '123', - 'content-security-policy': `script-src 'nonce-${trustedNonce}' 'strict-dynamic'`, - 'content-type': 'text/html; charset=utf-8', - etag: 'stale-after-transformation', - 'last-modified': 'Wed, 30 Sep 2026 00:00:00 GMT' - } - }); - - const response = await secureHtmlResponse(originalResponse, { allowDevelopmentConnections: true }); - const html = await response.text(); - const nonce = html.match(/'); - }); - - it('does not grant a nonce when the framework has not authorized any scripts', async () => { - const html = ''; - const response = await secureHtmlResponse(new Response(html, { headers: { 'content-type': 'text/html' } })); - - expect(await response.text()).toBe(html); - expect(response.headers.get('content-security-policy')).toContain("'strict-dynamic'"); - expect(response.headers.get('cache-control')).toBe('no-store'); - }); - - it.each([200, 206])('rejects partial HTML rather than rewriting its byte range (status %i)', async (status) => { - const originalResponse = new Response('', { - headers: { 'content-range': 'bytes 0-23/100', 'content-type': 'text/html' }, - status - }); - - await expect(secureHtmlResponse(originalResponse)).rejects.toThrow('complete HTML document'); - }); - - it('secures HEAD metadata without manufacturing a response body', async () => { - const response = await secureHtmlResponse( - new Response(null, { - headers: { 'accept-ranges': 'bytes', 'content-type': 'text/html', etag: 'stale', 'last-modified': 'Wed, 30 Sep 2026 00:00:00 GMT' } - }) - ); - - expect(response.body).toBeNull(); - expect(response.headers.get('cache-control')).toBe('no-store'); - expect(response.headers.has('etag')).toBe(false); - expect(response.headers.has('last-modified')).toBe(false); - expect(response.headers.has('accept-ranges')).toBe(false); - expect(response.headers.get('content-security-policy')).toContain("'strict-dynamic'"); - }); - - it('leaves non-HTML responses untouched', async () => { - const originalResponse = Response.json({ status: 'ok' }); - - const response = await secureHtmlResponse(originalResponse, { allowDevelopmentConnections: true }); - - expect(response).toBe(originalResponse); - expect(response.headers.has('content-security-policy')).toBe(false); - expect(response.headers.has('cache-control')).toBe(false); - }); - - it.each([204, 205, 304])('leaves bodyless HTML responses untouched for status %i', async (status) => { - const originalResponse = new Response(null, { - headers: { 'content-type': 'text/html; charset=utf-8' }, - status - }); - - const response = await secureHtmlResponse(originalResponse, { allowDevelopmentConnections: true }); - - expect(response).toBe(originalResponse); - expect(response.headers.has('content-security-policy')).toBe(false); - }); -}); - -function getDirective(policy: string, name: string): string[] { - const directive = policy.split('; ').find((value) => value.startsWith(`${name} `)); - - if (!directive) { - throw new Error(`Missing ${name} directive.`); - } - - return directive.slice(name.length + 1).split(' '); -} - -function normalizeDevelopmentPolicy(policy: string): Record { - return Object.fromEntries( - policy.split('; ').map((directive) => { - const [name, ...sources] = directive.split(' '); - const developmentSources = sources.filter((source) => source === 'ws:' || source === 'wss:'); - - if (name === 'connect-src') { - expect(developmentSources).toEqual(['ws:', 'wss:']); - } else { - expect(developmentSources).toEqual([]); - } - - return [name, sources.filter((source) => !source.startsWith("'nonce-") && source !== 'ws:' && source !== 'wss:').sort()]; - }) - ); -} - -function readPolicyContract(): Record { - const contractPath = resolve(process.cwd(), '../Security/frontend-content-security-policy.contract.json'); - const contract = JSON.parse(readFileSync(contractPath, 'utf8')) as Record; - - return Object.fromEntries(Object.entries(contract).map(([name, sources]) => [name, [...sources].sort()])); -} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/server/content-security-policy.ts b/src/Exceptionless.Web/ClientApp/src/lib/server/content-security-policy.ts deleted file mode 100644 index 666135387f..0000000000 --- a/src/Exceptionless.Web/ClientApp/src/lib/server/content-security-policy.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { randomBytes } from 'node:crypto'; - -const NONCE_BYTE_LENGTH = 32; -const NONCE_PATTERN = /^[A-Za-z\d+/]{43}=$/; -const NONCE_ATTRIBUTE_PATTERN = /("[^"]*"|'[^']*')|\s+nonce\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/gi; -const SCRIPT_ELEMENT_PATTERN = /(])*)>([\s\S]*?)(<\/script\s*>)/gi; - -// Exceptionless uses Intercom's US endpoints. Keep region-specific sources scoped to that workspace. -const contentSecurityPolicyDirectives: ReadonlyArray = [ - ['default-src', ["'self'"]], - [ - 'script-src', - [ - "'strict-dynamic'", - "'self'", - 'https://js.stripe.com', - 'https://*.js.stripe.com', - 'https://app.intercom.io', - 'https://widget.intercom.io', - 'https://js.intercomcdn.com' - ] - ], - ['style-src', ["'self'", "'unsafe-inline'"]], - [ - 'img-src', - ["'self'", 'blob:', 'data:', 'https://*.link.com', 'https://js.intercomcdn.com', 'https://static.intercomassets.com', 'https://www.gravatar.com'] - ], - ['font-src', ["'self'", 'https://js.intercomcdn.com', 'https://fonts.intercomcdn.com']], - [ - 'connect-src', - [ - "'self'", - 'https://collector.exceptionless.io', - 'https://api.stripe.com', - 'https://link.com', - 'https://*.link.com', - 'https://via.intercom.io', - 'https://api.intercom.io', - 'https://api-iam.intercom.io', - 'https://api-ping.intercom.io', - 'https://*.intercom-messenger.com', - 'wss://*.intercom-messenger.com', - 'https://nexus-websocket-a.intercom.io', - 'wss://nexus-websocket-a.intercom.io', - 'https://nexus-websocket-b.intercom.io', - 'wss://nexus-websocket-b.intercom.io' - ] - ], - ['frame-src', ["'self'", 'https://js.stripe.com', 'https://*.js.stripe.com', 'https://hooks.stripe.com', 'https://link.com', 'https://*.link.com']], - ['media-src', ["'self'", 'blob:', 'https://js.intercomcdn.com']], - ['worker-src', ["'self'"]], - ['form-action', ["'self'"]], - ['manifest-src', ["'self'"]], - ['base-uri', ["'none'"]], - ['object-src', ["'none'"]], - ['frame-ancestors', ["'none'"]] -]; - -interface ContentSecurityPolicyOptions { - allowDevelopmentConnections?: boolean; - siteBaseUrl?: string; -} - -export function createContentSecurityPolicy(nonce: string, options: ContentSecurityPolicyOptions = {}): string { - validateNonce(nonce); - - return contentSecurityPolicyDirectives - .map(([directive, sources]) => { - let effectiveSources = sources; - if (directive === 'script-src') { - effectiveSources = [`'nonce-${nonce}'`, ...sources]; - } else if (directive === 'connect-src' && options.allowDevelopmentConnections) { - effectiveSources = [...sources, 'ws:', 'wss:']; - } - - if (directive === 'connect-src' && options.siteBaseUrl !== undefined) { - effectiveSources = [...effectiveSources, getWebSocketOrigin(options.siteBaseUrl)]; - } - - return `${directive} ${effectiveSources.join(' ')}`; - }) - .join('; '); -} - -export function createNonce(): string { - return randomBytes(NONCE_BYTE_LENGTH).toString('base64'); -} - -export function getWebSocketOrigin(siteBaseUrl: string): string { - const url = new URL(siteBaseUrl); - if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.hostname.includes('*')) { - throw new Error('The CSP site base URL must be an absolute HTTP(S) URL without credentials or wildcard hosts.'); - } - - url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'; - return url.origin; -} - -export function replaceScriptNonce(html: string, trustedNonce: string, nonce: string): string { - validateNonce(nonce); - - return html.replace(SCRIPT_ELEMENT_PATTERN, (_scriptElement, scriptTagName: string, attributes: string, content: string, closingTag: string) => { - const updatedAttributes = attributes.replace( - NONCE_ATTRIBUTE_PATTERN, - (attribute, quoted: string | undefined, doubleQuoted: string | undefined, singleQuoted: string | undefined, unquoted: string | undefined) => { - if (!quoted && (doubleQuoted ?? singleQuoted ?? unquoted) === trustedNonce) { - return ` nonce="${nonce}"`; - } - - return attribute; - } - ); - - return `${scriptTagName}${updatedAttributes}>${content}${closingTag}`; - }); -} - -export async function secureHtmlResponse(response: Response, options: ContentSecurityPolicyOptions = {}): Promise { - if (!response.headers.get('content-type')?.toLowerCase().startsWith('text/html') || [204, 205, 304].includes(response.status)) { - return response; - } - - if (response.status === 206 || response.headers.has('content-range')) { - throw new Error('CSP requires a complete HTML document; partial HTML responses cannot be secured.'); - } - - const nonce = createNonce(); - // Only SvelteKit's unpredictable, per-response nonce identifies trusted scripts. - // Unmarked scripts, including injected tags, never receive authorization. - const scriptPolicy = response.headers - .get('content-security-policy') - ?.split(';') - .find((directive) => directive.trim().startsWith('script-src ')); - const trustedNonce = scriptPolicy?.match(/'nonce-([A-Za-z\d+/]+={0,2})'/)?.[1]; - let html = response.body === null ? null : await response.text(); - if (html !== null && trustedNonce) { - html = replaceScriptNonce(html, trustedNonce, nonce); - } - - const headers = new Headers(response.headers); - headers.delete('content-encoding'); - headers.delete('content-length'); - headers.delete('etag'); - headers.delete('last-modified'); - headers.delete('accept-ranges'); - headers.delete('content-range'); - headers.set('Cache-Control', 'no-store'); - headers.set('Content-Security-Policy', createContentSecurityPolicy(nonce, options)); - - return new Response(html, { - headers, - status: response.status, - statusText: response.statusText - }); -} - -function validateNonce(nonce: string): void { - if (!NONCE_PATTERN.test(nonce)) { - throw new Error('CSP nonce must be a base64-encoded 32-byte value.'); - } -} diff --git a/src/Exceptionless.Web/ClientApp/svelte.config.js b/src/Exceptionless.Web/ClientApp/svelte.config.js index 9dee9d83d5..5f6149f481 100644 --- a/src/Exceptionless.Web/ClientApp/svelte.config.js +++ b/src/Exceptionless.Web/ClientApp/svelte.config.js @@ -18,10 +18,35 @@ 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. csp: { - directives: { 'script-src': ['self', 'strict-dynamic'] }, + directives: { + 'base-uri': ['none'], + // ws: also permits wss:; * alone covers HTTP(S), not WebSockets. + 'connect-src': ['*', 'ws:'], + 'default-src': ['self'], + 'font-src': ['self', 'https://*.intercomcdn.com'], + 'form-action': ['self'], + 'frame-ancestors': ['none'], + 'frame-src': ['self', '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' + ], + 'manifest-src': ['self'], + 'media-src': ['self', 'blob:', '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', + 'worker-src': ['self'] + }, mode: 'auto' } }, diff --git a/src/Exceptionless.Web/Program.cs b/src/Exceptionless.Web/Program.cs index 3d1ae52b18..333e632dac 100644 --- a/src/Exceptionless.Web/Program.cs +++ b/src/Exceptionless.Web/Program.cs @@ -1,6 +1,5 @@ using System.Diagnostics; using System.Security.Claims; -using System.Text; using Exceptionless.Core; using Exceptionless.Core.Authorization; using Exceptionless.Core.Configuration; @@ -125,7 +124,6 @@ public static async Task Main(string[] args) builder.Services.AddAppOptions(options); builder.Services.AddHttpContextAccessor(); builder.Services.AddCsp(nonceByteAmount: 32); - builder.Services.AddSingleton(); builder.Services.AddCors(b => b.AddPolicy("AllowAny", p => p .AllowAnyHeader() @@ -269,7 +267,7 @@ ApplicationException applicationException when applicationException.Message.Cont if (ssl) app.UseHttpsRedirection(); - app.UseCsp(csp => FrontendContentSecurityPolicy.Configure(csp, options.BaseURL)); + app.UseCsp(csp => FrontendContentSecurityPolicy.Configure(csp, app.Environment.WebRootFileProvider, options.AppMode != AppMode.Development)); app.UseSerilogRequestLogging(o => { @@ -294,9 +292,9 @@ ApplicationException applicationException when applicationException.Message.Cont }; }); - app.UseDefaultFiles(); - app.Use(InjectCspNonceAsync); app.UseStaticFiles(); + app.UseDefaultFiles(); + app.UseFileServer(); app.UseRouting(); app.UseMiddleware(); app.UseCors("AllowAny"); @@ -382,73 +380,6 @@ internal static Task WriteProblemDetailsStatusCodeResponseAsync(StatusCodeContex .ExecuteAsync(statusCodeContext.HttpContext); } - internal static async Task InjectCspNonceAsync(HttpContext context, RequestDelegate next) - { - bool isHead = HttpMethods.IsHead(context.Request.Method); - bool hasNonHtmlExtension = Path.HasExtension(context.Request.Path) - && !context.Request.Path.Value!.EndsWith(".html", StringComparison.OrdinalIgnoreCase); - - if ((!HttpMethods.IsGet(context.Request.Method) && !isHead) - || context.Request.Path.StartsWithSegments("/api") - || context.Request.Path.StartsWithSegments("/mcp") - || hasNonHtmlExtension) - { - await next(context); - return; - } - - // Each HTML response needs a new nonce, so cached or partial bodies cannot be reused. - context.Request.Headers.Remove(HeaderNames.IfNoneMatch); - context.Request.Headers.Remove(HeaderNames.IfModifiedSince); - context.Request.Headers.Remove(HeaderNames.Range); - context.Request.Headers.Remove(HeaderNames.IfRange); - - Stream responseBody = context.Response.Body; - await using var buffer = new MemoryStream(); - context.Response.Body = buffer; - - try - { - await next(context); - - buffer.Position = 0; - if (context.Response.StatusCode != StatusCodes.Status200OK - || context.Response.ContentType?.StartsWith("text/html", StringComparison.OrdinalIgnoreCase) is not true) - { - context.Response.Body = responseBody; - await buffer.CopyToAsync(context.Response.Body, context.RequestAborted); - return; - } - - context.Response.Headers.CacheControl = "no-store"; - context.Response.Headers.Remove(HeaderNames.ETag); - context.Response.Headers.Remove(HeaderNames.LastModified); - context.Response.Headers.Remove(HeaderNames.AcceptRanges); - context.Response.Headers.Remove(HeaderNames.ContentRange); - - if (isHead) - { - context.Response.ContentLength = null; - return; - } - - using var reader = new StreamReader(buffer, Encoding.UTF8, detectEncodingFromByteOrderMarks: true, leaveOpen: true); - string html = await reader.ReadToEndAsync(context.RequestAborted); - string responseHtml = context.RequestServices.GetRequiredService() - .AddNonce(html, context.RequestServices.GetRequiredService().GetNonce()); - byte[] responseBytes = Encoding.UTF8.GetBytes(responseHtml); - - context.Response.ContentLength = responseBytes.Length; - - context.Response.Body = responseBody; - await context.Response.Body.WriteAsync(responseBytes, context.RequestAborted); - } - finally - { - context.Response.Body = responseBody; - } - } - internal static RequestDelegate CreateRequestDelegate(IEndpointRouteBuilder endpoints, string filePath) { var app = endpoints.CreateApplicationBuilder(); diff --git a/src/Exceptionless.Web/Security/ApiContentSecurityPolicy.cs b/src/Exceptionless.Web/Security/ApiContentSecurityPolicy.cs deleted file mode 100644 index 5f6b6b2cf8..0000000000 --- a/src/Exceptionless.Web/Security/ApiContentSecurityPolicy.cs +++ /dev/null @@ -1,29 +0,0 @@ -using Exceptionless.Core.Extensions; -using Joonasw.AspNetCore.SecurityHeaders.Csp.Builder; - -namespace Exceptionless.Web.Security; - -internal static class ApiContentSecurityPolicy -{ - public static void AllowConfiguredOrigins(CspBuilder csp, string? apiUrl) - { - foreach (string origin in GetConfiguredOrigins(apiUrl)) - csp.AllowConnections.To(origin); - } - - internal static string[] GetConfiguredOrigins(string? apiUrl) - { - // Accept only an administrator-configured HTTP origin, never request or forwarded headers. - if (!Uri.TryCreate(apiUrl?.Trim(), UriKind.Absolute, out var uri) || - uri.Scheme is not ("http" or "https") || - !String.IsNullOrEmpty(uri.UserInfo) || - uri.Host.Contains('*') || - uri.HostNameType is not (UriHostNameType.Dns or UriHostNameType.IPv4 or UriHostNameType.IPv6)) - return []; - - var origin = new UriBuilder(uri) { Host = uri.IdnHost, Path = String.Empty, Query = String.Empty, Fragment = String.Empty }; - string httpOrigin = origin.Uri.GetOrigin(); - origin.Scheme = uri.Scheme == "https" ? "wss" : "ws"; - return [httpOrigin, origin.Uri.GetOrigin()]; - } -} diff --git a/src/Exceptionless.Web/Security/FrontendContentSecurityPolicy.cs b/src/Exceptionless.Web/Security/FrontendContentSecurityPolicy.cs index 90acea32e7..d032b81698 100644 --- a/src/Exceptionless.Web/Security/FrontendContentSecurityPolicy.cs +++ b/src/Exceptionless.Web/Security/FrontendContentSecurityPolicy.cs @@ -1,83 +1,50 @@ -using Joonasw.AspNetCore.SecurityHeaders.Csp; +using System.Text.RegularExpressions; using Joonasw.AspNetCore.SecurityHeaders.Csp.Builder; +using Microsoft.Extensions.FileProviders; namespace Exceptionless.Web.Security; -internal static class FrontendContentSecurityPolicy +internal static partial class FrontendContentSecurityPolicy { - public static void Configure(CspBuilder csp) + public static void Configure(CspBuilder csp, IFileProvider files, bool upgradeInsecureRequests) { - Configure(csp, null); - } - - public static void Configure(CspBuilder csp, string? siteBaseUrl) - { - // Exceptionless uses Intercom's US endpoints. Keep region-specific sources scoped to that workspace. csp.ByDefaultAllow.FromSelf(); - - csp.AllowScripts.FromSelf() - .AddNonce() - .WithStrictDynamic() - .From("https://*.js.stripe.com") - .From("https://js.stripe.com") - .From("https://app.intercom.io") - .From("https://widget.intercom.io") + csp.AllowScripts.FromSelf().AddNonce().WithStrictDynamic() + .From("https://*.stripe.com") + .From("https://*.intercom.io") .From("https://js.intercomcdn.com"); - csp.AllowStyles.FromSelf() - .AllowUnsafeInline(); + // 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); + } - csp.AllowImages.FromSelf() - .From("data:") - .From("blob:") + csp.AllowStyles.FromSelf().AllowUnsafeInline(); + 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"); + csp.AllowFonts.FromSelf().From("https://*.intercomcdn.com"); - csp.AllowFonts.FromSelf() - .From("https://js.intercomcdn.com") - .From("https://fonts.intercomcdn.com"); - - csp.AllowConnections.ToSelf() - .To("https://collector.exceptionless.io") - .To("https://api.stripe.com") - .To("https://link.com") - .To("https://*.link.com") - .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"); - - // Use administrator configuration, never request Host or forwarded headers. - // Some browsers do not match WebSocket schemes against connect-src 'self'. - if (siteBaseUrl is not null) - csp.AllowConnections.To(GetWebSocketOrigin(siteBaseUrl)); - - csp.AllowFrames.FromSelf() - .From("https://*.js.stripe.com") - .From("https://js.stripe.com") - .From("https://hooks.stripe.com") - .From("https://link.com") - .From("https://*.link.com"); - - csp.AllowAudioAndVideo.FromSelf() - .From("blob:") - .From("https://js.intercomcdn.com"); - + // ws: also permits wss:; * alone covers HTTP(S), not WebSockets. + csp.AllowConnections.ToAnywhere().To("ws:"); + csp.AllowFrames.FromSelf().From("https://*.stripe.com") + .From("https://link.com").From("https://*.link.com"); + csp.AllowAudioAndVideo.FromSelf().From("blob:").From("https://js.intercomcdn.com"); csp.AllowWorkers.FromSelf(); - csp.AllowFormActions.ToSelf(); csp.AllowManifest.FromSelf(); csp.AllowPlugins.FromNowhere(); csp.AllowBaseUri.FromNowhere(); csp.AllowFraming.FromNowhere(); + if (upgradeInsecureRequests) + csp.SetUpgradeInsecureRequests(); csp.OnSendingHeader = context => { @@ -86,16 +53,6 @@ public static void Configure(CspBuilder csp, string? siteBaseUrl) }; } - internal static string GetWebSocketOrigin(string siteBaseUrl) - { - if (!Uri.TryCreate(siteBaseUrl, UriKind.Absolute, out Uri? uri) - || (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps) - || !String.IsNullOrEmpty(uri.UserInfo) - || uri.HostNameType is not (UriHostNameType.Dns or UriHostNameType.IPv4 or UriHostNameType.IPv6) - || uri.Host.Contains('*')) - throw new ArgumentException("The CSP site base URL must be an absolute HTTP(S) URL without credentials or wildcard hosts.", nameof(siteBaseUrl)); - - var origin = new UriBuilder(uri.Scheme == Uri.UriSchemeHttps ? "wss" : "ws", uri.Host, uri.IsDefaultPort ? -1 : uri.Port); - return origin.Uri.GetLeftPart(UriPartial.Authority); - } + [GeneratedRegex("'sha256-[A-Za-z0-9+/]{43}='", RegexOptions.CultureInvariant)] + private static partial Regex ScriptHashRegex(); } diff --git a/src/Exceptionless.Web/Security/FrontendScriptNonces.cs b/src/Exceptionless.Web/Security/FrontendScriptNonces.cs deleted file mode 100644 index e8fed6425c..0000000000 --- a/src/Exceptionless.Web/Security/FrontendScriptNonces.cs +++ /dev/null @@ -1,59 +0,0 @@ -using System.Security.Cryptography; -using System.Text; -using System.Text.RegularExpressions; -using Microsoft.Extensions.FileProviders; - -namespace Exceptionless.Web.Security; - -internal sealed partial class FrontendScriptNonces -{ - private readonly HashSet _trustedScripts = new(StringComparer.Ordinal); - - public FrontendScriptNonces(IWebHostEnvironment environment) : this(environment.WebRootFileProvider) - { - } - - internal FrontendScriptNonces(IFileProvider files) - { - // Read only the application's published entry pages, never response or request HTML. - foreach (string path in new[] { "index.html", "next/index.html" }) - { - IFileInfo file = files.GetFileInfo(path); - if (!file.Exists) - continue; - - using var reader = new StreamReader(file.CreateReadStream(), Encoding.UTF8, detectEncodingFromByteOrderMarks: true); - foreach (Match script in ScriptElementRegex().Matches(reader.ReadToEnd())) - _trustedScripts.Add(GetIdentity(script)); - } - } - - public string AddNonce(string html, string nonce) - { - return ScriptElementRegex().Replace(html, script => - { - if (!_trustedScripts.Contains(GetIdentity(script))) - return script.Value; - - string attributes = RemoveNonce(script.Groups["attributes"].Value); - return $"""", - TestContext.Current.CancellationToken); - - using var fileProvider = new PhysicalFileProvider(webRoot); - var services = new ServiceCollection(); - services.AddLogging(); - services.AddCsp(nonceByteAmount: 32); - services.AddSingleton(); - services.AddSingleton(new TestWebHostEnvironment(webRoot, fileProvider)); - await using var serviceProvider = services.BuildServiceProvider(); - - var app = new ApplicationBuilder(serviceProvider); - app.UseCsp(csp => csp.AllowScripts.FromSelf().AddNonce().WithStrictDynamic()); - app.Use(Exceptionless.Web.Program.InjectCspNonceAsync); - app.UseStaticFiles(new StaticFileOptions { FileProvider = fileProvider }); - RequestDelegate pipeline = app.Build(); - - var responses = new List<(string Html, string Policy)>(); - for (int index = 0; index < 2; index++) - { - await using AsyncServiceScope scope = serviceProvider.CreateAsyncScope(); - var context = new DefaultHttpContext - { - RequestServices = scope.ServiceProvider - }; - context.Request.Method = HttpMethods.Get; - context.Request.Path = "/index.html"; - context.Request.Headers.Accept = index == 0 ? "text/html" : "*/*"; - context.Response.Body = new MemoryStream(); - - await pipeline(context); - - context.Response.Body.Position = 0; - using var reader = new StreamReader(context.Response.Body, Encoding.UTF8); - responses.Add((await reader.ReadToEndAsync(TestContext.Current.CancellationToken), context.Response.Headers.ContentSecurityPolicy.ToString())); - } - - var nonces = responses - .Select(response => Regex.Match(response.Policy, "'nonce-(?[^']+)'").Groups["nonce"].Value) - .ToArray(); - - Assert.All(nonces, nonce => Assert.NotEmpty(nonce)); - Assert.NotEqual(nonces[0], nonces[1]); - - for (int index = 0; index < responses.Count; index++) - { - Assert.Equal(2, Regex.Matches(responses[index].Html, $"nonce=\"{Regex.Escape(nonces[index])}\"").Count); - Assert.DoesNotContain("stale", responses[index].Html); - Assert.Contains("""const marker = "", TestContext.Current.CancellationToken); - File.SetLastWriteTimeUtc(Path.Combine(webRoot, "index.html"), new DateTime(2023, 1, 1, 0, 0, 0, DateTimeKind.Utc)); + string hash = Convert.ToBase64String(SHA256.HashData(Encoding.UTF8.GetBytes("start()"))); + string html = $""""""; + await File.WriteAllTextAsync(Path.Combine(webRoot, "index.html"), html, TestContext.Current.CancellationToken); try { using IHost host = await CreatePipelineHostAsync(webRoot); using HttpClient client = host.GetTestClient(); - using var request = new HttpRequestMessage(new HttpMethod(method), "/index.html"); - request.Headers.TryAddWithoutValidation(header, value); - using HttpResponseMessage response = await client.SendAsync(request, TestContext.Current.CancellationToken); + foreach (string path in new[] { "/", "/index.html", "/deep-route" }) + { + using HttpResponseMessage response = await client.GetAsync(path, TestContext.Current.CancellationToken); + string policy = response.Headers.GetValues("Content-Security-Policy").Single(); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(html, await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); + Assert.Contains($"'sha256-{hash}'", policy); + Assert.Contains("'strict-dynamic'", policy); + Assert.Contains("connect-src * ws:", policy); + Assert.Contains("upgrade-insecure-requests", policy); + Assert.Contains("object-src 'none'", policy); + Assert.Contains("base-uri 'none'", policy); + Assert.Contains("frame-ancestors 'none'", policy); + Assert.DoesNotContain("'unsafe-eval'", policy); + Assert.DoesNotContain("'unsafe-inline'", policy.Split(';').Single(d => d.TrimStart().StartsWith("script-src ", StringComparison.Ordinal))); + } - Assert.Equal(System.Net.HttpStatusCode.OK, response.StatusCode); - Assert.Equal("no-store", response.Headers.CacheControl?.ToString()); - Assert.Null(response.Headers.ETag); - Assert.Null(response.Content.Headers.LastModified); - Assert.Empty(response.Headers.AcceptRanges); - string body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); - if (method == "HEAD") - Assert.Empty(body); - else - Assert.Contains($"'nonce-{GetScriptNonce(body)}'", response.Headers.GetValues("Content-Security-Policy").Single()); + // A later injected script is not authorized by hashing the response body. + const string injectedScript = "injected()"; + await File.WriteAllTextAsync(Path.Combine(webRoot, "index.html"), html + $"", TestContext.Current.CancellationToken); + using HttpResponseMessage injectedResponse = await client.GetAsync("/index.html", TestContext.Current.CancellationToken); + Assert.DoesNotContain(Convert.ToBase64String(SHA256.HashData(Encoding.UTF8.GetBytes(injectedScript))), injectedResponse.Headers.GetValues("Content-Security-Policy").Single()); + Assert.DoesNotContain("nonce=", await injectedResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); } finally { @@ -52,152 +58,33 @@ public async Task InjectCspNonceAsync_HtmlRequest_PreservesFreshResponse(string } [Fact] - public void AddScriptNonce_NonceTextInsideAttribute_PreservesAttribute() - { - const string html = ""; - - Assert.Equal("", AddNonceToTrustedHtml(html, "new")); - } - - [Theory] - [InlineData("")] - [InlineData("")] - [InlineData("")] - [InlineData("")] - public void AddScriptNonce_ScriptWithExistingNonce_ReplacesNonce(string html) - { - string result = AddNonceToTrustedHtml(html, "new"); - - Assert.Equal(1, CountOccurrences(result, "nonce=\"new\"")); - Assert.DoesNotContain("old", result, StringComparison.Ordinal); - } - - [Fact] - public void AddScriptNonce_QuotedGreaterThanInAttribute_PreservesOpeningTag() - { - const string html = ""; - - string result = AddNonceToTrustedHtml(html, "new"); - - Assert.Equal("", result); - } - - [Fact] - public void AddScriptNonce_InlineScriptContainsScriptLikeText_PreservesContent() - { - const string html = ""; - - string result = AddNonceToTrustedHtml(html, "new"); - - Assert.Equal("", result); - } - - [Fact] - public void AddScriptNonce_SimilarlyNamedAttributes_PreservesAttributes() - { - const string html = ""; - - string result = AddNonceToTrustedHtml(html, "new"); - - Assert.Contains("data-nonce=\"keep\"", result, StringComparison.Ordinal); - Assert.Contains("noncevalue=\"keep\"", result, StringComparison.Ordinal); - Assert.Contains("nonce-value=\"keep\"", result, StringComparison.Ordinal); - Assert.Equal(1, CountOccurrences(result, "nonce=\"new\"")); - } - - [Fact] - public void AddNonce_UntrustedOrModifiedScripts_DoesNotAuthorizeThem() - { - const string trustedHtml = ""; - const string injectedHtml = trustedHtml - + "" - + "" - + "" - + ""; - - string result = AddNonceToTrustedHtml(injectedHtml, "fresh", trustedHtml); - - Assert.StartsWith("", result); - Assert.Equal(2, CountOccurrences(result, "nonce=\"fresh\"")); - Assert.EndsWith(injectedHtml[trustedHtml.Length..], result); - } - - [Fact] - public async Task Configure_IndexAndFallbackRoutes_ServeFreshNoncedHtml() + public async Task Configure_Scalar_UsesFreshNoncesOnlyForItsOwnScripts() { - string webRoot = Path.Combine(Path.GetTempPath(), "Exceptionless-CspResponseTests", Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(Path.Combine(webRoot, "next")); - await File.WriteAllTextAsync(Path.Combine(webRoot, "index.html"), "root", TestContext.Current.CancellationToken); - await File.WriteAllTextAsync(Path.Combine(webRoot, "next", "index.html"), "next", TestContext.Current.CancellationToken); - await File.WriteAllTextAsync(Path.Combine(webRoot, "app.js"), "console.log('static');", TestContext.Current.CancellationToken); - + string webRoot = Path.Combine(Path.GetTempPath(), $"exceptionless-csp-{Guid.NewGuid():N}"); + Directory.CreateDirectory(webRoot); try { using IHost host = await CreatePipelineHostAsync(webRoot); using HttpClient client = host.GetTestClient(); string? previousNonce = null; - (string Path, string Marker)[] routes = - [ - ("/", "root"), - ("/index.html", "root"), - ("/next/", "next"), - ("/next/index.html", "next"), - ("/next/deep-route", "next") - ]; - - foreach ((string path, string marker) in routes) + for (int index = 0; index < 2; index++) { - using HttpResponseMessage response = await client.GetAsync(path, TestContext.Current.CancellationToken); + using HttpResponseMessage response = await client.GetAsync("/docs/", TestContext.Current.CancellationToken); string body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); string policy = response.Headers.GetValues("Content-Security-Policy").Single(); - string nonce = GetScriptNonce(body); - - Assert.Equal(StatusCodes.Status200OK, (int)response.StatusCode); - Assert.Contains(marker, body, StringComparison.Ordinal); - Assert.Equal("no-store", response.Headers.CacheControl?.ToString()); - Assert.Contains($"'nonce-{nonce}'", policy, StringComparison.Ordinal); - Assert.Contains("'strict-dynamic'", policy, StringComparison.Ordinal); + MatchCollection nonces = Regex.Matches(body, "]*\\bnonce=\"(?[^\"]+)\""); + Assert.Equal(3, nonces.Count); + string nonce = WebUtility.HtmlDecode(nonces[0].Groups["nonce"].Value); + Assert.All(nonces, match => Assert.Equal(nonce, WebUtility.HtmlDecode(match.Groups["nonce"].Value))); + Assert.Equal(32, Convert.FromBase64String(nonce).Length); Assert.NotEqual(previousNonce, nonce); + Assert.Contains($"'nonce-{nonce}'", policy); + Assert.Equal("no-store", response.Headers.CacheControl?.ToString()); + Assert.Contains("", body); + Assert.Contains("\"withDefaultFonts\":false", body); + Assert.DoesNotContain("cdn.jsdelivr.net", body); previousNonce = nonce; } - - using HttpResponseMessage staticResponse = await client.GetAsync("/app.js", TestContext.Current.CancellationToken); - Assert.Equal("console.log('static');", await staticResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); - Assert.NotEqual("no-store", staticResponse.Headers.CacheControl?.ToString()); - - using var poisonedRequest = new HttpRequestMessage(HttpMethod.Get, "/next/"); - poisonedRequest.Headers.Host = "untrusted.example"; - poisonedRequest.Headers.Add("X-Forwarded-Host", "forwarded.example"); - poisonedRequest.Headers.Add("X-Forwarded-Proto", "http"); - using HttpResponseMessage poisonedResponse = await client.SendAsync(poisonedRequest, TestContext.Current.CancellationToken); - string poisonedPolicy = poisonedResponse.Headers.GetValues("Content-Security-Policy").Single(); - Assert.Contains("wss://app.example.test", poisonedPolicy, StringComparison.Ordinal); - Assert.DoesNotContain("untrusted.example", poisonedPolicy, StringComparison.Ordinal); - Assert.DoesNotContain("forwarded.example", poisonedPolicy, StringComparison.Ordinal); - - using HttpResponseMessage scalarResponse = await client.GetAsync("/docs/", TestContext.Current.CancellationToken); - string scalarBody = await scalarResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); - string scalarNonce = GetNonceAttribute(scalarBody); - string scalarPolicy = scalarResponse.Headers.GetValues("Content-Security-Policy").Single(); - Assert.Equal("no-store", scalarResponse.Headers.CacheControl?.ToString()); - Assert.Contains($"'nonce-{scalarNonce}'", scalarPolicy, StringComparison.Ordinal); - Assert.Contains("scalar.js", scalarBody, StringComparison.Ordinal); - Assert.Contains("", scalarBody, StringComparison.Ordinal); - MatchCollection scalarScriptNonces = Regex.Matches(scalarBody, "]*\\bnonce=\"(?[^\"]+)\""); - Assert.Equal(3, scalarScriptNonces.Count); - Assert.All(scalarScriptNonces, script => Assert.Equal(scalarNonce, System.Net.WebUtility.HtmlDecode(script.Groups["nonce"].Value))); - Assert.Contains("scalar.aspnetcore.js", scalarBody, StringComparison.Ordinal); - Assert.Contains("\"withDefaultFonts\":false", scalarBody, StringComparison.Ordinal); - Assert.DoesNotContain("cdn.jsdelivr.net", scalarBody, StringComparison.Ordinal); - using HttpResponseMessage scalarScriptResponse = await client.GetAsync("/docs/scalar.js", TestContext.Current.CancellationToken); - Assert.Equal(StatusCodes.Status200OK, (int)scalarScriptResponse.StatusCode); - Assert.StartsWith("text/javascript", scalarScriptResponse.Content.Headers.ContentType?.ToString()); - - using HttpResponseMessage nextScalarResponse = await client.GetAsync("/docs/", TestContext.Current.CancellationToken); - string nextScalarBody = await nextScalarResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); - string nextScalarNonce = GetNonceAttribute(nextScalarBody); - Assert.NotEqual(scalarNonce, nextScalarNonce); - Assert.Contains($"'nonce-{nextScalarNonce}'", nextScalarResponse.Headers.GetValues("Content-Security-Policy").Single()); } finally { @@ -205,161 +92,20 @@ public async Task Configure_IndexAndFallbackRoutes_ServeFreshNoncedHtml() } } - [Fact] - public async Task ConfigureContentSecurityPolicy_DefaultAndScriptDirectives_AreLockedDown() - { - var builder = new CspBuilder(); - FrontendContentSecurityPolicy.Configure(builder); - var options = builder.BuildCspOptions(); - - (_, string policy) = options.ToString(new TestNonceService("policy-nonce")); - string scriptDirective = GetDirective(policy, "script-src"); - Assert.Contains("default-src 'self'", policy, StringComparison.Ordinal); - Assert.Contains("object-src 'none'", policy, StringComparison.Ordinal); - Assert.Contains("base-uri 'none'", policy, StringComparison.Ordinal); - Assert.Contains("frame-ancestors 'none'", policy, StringComparison.Ordinal); - Assert.Contains("'nonce-policy-nonce'", scriptDirective, StringComparison.Ordinal); - Assert.Contains("'strict-dynamic'", scriptDirective, StringComparison.Ordinal); - Assert.DoesNotContain("'unsafe-inline'", scriptDirective, StringComparison.Ordinal); - Assert.DoesNotContain("'unsafe-eval'", scriptDirective, StringComparison.Ordinal); - - var apiContext = new DefaultHttpContext(); - apiContext.Request.Path = "/api/v2/about"; - var sendingHeaderContext = new CspSendingHeaderContext(apiContext); - await options.OnSendingHeader(sendingHeaderContext); - Assert.True(sendingHeaderContext.ShouldNotSend); - } - - [Fact] - public void ConfigureContentSecurityPolicy_DefaultPolicy_MatchesCanonicalCrossRuntimeContract() - { - var builder = new CspBuilder(); - FrontendContentSecurityPolicy.Configure(builder); - (_, string policy) = builder.BuildCspOptions().ToString(new TestNonceService("contract-nonce")); - - IReadOnlyDictionary expected = ReadPolicyContract(); - IReadOnlyDictionary actual = NormalizePolicy(policy); - - Assert.Equal(expected.Keys.Order(), actual.Keys.Order()); - foreach ((string directive, string[] expectedSources) in expected) - Assert.Equal(expectedSources, actual[directive]); - } - - [Fact] - public void ConfigureContentSecurityPolicy_IntercomMessenger_AllowsHttpsAndWebSocketConnections() - { - var builder = new CspBuilder(); - FrontendContentSecurityPolicy.Configure(builder); - (_, string policy) = builder.BuildCspOptions().ToString(new TestNonceService("intercom-nonce")); - - IReadOnlyDictionary directives = NormalizePolicy(policy); - string[] messengerSources = directives["connect-src"] - .Where(source => source.Contains("intercom-messenger.com", StringComparison.Ordinal)) - .ToArray(); - - Assert.Equal(["https://*.intercom-messenger.com", "wss://*.intercom-messenger.com"], messengerSources); - Assert.Contains("'strict-dynamic'", directives["script-src"]); - Assert.DoesNotContain("'unsafe-inline'", directives["script-src"]); - Assert.DoesNotContain("'unsafe-eval'", directives["script-src"]); - } - - [Fact] - public void ConfigureContentSecurityPolicy_ModernSite_ExcludesUnusedVendorSources() - { - var builder = new CspBuilder(); - FrontendContentSecurityPolicy.Configure(builder); - (_, string policy) = builder.BuildCspOptions().ToString(new TestNonceService("modern-nonce")); - - Assert.DoesNotContain("fonts.googleapis.com", policy, StringComparison.Ordinal); - Assert.DoesNotContain("fonts.gstatic.com", policy, StringComparison.Ordinal); - Assert.DoesNotContain("user-images.githubusercontent.com", policy, StringComparison.Ordinal); - IReadOnlyDictionary directives = NormalizePolicy(policy); - Assert.DoesNotContain("https:", directives["connect-src"]); - Assert.DoesNotContain("ws:", directives["connect-src"]); - Assert.DoesNotContain("wss:", directives["connect-src"]); - foreach (string unusedSource in new[] - { - "config.exceptionless.io", "heartbeat.exceptionless.io", "maps.googleapis.com", "cdn.jsdelivr.net", "intercom-sheets.com", "intercom-reporting.com", - "youtube.com", "vimeo.com", "wistia.net", "intercom-attachments-", "uploads.intercom", - "downloads.intercom", "gifs.intercom", "video-messages.intercom", "messenger-apps.intercom", "intercom.help" - }) - Assert.DoesNotContain(unusedSource, policy, StringComparison.Ordinal); - Assert.DoesNotContain("https://*.stripe.com", directives["img-src"]); - Assert.Contains("https://api.stripe.com", directives["connect-src"]); - Assert.Equal(["'self'"], directives["form-action"]); - Assert.Equal(["'self'"], directives["worker-src"]); - } - - [Theory] - [InlineData("https://app.example.test/next/?query=value#fragment", "wss://app.example.test")] - [InlineData("https://app.example.test:8443/next", "wss://app.example.test:8443")] - [InlineData("http://localhost:7110", "ws://localhost:7110")] - [InlineData("http://localhost:80", "ws://localhost")] - [InlineData("https://[::1]:8443/next", "wss://[::1]:8443")] - public void ConfigureContentSecurityPolicy_ConfiguredSite_AddsOnlyItsWebSocketOrigin(string siteBaseUrl, string expectedOrigin) - { - var builder = new CspBuilder(); - FrontendContentSecurityPolicy.Configure(builder, siteBaseUrl); - (_, string policy) = builder.BuildCspOptions().ToString(new TestNonceService("origin-nonce")); - - string[] connections = NormalizePolicy(policy)["connect-src"]; - Assert.Contains(expectedOrigin, connections); - Assert.DoesNotContain("ws:", connections); - Assert.DoesNotContain("wss:", connections); - Assert.DoesNotContain("query=value", policy, StringComparison.Ordinal); - } - - [Theory] - [InlineData("")] - [InlineData("/next")] - [InlineData("ftp://app.example.test")] - [InlineData("https://user:password@app.example.test")] - [InlineData("https://*.example.test")] - public void ConfigureContentSecurityPolicy_InvalidSite_RejectsConfiguration(string siteBaseUrl) - { - Assert.Throws(() => FrontendContentSecurityPolicy.Configure(new CspBuilder(), siteBaseUrl)); - } - - [Fact] - public void AddCsp_SeparateScopes_ProvidesDistinct32ByteNonces() - { - var services = new ServiceCollection(); - services.AddCsp(nonceByteAmount: 32); - using ServiceProvider provider = services.BuildServiceProvider(); - string firstNonce; - - using (IServiceScope firstScope = provider.CreateScope()) - { - var nonceService = firstScope.ServiceProvider.GetRequiredService(); - firstNonce = nonceService.GetNonce(); - Assert.Equal(firstNonce, nonceService.GetNonce()); - Assert.Equal(32, Convert.FromBase64String(firstNonce).Length); - } - - using IServiceScope secondScope = provider.CreateScope(); - string secondNonce = secondScope.ServiceProvider.GetRequiredService().GetNonce(); - Assert.Equal(32, Convert.FromBase64String(secondNonce).Length); - Assert.NotEqual(firstNonce, secondNonce); - } - private static async Task CreatePipelineHostAsync(string webRoot) { IHost host = Host.CreateDefaultBuilder() - .ConfigureWebHost(webBuilder => webBuilder - .UseContentRoot(webRoot) - .UseWebRoot(webRoot) - .UseTestServer() + .ConfigureWebHost(builder => builder.UseContentRoot(webRoot).UseWebRoot(webRoot).UseTestServer() .ConfigureServices(services => { services.AddCsp(nonceByteAmount: 32); - services.AddSingleton(); services.AddRouting(); }) .Configure(app => { - app.UseCsp(csp => FrontendContentSecurityPolicy.Configure(csp, "https://app.example.test/next")); + using var files = new PhysicalFileProvider(webRoot); + app.UseCsp(csp => FrontendContentSecurityPolicy.Configure(csp, files, upgradeInsecureRequests: true)); app.UseDefaultFiles(); - app.Use(Exceptionless.Web.Program.InjectCspNonceAsync); app.UseStaticFiles(); app.UseRouting(); app.UseEndpoints(endpoints => @@ -367,82 +113,11 @@ private static async Task CreatePipelineHostAsync(string webRoot) endpoints.MapScalarApiReference("/docs", (options, context) => options .WithNonce(context.RequestServices.GetRequiredService().GetNonce()) .DisableDefaultFonts() - .AddHeaderContent("")); + .AddHeaderContent("")); endpoints.MapFallback("{**slug:nonfile}", Exceptionless.Web.Program.CreateRequestDelegate(endpoints, "/index.html")); }); - })) - .Build(); - + })).Build(); await host.StartAsync(TestContext.Current.CancellationToken); return host; } - - private static string AddNonceToTrustedHtml(string html, string nonce, string? trustedHtml = null) - { - string webRoot = Path.Combine(Path.GetTempPath(), $"exceptionless-csp-{Guid.NewGuid():N}"); - Directory.CreateDirectory(webRoot); - try - { - File.WriteAllText(Path.Combine(webRoot, "index.html"), trustedHtml ?? html); - using var files = new PhysicalFileProvider(webRoot); - return new FrontendScriptNonces(files).AddNonce(html, nonce); - } - finally - { - Directory.Delete(webRoot, recursive: true); - } - } - - private static int CountOccurrences(string value, string search) - { - return value.Split(search, StringSplitOptions.None).Length - 1; - } - - private static string GetScriptNonce(string html) - { - Assert.Contains("= 0); - nonceStart += noncePrefix.Length; - int nonceEnd = html.IndexOf('"', nonceStart); - Assert.True(nonceEnd > nonceStart); - return System.Net.WebUtility.HtmlDecode(html[nonceStart..nonceEnd]); - } - - private static string GetDirective(string policy, string directiveName) - { - return policy.Split(';').Single(directive => directive.StartsWith(directiveName + " ", StringComparison.Ordinal)); - } - - private static IReadOnlyDictionary NormalizePolicy(string policy) - { - return policy.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) - .Select(directive => directive.Split(' ', StringSplitOptions.RemoveEmptyEntries)) - .ToDictionary( - parts => parts[0], - parts => parts.Skip(1) - .Where(source => !source.StartsWith("'nonce-", StringComparison.Ordinal)) - .Order() - .ToArray()); - } - - private static IReadOnlyDictionary ReadPolicyContract() - { - string contractPath = Path.Combine(AppContext.BaseDirectory, "Security", "frontend-content-security-policy.contract.json"); - var contract = JsonSerializer.Deserialize>(File.ReadAllText(contractPath)); - Assert.NotNull(contract); - - return contract.ToDictionary(entry => entry.Key, entry => entry.Value.Order().ToArray()); - } - - private sealed class TestNonceService(string nonce) : ICspNonceService - { - public string GetNonce() => nonce; - } } From 993dceb82da788b13c4fe132da60e6ce2aebc8a7 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Mon, 5 Oct 2026 22:02:25 -0500 Subject: [PATCH 20/26] Align SPA hosting assertions with the simplified CSP --- .../Exceptionless.Tests/Api/SpaHostingTests.cs | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/tests/Exceptionless.Tests/Api/SpaHostingTests.cs b/tests/Exceptionless.Tests/Api/SpaHostingTests.cs index e8ab812fbe..7c731c2644 100644 --- a/tests/Exceptionless.Tests/Api/SpaHostingTests.cs +++ b/tests/Exceptionless.Tests/Api/SpaHostingTests.cs @@ -72,7 +72,7 @@ public async Task PostAsync_ApplicationRoute_DoesNotReturnShell() } [Fact] - public async Task GetAsync_ConfiguredApiOrigin_AllowsApiAndWebSocketConnections() + public async Task GetAsync_ConfiguredApiOrigin_UsesOpenConnectionsAndStrictScripts() { // Arrange const string apiUrl = "https://localhost:9443/backend?ignored=true"; @@ -89,18 +89,16 @@ 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.Contains("https://localhost:9443", sources); - Assert.Contains("wss://localhost:9443", sources); - Assert.DoesNotContain("*", sources); + Assert.Equal(["connect-src", "*", "ws:"], sources); Assert.DoesNotContain(apiUrl, sources); - // API-origin validation must preserve the existing script compatibility policy. + // Open connections 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); } } From 187b1fe90818cbc4bdcbbbb8bc539f543edd4529 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Mon, 5 Oct 2026 22:08:11 -0500 Subject: [PATCH 21/26] Drop unused blob media permission --- src/Exceptionless.Web/ClientApp/svelte.config.js | 2 +- src/Exceptionless.Web/Security/FrontendContentSecurityPolicy.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Exceptionless.Web/ClientApp/svelte.config.js b/src/Exceptionless.Web/ClientApp/svelte.config.js index 5f6149f481..33bf60a1bf 100644 --- a/src/Exceptionless.Web/ClientApp/svelte.config.js +++ b/src/Exceptionless.Web/ClientApp/svelte.config.js @@ -40,7 +40,7 @@ const config = { 'https://www.gravatar.com' ], 'manifest-src': ['self'], - 'media-src': ['self', 'blob:', 'https://js.intercomcdn.com'], + 'media-src': ['self', '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'], diff --git a/src/Exceptionless.Web/Security/FrontendContentSecurityPolicy.cs b/src/Exceptionless.Web/Security/FrontendContentSecurityPolicy.cs index d032b81698..92e11cabd0 100644 --- a/src/Exceptionless.Web/Security/FrontendContentSecurityPolicy.cs +++ b/src/Exceptionless.Web/Security/FrontendContentSecurityPolicy.cs @@ -36,7 +36,7 @@ public static void Configure(CspBuilder csp, IFileProvider files, bool upgradeIn csp.AllowConnections.ToAnywhere().To("ws:"); csp.AllowFrames.FromSelf().From("https://*.stripe.com") .From("https://link.com").From("https://*.link.com"); - csp.AllowAudioAndVideo.FromSelf().From("blob:").From("https://js.intercomcdn.com"); + csp.AllowAudioAndVideo.FromSelf().From("https://js.intercomcdn.com"); csp.AllowWorkers.FromSelf(); csp.AllowFormActions.ToSelf(); csp.AllowManifest.FromSelf(); From 1a9299c14b48d210f88f228cec809d3fcddc77c6 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Mon, 5 Oct 2026 22:54:09 -0500 Subject: [PATCH 22/26] Clarify Scalar nonce setup and align CSP tests with conventions --- AGENTS.md | 1 + src/Exceptionless.Web/Program.cs | 23 ++- .../Utility/Handlers/CspResponseTests.cs | 155 +++++++++--------- 3 files changed, 91 insertions(+), 88 deletions(-) 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/Program.cs b/src/Exceptionless.Web/Program.cs index 333e632dac..81de1a0d22 100644 --- a/src/Exceptionless.Web/Program.cs +++ b/src/Exceptionless.Web/Program.cs @@ -319,14 +319,7 @@ ApplicationException applicationException when applicationException.Message.Cont } app.MapOpenApi("/docs/v2/openapi.json"); - app.MapScalarApiReference("/docs", (o, context) => - { - o.WithNonce(context.RequestServices.GetRequiredService().GetNonce()) - .DisableDefaultFonts() - .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) @@ -380,7 +373,19 @@ internal static Task WriteProblemDetailsStatusCodeResponseAsync(StatusCodeContex .ExecuteAsync(statusCodeContext.HttpContext); } - internal static RequestDelegate CreateRequestDelegate(IEndpointRouteBuilder endpoints, string filePath) + 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() + .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(); string[] reservedPrefixes = ["/api", "/docs", "/health", "/ready", "/mcp", "/.well-known", "/_app"]; diff --git a/tests/Exceptionless.Tests/Utility/Handlers/CspResponseTests.cs b/tests/Exceptionless.Tests/Utility/Handlers/CspResponseTests.cs index 8a1ccb487f..be46fb3488 100644 --- a/tests/Exceptionless.Tests/Utility/Handlers/CspResponseTests.cs +++ b/tests/Exceptionless.Tests/Utility/Handlers/CspResponseTests.cs @@ -3,8 +3,8 @@ using System.Text; using System.Text.RegularExpressions; using Exceptionless.Web.Security; +using Foundatio.Xunit; using Joonasw.AspNetCore.SecurityHeaders; -using Joonasw.AspNetCore.SecurityHeaders.Csp; using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.FileProviders; using Scalar.AspNetCore; @@ -12,44 +12,37 @@ namespace Exceptionless.Tests.Utility.Handlers; -public sealed class CspResponseTests +public sealed class CspResponseTests(ITestOutputHelper output) : TestWithLoggingBase(output) { [Fact] - public async Task Configure_StaticSpa_TrustsPublishedHashWithoutRewritingResponses() + 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 = $""""""; - await File.WriteAllTextAsync(Path.Combine(webRoot, "index.html"), html, TestContext.Current.CancellationToken); + string injectedHash = Convert.ToBase64String(SHA256.HashData(Encoding.UTF8.GetBytes("injected()"))); + string changedHtml = html + ""; try { - using IHost host = await CreatePipelineHostAsync(webRoot); + await File.WriteAllTextAsync(Path.Combine(webRoot, "index.html"), html, TestContext.Current.CancellationToken); + using IHost host = await CreateMiddlewareHostAsync(webRoot); using HttpClient client = host.GetTestClient(); - foreach (string path in new[] { "/", "/index.html", "/deep-route" }) - { - using HttpResponseMessage response = await client.GetAsync(path, TestContext.Current.CancellationToken); - string policy = response.Headers.GetValues("Content-Security-Policy").Single(); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.Equal(html, await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); - Assert.Contains($"'sha256-{hash}'", policy); - Assert.Contains("'strict-dynamic'", policy); - Assert.Contains("connect-src * ws:", policy); - Assert.Contains("upgrade-insecure-requests", policy); - Assert.Contains("object-src 'none'", policy); - Assert.Contains("base-uri 'none'", policy); - Assert.Contains("frame-ancestors 'none'", policy); - Assert.DoesNotContain("'unsafe-eval'", policy); - Assert.DoesNotContain("'unsafe-inline'", policy.Split(';').Single(d => d.TrimStart().StartsWith("script-src ", StringComparison.Ordinal))); - } + 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(); - // A later injected script is not authorized by hashing the response body. - const string injectedScript = "injected()"; - await File.WriteAllTextAsync(Path.Combine(webRoot, "index.html"), html + $"", TestContext.Current.CancellationToken); - using HttpResponseMessage injectedResponse = await client.GetAsync("/index.html", TestContext.Current.CancellationToken); - Assert.DoesNotContain(Convert.ToBase64String(SHA256.HashData(Encoding.UTF8.GetBytes(injectedScript))), injectedResponse.Headers.GetValues("Content-Security-Policy").Single()); - Assert.DoesNotContain("nonce=", await injectedResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(changedHtml, body); + Assert.Contains($"'sha256-{hash}'", policy); + Assert.DoesNotContain(injectedHash, policy); + Assert.DoesNotContain("nonce=", body); } finally { @@ -58,65 +51,69 @@ public async Task Configure_StaticSpa_TrustsPublishedHashWithoutRewritingRespons } [Fact] - public async Task Configure_Scalar_UsesFreshNoncesOnlyForItsOwnScripts() + public async Task ConfigureScalar_TwoRequests_UsesFreshNoncesOnlyForScalarScripts() { - string webRoot = Path.Combine(Path.GetTempPath(), $"exceptionless-csp-{Guid.NewGuid():N}"); - Directory.CreateDirectory(webRoot); - try - { - using IHost host = await CreatePipelineHostAsync(webRoot); - using HttpClient client = host.GetTestClient(); - string? previousNonce = null; - for (int index = 0; index < 2; index++) - { - using HttpResponseMessage response = await client.GetAsync("/docs/", TestContext.Current.CancellationToken); - string body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); - string policy = response.Headers.GetValues("Content-Security-Policy").Single(); - MatchCollection nonces = Regex.Matches(body, "]*\\bnonce=\"(?[^\"]+)\""); - Assert.Equal(3, nonces.Count); - string nonce = WebUtility.HtmlDecode(nonces[0].Groups["nonce"].Value); - Assert.All(nonces, match => Assert.Equal(nonce, WebUtility.HtmlDecode(match.Groups["nonce"].Value))); - Assert.Equal(32, Convert.FromBase64String(nonce).Length); - Assert.NotEqual(previousNonce, nonce); - Assert.Contains($"'nonce-{nonce}'", policy); - Assert.Equal("no-store", response.Headers.CacheControl?.ToString()); - Assert.Contains("", body); - Assert.Contains("\"withDefaultFonts\":false", body); - Assert.DoesNotContain("cdn.jsdelivr.net", body); - previousNonce = nonce; - } - } - finally - { - Directory.Delete(webRoot, recursive: true); - } + // 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.DoesNotContain("cdn.jsdelivr.net", firstBody); } - private static async Task CreatePipelineHostAsync(string webRoot) + // Exercise the middleware boundary without starting unrelated databases or copying app routing. + private async Task CreateMiddlewareHostAsync(string? webRoot = null) { IHost host = Host.CreateDefaultBuilder() - .ConfigureWebHost(builder => builder.UseContentRoot(webRoot).UseWebRoot(webRoot).UseTestServer() - .ConfigureServices(services => - { - services.AddCsp(nonceByteAmount: 32); - services.AddRouting(); - }) - .Configure(app => - { - using var files = new PhysicalFileProvider(webRoot); - app.UseCsp(csp => FrontendContentSecurityPolicy.Configure(csp, files, upgradeInsecureRequests: true)); - app.UseDefaultFiles(); - app.UseStaticFiles(); - app.UseRouting(); - app.UseEndpoints(endpoints => + .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 => { - endpoints.MapScalarApiReference("/docs", (options, context) => options - .WithNonce(context.RequestServices.GetRequiredService().GetNonce()) - .DisableDefaultFonts() - .AddHeaderContent("")); - endpoints.MapFallback("{**slug:nonfile}", Exceptionless.Web.Program.CreateRequestDelegate(endpoints, "/index.html")); + var environment = app.ApplicationServices.GetRequiredService(); + app.UseCsp(csp => FrontendContentSecurityPolicy.Configure(csp, environment.WebRootFileProvider, upgradeInsecureRequests: true)); + app.UseStaticFiles(); + app.UseRouting(); + app.UseEndpoints(endpoints => endpoints.MapScalarApiReference("/docs", (options, context) => + { + Exceptionless.Web.Program.ConfigureScalar(options, context); + options.AddHeaderContent(""); + })); }); - })).Build(); + }).Build(); await host.StartAsync(TestContext.Current.CancellationToken); return host; } From 461fb8ddb9b000be1d9f2d5eeeec8f93660ddf8d Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Mon, 5 Oct 2026 23:21:44 -0500 Subject: [PATCH 23/26] Restrict backend CSP connections and explain policy sources --- .../ClientApp/src/hooks.client.ts | 3 + src/Exceptionless.Web/Program.cs | 3 +- .../Security/ApiContentSecurityPolicy.cs | 29 +++++++++ .../Security/FrontendContentSecurityPolicy.cs | 61 ++++++++++++++++++- .../Api/ApiContentSecurityPolicyTests.cs | 45 ++++++++++++++ .../Api/SpaHostingTests.cs | 15 +++-- .../Utility/Handlers/CspResponseTests.cs | 30 ++++++++- 7 files changed, 175 insertions(+), 11 deletions(-) create mode 100644 src/Exceptionless.Web/Security/ApiContentSecurityPolicy.cs create mode 100644 tests/Exceptionless.Tests/Api/ApiContentSecurityPolicyTests.cs diff --git a/src/Exceptionless.Web/ClientApp/src/hooks.client.ts b/src/Exceptionless.Web/ClientApp/src/hooks.client.ts index 2093e20c2d..8e7e8c4c9f 100644 --- a/src/Exceptionless.Web/ClientApp/src/hooks.client.ts +++ b/src/Exceptionless.Web/ClientApp/src/hooks.client.ts @@ -12,6 +12,9 @@ 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/Program.cs b/src/Exceptionless.Web/Program.cs index 81de1a0d22..791801c4e3 100644 --- a/src/Exceptionless.Web/Program.cs +++ b/src/Exceptionless.Web/Program.cs @@ -267,7 +267,8 @@ ApplicationException applicationException when applicationException.Message.Cont if (ssl) app.UseHttpsRedirection(); - app.UseCsp(csp => FrontendContentSecurityPolicy.Configure(csp, app.Environment.WebRootFileProvider, options.AppMode != AppMode.Development)); + app.UseCsp(csp => FrontendContentSecurityPolicy.Configure(csp, app.Environment.WebRootFileProvider, + options.AppMode != AppMode.Development, configuration.GetValue("BaseURL"), configuration.GetValue("ApiUrl"))); app.UseSerilogRequestLogging(o => { diff --git a/src/Exceptionless.Web/Security/ApiContentSecurityPolicy.cs b/src/Exceptionless.Web/Security/ApiContentSecurityPolicy.cs new file mode 100644 index 0000000000..5f6b6b2cf8 --- /dev/null +++ b/src/Exceptionless.Web/Security/ApiContentSecurityPolicy.cs @@ -0,0 +1,29 @@ +using Exceptionless.Core.Extensions; +using Joonasw.AspNetCore.SecurityHeaders.Csp.Builder; + +namespace Exceptionless.Web.Security; + +internal static class ApiContentSecurityPolicy +{ + public static void AllowConfiguredOrigins(CspBuilder csp, string? apiUrl) + { + foreach (string origin in GetConfiguredOrigins(apiUrl)) + csp.AllowConnections.To(origin); + } + + internal static string[] GetConfiguredOrigins(string? apiUrl) + { + // Accept only an administrator-configured HTTP origin, never request or forwarded headers. + if (!Uri.TryCreate(apiUrl?.Trim(), UriKind.Absolute, out var uri) || + uri.Scheme is not ("http" or "https") || + !String.IsNullOrEmpty(uri.UserInfo) || + uri.Host.Contains('*') || + uri.HostNameType is not (UriHostNameType.Dns or UriHostNameType.IPv4 or UriHostNameType.IPv6)) + return []; + + var origin = new UriBuilder(uri) { Host = uri.IdnHost, Path = String.Empty, Query = String.Empty, Fragment = String.Empty }; + string httpOrigin = origin.Uri.GetOrigin(); + origin.Scheme = uri.Scheme == "https" ? "wss" : "ws"; + return [httpOrigin, origin.Uri.GetOrigin()]; + } +} diff --git a/src/Exceptionless.Web/Security/FrontendContentSecurityPolicy.cs b/src/Exceptionless.Web/Security/FrontendContentSecurityPolicy.cs index 92e11cabd0..79ad6804c7 100644 --- a/src/Exceptionless.Web/Security/FrontendContentSecurityPolicy.cs +++ b/src/Exceptionless.Web/Security/FrontendContentSecurityPolicy.cs @@ -6,9 +6,17 @@ namespace Exceptionless.Web.Security; internal static partial class FrontendContentSecurityPolicy { - public static void Configure(CspBuilder csp, IFileProvider files, bool upgradeInsecureRequests) + public static void Configure(CspBuilder csp, IFileProvider files, bool upgradeInsecureRequests, string? siteBaseUrl = null, string? apiUrl = null) { + // Same-origin resources are the fallback for directives without their own source list. + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy/default-src csp.ByDefaultAllow.FromSelf(); + + // Nonces/hashes authorize our bootstrap; strict-dynamic trusts scripts it loads. + // Provider host wildcards consolidate Stripe.js and Intercom's script host families. + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy/script-src + // https://docs.stripe.com/security/guide#content-security-policy + // https://www.intercom.com/help/en/articles/3894-using-intercom-with-content-security-policy csp.AllowScripts.FromSelf().AddNonce().WithStrictDynamic() .From("https://*.stripe.com") .From("https://*.intercom.io") @@ -16,6 +24,7 @@ public static void Configure(CspBuilder csp, IFileProvider files, bool upgradeIn // Trust only hashes from the published SPA, never request or response HTML. // SvelteKit generates these for its bootstrap; static responses need no nonce rewriting. + // https://svelte.dev/docs/kit/configuration#csp IFileInfo index = files.GetFileInfo("index.html"); if (index.Exists) { @@ -24,25 +33,71 @@ public static void Configure(CspBuilder csp, IFileProvider files, bool upgradeIn csp.AllowScripts.From(hash.Value); } + // UI style attributes and Scalar/Intercom's injected styles still need inline CSS. + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy/style-src + // https://www.intercom.com/help/en/articles/3894-using-intercom-with-content-security-policy csp.AllowStyles.FromSelf().AllowUnsafeInline(); + + // Local previews, Stripe Link assets, core Intercom assets and user Gravatar images. + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy/img-src + // https://docs.stripe.com/security/guide#content-security-policy + // https://www.intercom.com/help/en/articles/3894-using-intercom-with-content-security-policy + // https://docs.gravatar.com/sdk/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. + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy/font-src + // https://www.intercom.com/help/en/articles/3894-using-intercom-with-content-security-policy csp.AllowFonts.FromSelf().From("https://*.intercomcdn.com"); - // ws: also permits wss:; * alone covers HTTP(S), not WebSockets. - csp.AllowConnections.ToAnywhere().To("ws:"); + // The backend serves a fixed policy; only Vite allows arbitrary environment connections. + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy/connect-src + csp.AllowConnections.ToSelf() + // Browser telemetry to Exceptionless collectors (hooks.client.ts). + // https://exceptionless.com/docs/clients/javascript/ + .To("https://*.exceptionless.io") + // Payment Element uses Stripe.js; Link is enabled by its default payment options. + // https://docs.stripe.com/security/guide#content-security-policy + .To("https://api.stripe.com") + .To("https://link.com").To("https://*.link.com") + // Messenger API/ping and realtime connections; no upload or attachment hosts. + // https://www.intercom.com/help/en/articles/3894-using-intercom-with-content-security-policy + .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. + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy/connect-src + ApiContentSecurityPolicy.AllowConfiguredOrigins(csp, siteBaseUrl); + ApiContentSecurityPolicy.AllowConfiguredOrigins(csp, apiUrl); + + // Stripe Payment Element, 3DS and Link frames; self includes the Scalar request editor. + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy/frame-src + // https://docs.stripe.com/security/guide#content-security-policy csp.AllowFrames.FromSelf().From("https://*.stripe.com") .From("https://link.com").From("https://*.link.com"); + + // Intercom's core messenger sounds; optional video/attachment sources are excluded. + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy/media-src + // https://www.intercom.com/help/en/articles/3894-using-intercom-with-content-security-policy csp.AllowAudioAndVideo.FromSelf().From("https://js.intercomcdn.com"); + + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy/worker-src csp.AllowWorkers.FromSelf(); + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy/form-action csp.AllowFormActions.ToSelf(); + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy/manifest-src csp.AllowManifest.FromSelf(); + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy/object-src csp.AllowPlugins.FromNowhere(); + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy/base-uri csp.AllowBaseUri.FromNowhere(); + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy/frame-ancestors csp.AllowFraming.FromNowhere(); + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy/upgrade-insecure-requests if (upgradeInsecureRequests) csp.SetUpgradeInsecureRequests(); diff --git a/tests/Exceptionless.Tests/Api/ApiContentSecurityPolicyTests.cs b/tests/Exceptionless.Tests/Api/ApiContentSecurityPolicyTests.cs new file mode 100644 index 0000000000..1df1cbcc68 --- /dev/null +++ b/tests/Exceptionless.Tests/Api/ApiContentSecurityPolicyTests.cs @@ -0,0 +1,45 @@ +using Exceptionless.Web.Security; +using Foundatio.Xunit; +using Xunit; + +namespace Exceptionless.Tests.Api; + +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")] + [InlineData("http://api.localhost:8111/backend", "http://api.localhost:8111", "ws://api.localhost:8111")] + [InlineData("HTTPS://API.LOCALHOST:443/backend", "https://api.localhost", "wss://api.localhost")] + [InlineData("http://[::1]:8111/backend", "http://[::1]:8111", "ws://[::1]:8111")] + public void GetConfiguredOrigins_HttpUrl_AllowsOnlyHttpAndWebSocketOrigins(string apiUrl, string httpOrigin, string webSocketOrigin) + { + // Arrange: configuration is supplied by the theory. + + // Act + string[] origins = ApiContentSecurityPolicy.GetConfiguredOrigins(apiUrl); + + // Assert + Assert.Equal([httpOrigin, webSocketOrigin], origins); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("/api")] + [InlineData("//api.localhost")] + [InlineData("ftp://api.localhost")] + [InlineData("wss://api.localhost")] + [InlineData("https://user:password@api.localhost")] + [InlineData("https://*.example.test")] + [InlineData("https://api.localhost;script-src")] + public void GetConfiguredOrigins_InvalidConfiguration_DoesNotExpandPolicy(string? apiUrl) + { + // Arrange: configuration is supplied by the theory. + + // Act + string[] origins = ApiContentSecurityPolicy.GetConfiguredOrigins(apiUrl); + + // Assert + Assert.Empty(origins); + } +} diff --git a/tests/Exceptionless.Tests/Api/SpaHostingTests.cs b/tests/Exceptionless.Tests/Api/SpaHostingTests.cs index 7c731c2644..41dbc1322a 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_UsesOpenConnectionsAndStrictScripts() + public async Task GetAsync_ConfiguredApiOrigin_UsesRestrictedConnectionsAndStrictScripts() { // Arrange const string apiUrl = "https://localhost:9443/backend?ignored=true"; @@ -89,10 +90,14 @@ public async Task GetAsync_ConfiguredApiOrigin_UsesOpenConnectionsAndStrictScrip 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.Equal(["connect-src", "*", "ws:"], 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(apiUrl, sources); - // Open connections must not weaken script execution restrictions. + // 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.DoesNotContain("'unsafe-inline'", scriptSources); diff --git a/tests/Exceptionless.Tests/Utility/Handlers/CspResponseTests.cs b/tests/Exceptionless.Tests/Utility/Handlers/CspResponseTests.cs index be46fb3488..75f53d7777 100644 --- a/tests/Exceptionless.Tests/Utility/Handlers/CspResponseTests.cs +++ b/tests/Exceptionless.Tests/Utility/Handlers/CspResponseTests.cs @@ -86,8 +86,33 @@ public async Task ConfigureScalar_TwoRequests_UsesFreshNoncesOnlyForScalarScript 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) + private async Task CreateMiddlewareHostAsync(string? webRoot = null, string? siteBaseUrl = null, string? apiUrl = null) { IHost host = Host.CreateDefaultBuilder() .ConfigureServices(services => services.AddSingleton(Log)) @@ -104,7 +129,8 @@ private async Task CreateMiddlewareHostAsync(string? webRoot = null) .Configure(app => { var environment = app.ApplicationServices.GetRequiredService(); - app.UseCsp(csp => FrontendContentSecurityPolicy.Configure(csp, environment.WebRootFileProvider, upgradeInsecureRequests: true)); + app.UseCsp(csp => FrontendContentSecurityPolicy.Configure(csp, environment.WebRootFileProvider, + upgradeInsecureRequests: true, siteBaseUrl, apiUrl)); app.UseStaticFiles(); app.UseRouting(); app.UseEndpoints(endpoints => endpoints.MapScalarApiReference("/docs", (options, context) => From dc2be830cc68a5fa773933f0686b5dc92a30e4e8 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Mon, 5 Oct 2026 23:22:39 -0500 Subject: [PATCH 24/26] Clarify the payment frame policy comment --- src/Exceptionless.Web/Security/FrontendContentSecurityPolicy.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Exceptionless.Web/Security/FrontendContentSecurityPolicy.cs b/src/Exceptionless.Web/Security/FrontendContentSecurityPolicy.cs index 79ad6804c7..2f9ddc3cd4 100644 --- a/src/Exceptionless.Web/Security/FrontendContentSecurityPolicy.cs +++ b/src/Exceptionless.Web/Security/FrontendContentSecurityPolicy.cs @@ -74,7 +74,7 @@ public static void Configure(CspBuilder csp, IFileProvider files, bool upgradeIn ApiContentSecurityPolicy.AllowConfiguredOrigins(csp, siteBaseUrl); ApiContentSecurityPolicy.AllowConfiguredOrigins(csp, apiUrl); - // Stripe Payment Element, 3DS and Link frames; self includes the Scalar request editor. + // Stripe Payment Element, 3DS and Link frames. // https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy/frame-src // https://docs.stripe.com/security/guide#content-security-policy csp.AllowFrames.FromSelf().From("https://*.stripe.com") From f4a6048a4e64af2a2da9f9288f0688616bfbd610 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Mon, 5 Oct 2026 23:33:37 -0500 Subject: [PATCH 25/26] Default CSP to deny unspecified resources --- .../ClientApp/svelte.config.js | 2 +- src/Exceptionless.Web/Program.cs | 2 + .../Security/FrontendContentSecurityPolicy.cs | 48 +++++++------------ .../Utility/Handlers/CspResponseTests.cs | 2 + 4 files changed, 22 insertions(+), 32 deletions(-) diff --git a/src/Exceptionless.Web/ClientApp/svelte.config.js b/src/Exceptionless.Web/ClientApp/svelte.config.js index 33bf60a1bf..918bd688bc 100644 --- a/src/Exceptionless.Web/ClientApp/svelte.config.js +++ b/src/Exceptionless.Web/ClientApp/svelte.config.js @@ -25,7 +25,7 @@ const config = { 'base-uri': ['none'], // ws: also permits wss:; * alone covers HTTP(S), not WebSockets. 'connect-src': ['*', 'ws:'], - 'default-src': ['self'], + 'default-src': ['none'], 'font-src': ['self', 'https://*.intercomcdn.com'], 'form-action': ['self'], 'frame-ancestors': ['none'], diff --git a/src/Exceptionless.Web/Program.cs b/src/Exceptionless.Web/Program.cs index 791801c4e3..2db260ac1a 100644 --- a/src/Exceptionless.Web/Program.cs +++ b/src/Exceptionless.Web/Program.cs @@ -381,6 +381,8 @@ internal static void ConfigureScalar(ScalarOptions options, HttpContext context) 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"); diff --git a/src/Exceptionless.Web/Security/FrontendContentSecurityPolicy.cs b/src/Exceptionless.Web/Security/FrontendContentSecurityPolicy.cs index 2f9ddc3cd4..dd4366e091 100644 --- a/src/Exceptionless.Web/Security/FrontendContentSecurityPolicy.cs +++ b/src/Exceptionless.Web/Security/FrontendContentSecurityPolicy.cs @@ -6,25 +6,35 @@ 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 + // 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) { - // Same-origin resources are the fallback for directives without their own source list. - // https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy/default-src - csp.ByDefaultAllow.FromSelf(); + // 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. - // https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy/script-src - // https://docs.stripe.com/security/guide#content-security-policy - // https://www.intercom.com/help/en/articles/3894-using-intercom-with-content-security-policy 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. - // https://svelte.dev/docs/kit/configuration#csp IFileInfo index = files.GetFileInfo("index.html"); if (index.Exists) { @@ -34,70 +44,46 @@ public static void Configure(CspBuilder csp, IFileProvider files, bool upgradeIn } // UI style attributes and Scalar/Intercom's injected styles still need inline CSS. - // https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy/style-src - // https://www.intercom.com/help/en/articles/3894-using-intercom-with-content-security-policy csp.AllowStyles.FromSelf().AllowUnsafeInline(); // Local previews, Stripe Link assets, core Intercom assets and user Gravatar images. - // https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy/img-src - // https://docs.stripe.com/security/guide#content-security-policy - // https://www.intercom.com/help/en/articles/3894-using-intercom-with-content-security-policy - // https://docs.gravatar.com/sdk/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. - // https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy/font-src - // https://www.intercom.com/help/en/articles/3894-using-intercom-with-content-security-policy csp.AllowFonts.FromSelf().From("https://*.intercomcdn.com"); // The backend serves a fixed policy; only Vite allows arbitrary environment connections. - // https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy/connect-src csp.AllowConnections.ToSelf() // Browser telemetry to Exceptionless collectors (hooks.client.ts). - // https://exceptionless.com/docs/clients/javascript/ .To("https://*.exceptionless.io") // Payment Element uses Stripe.js; Link is enabled by its default payment options. - // https://docs.stripe.com/security/guide#content-security-policy .To("https://api.stripe.com") .To("https://link.com").To("https://*.link.com") // Messenger API/ping and realtime connections; no upload or attachment hosts. - // https://www.intercom.com/help/en/articles/3894-using-intercom-with-content-security-policy .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. - // https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy/connect-src ApiContentSecurityPolicy.AllowConfiguredOrigins(csp, siteBaseUrl); ApiContentSecurityPolicy.AllowConfiguredOrigins(csp, apiUrl); // Stripe Payment Element, 3DS and Link frames. - // https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy/frame-src - // https://docs.stripe.com/security/guide#content-security-policy csp.AllowFrames.FromSelf().From("https://*.stripe.com") .From("https://link.com").From("https://*.link.com"); // Intercom's core messenger sounds; optional video/attachment sources are excluded. - // https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy/media-src - // https://www.intercom.com/help/en/articles/3894-using-intercom-with-content-security-policy csp.AllowAudioAndVideo.FromSelf().From("https://js.intercomcdn.com"); - // https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy/worker-src csp.AllowWorkers.FromSelf(); - // https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy/form-action csp.AllowFormActions.ToSelf(); - // https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy/manifest-src csp.AllowManifest.FromSelf(); - // https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy/object-src csp.AllowPlugins.FromNowhere(); - // https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy/base-uri csp.AllowBaseUri.FromNowhere(); - // https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy/frame-ancestors csp.AllowFraming.FromNowhere(); - // https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy/upgrade-insecure-requests if (upgradeInsecureRequests) csp.SetUpgradeInsecureRequests(); diff --git a/tests/Exceptionless.Tests/Utility/Handlers/CspResponseTests.cs b/tests/Exceptionless.Tests/Utility/Handlers/CspResponseTests.cs index 75f53d7777..24c3746b7e 100644 --- a/tests/Exceptionless.Tests/Utility/Handlers/CspResponseTests.cs +++ b/tests/Exceptionless.Tests/Utility/Handlers/CspResponseTests.cs @@ -40,6 +40,7 @@ public async Task Configure_PublishedSpa_TrustsStartupHashWithoutRewritingRespon // 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); @@ -83,6 +84,7 @@ public async Task ConfigureScalar_TwoRequests_UsesFreshNoncesOnlyForScalarScript Assert.Contains("", firstBody); Assert.Contains("", secondBody); Assert.Contains("\"withDefaultFonts\":false", firstBody); + Assert.Contains("\"agent\":{\"disabled\":true}", firstBody); Assert.DoesNotContain("cdn.jsdelivr.net", firstBody); } From 54c5032255ba633f3930e57414c7a737ca0a10a9 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Wed, 7 Oct 2026 19:40:52 -0500 Subject: [PATCH 26/26] Tighten unused frontend CSP sources --- .../ClientApp/svelte.config.js | 11 +++++----- .../Security/FrontendContentSecurityPolicy.cs | 21 +++++++++++++++---- .../Api/SpaHostingTests.cs | 16 ++++++++++++++ 3 files changed, 39 insertions(+), 9 deletions(-) diff --git a/src/Exceptionless.Web/ClientApp/svelte.config.js b/src/Exceptionless.Web/ClientApp/svelte.config.js index 918bd688bc..a4e6ec5a55 100644 --- a/src/Exceptionless.Web/ClientApp/svelte.config.js +++ b/src/Exceptionless.Web/ClientApp/svelte.config.js @@ -20,6 +20,8 @@ const config = { $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'], @@ -28,8 +30,7 @@ const config = { 'default-src': ['none'], 'font-src': ['self', 'https://*.intercomcdn.com'], 'form-action': ['self'], - 'frame-ancestors': ['none'], - 'frame-src': ['self', 'https://*.stripe.com', 'https://link.com', 'https://*.link.com'], + 'frame-src': ['https://*.stripe.com', 'https://link.com', 'https://*.link.com'], 'img-src': [ 'self', 'blob:', @@ -39,13 +40,13 @@ const config = { 'https://static.intercomassets.com', 'https://www.gravatar.com' ], - 'manifest-src': ['self'], - 'media-src': ['self', 'https://js.intercomcdn.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', - 'worker-src': ['self'] + // Explicitly deny workers; without this, worker-src falls back to script-src. + 'worker-src': ['none'] }, mode: 'auto' } diff --git a/src/Exceptionless.Web/Security/FrontendContentSecurityPolicy.cs b/src/Exceptionless.Web/Security/FrontendContentSecurityPolicy.cs index dd4366e091..32db5564ea 100644 --- a/src/Exceptionless.Web/Security/FrontendContentSecurityPolicy.cs +++ b/src/Exceptionless.Web/Security/FrontendContentSecurityPolicy.cs @@ -12,6 +12,16 @@ internal static partial class FrontendContentSecurityPolicy // 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 @@ -22,6 +32,8 @@ internal static partial class FrontendContentSecurityPolicy // 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(); @@ -72,15 +84,16 @@ public static void Configure(CspBuilder csp, IFileProvider files, bool upgradeIn ApiContentSecurityPolicy.AllowConfiguredOrigins(csp, apiUrl); // Stripe Payment Element, 3DS and Link frames. - csp.AllowFrames.FromSelf().From("https://*.stripe.com") + 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.FromSelf().From("https://js.intercomcdn.com"); + csp.AllowAudioAndVideo.From("https://js.intercomcdn.com"); - csp.AllowWorkers.FromSelf(); + // 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.AllowManifest.FromSelf(); csp.AllowPlugins.FromNowhere(); csp.AllowBaseUri.FromNowhere(); csp.AllowFraming.FromNowhere(); diff --git a/tests/Exceptionless.Tests/Api/SpaHostingTests.cs b/tests/Exceptionless.Tests/Api/SpaHostingTests.cs index 41dbc1322a..aa4cc95d5c 100644 --- a/tests/Exceptionless.Tests/Api/SpaHostingTests.cs +++ b/tests/Exceptionless.Tests/Api/SpaHostingTests.cs @@ -97,6 +97,22 @@ public async Task GetAsync_ConfiguredApiOrigin_UsesRestrictedConnectionsAndStric Assert.Contains("wss://localhost:9443", sources); Assert.DoesNotContain(apiUrl, sources); + // 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);