xss-filters 0.2.0

Context-aware, just-sufficient output encoding to prevent XSS in HTML, attribute, URI, and CSS contexts
Documentation
//! CSS context filters.
//!
//! These live behind the `css` feature. They neutralize CSS `expression(...)`
//! and `url(...)` tricks, escape dangerous characters in single- or
//! double-quoted strings, and block dangerous URL protocols. Do not place
//! untrusted text in a `style` attribute or a `<style>` block without one of
//! these.

use crate::core::{get_protocol, html_decode, yufull, NamedRefSet, BLACKLIST};
use alloc::format;
use alloc::string::String;
use alloc::vec::Vec;

/// Backslash-hex escape for one character: `\HEX ` with lowercase hex.
fn css_encode(c: char) -> String {
    format!("\\{:x} ", c as u32)
}

/// Replace `url(` (and sub/superscript parens) with `-x-url(`.
fn css_blacklist(s: &str) -> String {
    // CSS_BLACKLIST = /url[\(⁽₍]+/g
    let chars: Vec<char> = s.chars().collect();
    let mut out = String::with_capacity(s.len());
    let mut i = 0;
    let n = chars.len();
    while i < n {
        if i + 3 < n
            && chars[i] == 'u'
            && chars[i + 1] == 'r'
            && chars[i + 2] == 'l'
            && is_open_paren(chars[i + 3])
        {
            let mut j = i + 3;
            while j < n && is_open_paren(chars[j]) {
                j += 1;
            }
            out.push_str("-x-");
            out.extend(&chars[i..j]);
            i = j;
        } else {
            out.push(chars[i]);
            i += 1;
        }
    }
    out
}

fn is_open_paren(c: char) -> bool {
    matches!(c, '(' | '\u{207D}' | '\u{208D}')
}

/// Escape characters in `s` using `css_encode` when `pred` matches.
fn escape_chars(s: &str, pred: impl Fn(char) -> bool) -> String {
    let mut out = String::with_capacity(s.len());
    for c in s.chars() {
        if pred(c) {
            out.push_str(&css_encode(c));
        } else {
            out.push(c);
        }
    }
    out
}

/// Characters escaped in a double-quoted CSS string: controls, DEL, `[ ] { } \
/// "`, and `<` so a value cannot close a `<style>` block.
fn is_double_quoted_char(c: char) -> bool {
    let n = c as u32;
    n <= 0x1F || n == 0x7F || matches!(c, '[' | ']' | '{' | '}' | '\\' | '"' | '<')
}

/// Characters escaped in a single-quoted CSS string: controls, DEL, `[ ] { } \
/// '`, and `<` so a value cannot close a `<style>` block.
fn is_single_quoted_char(c: char) -> bool {
    let n = c as u32;
    n <= 0x1F || n == 0x7F || matches!(c, '[' | ']' | '{' | '}' | '\\' | '\'' | '<')
}

/// Test the `CSS_VALID_VALUE` grammar against a decoded value.
///
/// The value is safe when it is an identifier or hex color that does not start
/// with `expression` (after optional leading dashes), a number with an optional
/// unit, `!important`, or empty.
fn css_valid_value(s: &str) -> bool {
    if s.is_empty() {
        return true;
    }
    if s.eq_ignore_ascii_case("!important") {
        return true;
    }
    if ident_or_hexcolor(s) {
        return true;
    }
    number_with_unit(s)
}

/// Match `(?!-*expression)#?[-\w]+` case-insensitively.
fn ident_or_hexcolor(s: &str) -> bool {
    let mut chars = s.chars().peekable();
    if chars.peek() == Some(&'#') {
        chars.next();
    }
    let rest: String = chars.collect();
    if rest.is_empty() {
        return false;
    }
    // [-\w]+  where \w is [A-Za-z0-9_]
    if !rest
        .chars()
        .all(|c| c == '-' || c == '_' || c.is_ascii_alphanumeric())
    {
        return false;
    }
    // negative lookahead: not -*expression at the start of `rest`
    let trimmed = rest.trim_start_matches('-');
    let head: String = trimmed.chars().take("expression".len()).collect();
    if head.eq_ignore_ascii_case("expression") {
        return false;
    }
    true
}

/// Match `[+-]?(?:\d+|\d*\.\d+)(?:unit)?` case-insensitively over the whole value.
fn number_with_unit(s: &str) -> bool {
    let bytes = s.as_bytes();
    let mut i = 0;
    let n = bytes.len();
    if i < n && (bytes[i] == b'+' || bytes[i] == b'-') {
        i += 1;
    }
    let int_start = i;
    while i < n && bytes[i].is_ascii_digit() {
        i += 1;
    }
    let int_digits = i - int_start;
    let mut has_fraction = false;
    if i < n && bytes[i] == b'.' {
        i += 1;
        let frac_start = i;
        while i < n && bytes[i].is_ascii_digit() {
            i += 1;
        }
        if i == frac_start {
            return false; // need digits after dot
        }
        has_fraction = true;
    }
    // \d+ alone, or \d*\.\d+
    if !has_fraction && int_digits == 0 {
        return false;
    }
    let unit = &s[i..];
    unit_valid(unit)
}

/// Validate the optional CSS unit suffix.
fn unit_valid(unit: &str) -> bool {
    if unit.is_empty() {
        return true;
    }
    const UNITS: [&str; 15] = [
        "em", "rem", "ex", "ch", "cm", "mm", "in", "px", "pt", "pc", "%", "vh", "vw", "vmin",
        "vmax",
    ];
    UNITS.iter().any(|u| unit.eq_ignore_ascii_case(u))
}

/// Filter for a CSS value placed where an identifier is expected.
///
/// Safe values pass through. Otherwise the value is wrapped in a poison-pill
/// `;-x:'<escaped>';-v:` that forces a benign declaration.
pub fn css_value(s: &str) -> String {
    let decoded = html_decode(s, NamedRefSet::Sensitive, false);
    if css_valid_value(&decoded) {
        decoded
    } else {
        let escaped = css_blacklist(&escape_chars(&decoded, is_single_quoted_char));
        format!(";-x:'{escaped}';-v:")
    }
}

/// Filter for a double-quoted CSS string.
pub fn css_double_quoted(s: &str) -> String {
    let decoded = html_decode(s, NamedRefSet::Sensitive, false);
    css_blacklist(&escape_chars(&decoded, is_double_quoted_char))
}

/// Filter for a single-quoted CSS string.
pub fn css_single_quoted(s: &str) -> String {
    let decoded = html_decode(s, NamedRefSet::Sensitive, false);
    css_blacklist(&escape_chars(&decoded, is_single_quoted_char))
}

/// Decode, re-encode as a URI, and `##`-prefix a blacklisted protocol.
fn css_url(s: &str) -> String {
    let decoded = html_decode(s, NamedRefSet::Sensitive, false);
    let encoded = yufull(&decoded);
    match get_protocol(&encoded) {
        Some(p) if BLACKLIST.contains(&p.to_lowercase().as_str()) => format!("##{encoded}"),
        _ => encoded,
    }
}

/// Filter for an unquoted CSS url value.
pub fn css_unquoted_url(s: &str) -> String {
    let url = css_url(s);
    let mut out = String::with_capacity(url.len());
    for c in url.chars() {
        match c {
            '\'' => out.push_str("\\27 "),
            '(' => out.push_str("%28"),
            ')' => out.push_str("%29"),
            _ => out.push(c),
        }
    }
    out
}

/// Filter for a double-quoted CSS url value.
pub fn css_double_quoted_url(s: &str) -> String {
    css_url(s)
}

/// Filter for a single-quoted CSS url value.
pub fn css_single_quoted_url(s: &str) -> String {
    css_url(s).replace('\'', "\\27 ")
}