using AngleSharp.Dom;
namespace XFESystem.Browser;
///
/// This is not a general CSP implementation. Author styles are allowed only
/// when every understood policy leaves them unrestricted; automatic scripts
/// remain disabled whenever any CSP is declared.
///
internal static class BrowserCspStylePolicy
{
internal readonly record struct Decision(bool Declared, bool BlockAuthorStyles);
public static Decision Evaluate(string? header, IDocument document)
{
bool declared = header is not null, block = false;
bool hasBase = document.QuerySelector("base[href]") is not null;
if (header is not null) block = !LeavesStylesUnrestricted(header, hasBase);
foreach (var meta in document.QuerySelectorAll("meta"))
{
if (!string.Equals(meta.GetAttribute("http-equiv"), "Content-Security-Policy", StringComparison.OrdinalIgnoreCase)) continue;
declared = true;
// All policies apply. A later permissive meta cannot undo a header
// or an earlier meta, and a missing content value is not trusted.
if (!LeavesStylesUnrestricted(meta.GetAttribute("content") ?? "", hasBase)) block = true;
}
return new(declared, block);
}
private static bool LeavesStylesUnrestricted(string policies, bool hasBase)
{
if (policies.Length is 0 or > 65_536) return false;
foreach (char c in policies)
if (c != '\t' && (c < ' ' || c > '~')) return false;
// A comma separates independently enforced header policies. Rejecting
// ambiguous commas in source values is intentional in this profile.
var policyList = policies.Split(',');
if (policyList.Length > 64) return false;
foreach (string policy in policyList)
{
bool hasDirective = false;
var directives = policy.Split(';');
if (directives.Length > 128) return false;
foreach (string directive in directives)
{
var tokens = directive.Split([' ', '\t'], StringSplitOptions.RemoveEmptyEntries);
if (tokens.Length == 0) continue;
hasDirective = true;
string name = tokens[0].ToLowerInvariant();
if (name is "default-src" or "style-src" or "style-src-elem" or "style-src-attr") return false;
bool script = name is "script-src" or "script-src-elem" or "script-src-attr";
bool sourceList = script || name is "base-uri" or "child-src" or "connect-src" or "font-src" or
"frame-src" or "img-src" or "manifest-src" or "media-src" or "object-src" or "worker-src" or
"frame-ancestors" or "form-action";
if (sourceList)
{
// ResourceAddress does not yet enforce base-uri. Do not
// resolve a stylesheet against an unverified .
if (name == "base-uri" && hasBase) return false;
for (int i = 1; i < tokens.Length; i++)
if (!ValidSource(tokens[i], script)) return false;
}
else if (name == "report-uri")
{
if (tokens.Length < 2) return false;
for (int i = 1; i < tokens.Length; i++)
if (!Uri.TryCreate(tokens[i], UriKind.RelativeOrAbsolute, out _) || tokens[i].Contains('\'')) return false;
}
else if (name == "report-to")
{
if (tokens.Length != 2 || !tokens[1].All(c => char.IsAsciiLetterOrDigit(c) || c is '-' or '_' or '.')) return false;
}
else return false; // Unknown semantics cannot relax the profile.
}
if (!hasDirective) return false;
}
return true;
}
private static bool ValidSource(string token, bool script)
{
if (token.Length is 0 or > 2_048) return false;
if (token[0] == '\'')
{
if (token.Length < 3 || token[^1] != '\'') return false;
string value = token[1..^1];
if (value is "self" or "none") return true;
if (!script) return false;
if (value is "strict-dynamic" or "report-sample" or "unsafe-inline" or "unsafe-eval" or
"wasm-unsafe-eval" or "unsafe-hashes" or "inline-speculation-rules") return true;
int prefix = value.StartsWith("nonce-", StringComparison.Ordinal) ? 6 :
value.StartsWith("sha256-", StringComparison.Ordinal) || value.StartsWith("sha384-", StringComparison.Ordinal) ||
value.StartsWith("sha512-", StringComparison.Ordinal) ? 7 : 0;
if (prefix == 0 || value.Length == prefix) return false;
int padding = 0;
foreach (char c in value.AsSpan(prefix))
{
if (c == '=') { if (++padding > 2) return false; }
else if (padding != 0 || !(char.IsAsciiLetterOrDigit(c) || c is '+' or '/' or '-' or '_')) return false;
}
return true;
}
if (token.Contains('\'') || token.Contains('"') || token.Contains('\\') || token.Contains('?') || token.Contains('#')) return false;
if (token == "*") return true;
if (token.EndsWith(':') && !token.Contains('/'))
return ValidScheme(token.AsSpan(0, token.Length - 1));
// Parse a bounded host-source, including CSP wildcard hosts/ports.
string host = token;
int scheme = host.IndexOf("://", StringComparison.Ordinal);
if (scheme >= 0)
{
if (!ValidScheme(host.AsSpan(0, scheme))) return false;
host = host[(scheme + 3)..];
}
int slash = host.IndexOf('/');
string authority = slash < 0 ? host : host[..slash];
if (authority.Length == 0 || authority.Contains('@')) return false;
int colon = authority.LastIndexOf(':');
if (colon >= 0)
{
string port = authority[(colon + 1)..];
if (port != "*" && (!int.TryParse(port, out int number) || number is < 0 or > 65_535)) return false;
authority = authority[..colon];
}
if (authority.StartsWith("*.", StringComparison.Ordinal)) authority = authority[2..];
if (authority.Length == 0 || !authority.All(c => char.IsAsciiLetterOrDigit(c) || c is '-' or '.')) return false;
return slash < 0 || host.AsSpan(slash).IndexOfAny('\'', '"', '*') < 0;
}
private static bool ValidScheme(ReadOnlySpan value)
{
if (value.IsEmpty || !char.IsAsciiLetter(value[0])) return false;
foreach (char c in value)
if (!(char.IsAsciiLetterOrDigit(c) || c is '+' or '-' or '.')) return false;
return true;
}
}