using System.Net;
namespace XFESystem.Browser.Tests;
public sealed class BrowserCspStylePolicyTests
{
// The directive shape returned by Bing, with a fixed test nonce. This is
// deliberately not an origin-specific exception in the browser.
private const string ScriptOnlyPolicy = "script-src https: 'strict-dynamic' 'report-sample' 'wasm-unsafe-eval' 'nonce-gFxVK4F/aqAnFOE4KSUX6OzsblspGZEJdoCE8R5cPbY='; base-uri 'self';";
private const string Body = """
<link rel="stylesheet" href="/Theme.css">
<style>#InlineSheet{color:#345678}</style>
<p id="External">external text</p><p id="InlineSheet">inline sheet text</p>
<p id="InlineAttribute" style="color:#56789a">inline attribute text</p>
<p id="ScriptResult">safe original</p>
<script nonce="gFxVK4F/aqAnFOE4KSUX6OzsblspGZEJdoCE8R5cPbY=">document.getElementById('ScriptResult').textContent='unsafe inline'</script>
<script src="/Blocked.js"></script>
""";
[Theory]
[InlineData(false)]
[InlineData(true)]
public void ScriptOnlyPolicyAllowsExternalAndBothInlineStyleFormsButBlocksAutomaticScripts(bool meta)
{
var (browser, transport) = Open(meta ? null : ScriptOnlyPolicy, meta ? Meta(ScriptOnlyPolicy) : "");
AssertStyled(browser, transport);
Assert.Contains("safe original", browser.CurrentPage.Text, StringComparison.Ordinal);
Assert.DoesNotContain("unsafe inline", browser.CurrentPage.Text, StringComparison.Ordinal);
Assert.DoesNotContain("unsafe external", browser.CurrentPage.Text, StringComparison.Ordinal);
Assert.Contains("Automatic scripts blocked", browser.CurrentPage.Text, StringComparison.Ordinal);
Assert.DoesNotContain(transport.Requests, address => address.EndsWith("Blocked.js", StringComparison.Ordinal));
Assert.False(browser.Capabilities.FullContentSecurityPolicy);
}
[Theory]
[InlineData("default-src 'none'")]
[InlineData("default-src https:")]
[InlineData("style-src 'self'")]
[InlineData("style-src-elem 'unsafe-inline'")]
[InlineData("style-src-attr *")]
[InlineData("STYLE-SRC 'none'")]
public void AnyStyleOrDefaultDirectiveConservativelyBlocksExternalAndInlineStyles(string restriction)
{
var (browser, transport) = Open(ScriptOnlyPolicy + restriction);
AssertBlocked(browser, transport);
}
[Theory]
[InlineData("style-src 'none'", false)]
[InlineData("default-src 'none'", true)]
[InlineData("script-src 'none'; future-policy x", false)]
public void EveryCommaSeparatedHeaderPolicyIntersectsEvenWhenFirstPolicyIsPermissive(string second, bool first)
{
string policies = first ? second + ", " + ScriptOnlyPolicy : ScriptOnlyPolicy + ", " + second;
var (browser, transport) = Open(policies);
AssertBlocked(browser, transport);
}
[Fact]
public void HeaderAndAllMetaPoliciesIntersectInsteadOfOnlyReadingFirstMeta()
{
var (browser, transport) = Open(ScriptOnlyPolicy,
Meta("script-src 'none'") + Meta("style-src 'none'") + Meta(ScriptOnlyPolicy));
AssertBlocked(browser, transport);
(browser, transport) = Open("style-src 'none'", Meta(ScriptOnlyPolicy));
AssertBlocked(browser, transport);
}
[Fact]
public void MultipleNonStyleHeaderAndMetaPoliciesStillAllowStyles()
{
var (browser, transport) = Open(ScriptOnlyPolicy + ", script-src 'none'; object-src 'none'",
Meta("img-src https: data:; font-src 'self'") + Meta("connect-src https://api.example.test; frame-ancestors 'none'"));
AssertStyled(browser, transport);
}
[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData("script-src 'unterminated")]
[InlineData("script-src 'nonce-'")]
[InlineData("script-src 'nonce-a===b'")]
[InlineData("script-src 'unrecognized-keyword'")]
[InlineData("future-directive 'self'")]
[InlineData("script-src https:; sandbox allow-scripts")]
[InlineData("script-src https:\r\n style-src 'none'")]
[InlineData("script-src https:, ")]
[InlineData("script-src https:; report-to")]
public void MalformedOrUnknownHeaderPolicyFailsClosed(string policy)
{
var (browser, transport) = Open(policy);
AssertBlocked(browser, transport);
}
[Theory]
[InlineData("<meta http-equiv='Content-Security-Policy'>")]
[InlineData("<meta http-equiv='Content-Security-Policy' content=''>")]
[InlineData("<meta http-equiv='Content-Security-Policy' content='unknown-directive x'>")]
public void EmptyOrUnknownLaterMetaCannotRelaxHeader(string meta)
{
var (browser, transport) = Open(ScriptOnlyPolicy, meta);
AssertBlocked(browser, transport);
}
[Theory]
[InlineData("base-uri 'none'")]
[InlineData("base-uri 'self'")]
public void UnenforcedBaseUriWithDeclaredBaseCannotFetchStyles(string policy)
{
var (browser, transport) = Open("script-src 'none'; " + policy, "<base href='https://evil.example/'>");
AssertBlocked(browser, transport);
Assert.DoesNotContain(transport.Requests, address => address.Contains("evil.example", StringComparison.Ordinal));
}
[Fact]
public void NavigationReplacesBothCspFlagsWithoutLeakingPreviousStylePermission()
{
var transport = new Fixture(ScriptOnlyPolicy, "");
var browser = new BrowserService(transport);
browser.Navigate("https://page.example/");
AssertStyled(browser, transport);
transport.Policy = "default-src 'none'";
transport.Requests.Clear();
browser.Navigate("https://page.example/");
AssertBlocked(browser, transport);
transport.Policy = null;
transport.Requests.Clear();
browser.Navigate("https://page.example/");
AssertStyled(browser, transport);
Assert.Contains("unsafe external", browser.CurrentPage.Text, StringComparison.Ordinal);
}
private static (BrowserService Browser, Fixture Transport) Open(string? header, string prefix = "")
{
var transport = new Fixture(header, prefix);
var browser = new BrowserService(transport);
browser.Navigate("https://page.example/");
Assert.NotEqual("Navigation failed", browser.CurrentPage.Title);
return (browser, transport);
}
private static void AssertStyled(BrowserService browser, Fixture transport)
{
var commands = browser.Layout(new(600, 400)).Commands;
Assert.Contains(commands, command => command.Kind == BrowserDrawKind.Text && command.Text == "external" && command.Color == 0x123456);
Assert.Contains(commands, command => command.Kind == BrowserDrawKind.Text && command.Text == "sheet" && command.Color == 0x345678);
Assert.Contains(commands, command => command.Kind == BrowserDrawKind.Text && command.Text == "attribute" && command.Color == 0x56789A);
Assert.Single(browser.LoadedResources, resource => resource.Kind == "style");
Assert.Contains("https://page.example/Theme.css", transport.Requests);
}
private static void AssertBlocked(BrowserService browser, Fixture transport)
{
var list = browser.Layout(new(600, 400));
Assert.Single(transport.Requests);
Assert.Empty(browser.LoadedResources);
Assert.DoesNotContain(list.Commands, command => command.Color is 0x123456 or 0x345678 or 0x56789A);
}
private static string Meta(string content) => "<meta http-equiv='Content-Security-Policy' content=\"" + WebUtility.HtmlEncode(content) + "\">";
private sealed class Fixture(string? policy, string prefix) : IWebTransport
{
public string? Policy { get; set; } = policy;
public List<string> Requests { get; } = [];
public WebResponse Get(Uri address, BrowserLimits limits)
{
Requests.Add(address.AbsoluteUri);
return address.AbsolutePath switch
{
"/Theme.css" => new(address, 200, "text/css", "#External{color:#123456}", true),
"/Blocked.js" => new(address, 200, "text/javascript", "document.getElementById('ScriptResult').textContent='unsafe external'", true),
_ => new(address, 200, "text/html", prefix + Body, true, Policy)
};
}
}
}
using System.Net;
namespace XFESystem.Browser.Tests;
public sealed class BrowserCspStylePolicyTests
{
// The directive shape returned by Bing, with a fixed test nonce. This is
// deliberately not an origin-specific exception in the browser.
private const string ScriptOnlyPolicy = "script-src https: 'strict-dynamic' 'report-sample' 'wasm-unsafe-eval' 'nonce-gFxVK4F/aqAnFOE4KSUX6OzsblspGZEJdoCE8R5cPbY='; base-uri 'self';";
private const string Body = """
<link rel="stylesheet" href="/Theme.css">
<style>#InlineSheet{color:#345678}</style>
<p id="External">external text</p><p id="InlineSheet">inline sheet text</p>
<p id="InlineAttribute" style="color:#56789a">inline attribute text</p>
<p id="ScriptResult">safe original</p>
<script nonce="gFxVK4F/aqAnFOE4KSUX6OzsblspGZEJdoCE8R5cPbY=">document.getElementById('ScriptResult').textContent='unsafe inline'</script>
<script src="/Blocked.js"></script>
""";
[Theory]
[InlineData(false)]
[InlineData(true)]
public void ScriptOnlyPolicyAllowsExternalAndBothInlineStyleFormsButBlocksAutomaticScripts(bool meta)
{
var (browser, transport) = Open(meta ? null : ScriptOnlyPolicy, meta ? Meta(ScriptOnlyPolicy) : "");
AssertStyled(browser, transport);
Assert.Contains("safe original", browser.CurrentPage.Text, StringComparison.Ordinal);
Assert.DoesNotContain("unsafe inline", browser.CurrentPage.Text, StringComparison.Ordinal);
Assert.DoesNotContain("unsafe external", browser.CurrentPage.Text, StringComparison.Ordinal);
Assert.Contains("Automatic scripts blocked", browser.CurrentPage.Text, StringComparison.Ordinal);
Assert.DoesNotContain(transport.Requests, address => address.EndsWith("Blocked.js", StringComparison.Ordinal));
Assert.False(browser.Capabilities.FullContentSecurityPolicy);
}
[Theory]
[InlineData("default-src 'none'")]
[InlineData("default-src https:")]
[InlineData("style-src 'self'")]
[InlineData("style-src-elem 'unsafe-inline'")]
[InlineData("style-src-attr *")]
[InlineData("STYLE-SRC 'none'")]
public void AnyStyleOrDefaultDirectiveConservativelyBlocksExternalAndInlineStyles(string restriction)
{
var (browser, transport) = Open(ScriptOnlyPolicy + restriction);
AssertBlocked(browser, transport);
}
[Theory]
[InlineData("style-src 'none'", false)]
[InlineData("default-src 'none'", true)]
[InlineData("script-src 'none'; future-policy x", false)]
public void EveryCommaSeparatedHeaderPolicyIntersectsEvenWhenFirstPolicyIsPermissive(string second, bool first)
{
string policies = first ? second + ", " + ScriptOnlyPolicy : ScriptOnlyPolicy + ", " + second;
var (browser, transport) = Open(policies);
AssertBlocked(browser, transport);
}
[Fact]
public void HeaderAndAllMetaPoliciesIntersectInsteadOfOnlyReadingFirstMeta()
{
var (browser, transport) = Open(ScriptOnlyPolicy,
Meta("script-src 'none'") + Meta("style-src 'none'") + Meta(ScriptOnlyPolicy));
AssertBlocked(browser, transport);
(browser, transport) = Open("style-src 'none'", Meta(ScriptOnlyPolicy));
AssertBlocked(browser, transport);
}
[Fact]
public void MultipleNonStyleHeaderAndMetaPoliciesStillAllowStyles()
{
var (browser, transport) = Open(ScriptOnlyPolicy + ", script-src 'none'; object-src 'none'",
Meta("img-src https: data:; font-src 'self'") + Meta("connect-src https://api.example.test; frame-ancestors 'none'"));
AssertStyled(browser, transport);
}
[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData("script-src 'unterminated")]
[InlineData("script-src 'nonce-'")]
[InlineData("script-src 'nonce-a===b'")]
[InlineData("script-src 'unrecognized-keyword'")]
[InlineData("future-directive 'self'")]
[InlineData("script-src https:; sandbox allow-scripts")]
[InlineData("script-src https:\r\n style-src 'none'")]
[InlineData("script-src https:, ")]
[InlineData("script-src https:; report-to")]
public void MalformedOrUnknownHeaderPolicyFailsClosed(string policy)
{
var (browser, transport) = Open(policy);
AssertBlocked(browser, transport);
}
[Theory]
[InlineData("<meta http-equiv='Content-Security-Policy'>")]
[InlineData("<meta http-equiv='Content-Security-Policy' content=''>")]
[InlineData("<meta http-equiv='Content-Security-Policy' content='unknown-directive x'>")]
public void EmptyOrUnknownLaterMetaCannotRelaxHeader(string meta)
{
var (browser, transport) = Open(ScriptOnlyPolicy, meta);
AssertBlocked(browser, transport);
}
[Theory]
[InlineData("base-uri 'none'")]
[InlineData("base-uri 'self'")]
public void UnenforcedBaseUriWithDeclaredBaseCannotFetchStyles(string policy)
{
var (browser, transport) = Open("script-src 'none'; " + policy, "<base href='https://evil.example/'>");
AssertBlocked(browser, transport);
Assert.DoesNotContain(transport.Requests, address => address.Contains("evil.example", StringComparison.Ordinal));
}
[Fact]
public void NavigationReplacesBothCspFlagsWithoutLeakingPreviousStylePermission()
{
var transport = new Fixture(ScriptOnlyPolicy, "");
var browser = new BrowserService(transport);
browser.Navigate("https://page.example/");
AssertStyled(browser, transport);
transport.Policy = "default-src 'none'";
transport.Requests.Clear();
browser.Navigate("https://page.example/");
AssertBlocked(browser, transport);
transport.Policy = null;
transport.Requests.Clear();
browser.Navigate("https://page.example/");
AssertStyled(browser, transport);
Assert.Contains("unsafe external", browser.CurrentPage.Text, StringComparison.Ordinal);
}
private static (BrowserService Browser, Fixture Transport) Open(string? header, string prefix = "")
{
var transport = new Fixture(header, prefix);
var browser = new BrowserService(transport);
browser.Navigate("https://page.example/");
Assert.NotEqual("Navigation failed", browser.CurrentPage.Title);
return (browser, transport);
}
private static void AssertStyled(BrowserService browser, Fixture transport)
{
var commands = browser.Layout(new(600, 400)).Commands;
Assert.Contains(commands, command => command.Kind == BrowserDrawKind.Text && command.Text == "external" && command.Color == 0x123456);
Assert.Contains(commands, command => command.Kind == BrowserDrawKind.Text && command.Text == "sheet" && command.Color == 0x345678);
Assert.Contains(commands, command => command.Kind == BrowserDrawKind.Text && command.Text == "attribute" && command.Color == 0x56789A);
Assert.Single(browser.LoadedResources, resource => resource.Kind == "style");
Assert.Contains("https://page.example/Theme.css", transport.Requests);
}
private static void AssertBlocked(BrowserService browser, Fixture transport)
{
var list = browser.Layout(new(600, 400));
Assert.Single(transport.Requests);
Assert.Empty(browser.LoadedResources);
Assert.DoesNotContain(list.Commands, command => command.Color is 0x123456 or 0x345678 or 0x56789A);
}
private static string Meta(string content) => "<meta http-equiv='Content-Security-Policy' content=\"" + WebUtility.HtmlEncode(content) + "\">";
private sealed class Fixture(string? policy, string prefix) : IWebTransport
{
public string? Policy { get; set; } = policy;
public List<string> Requests { get; } = [];
public WebResponse Get(Uri address, BrowserLimits limits)
{
Requests.Add(address.AbsoluteUri);
return address.AbsolutePath switch
{
"/Theme.css" => new(address, 200, "text/css", "#External{color:#123456}", true),
"/Blocked.js" => new(address, 200, "text/javascript", "document.getElementById('ScriptResult').textContent='unsafe external'", true),
_ => new(address, 200, "text/html", prefix + Body, true, Policy)
};
}
}
}