diff --git a/test/Classes/CspPolicyTests.cs b/test/Classes/CspPolicyTests.cs
new file mode 100644
index 000000000..5454791b9
--- /dev/null
+++ b/test/Classes/CspPolicyTests.cs
@@ -0,0 +1,92 @@
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.StaticFiles;
+using Microsoft.Extensions.FileProviders;
+using Microsoft.Net.Http.Headers;
+using Viper.Classes;
+
+namespace Viper.test.Classes;
+
+/// Built Vue SPA responses must not permit dynamic code evaluation. See CspPolicy.
+public class CspPolicyTests
+{
+ // Shape of the emitted header. Joonasw's CspOptions joins directives with ';' and no space.
+ private const string ApplicationPolicy =
+ "script-src 'self' 'nonce-abc123' 'unsafe-eval';style-src 'self' fonts.googleapis.com 'unsafe-inline';img-src 'self' data:;frame-src 'none'";
+
+ private const string TightenedPolicy =
+ "script-src 'self' 'nonce-abc123';style-src 'self' fonts.googleapis.com 'unsafe-inline';img-src 'self' data:;frame-src 'none'";
+
+ [Fact]
+ public void WithoutUnsafeEval_ApplicationPolicy_DropsOnlyTheAllowance()
+ {
+ Assert.Equal(TightenedPolicy, CspPolicy.WithoutUnsafeEval(ApplicationPolicy));
+ }
+
+ [Theory]
+ [InlineData("script-src 'self' 'unsafe-eval'", "script-src 'self'")]
+ [InlineData("script-src 'unsafe-eval' 'self'", "script-src 'self'")]
+ [InlineData("script-src 'self' 'unsafe-eval' 'nonce-x'", "script-src 'self' 'nonce-x'")]
+ public void WithoutUnsafeEval_HandlesEveryPositionInADirective(string policy, string expected)
+ {
+ Assert.Equal(expected, CspPolicy.WithoutUnsafeEval(policy));
+ }
+
+ [Theory]
+ [InlineData("script-src 'unsafe-eval'", "script-src 'none'")]
+ [InlineData("script-src 'unsafe-eval';img-src 'self'", "script-src 'none';img-src 'self'")]
+ public void WithoutUnsafeEval_SoleSourceExpression_FallsBackToNone(string policy, string expected)
+ {
+ Assert.Equal(expected, CspPolicy.WithoutUnsafeEval(policy));
+ }
+
+ [Fact]
+ public void WithoutUnsafeEval_ValuelessDirective_IsNotGivenASource()
+ {
+ Assert.Equal(
+ "script-src 'self';upgrade-insecure-requests",
+ CspPolicy.WithoutUnsafeEval("script-src 'self' 'unsafe-eval';upgrade-insecure-requests"));
+ }
+
+ [Fact]
+ public void WithoutUnsafeEval_PolicyWithoutTheAllowance_IsUnchanged()
+ {
+ const string policy = "script-src 'self' 'nonce-abc123';img-src 'self'";
+
+ Assert.Equal(policy, CspPolicy.WithoutUnsafeEval(policy));
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ public void WithoutUnsafeEval_MissingHeader_ReturnsEmpty(string? policy)
+ {
+ Assert.Equal(string.Empty, CspPolicy.WithoutUnsafeEval(policy));
+ }
+
+ [Fact]
+ public void TightenForBuiltSpa_RewritesThePolicyOnTheResponse()
+ {
+ var http = new DefaultHttpContext();
+ http.Response.Headers[HeaderNames.ContentSecurityPolicy] = ApplicationPolicy;
+
+ CspPolicy.TightenForBuiltSpa(ResponseContextFor(http));
+
+ Assert.Equal(TightenedPolicy, http.Response.Headers[HeaderNames.ContentSecurityPolicy].ToString());
+ }
+
+ [Fact]
+ public void TightenForBuiltSpa_NoPolicyOnTheResponse_AddsNoHeader()
+ {
+ // The CSP middleware is skipped for HealthChecks UI paths, so the header can be absent.
+ var http = new DefaultHttpContext();
+
+ CspPolicy.TightenForBuiltSpa(ResponseContextFor(http));
+
+ Assert.False(http.Response.Headers.ContainsKey(HeaderNames.ContentSecurityPolicy));
+ }
+
+ private static StaticFileResponseContext ResponseContextFor(HttpContext http)
+ {
+ return new StaticFileResponseContext(http, new NotFoundFileInfo("index.html"));
+ }
+}
diff --git a/web/Classes/CspPolicy.cs b/web/Classes/CspPolicy.cs
new file mode 100644
index 000000000..eb4dc3ebc
--- /dev/null
+++ b/web/Classes/CspPolicy.cs
@@ -0,0 +1,51 @@
+using Microsoft.AspNetCore.StaticFiles;
+using Microsoft.Net.Http.Headers;
+
+namespace Viper.Classes
+{
+ ///
+ /// The app-wide policy keeps 'unsafe-eval': _VIPERLayout Razor pages mount Vue's full build on
+ /// the document body, compiling that in-DOM template via Function(code)(). Dropping it blanks them.
+ ///
+ public static class CspPolicy
+ {
+ private const string UnsafeEval = "'unsafe-eval'";
+
+ /// Drops the allowance from a built SPA response with precompiled templates.
+ public static void TightenForBuiltSpa(StaticFileResponseContext ctx)
+ {
+ var headers = ctx.Context.Response.Headers;
+ if (headers.TryGetValue(HeaderNames.ContentSecurityPolicy, out var policy))
+ {
+ headers[HeaderNames.ContentSecurityPolicy] = WithoutUnsafeEval(policy.ToString());
+ }
+ }
+
+ /// Returns the policy with every 'unsafe-eval' source expression removed.
+ public static string WithoutUnsafeEval(string? headerValue)
+ {
+ return string.IsNullOrEmpty(headerValue)
+ ? string.Empty
+ : string.Join(';', headerValue.Split(';').Select(StripUnsafeEval));
+ }
+
+ private static string StripUnsafeEval(string directive)
+ {
+ // Without this, a valueless directive like upgrade-insecure-requests would gain 'none'.
+ if (!directive.Contains(UnsafeEval, StringComparison.Ordinal))
+ {
+ return directive;
+ }
+
+ string[] kept = directive
+ .Split(' ', StringSplitOptions.RemoveEmptyEntries)
+ .Where(token => !string.Equals(token, UnsafeEval, StringComparison.Ordinal))
+ .ToArray();
+
+ // A bare directive name already matches nothing; 'none' states that explicitly.
+ return kept.Length == 1
+ ? kept[0] + " 'none'"
+ : string.Join(' ', kept);
+ }
+ }
+}
diff --git a/web/Program.cs b/web/Program.cs
index 0b80fcd7c..126ab15c8 100644
--- a/web/Program.cs
+++ b/web/Program.cs
@@ -326,11 +326,12 @@ void RegisterDbContext(string connectionStringKey) where TContext : Db
ctx => !HealthCheckExtensions.IsUIPath(ctx.Request.Path),
branch => branch.UseCsp(csp =>
{
+ // Legacy Razor pages need 'unsafe-eval'; the /2/vue branch below drops it. See CspPolicy.
// Allow JavaScript from:
csp.AllowScripts
.FromSelf() // This domain
.AddNonce() // Inline scripts only with Nonce
- .AllowUnsafeEval(); // allow JS eval command (must also fit within other restrictions)
+ .AllowUnsafeEval();
// Allow connections for WebSocket HMR and legacy systems in development
if (app.Environment.IsDevelopment())
@@ -487,11 +488,14 @@ void RegisterDbContext(string connectionStringKey) where TContext : Db
// Prod (and dev fallback): rewrite SPA routes to the built SPA shell,
// then serve the static file from wwwroot/vue.
branch.UseRewriter(rewriteOptions);
+ // Only the SPA shell reaches here; under /2, assets are served above and keep the
+ // permissive header, which is harmless since CSP on a subresource governs nothing.
branch.UseStaticFiles(new StaticFileOptions
{
FileProvider = new PhysicalFileProvider(
Path.Join(builder.Environment.WebRootPath, "vue")),
- RequestPath = "/2/vue"
+ RequestPath = "/2/vue",
+ OnPrepareResponse = CspPolicy.TightenForBuiltSpa
});
});