Security

Security headers

A response's security headers graded A+ to F: HSTS, CSP, framing, sniffing, referrers, permissions, cookies' flags, CORS and version leaks, each with its fix; and a good set to send.

Version 1.0.0 · 2 functions · 1 crate

#headers#security#http#cookies

Functions

What use security_headers.qwer gives a program, with the types Quillo sees. To take only some, or under other names: use security_headers_check, security_headers_recommended from security_headers (Quillo 0.6).

security_headers_check(headers: map, https: bool) → any

Grades a response's headers (a map of names to values, any case; several Set-Cookie headers joined by line breaks): { grade, score, findings: [{ header, severity, message, fix }], missing }. https says whether the site is served over HTTPS, where HSTS counts. Severity is "high", "medium", "low" or "info"; the score starts at 100 and each finding takes some off.

Get it

$ quillo install security_headers               # security_headers.qwer into this folder$ quillo install security_headers --example     # and its example, security_headers.qwe

Or with curl:

$ curl -O https://quillolang.org/plugins/files/security_headers.qwer$ curl -O https://quillolang.org/plugins/files/security_headers.qwe

Then, in your program, all of it or only what you call:

main.qwe
use security_headers.qweruse security_headers_check, security_headers_recommended from security_headers      // Quillo 0.6: only these

It uses this crate, which cargo fetches the first time the plugin is built:

[dependencies]
serde_json = { version = "1", features = ["preserve_order"] }

Example

security_headers.qwe
// The headers of an old PHP site graded, and this app's own answers sent with a good set.////   quillo run security_headers.qwe      then open http://localhost:8080/gradeuse "security_headers.qwer"let HEADERS = security_headers_recommended({ hsts_preload: true })// Every page of this app carries the recommended headers.route GET / = respond(200, "Hello, safely.", { ...HEADERS, "content-type": "text/plain" })// What curl -I https://legacy.example.com printed.let LEGACY = {    "Server": "Apache/2.4.29 (Ubuntu)",    "X-Powered-By": "PHP/7.2.24",    "Strict-Transport-Security": "max-age=3600",    "X-XSS-Protection": "1; mode=block",    "Referrer-Policy": "unsafe-url",    "Set-Cookie": "PHPSESSID=8d1f0c2b; path=/\ntheme=dark; Path=/; Secure; HttpOnly; SameSite=Lax",}route GET /grade = {    legacy: security_headers_check(LEGACY, true),    ours: security_headers_check(HEADERS, true),}route GET /headers = HEADERS
$ quillo run security_headers.qwe$ curl 'localhost:8080/grade'$ curl 'localhost:8080/headers'

Source

security_headers.qwer
//! A response's security headers graded A+ to F, as securityheaders.com and Mozilla Observatory do: HSTS, CSP, framing, sniffing, referrers, permissions, cross-origin isolation, cookies' flags, CORS and version leaks, each finding with its fix; and a good set to start from.//! version: 1.0.0//!//! [dependencies]//! serde_json = { version = "1", features = ["preserve_order"] }use serde_json::{json, Map, Value};use std::collections::HashMap;struct Findings(Vec<Value>);impl Findings {    fn add(&mut self, header: &str, severity: &str, message: &str, fix: &str) {        self.0.push(json!({ "header": header, "severity": severity, "message": message, "fix": fix }));    }}fn directive<'a>(value: &'a str, name: &str) -> Option<&'a str> {    value.split(';').map(str::trim).find_map(|d| {        let (k, v) = d.split_once('=').map_or((d, ""), |(k, v)| (k.trim(), v.trim()));        k.eq_ignore_ascii_case(name).then_some(v.trim_matches('"'))    })}/// Grades a response's headers (a map of names to values, any case; several `Set-Cookie` headers joined by line breaks):/// `{ grade, score, findings: [{ header, severity, message, fix }], missing }`. `https` says whether the site is served over/// HTTPS, where HSTS counts. Severity is "high", "medium", "low" or "info"; the score starts at 100 and each finding takes some off.pub fn security_headers_check(headers: std::collections::HashMap<String, String>, https: bool) -> Value {    let h: HashMap<String, String> = headers.into_iter().map(|(k, v)| (k.to_ascii_lowercase(), v)).collect();    let get = |k: &str| h.get(k).map(|v| v.trim()).filter(|v| !v.is_empty());    let mut f = Findings(vec![]);    let mut missing: Vec<&str> = vec![];    match get("strict-transport-security") {        None if https => {            missing.push("strict-transport-security");            f.add("strict-transport-security", "high", "no HSTS: a first visit over http can be taken over on the way", "Strict-Transport-Security: max-age=63072000; includeSubDomains");        }        None => f.add("strict-transport-security", "info", "the site is not served over HTTPS, so HSTS cannot apply", "serve it over HTTPS, then add HSTS"),        Some(v) => {            let age: i64 = directive(v, "max-age").and_then(|a| a.parse().ok()).unwrap_or(-1);            if age < 0 {                f.add("strict-transport-security", "high", "HSTS without a valid max-age is ignored", "max-age=63072000; includeSubDomains");            } else if age < 15_552_000 {                f.add("strict-transport-security", "medium", &format!("max-age is {} s, under the 180 days browsers and preload lists expect", age), "max-age=63072000 (two years)");            }            if directive(v, "includeSubDomains").is_none() {                f.add("strict-transport-security", "low", "subdomains are not covered", "add includeSubDomains once every subdomain has HTTPS");            }        }    }    let csp = get("content-security-policy");    match csp {        None => {            missing.push("content-security-policy");            let ro = get("content-security-policy-report-only").is_some();            f.add("content-security-policy", if ro { "medium" } else { "high" }, if ro { "the CSP is only reported, not enforced" } else { "no Content-Security-Policy: an injected script runs" }, "default-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'");        }        Some(v) => {            let named = |n: &str| v.split(';').map(str::trim).find(|d| d.split_whitespace().next().is_some_and(|w| w.eq_ignore_ascii_case(n))).map(|d| d.to_string());            let script = named("script-src").or_else(|| named("default-src"));            match script {                None => f.add("content-security-policy", "medium", "the CSP has neither script-src nor default-src: scripts load from anywhere", "add script-src 'self' with a nonce"),                Some(s) => {                    let s = s.as_str();                    if s.contains("'unsafe-inline'") && !s.contains("'nonce-") && !s.contains("'sha") {                        f.add("content-security-policy", "medium", "script-src allows 'unsafe-inline', which undoes most of the CSP", "use nonces or hashes");                    }                    if s.split_whitespace().any(|w| w == "*" || w == "https:" || w == "http:" || w == "data:") {                        f.add("content-security-policy", "medium", "script-src allows any host", "name the hosts, or use 'strict-dynamic' with a nonce");                    }                }            }        }    }    let framing_by_csp = csp.is_some_and(|v| v.to_ascii_lowercase().contains("frame-ancestors"));    match get("x-frame-options") {        None if !framing_by_csp => {            missing.push("x-frame-options");            f.add("x-frame-options", "medium", "other sites may frame the page (clickjacking)", "Content-Security-Policy: frame-ancestors 'none' (or X-Frame-Options: DENY)");        }        Some(v) if !["deny", "sameorigin"].contains(&v.to_ascii_lowercase().as_str()) => {            f.add("x-frame-options", "medium", &format!("X-Frame-Options `{}` is not DENY or SAMEORIGIN, so browsers ignore it", v), "X-Frame-Options: DENY");        }        _ => {}    }    if !get("x-content-type-options").is_some_and(|v| v.eq_ignore_ascii_case("nosniff")) {        missing.push("x-content-type-options");        f.add("x-content-type-options", "medium", "browsers may guess a file's type and run an upload as a script", "X-Content-Type-Options: nosniff");    }    match get("referrer-policy").map(|v| v.to_ascii_lowercase()) {        None => {            missing.push("referrer-policy");            f.add("referrer-policy", "low", "no Referrer-Policy (browsers default to strict-origin-when-cross-origin)", "Referrer-Policy: strict-origin-when-cross-origin");        }        Some(v) if v.split(',').last().is_some_and(|p| ["unsafe-url", "no-referrer-when-downgrade"].contains(&p.trim())) => {            f.add("referrer-policy", "medium", "full URLs, query strings included, are sent to other sites", "Referrer-Policy: strict-origin-when-cross-origin");        }        _ => {}    }    if get("permissions-policy").is_none() {        missing.push("permissions-policy");        f.add("permissions-policy", "low", "no Permissions-Policy: embedded frames may ask for the camera, microphone or location", "Permissions-Policy: camera=(), microphone=(), geolocation=()");    }    if get("cross-origin-opener-policy").is_none() {        missing.push("cross-origin-opener-policy");        f.add("cross-origin-opener-policy", "low", "a page this one opens can reach back to it (window.opener)", "Cross-Origin-Opener-Policy: same-origin");    }    if let Some(v) = get("x-xss-protection") {        if !v.starts_with('0') {            f.add("x-xss-protection", "low", "the old XSS auditor is gone from browsers, and turned on it made some pages leak", "remove it, or X-XSS-Protection: 0");        }    }    for name in ["server", "x-powered-by", "x-aspnet-version", "x-aspnetmvc-version", "x-generator"] {        if let Some(v) = get(name) {            if name != "server" || v.chars().any(|c| c.is_ascii_digit()) {                f.add(name, "low", &format!("`{}` tells attackers what to look up", v), &format!("remove {}, or its version", name));            }        }    }    match (get("access-control-allow-origin"), get("access-control-allow-credentials")) {        (Some("*"), Some(c)) if c.eq_ignore_ascii_case("true") => f.add("access-control-allow-origin", "high", "any site may read answers, with credentials asked for", "name the allowed origins"),        (Some("null"), _) => f.add("access-control-allow-origin", "high", "the `null` origin is any sandboxed page or local file", "name the allowed origins"),        (Some("*"), _) => f.add("access-control-allow-origin", "info", "any site may read these answers (fine for public data)", "name the allowed origins if the data is not public"),        _ => {}    }    if let Some(cookies) = get("set-cookie") {        for c in cookies.lines().map(str::trim).filter(|c| !c.is_empty()) {            let name = c.split('=').next().unwrap_or("").trim();            let has = |a: &str| c.split(';').skip(1).any(|p| p.trim().split('=').next().unwrap_or("").trim().eq_ignore_ascii_case(a));            let mut lacks = vec![];            if https && !has("secure") {                lacks.push("Secure");            }            if !has("httponly") {                lacks.push("HttpOnly");            }            if !has("samesite") {                lacks.push("SameSite");            }            if !lacks.is_empty() {                let sev = if lacks.contains(&"Secure") || (lacks.contains(&"HttpOnly") && name.to_lowercase().contains("sess")) { "medium" } else { "low" };                f.add("set-cookie", sev, &format!("cookie `{}` lacks {}", name, lacks.join(", ")), &format!("{}; Secure; HttpOnly; SameSite=Lax", c.split(';').next().unwrap_or(c)));            }        }    }    let score: i64 = 100 - f.0.iter().map(|x| match x["severity"].as_str() { Some("high") => 25, Some("medium") => 10, Some("low") => 5, _ => 0 }).sum::<i64>();    let score = score.max(0);    let grade = match score {        100 => "A+",        90..=99 => "A",        75..=89 => "B",        60..=74 => "C",        45..=59 => "D",        _ => "F",    };    json!({ "grade": grade, "score": score, "findings": f.0, "missing": missing })}/// A good set of security headers to start from, in the order to send them. `options` may hold `csp` (a policy of your own),/// `hsts_preload` (true to add `preload`), `frames` ("deny" or "sameorigin") and `cross_origin_isolated` (true for COEP too).pub fn security_headers_recommended(options: std::collections::HashMap<String, serde_json::Value>) -> Value {    let frames = options.get("frames").and_then(|v| v.as_str()).unwrap_or("deny").to_lowercase();    let ancestors = if frames == "sameorigin" { "'self'" } else { "'none'" };    let csp = options.get("csp").and_then(|v| v.as_str()).map(|s| s.to_string()).unwrap_or_else(|| format!("default-src 'self'; object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors {}", ancestors));    let preload = options.get("hsts_preload").and_then(|v| v.as_bool()).unwrap_or(false);    let mut m = Map::new();    let mut put = |k: &str, v: String| { m.insert(k.to_string(), Value::String(v)); };    put("strict-transport-security", format!("max-age=63072000; includeSubDomains{}", if preload { "; preload" } else { "" }));    put("content-security-policy", csp);    put("x-content-type-options", "nosniff".into());    put("x-frame-options", if frames == "sameorigin" { "SAMEORIGIN".into() } else { "DENY".into() });    put("referrer-policy", "strict-origin-when-cross-origin".into());    put("permissions-policy", "camera=(), microphone=(), geolocation=(), payment=(), usb=()".into());    put("cross-origin-opener-policy", "same-origin".into());    put("cross-origin-resource-policy", "same-origin".into());    if options.get("cross_origin_isolated").and_then(|v| v.as_bool()).unwrap_or(false) {        put("cross-origin-embedder-policy", "require-corp".into());    }    Value::Object(m)}
Content-Security-Policy csp.qwer · 1.0.0Content-Security-Policy headers built from a map and read back, a nonce per page, the policy graded for the holes that let scripts in, and whether a URL would load under it.headerssecurityxsshtml7 views · 7 downloadsArgon2 passwords argon2.qwer · 1.0.0Argon2id password hashes with the memory and passes you choose, any Argon2 hash verified, its parameters read, old ones marked for a rehash, and the cost timed on this server.passwordsauthsecuritymigration7 views · 6 downloadsCSRF tokens csrf_tokens.qwer · 1.0.0CSRF protection for forms and fetch calls: tokens signed for one session that expire, the signed double-submit cookie check, and the Origin, Referer and Sec-Fetch-Site checks.formssecuritysessionstokens7 views · 7 downloadsDKIM, SPF and DMARC email_auth.qwer · 1.0.0Messages signed and checked with DKIM (RSA and Ed25519), SPF records read and evaluated for a sending IP, and DMARC policies applied, with DNS records given as a map so it runs offline.emailsigningdnssecurity6 views · 5 downloadsOSV vulnerabilities osv.qwer · 1.0.0Ask OSV.dev about packages by name or purl and in batches with pages followed, evaluate affected ranges with each ecosystem's own version order (semver, PEP 440, Debian, RubyGems, Maven), and turn CVSS 3 vectors into scores.securitydependenciesapisemver2 views · 7 downloadsSecrets scanner secrets_scan.qwer · 1.0.0API keys, tokens, private keys, JWTs and passwords in URLs found in text by gitleaks-style rules, with documentation placeholders told apart, and the text given back with them masked.secretssecuritylogsprivacy7 views · 8 downloads
Thread
86Useful? Like it, or add to its thread below.

Thread 0 posts

On Security headers: questions, tips, corrections, and code that uses it.

No posts yet. The first one opens the thread.