///|
/// A parsed Content-Security-Policy header.
pub struct Policy {
directives : Array[Directive]
}
///|
/// One CSP directive such as `script-src 'self' https:`.
pub struct Directive {
name : String
sources : Array[Source]
}
///|
/// A source expression inside a CSP directive.
pub enum Source {
None
Self
UnsafeInline
UnsafeEval
StrictDynamic
UnsafeHashes
ReportSample
WasmUnsafeEval
Host(String)
Scheme(String)
Nonce(String)
Hash(String, String)
Keyword(String)
} derive(Eq, Debug)
///|
/// A resource request used by the violation checker.
pub struct Request {
kind : String
url : String
origin : String
nonce : String?
hash_sha256 : String?
}
///|
/// Build a checker request.
pub fn request(
kind : String,
url : String,
origin : String,
nonce : String?,
hash_sha256 : String?,
) -> Request {
{ kind, url, origin, nonce, hash_sha256, }
}
///|
/// Parse a CSP header into directives and source expressions.
pub fn parse(input : String) -> Result[Policy, String] {
let trimmed = input.trim().to_owned()
if trimmed == "" {
return Err("csp.empty")
}
let dirs : Array[Directive] = []
for chunk in trimmed.split(";") {
let item = chunk.trim().to_owned()
if item == "" {
continue
}
let tokens = split_ws(item)
if tokens.length() == 0 {
continue
}
let name = tokens[0].to_lower()
if !valid_name(name) {
return Err("csp.name")
}
let sources : Array[Source] = []
for i = 1; i < tokens.length(); i = i + 1 {
match parse_source(tokens[i]) {
Ok(src) => sources.push(src)
Err(e) => return Err(e)
}
}
dirs.push({ name, sources, })
}
if dirs.length() == 0 {
return Err("csp.empty")
}
Ok({ directives: dirs, })
}
///|
/// Parse or return a diagnostic explanation.
pub fn parse_or_error(input : String) -> String {
match parse(input) {
Ok(policy) => Policy::to_string(policy)
Err(e) => explain(e)
}
}
///|
/// Directives in source order.
pub fn Policy::directives(self : Policy) -> Array[Directive] {
self.directives
}
///|
/// Look up the first directive by case-insensitive name.
pub fn Policy::names(self : Policy) -> Array[String] {
let out : Array[String] = []
for d in self.directives {
out.push(d.name)
}
out
}
///|
pub fn Policy::directive(self : Policy, name : String) -> Directive? {
let wanted = name.to_lower()
for d in self.directives {
if d.name == wanted {
return Some(d)
}
}
None
}
///|
/// Canonical serialization: names and keywords lower-cased, order preserved.
pub fn Policy::to_string(self : Policy) -> String {
let mut out = ""
let mut first_dir = true
for d in self.directives {
if first_dir {
first_dir = false
} else {
out = out + "; "
}
out = out + d.name
for s in d.sources {
out = out + " " + source_to_string(s)
}
}
out
}
///|
/// Whether a request is allowed by this policy.
pub fn Policy::allows(self : Policy, request : Request) -> Bool {
decide(self, request) == "allow"
}
///|
/// Return `allow` or a stable violation code.
pub fn decide(policy : Policy, request : Request) -> String {
let kind = request.kind.to_lower()
if !valid_kind(kind) {
return "csp.kind"
}
match effective_directive(policy, kind) {
None => "allow"
Some(d) => check_directive(d, request)
}
}
///|
fn effective_directive(policy : Policy, kind : String) -> Directive? {
match Policy::directive(policy, kind + "-src") {
Some(d) => Some(d)
None => Policy::directive(policy, "default-src")
}
}
///|
fn check_directive(d : Directive, request : Request) -> String {
if d.sources.length() == 0 {
return "csp.blocked"
}
for s in d.sources {
match s {
Source::None => return "csp.none"
_ => ()
}
}
match request.nonce {
Some(n) =>
for s in d.sources {
match s {
Source::Nonce(v) => if v == n { return "allow" }
_ => ()
}
}
None => ()
}
match request.hash_sha256 {
Some(h) =>
for s in d.sources {
match s {
Source::Hash(algo, v) =>
if algo == "sha256" && v == h {
return "allow"
}
_ => ()
}
}
None => ()
}
if has_strict_dynamic(d) {
return "csp.strict-dynamic"
}
for s in d.sources {
if source_matches_url(s, request.url, request.origin) {
return "allow"
}
}
"csp.blocked"
}
///|
fn has_strict_dynamic(d : Directive) -> Bool {
for s in d.sources {
match s {
Source::StrictDynamic => return true
_ => ()
}
}
false
}
///|
fn source_matches_url(s : Source, url : String, origin : String) -> Bool {
match s {
Source::Self => url_host(url) != "" && url_host(url) == url_host(origin)
Source::Scheme(scheme) => url_scheme(url) == scheme
Source::Host(host) => host_matches(host, url)
_ => false
}
}
///|
fn url_scheme(url : String) -> String {
match url.find(":") {
Some(i) => url.sub(end=i).to_owned().to_lower()
None => ""
}
}
///|
fn host_matches(pattern : String, url : String) -> Bool {
let host = url_host(url)
if host == "" {
return false
}
if pattern.has_prefix("*.") {
let suffix = pattern.sub(start=1).to_owned()
host.has_suffix(suffix)
} else {
host == pattern || host.has_suffix("." + pattern)
}
}
///|
fn url_host(url : String) -> String {
let rest = match url.find("://") {
Some(i) => url.sub(start=i + 3).to_owned()
None => url
}
let authority = match rest.find("/") {
Some(i) => rest.sub(end=i).to_owned()
None => rest
}
let hostport = match authority.find("@") {
Some(i) => authority.sub(start=i + 1).to_owned()
None => authority
}
let host = match hostport.find(":") {
Some(i) => hostport.sub(end=i).to_owned()
None => hostport
}
host.to_lower()
}
///|
fn parse_source(token : String) -> Result[Source, String] {
if token == "" {
return Err("csp.source")
}
if token.has_prefix("'") {
if token.length() < 2 || !token.has_suffix("'") {
return Err("csp.quote")
}
let inner = token.sub(start=1, end=token.length() - 1).to_owned().to_lower()
if inner == "none" {
return Ok(Source::None)
}
if inner == "self" {
return Ok(Source::Self)
}
if inner == "unsafe-inline" {
return Ok(Source::UnsafeInline)
}
if inner == "unsafe-eval" {
return Ok(Source::UnsafeEval)
}
if inner == "strict-dynamic" {
return Ok(Source::StrictDynamic)
}
if inner == "unsafe-hashes" {
return Ok(Source::UnsafeHashes)
}
if inner == "report-sample" {
return Ok(Source::ReportSample)
}
if inner == "wasm-unsafe-eval" {
return Ok(Source::WasmUnsafeEval)
}
if inner.has_prefix("nonce-") {
let n = inner.sub(start=6).to_owned()
if n == "" {
return Err("csp.nonce")
}
return Ok(Source::Nonce(n))
}
if inner.has_prefix("sha256-") ||
inner.has_prefix("sha384-") ||
inner.has_prefix("sha512-") {
match inner.find("-") {
Some(dash) => {
let algo = inner.sub(end=dash).to_owned()
let value = inner.sub(start=dash + 1).to_owned()
if value == "" {
return Err("csp.hash")
}
return Ok(Source::Hash(algo, value))
}
None => return Err("csp.hash")
}
}
return Ok(Source::Keyword(inner))
}
if token.has_suffix(":") &&
url_scheme(token.sub(end=token.length() - 1).to_owned() + "://x") ==
token.sub(end=token.length() - 1).to_owned().to_lower() {
let scheme = token.sub(end=token.length() - 1).to_owned().to_lower()
if scheme == "" {
return Err("csp.scheme")
}
if !scheme.contains("/") {
return Ok(Source::Scheme(scheme))
}
}
if token.has_suffix(":") && !token.contains("/") {
let scheme = token.sub(end=token.length() - 1).to_owned().to_lower()
if scheme == "" {
return Err("csp.scheme")
}
return Ok(Source::Scheme(scheme))
}
Ok(Source::Host(token.to_lower()))
}
///|
fn source_to_string(s : Source) -> String {
match s {
Source::None => "'none'"
Source::Self => "'self'"
Source::UnsafeInline => "'unsafe-inline'"
Source::UnsafeEval => "'unsafe-eval'"
Source::StrictDynamic => "'strict-dynamic'"
Source::UnsafeHashes => "'unsafe-hashes'"
Source::ReportSample => "'report-sample'"
Source::WasmUnsafeEval => "'wasm-unsafe-eval'"
Source::Host(h) => h
Source::Scheme(sc) => sc + ":"
Source::Nonce(n) => "'nonce-" + n + "'"
Source::Hash(algo, v) => "'" + algo + "-" + v + "'"
Source::Keyword(k) => "'" + k + "'"
}
}
///|
fn split_ws(s : String) -> Array[String] {
let out : Array[String] = []
let mut acc = ""
fn flush() {
if acc != "" {
out.push(acc)
acc = ""
}
}
for c in s {
if c == ' ' || c == '\t' || c == '\n' || c == '\r' {
flush()
} else {
acc = acc + c.to_string()
}
}
flush()
out
}
///|
fn valid_name(s : String) -> Bool {
if s == "" {
return false
}
for c in s {
if !(is_alnum(c) || c == '-') {
return false
}
}
true
}
///|
fn is_alnum(c : Char) -> Bool {
(c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9')
}
///|
fn valid_kind(s : String) -> Bool {
s == "script" ||
s == "style" ||
s == "img" ||
s == "connect" ||
s == "font" ||
s == "frame" ||
s == "media" ||
s == "object" ||
s == "default"
}
///|
/// Explain parser and checker diagnostics.
pub fn explain(error : String) -> String {
match error {
"csp.empty" => "policy is empty"
"csp.name" => "directive name is invalid"
"csp.source" => "source expression is empty"
"csp.quote" => "quoted source is unterminated"
"csp.nonce" => "nonce source is missing a value"
"csp.hash" => "hash source is missing a value"
"csp.scheme" => "scheme source is empty"
"csp.kind" => "request kind is not supported"
"csp.none" => "directive contains 'none'"
"csp.blocked" => "request is not allowed by the effective directive"
"csp.strict-dynamic" => "strict-dynamic requires a matching nonce or hash"
"allow" => "request is allowed"
_ => "invalid content-security-policy"
}
}