Sends the security headers from the application instead of the proxy
U3. These headers normally come from nginx, but the deployment target does
not allow server configuration, so the application emits them itself. That
changes the failure mode: a bad nginx config fails loudly at reload, while a
middleware that never runs sends nothing and says nothing.
Two policies, defined in code. Strict for /admin, /api/v1 and /health;
relaxed for the public website, which is authored elsewhere by someone who
has never seen this policy. Configuration decides where a policy applies and
which external origins are permitted; it cannot invent a policy that is
subtly permissive.
script-src 'self' under Strict has no 'unsafe-inline' and no 'unsafe-eval',
asserted by a test so that loosening it means deleting a test that says why.
style-src does carry 'unsafe-inline' and cannot not: Radix positions its
overlays with inline style attributes, which nonces cannot reach at all.
Two traps handled explicitly. StartsWithSegments rather than string
StartsWith, because "/administrator".StartsWith("/admin") is true and a
public page would silently lose its inline scripts with no server-side trace.
And all decision logic sits in a static writer rather than in the middleware,
because DefaultHttpContext.Response.OnStarting is a no-op — the obvious
middleware test observes nothing and an assertion that nothing was written
passes for entirely the wrong reason.
An unknown policy name fails ValidateOnStart, so the process exits rather
than quietly serving /admin under the relaxed policy. Origin format is
validated too, beyond what the design asked: a CSP source list silently
ignores a malformed source, so a URL with a path would look configured and
block the script anyway.
BR-U3-22's Umami startup warning is withdrawn (REF-U3-01) — the backend
never sees VITE_UMAMI_WEBSITE_ID. It becomes a blocking CI gate in U5.
Build 0 errors; 315 tests pass, up from 253.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HHoJpxYXzHACSQguHrC5fw
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
using FluentAssertions;
|
||||
using SlpModularCms.Core.Hosting.Security;
|
||||
using Xunit;
|
||||
|
||||
namespace SlpModularCms.Core.Tests.Hosting.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Guards the content of the two policies.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The strict policy's <c>script-src 'self'</c> is the directive that matters: relax it and the
|
||||
/// value of the whole policy collapses. It is asserted here rather than left as a default so
|
||||
/// that loosening it requires deleting a test that says why.
|
||||
/// </remarks>
|
||||
public class CspPolicyCatalogTests
|
||||
{
|
||||
[Fact]
|
||||
public void Strict_ShouldNotAllowInlineOrEvalScript()
|
||||
{
|
||||
var set = CspPolicyCatalog.Build(CspPolicyCatalog.Strict, [], []);
|
||||
|
||||
set.ContentSecurityPolicy.Should().Contain("script-src 'self';");
|
||||
set.ContentSecurityPolicy.Should().NotContain("script-src 'self' 'unsafe-inline'");
|
||||
set.ContentSecurityPolicy.Should().NotContain("'unsafe-eval'");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The one documented exception. Radix UI positions its overlays with inline style
|
||||
/// ATTRIBUTES, which no nonce or hash variant can permit, so this cannot be tightened
|
||||
/// without breaking the admin UI. Bounded to style-src: injected CSS cannot execute.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Strict_ShouldAllowInlineStyle_ButOnlyStyle()
|
||||
{
|
||||
var set = CspPolicyCatalog.Build(CspPolicyCatalog.Strict, [], []);
|
||||
|
||||
set.ContentSecurityPolicy.Should().Contain("style-src 'self' 'unsafe-inline'");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Strict_ShouldDenyFramingEntirely()
|
||||
{
|
||||
var set = CspPolicyCatalog.Build(CspPolicyCatalog.Strict, [], []);
|
||||
|
||||
set.FrameOptions.Should().Be("DENY");
|
||||
set.ContentSecurityPolicy.Should().Contain("frame-ancestors 'none'");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Relaxed_ShouldAllowSameOriginFraming_SoACustomerCanEmbedTheirOwnPages()
|
||||
{
|
||||
var set = CspPolicyCatalog.Build(CspPolicyCatalog.Relaxed, [], []);
|
||||
|
||||
set.FrameOptions.Should().Be("SAMEORIGIN");
|
||||
set.ContentSecurityPolicy.Should().Contain("frame-ancestors 'self'");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// "Relaxed" must not mean "absent": a third-party script still requires its origin to be
|
||||
/// added to configuration, which is what keeps the website policy from being a rubber stamp.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Relaxed_ShouldNotPermitExternalScriptOrigins_ByDefault()
|
||||
{
|
||||
var set = CspPolicyCatalog.Build(CspPolicyCatalog.Relaxed, [], []);
|
||||
|
||||
set.ContentSecurityPolicy.Should().Contain("script-src 'self' 'unsafe-inline';");
|
||||
set.ContentSecurityPolicy.Should().NotContain("https://");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Relaxed_ShouldAddConfiguredScriptOrigins()
|
||||
{
|
||||
var set = CspPolicyCatalog.Build(
|
||||
CspPolicyCatalog.Relaxed,
|
||||
["https://analytics.example.com"],
|
||||
[]);
|
||||
|
||||
set.ContentSecurityPolicy.Should().Contain("script-src 'self' 'unsafe-inline' https://analytics.example.com;");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configured script origins must never reach the admin UI's policy.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Strict_ShouldIgnoreConfiguredScriptOrigins()
|
||||
{
|
||||
var set = CspPolicyCatalog.Build(
|
||||
CspPolicyCatalog.Strict,
|
||||
["https://analytics.example.com"],
|
||||
[]);
|
||||
|
||||
set.ContentSecurityPolicy.Should().NotContain("analytics.example.com");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(CspPolicyCatalog.Strict)]
|
||||
[InlineData(CspPolicyCatalog.Relaxed)]
|
||||
public void BothPolicies_ShouldAddConfiguredConnectOrigins(string policy)
|
||||
{
|
||||
var set = CspPolicyCatalog.Build(policy, [], ["https://api.example.com"]);
|
||||
|
||||
set.ContentSecurityPolicy.Should().Contain("connect-src 'self' https://api.example.com;");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(CspPolicyCatalog.Strict)]
|
||||
[InlineData(CspPolicyCatalog.Relaxed)]
|
||||
public void BothPolicies_ShouldBlockPluginsAndBaseTagInjection(string policy)
|
||||
{
|
||||
var set = CspPolicyCatalog.Build(policy, [], []);
|
||||
|
||||
set.ContentSecurityPolicy.Should().Contain("object-src 'none'");
|
||||
set.ContentSecurityPolicy.Should().Contain("base-uri 'self'");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_ShouldIgnoreBlankAndDuplicateOrigins()
|
||||
{
|
||||
var set = CspPolicyCatalog.Build(
|
||||
CspPolicyCatalog.Relaxed,
|
||||
["https://a.example.com/", " ", "https://A.example.com"],
|
||||
[]);
|
||||
|
||||
set.ContentSecurityPolicy.Should().Contain("script-src 'self' 'unsafe-inline' https://a.example.com;");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_ShouldThrow_ForAnUnknownPolicy()
|
||||
{
|
||||
var act = () => CspPolicyCatalog.Build("Stricct", [], []);
|
||||
|
||||
act.Should().Throw<ArgumentException>();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Strict", true)]
|
||||
[InlineData("strict", true)]
|
||||
[InlineData("Relaxed", true)]
|
||||
[InlineData("Stricct", false)]
|
||||
[InlineData("", false)]
|
||||
[InlineData(null, false)]
|
||||
public void IsKnownPolicy_ShouldRecogniseOnlyTheTwoPolicies(string? policy, bool expected)
|
||||
{
|
||||
CspPolicyCatalog.IsKnownPolicy(policy).Should().Be(expected);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Options;
|
||||
using SlpModularCms.Core.Hosting.Security;
|
||||
using Xunit;
|
||||
|
||||
namespace SlpModularCms.Core.Tests.Hosting.Security;
|
||||
|
||||
public class PathPolicyResolverTests
|
||||
{
|
||||
private static PathPolicyResolver CreateDefault() =>
|
||||
new(Options.Create(new SecurityHeadersOptions()));
|
||||
|
||||
[Theory]
|
||||
[InlineData("/admin")]
|
||||
[InlineData("/admin/")]
|
||||
[InlineData("/admin/dashboard")]
|
||||
[InlineData("/ADMIN/dashboard")]
|
||||
[InlineData("/api/v1/Users")]
|
||||
[InlineData("/health")]
|
||||
public void Resolve_ShouldReturnStrict_ForAdminApiAndHealth(string path)
|
||||
{
|
||||
CreateDefault().Resolve(new PathString(path)).Should().Be(CspPolicyCatalog.Strict);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("/")]
|
||||
[InlineData("/about")]
|
||||
[InlineData("/assets/site.css")]
|
||||
public void Resolve_ShouldReturnRelaxed_ForTheWebsite(string path)
|
||||
{
|
||||
CreateDefault().Resolve(new PathString(path)).Should().Be(CspPolicyCatalog.Relaxed);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The trap this resolver exists to avoid. <c>"/administrator".StartsWith("/admin")</c> is
|
||||
/// true, so a naive prefix check would hand a public page the strict policy and strip its
|
||||
/// inline scripts — a failure that appears only as a broken page and a browser console
|
||||
/// error, with no server-side trace whatsoever.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("/administrator")]
|
||||
[InlineData("/admin-tools")]
|
||||
[InlineData("/administration/contact")]
|
||||
[InlineData("/healthcheck")]
|
||||
[InlineData("/api/v10/Users")]
|
||||
public void Resolve_ShouldNotMatchPartialSegments(string path)
|
||||
{
|
||||
CreateDefault().Resolve(new PathString(path)).Should().Be(CspPolicyCatalog.Relaxed);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rules are evaluated in configured order and the first match wins — not the longest
|
||||
/// prefix. Order in configuration is therefore meaningful, and this asserts it rather than
|
||||
/// leaving readers to infer it.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Resolve_ShouldUseFirstMatch_NotLongestPrefix()
|
||||
{
|
||||
var resolver = new PathPolicyResolver(Options.Create(new SecurityHeadersOptions
|
||||
{
|
||||
DefaultPolicy = CspPolicyCatalog.Strict,
|
||||
PathPolicies =
|
||||
[
|
||||
new PathPolicyRule { PathPrefix = "/admin", Policy = CspPolicyCatalog.Relaxed },
|
||||
new PathPolicyRule { PathPrefix = "/admin/preview", Policy = CspPolicyCatalog.Strict }
|
||||
]
|
||||
}));
|
||||
|
||||
resolver.Resolve(new PathString("/admin/preview")).Should().Be(CspPolicyCatalog.Relaxed);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("admin")]
|
||||
[InlineData("/admin/")]
|
||||
[InlineData(" /admin ")]
|
||||
public void Resolve_ShouldNormalisePrefixShapes(string configuredPrefix)
|
||||
{
|
||||
var resolver = new PathPolicyResolver(Options.Create(new SecurityHeadersOptions
|
||||
{
|
||||
DefaultPolicy = CspPolicyCatalog.Relaxed,
|
||||
PathPolicies = [new PathPolicyRule { PathPrefix = configuredPrefix, Policy = CspPolicyCatalog.Strict }]
|
||||
}));
|
||||
|
||||
resolver.Resolve(new PathString("/admin/dashboard")).Should().Be(CspPolicyCatalog.Strict);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_ShouldFallBackToDefault_WhenNoRulesAreConfigured()
|
||||
{
|
||||
var resolver = new PathPolicyResolver(Options.Create(new SecurityHeadersOptions
|
||||
{
|
||||
DefaultPolicy = CspPolicyCatalog.Relaxed,
|
||||
PathPolicies = []
|
||||
}));
|
||||
|
||||
resolver.Resolve(new PathString("/admin/dashboard")).Should().Be(CspPolicyCatalog.Relaxed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using SlpModularCms.Core.Hosting.Security;
|
||||
using Xunit;
|
||||
|
||||
namespace SlpModularCms.Core.Tests.Hosting.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Covers per-header scoping, HSTS gating and the never-overwrite rule.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Tested against a bare <see cref="HeaderDictionary"/> rather than through the middleware,
|
||||
/// because <c>DefaultHttpContext.Response.OnStarting</c> is a no-op: a middleware-level test
|
||||
/// would observe no headers at all and an assertion that nothing was written would pass for
|
||||
/// entirely the wrong reason.
|
||||
/// </remarks>
|
||||
public class SecurityHeaderWriterTests
|
||||
{
|
||||
private static readonly SecurityHeaderSet StrictSet =
|
||||
CspPolicyCatalog.Build(CspPolicyCatalog.Strict, [], []);
|
||||
|
||||
[Theory]
|
||||
[InlineData("text/html")]
|
||||
[InlineData("text/html; charset=utf-8")]
|
||||
[InlineData("TEXT/HTML; charset=utf-8")]
|
||||
public void Apply_ShouldWriteAllFiveHeaders_ForHtmlResponses(string contentType)
|
||||
{
|
||||
var headers = new HeaderDictionary();
|
||||
|
||||
SecurityHeaderWriter.Apply(headers, contentType, StrictSet, sendHsts: true);
|
||||
|
||||
headers[SecurityHeaderWriter.ContentTypeOptionsHeader].ToString().Should().Be("nosniff");
|
||||
headers[SecurityHeaderWriter.StrictTransportSecurityHeader].ToString().Should().Be(SecurityHeaderWriter.HstsValue);
|
||||
headers[SecurityHeaderWriter.ContentSecurityPolicyHeader].ToString().Should().Be(StrictSet.ContentSecurityPolicy);
|
||||
headers[SecurityHeaderWriter.FrameOptionsHeader].ToString().Should().Be("DENY");
|
||||
headers[SecurityHeaderWriter.ReferrerPolicyHeader].ToString().Should().Be(StrictSet.ReferrerPolicy);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("application/json")]
|
||||
[InlineData("text/javascript")]
|
||||
[InlineData("image/png")]
|
||||
[InlineData("application/xhtml+xml")]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
public void Apply_ShouldWriteOnlyAlwaysHeaders_ForNonHtmlResponses(string? contentType)
|
||||
{
|
||||
var headers = new HeaderDictionary();
|
||||
|
||||
SecurityHeaderWriter.Apply(headers, contentType, StrictSet, sendHsts: true);
|
||||
|
||||
headers.Should().ContainKey(SecurityHeaderWriter.ContentTypeOptionsHeader);
|
||||
headers.Should().ContainKey(SecurityHeaderWriter.StrictTransportSecurityHeader);
|
||||
headers.Should().NotContainKey(SecurityHeaderWriter.ContentSecurityPolicyHeader);
|
||||
headers.Should().NotContainKey(SecurityHeaderWriter.FrameOptionsHeader);
|
||||
headers.Should().NotContainKey(SecurityHeaderWriter.ReferrerPolicyHeader);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// nosniff must reach non-HTML responses in particular — that is the whole point of it.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Apply_ShouldWriteNosniff_ForAScriptFile()
|
||||
{
|
||||
var headers = new HeaderDictionary();
|
||||
|
||||
SecurityHeaderWriter.Apply(headers, "text/javascript", StrictSet, sendHsts: false);
|
||||
|
||||
headers[SecurityHeaderWriter.ContentTypeOptionsHeader].ToString().Should().Be("nosniff");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Apply_ShouldSkipHsts_WhenSendHstsIsFalse()
|
||||
{
|
||||
var headers = new HeaderDictionary();
|
||||
|
||||
SecurityHeaderWriter.Apply(headers, "text/html", StrictSet, sendHsts: false);
|
||||
|
||||
headers.Should().NotContainKey(SecurityHeaderWriter.StrictTransportSecurityHeader);
|
||||
// Every other header still applies in Development, so a CSP violation surfaces there.
|
||||
headers.Should().ContainKey(SecurityHeaderWriter.ContentSecurityPolicyHeader);
|
||||
headers.Should().ContainKey(SecurityHeaderWriter.ContentTypeOptionsHeader);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Apply_ShouldNotOverwrite_HeadersAlreadyPresent()
|
||||
{
|
||||
var headers = new HeaderDictionary
|
||||
{
|
||||
[SecurityHeaderWriter.ReferrerPolicyHeader] = "no-referrer",
|
||||
[SecurityHeaderWriter.FrameOptionsHeader] = "SAMEORIGIN",
|
||||
[SecurityHeaderWriter.ContentTypeOptionsHeader] = "custom"
|
||||
};
|
||||
|
||||
SecurityHeaderWriter.Apply(headers, "text/html", StrictSet, sendHsts: true);
|
||||
|
||||
headers[SecurityHeaderWriter.ReferrerPolicyHeader].ToString().Should().Be("no-referrer");
|
||||
headers[SecurityHeaderWriter.FrameOptionsHeader].ToString().Should().Be("SAMEORIGIN");
|
||||
headers[SecurityHeaderWriter.ContentTypeOptionsHeader].ToString().Should().Be("custom");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Apply_ShouldUseOneYearHsts_WithSubdomains()
|
||||
{
|
||||
var headers = new HeaderDictionary();
|
||||
|
||||
SecurityHeaderWriter.Apply(headers, null, StrictSet, sendHsts: true);
|
||||
|
||||
headers[SecurityHeaderWriter.StrictTransportSecurityHeader].ToString()
|
||||
.Should().Be("max-age=31536000; includeSubDomains");
|
||||
}
|
||||
}
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using SlpModularCms.Core.Hosting.Security;
|
||||
using Xunit;
|
||||
|
||||
namespace SlpModularCms.Core.Tests.Hosting.Security;
|
||||
|
||||
/// <summary>
|
||||
/// A typo in a path-policy mapping must stop a deployment, not quietly serve /admin under the
|
||||
/// relaxed policy. These tests drive the real options pipeline, because the guarantee comes
|
||||
/// from <c>ValidateOnStart</c> rather than from any code that could be tested in isolation.
|
||||
/// </summary>
|
||||
public class SecurityHeadersOptionsValidationTests
|
||||
{
|
||||
private static IServiceProvider BuildProvider(Dictionary<string, string?> settings)
|
||||
{
|
||||
var configuration = new ConfigurationBuilder().AddInMemoryCollection(settings).Build();
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.AddCmsSecurityHeaders(configuration);
|
||||
return services.BuildServiceProvider();
|
||||
}
|
||||
|
||||
private static void Validate(IServiceProvider provider) =>
|
||||
provider.GetRequiredService<IStartupValidator>().Validate();
|
||||
|
||||
[Fact]
|
||||
public void Validation_ShouldPass_WithNoConfigurationAtAll()
|
||||
{
|
||||
// The built-in defaults are the production values: an environment that supplies no
|
||||
// SecurityHeaders section still gets Strict on /admin, /api/v1 and /health.
|
||||
var provider = BuildProvider([]);
|
||||
|
||||
var act = () => Validate(provider);
|
||||
|
||||
act.Should().NotThrow();
|
||||
|
||||
var options = provider.GetRequiredService<IOptions<SecurityHeadersOptions>>().Value;
|
||||
options.Enabled.Should().BeTrue();
|
||||
options.DefaultPolicy.Should().Be(CspPolicyCatalog.Relaxed);
|
||||
options.PathPolicies.Should().HaveCount(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validation_ShouldFail_ForAnUnknownPolicyNameInPathPolicies()
|
||||
{
|
||||
var provider = BuildProvider(new Dictionary<string, string?>
|
||||
{
|
||||
["SecurityHeaders:PathPolicies:0:PathPrefix"] = "/admin",
|
||||
["SecurityHeaders:PathPolicies:0:Policy"] = "Stricct"
|
||||
});
|
||||
|
||||
var act = () => Validate(provider);
|
||||
|
||||
act.Should().Throw<OptionsValidationException>()
|
||||
.WithMessage("*unknown policy name*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validation_ShouldFail_ForAnUnknownDefaultPolicy()
|
||||
{
|
||||
var provider = BuildProvider(new Dictionary<string, string?>
|
||||
{
|
||||
["SecurityHeaders:DefaultPolicy"] = "Permissive"
|
||||
});
|
||||
|
||||
var act = () => Validate(provider);
|
||||
|
||||
act.Should().Throw<OptionsValidationException>()
|
||||
.WithMessage("*DefaultPolicy*");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("analytics.example.com")] // no scheme
|
||||
[InlineData("https://analytics.example.com/script.js")] // has a path
|
||||
[InlineData("htp://analytics.example.com")] // typo in the scheme
|
||||
public void Validation_ShouldFail_ForOriginsThatAreNotSchemeAndHost(string origin)
|
||||
{
|
||||
var provider = BuildProvider(new Dictionary<string, string?>
|
||||
{
|
||||
["SecurityHeaders:AllowedScriptOrigins:0"] = origin
|
||||
});
|
||||
|
||||
var act = () => Validate(provider);
|
||||
|
||||
act.Should().Throw<OptionsValidationException>()
|
||||
.WithMessage("*origins must be absolute scheme-and-host*");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("https://analytics.example.com")]
|
||||
[InlineData("https://analytics.example.com/")]
|
||||
[InlineData("http://localhost:3000")]
|
||||
public void Validation_ShouldPass_ForValidOrigins(string origin)
|
||||
{
|
||||
var provider = BuildProvider(new Dictionary<string, string?>
|
||||
{
|
||||
["SecurityHeaders:AllowedScriptOrigins:0"] = origin
|
||||
});
|
||||
|
||||
var act = () => Validate(provider);
|
||||
|
||||
act.Should().NotThrow();
|
||||
}
|
||||
|
||||
/// <summary>Empty origin lists are a normal state; the policy is simply stricter.</summary>
|
||||
[Fact]
|
||||
public void Provider_ShouldComposeBothPolicies_WithNoOriginsConfigured()
|
||||
{
|
||||
var provider = BuildProvider([]);
|
||||
|
||||
var policyProvider = provider.GetRequiredService<ICspPolicyProvider>();
|
||||
|
||||
policyProvider.Get(CspPolicyCatalog.Strict).ContentSecurityPolicy.Should().Contain("script-src 'self';");
|
||||
policyProvider.Get(CspPolicyCatalog.Relaxed).FrameOptions.Should().Be("SAMEORIGIN");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Provider_ShouldThrow_ForAnUnknownPolicyName()
|
||||
{
|
||||
var provider = BuildProvider([]);
|
||||
|
||||
var act = () => provider.GetRequiredService<ICspPolicyProvider>().Get("Stricct");
|
||||
|
||||
act.Should().Throw<ArgumentException>();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user