Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
b45baf8
Simplify CSP nonce injection
niemyjski Jul 30, 2026
697a1d6
Handle wildcard CSP navigation requests
niemyjski Jul 30, 2026
187e956
Secure SPA scripts with strict CSP
niemyjski Jul 10, 2026
bfee14b
Scope Intercom CSP sources to US
niemyjski Jul 10, 2026
561f519
fix: harden strict CSP response handling
niemyjski Jul 10, 2026
93ca952
Harden and synchronize CSP policies
niemyjski Jul 12, 2026
f2ae0d3
Apply Svelte formatting
niemyjski Jul 12, 2026
126a965
Verify development CSP policy parity
niemyjski Jul 12, 2026
5a0cb3e
Update CSP test fallback to MapFallbackToFile
niemyjski Aug 8, 2026
36f8ed4
Fix CSP fallback integration coverage
niemyjski Aug 12, 2026
991b8ca
Harden CSP response handling and align production coverage
niemyjski Sep 16, 2026
d4fe31c
Verify Intercom messenger CSP connection sources
niemyjski Sep 30, 2026
4bf2100
Scope strict CSP to the modern Svelte frontend
niemyjski Sep 30, 2026
5ae85aa
Preserve empty same-origin CSP configuration
niemyjski Sep 30, 2026
7bfed86
Limit modern frontend CSP to used vendor capabilities
niemyjski Sep 30, 2026
b3086d3
Remove unused telemetry sources and blob worker permission
niemyjski Sep 30, 2026
bc0859a
Drop unused Checkout and Connect image allowance
niemyjski Sep 30, 2026
adeee18
Authorize only trusted frontend scripts and reject partial HTML
niemyjski Oct 2, 2026
ebcac53
Simplify CSP for the single static Svelte frontend
niemyjski Oct 6, 2026
993dceb
Align SPA hosting assertions with the simplified CSP
niemyjski Oct 6, 2026
187b1fe
Drop unused blob media permission
niemyjski Oct 6, 2026
1a9299c
Clarify Scalar nonce setup and align CSP tests with conventions
niemyjski Oct 6, 2026
461fb8d
Restrict backend CSP connections and explain policy sources
niemyjski Oct 6, 2026
dc2be83
Clarify the payment frame policy comment
niemyjski Oct 6, 2026
f4a6048
Default CSP to deny unspecified resources
niemyjski Oct 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions src/Exceptionless.Web/ClientApp/src/hooks.client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ import { normalizePath, normalizeRouteId } from '$lib/telemetry';
import { installSvelteEffectDepthDiagnostics } from '$lib/telemetry/svelte-effect-depth-diagnostics';
import { Exceptionless, guid, toError } from '@exceptionless/browser';
import { useMiddleware } from '@foundatiofx/fetchclient';
import { config } from 'zod';

// Zod's object-validator JIT uses Function(), which strict CSP blocks without unsafe-eval.
// Use its interpreted validator instead of weakening script-src (validation rules are unchanged).
// https://github.com/colinhacks/zod/blob/main/packages/zod/src/v4/core/util.ts
config({ jitless: true });

installSvelteEffectDepthDiagnostics();

Expand Down
30 changes: 30 additions & 0 deletions src/Exceptionless.Web/ClientApp/svelte.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,36 @@ 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: {
'base-uri': ['none'],
// ws: also permits wss:; * alone covers HTTP(S), not WebSockets.
'connect-src': ['*', 'ws:'],
'default-src': ['none'],
'font-src': ['self', 'https://*.intercomcdn.com'],
'form-action': ['self'],
'frame-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', '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'
}
},
preprocess: vitePreprocess()
Expand Down
79 changes: 19 additions & 60 deletions src/Exceptionless.Web/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@
using Exceptionless.Insulation.Configuration;
using Exceptionless.Insulation.Security;
using Exceptionless.Web.Api;
using Exceptionless.Web.Assistant;
using Exceptionless.Web.Api.Results;
using Exceptionless.Web.Assistant;
using Exceptionless.Web.Extensions;
using Exceptionless.Web.Hubs;
using Exceptionless.Web.Mcp;
Expand Down Expand Up @@ -123,6 +123,7 @@ public static async Task<int> 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()
Expand Down Expand Up @@ -266,59 +267,8 @@ ApplicationException applicationException when applicationException.Message.Cont
if (ssl)
app.UseHttpsRedirection();

app.UseCsp(csp =>
{
csp.AllowFonts.FromSelf()
.From("https://fonts.gstatic.com")
.From("https://www.gravatar.com")
.From("https://fonts.intercomcdn.com")
.From("https://cdn.jsdelivr.net");
csp.AllowImages.FromSelf()
.From("data:")
.From("https://q.stripe.com")
.From("https://js.intercomcdn.com")
.From("https://downloads.intercomcdn.com")
.From("https://uploads.intercomcdn.com")
.From("https://static.intercomassets.com")
.From("https://user-images.githubusercontent.com")
.From("https://www.gravatar.com")
.From("http://www.gravatar.com");
csp.AllowScripts.FromSelf()
.AllowUnsafeInline()
.AllowUnsafeEval()
.From("https://js.stripe.com")
.From("https://widget.intercom.io")
.From("https://js.intercomcdn.com")
.From("https://cdn.jsdelivr.net");
csp.AllowStyles.FromSelf()
.AllowUnsafeInline()
.From("https://fonts.googleapis.com")
.From("https://cdn.jsdelivr.net");
csp.AllowConnections.ToSelf()
.To("https://collector.exceptionless.io")
.To("https://config.exceptionless.io")
.To("https://heartbeat.exceptionless.io")
.To("https://via.intercom.io")
.To("https://api.intercom.io")
.To("https://api-iam.intercom.io/")
.To("https://api-ping.intercom.io")
.To("https://*.intercom-messenger.com")
.To("wss://*.intercom-messenger.com")
.To("https://nexus-websocket-a.intercom.io")
.To("wss://nexus-websocket-a.intercom.io")
.To("https://nexus-websocket-b.intercom.io")
.To("wss://nexus-websocket-b.intercom.io")
.To("https://uploads.intercomcdn.com")
.To("https://uploads.intercomusercontent.com");

ApiContentSecurityPolicy.AllowConfiguredOrigins(csp, configuration.GetValue<string>("ApiUrl"));

csp.OnSendingHeader = new Func<CspSendingHeaderContext, Task>(context =>
{
context.ShouldNotSend = context.HttpContext.Request.Path.StartsWithSegments("/api");
return Task.CompletedTask;
});
});
app.UseCsp(csp => FrontendContentSecurityPolicy.Configure(csp, app.Environment.WebRootFileProvider,
options.AppMode != AppMode.Development, configuration.GetValue<string>("BaseURL"), configuration.GetValue<string>("ApiUrl")));

app.UseSerilogRequestLogging(o =>
{
Expand Down Expand Up @@ -370,12 +320,7 @@ ApplicationException applicationException when applicationException.Message.Cont
}

app.MapOpenApi("/docs/v2/openapi.json");
app.MapScalarApiReference("/docs", o =>
{
o.WithOpenApiRoutePattern("/docs/{documentName}/openapi.json")
.AddDocument("v2", "Exceptionless API", "/docs/{documentName}/openapi.json", true)
.AddPreferredSecuritySchemes("Bearer");
});
app.MapScalarApiReference("/docs", ConfigureScalar);
app.MapApiEndpoints();
app.MapGet("/mcp", () => Results.StatusCode(StatusCodes.Status405MethodNotAllowed))
.RequireAuthorization(AuthorizationRoles.McpPolicy)
Expand Down Expand Up @@ -429,6 +374,20 @@ internal static Task WriteProblemDetailsStatusCodeResponseAsync(StatusCodeContex
.ExecuteAsync(statusCodeContext.HttpContext);
}

internal static void ConfigureScalar(ScalarOptions options, HttpContext context)
{
// Resolve per request so the document and CSP header share a fresh nonce.
var nonceService = context.RequestServices.GetRequiredService<ICspNonceService>();
string nonce = nonceService.GetNonce();
options.WithNonce(nonce)
.DisableDefaultFonts()
// The optional AI agent queries Scalar services even without a key on localhost.
.DisableAgent()
.WithOpenApiRoutePattern("/docs/{documentName}/openapi.json")
.AddDocument("v2", "Exceptionless API", "/docs/{documentName}/openapi.json", true)
.AddPreferredSecuritySchemes("Bearer");
}

private static RequestDelegate CreateRequestDelegate(IEndpointRouteBuilder endpoints, string filePath)
{
var app = endpoints.CreateApplicationBuilder();
Expand Down
99 changes: 99 additions & 0 deletions src/Exceptionless.Web/Security/FrontendContentSecurityPolicy.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
using System.Text.RegularExpressions;
using Joonasw.AspNetCore.SecurityHeaders.Csp.Builder;
using Microsoft.Extensions.FileProviders;

namespace Exceptionless.Web.Security;

internal static partial class FrontendContentSecurityPolicy
{
// CSP directives and source syntax:
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy/default-src
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy/script-src
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy/connect-src
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy/style-src
// 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)
{
// Deny resource types unless their directive explicitly allows them.
csp.ByDefaultAllow.FromNowhere();

// Nonces/hashes authorize our bootstrap; strict-dynamic trusts scripts it loads.
// Provider host wildcards consolidate Stripe.js and Intercom's script host families.
csp.AllowScripts.FromSelf().AddNonce().WithStrictDynamic()
.From("https://*.stripe.com")
.From("https://*.intercom.io")
.From("https://js.intercomcdn.com");

// Read once when UseCsp configures the pipeline at startup, never per request.
// Trust only hashes from the published SPA, never request or response HTML.
// SvelteKit generates these for its bootstrap; static responses need no nonce rewriting.
IFileInfo index = files.GetFileInfo("index.html");
if (index.Exists)
{
using var reader = new StreamReader(index.CreateReadStream());
foreach (Match hash in ScriptHashRegex().Matches(reader.ReadToEnd()))
csp.AllowScripts.From(hash.Value);
}

// UI style attributes and Scalar/Intercom's injected styles still need inline CSS.
csp.AllowStyles.FromSelf().AllowUnsafeInline();

// Local previews, Stripe Link assets, core Intercom assets and user Gravatar images.
csp.AllowImages.FromSelf().From("blob:").From("data:")
.From("https://*.link.com")
.From("https://js.intercomcdn.com")
.From("https://static.intercomassets.com")
.From("https://www.gravatar.com");
// Bundled app fonts and Intercom's js/fonts CDN hosts.
csp.AllowFonts.FromSelf().From("https://*.intercomcdn.com");

// The backend serves a fixed policy; only Vite allows arbitrary environment connections.
csp.AllowConnections.ToSelf()
// Browser telemetry to Exceptionless collectors (hooks.client.ts).
.To("https://*.exceptionless.io")
// Payment Element uses Stripe.js; Link is enabled by its default payment options.
.To("https://api.stripe.com")
.To("https://link.com").To("https://*.link.com")
// Messenger API/ping and realtime connections; no upload or attachment hosts.
.To("https://*.intercom.io").To("wss://*.intercom.io")
.To("https://*.intercom-messenger.com").To("wss://*.intercom-messenger.com");
// Explicit configured WebSocket origins cover browsers where 'self' does not match WSS.
// BaseURL/ApiUrl must match the externally served origins, including behind reverse proxies.
// Invalid or missing origins add no sources; request/forwarded headers are never trusted.
ApiContentSecurityPolicy.AllowConfiguredOrigins(csp, siteBaseUrl);
ApiContentSecurityPolicy.AllowConfiguredOrigins(csp, apiUrl);

// Stripe Payment Element, 3DS and Link frames.
csp.AllowFrames.FromSelf().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.AllowWorkers.FromSelf();
csp.AllowFormActions.ToSelf();
csp.AllowManifest.FromSelf();
csp.AllowPlugins.FromNowhere();
csp.AllowBaseUri.FromNowhere();
csp.AllowFraming.FromNowhere();
if (upgradeInsecureRequests)
csp.SetUpgradeInsecureRequests();

csp.OnSendingHeader = context =>
{
context.ShouldNotSend = context.HttpContext.Request.Path.StartsWithSegments("/api");
return Task.CompletedTask;
};
}

[GeneratedRegex("'sha256-[A-Za-z0-9+/]{43}='", RegexOptions.CultureInvariant)]
private static partial Regex ScriptHashRegex();
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
using Exceptionless.Web.Security;
using Foundatio.Xunit;
using Xunit;

namespace Exceptionless.Tests.Api;

public sealed class ApiContentSecurityPolicyTests
public sealed class ApiContentSecurityPolicyTests(ITestOutputHelper output) : TestWithLoggingBase(output)
{
[Theory]
[InlineData(" https://api.localhost:9443/backend?ignored=true#ignored ", "https://api.localhost:9443", "wss://api.localhost:9443")]
Expand Down
23 changes: 13 additions & 10 deletions tests/Exceptionless.Tests/Api/SpaHostingTests.cs
Original file line number Diff line number Diff line change
@@ -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<AppWebHostFactory>
public sealed class SpaHostingTests : TestWithLoggingBase, IClassFixture<AppWebHostFactory>
{
private readonly AppWebHostFactory _factory;

public SpaHostingTests(AppWebHostFactory factory) => _factory = factory;
public SpaHostingTests(ITestOutputHelper output, AppWebHostFactory factory) : base(output) => _factory = factory;

[Theory]
[InlineData("/login")]
Expand Down Expand Up @@ -72,7 +73,7 @@ public async Task PostAsync_ApplicationRoute_DoesNotReturnShell()
}

[Fact]
public async Task GetAsync_ConfiguredApiOrigin_AllowsApiAndWebSocketConnections()
public async Task GetAsync_ConfiguredApiOrigin_UsesRestrictedConnectionsAndStrictScripts()
{
// Arrange
const string apiUrl = "https://localhost:9443/backend?ignored=true";
Expand All @@ -89,18 +90,20 @@ public async Task GetAsync_ConfiguredApiOrigin_AllowsApiAndWebSocketConnections(
string policy = Assert.Single(response.Headers.GetValues("Content-Security-Policy"));
string connections = Assert.Single(policy.Split(';'), directive => directive.StartsWith("connect-src ", StringComparison.Ordinal));
string[] sources = connections.Split(' ', StringSplitOptions.RemoveEmptyEntries);
Assert.Contains("'self'", sources);
Assert.DoesNotContain("*", sources);
Assert.DoesNotContain("ws:", sources);
Assert.DoesNotContain("wss:", sources);
Assert.Contains("https://localhost:9443", sources);
Assert.Contains("wss://localhost:9443", sources);
Assert.DoesNotContain("*", sources);
Assert.DoesNotContain(apiUrl, sources);

// API-origin validation must preserve the existing script compatibility policy.
// Connection configuration must not weaken script execution restrictions.
string scripts = Assert.Single(policy.Split(';'), directive => directive.StartsWith("script-src ", StringComparison.Ordinal));
string[] scriptSources = scripts.Split(' ', StringSplitOptions.RemoveEmptyEntries);
Assert.Contains("'unsafe-inline'", scriptSources);
Assert.Contains("'unsafe-eval'", scriptSources);
Assert.Contains("https://js.stripe.com", scriptSources);
Assert.Contains("https://widget.intercom.io", scriptSources);
Assert.DoesNotContain("'unsafe-inline'", scriptSources);
Assert.DoesNotContain("'unsafe-eval'", scriptSources);
Assert.Contains("'strict-dynamic'", scriptSources);
Assert.Contains("https://*.stripe.com", scriptSources);
Assert.Contains("https://*.intercom.io", scriptSources);
}
}
Loading
Loading