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 }); });