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