#![cfg_attr(
not(test),
deny(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::unreachable,
clippy::todo,
clippy::unimplemented,
clippy::indexing_slicing,
clippy::string_slice,
clippy::arithmetic_side_effects,
)
)]
#[rustfmt::skip]
pub const ALLOWED_TAGS: &[&str] = &[
"div", "span", "section", "article", "aside", "header", "footer", "main",
"nav", "figure", "figcaption", "details", "summary", "dialog",
"p", "br", "hr", "blockquote", "pre", "code", "kbd", "samp", "var",
"h1", "h2", "h3", "h4", "h5", "h6",
"em", "strong", "b", "i", "u", "s", "del", "ins", "mark", "small",
"sub", "sup", "abbr", "cite", "q", "time", "address", "wbr",
"ul", "ol", "li", "dl", "dt", "dd",
"a", "img", "picture", "source", "audio", "video", "track",
"table", "thead", "tbody", "tfoot", "tr", "th", "td", "caption",
"colgroup", "col",
"form", "label", "input", "textarea", "select", "option", "optgroup",
"button", "fieldset", "legend", "datalist", "output", "progress", "meter",
];
#[rustfmt::skip]
pub const ALLOWED_ATTRS: &[&str] = &[
"class", "id", "title", "lang", "dir", "hidden", "role", "tabindex",
"translate", "slot", "part",
"target", "rel", "download", "hreflang", "type", "referrerpolicy",
"alt", "width", "height", "loading", "decoding",
"controls", "autoplay", "loop", "muted", "playsinline", "preload",
"kind", "srclang", "label", "default",
"colspan", "rowspan", "headers", "scope", "span", "abbr",
"name", "value", "placeholder", "required", "disabled", "readonly",
"checked", "selected", "multiple", "min", "max", "step", "minlength",
"maxlength", "pattern", "size", "rows", "cols", "wrap", "for", "form",
"list", "accept", "autocomplete", "autofocus", "inputmode", "enterkeyhint",
"spellcheck", "novalidate", "method", "low", "high", "optimum",
"open",
];
pub const URL_ATTRS: &[&str] = &["href", "src", "action", "formaction", "poster", "cite"];
pub const ALLOWED_URL_SCHEMES: &[&str] = &["http", "https", "mailto", "tel"];
#[rustfmt::skip]
pub const ID_REF_ATTRS: &[&str] = &[
"id", "for", "form", "list", "headers",
"aria-labelledby", "aria-describedby", "aria-controls", "aria-owns",
"aria-activedescendant", "aria-details", "aria-errormessage", "aria-flowto",
];
#[rustfmt::skip]
pub const VOID_TAGS: &[&str] = &[
"br", "hr", "img", "input", "source", "track", "wbr", "col",
];
#[must_use]
pub fn is_allowed_tag(tag: &str) -> bool {
let lower = tag.to_ascii_lowercase();
ALLOWED_TAGS.contains(&lower.as_str())
}
#[must_use]
pub fn is_void_tag(tag: &str) -> bool {
let lower = tag.to_ascii_lowercase();
VOID_TAGS.contains(&lower.as_str())
}
#[must_use]
pub fn canonical_event(event: &str) -> String {
event
.chars()
.filter(|c| c.is_ascii_alphanumeric() || *c == '-')
.map(|c| c.to_ascii_lowercase())
.take(32)
.collect()
}
pub const RESERVED_ATTR_PREFIX: &str = "data-constela-";
#[must_use]
pub fn is_reserved_attr(attr: &str) -> bool {
attr.to_ascii_lowercase().starts_with(RESERVED_ATTR_PREFIX)
}
#[must_use]
pub fn canonical_attr(name: &str) -> String {
match name {
"className" => "class".to_string(),
"htmlFor" => "for".to_string(),
other => other.to_ascii_lowercase(),
}
}
#[must_use]
pub fn is_allowed_attr(attr: &str) -> bool {
let lower = attr.to_ascii_lowercase();
if lower.starts_with("on") {
return false;
}
if let Some(suffix) = lower
.strip_prefix("data-")
.or_else(|| lower.strip_prefix("aria-"))
{
return !suffix.is_empty()
&& suffix
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_');
}
ALLOWED_ATTRS.contains(&lower.as_str()) || URL_ATTRS.contains(&lower.as_str())
}
#[must_use]
pub fn is_url_attr(attr: &str) -> bool {
let lower = attr.to_ascii_lowercase();
URL_ATTRS.contains(&lower.as_str())
}
#[must_use]
pub fn is_id_ref_attr(attr: &str) -> bool {
let lower = attr.to_ascii_lowercase();
ID_REF_ATTRS.contains(&lower.as_str())
}
#[must_use]
pub fn is_allowed_url(url: &str) -> bool {
let stripped: String = url
.chars()
.filter(|c| !c.is_ascii_whitespace() && !c.is_control())
.collect();
let mut scheme = String::new();
let mut delimited = false;
for c in stripped.chars() {
match c {
':' => {
delimited = true;
break;
}
'/' | '?' | '#' => return true,
other => scheme.push(other),
}
}
if !delimited {
return true;
}
let mut bytes = scheme.bytes();
let scheme_shaped = bytes.next().is_some_and(|b| b.is_ascii_alphabetic())
&& bytes.all(|b| b.is_ascii_alphanumeric() || b == b'+' || b == b'-' || b == b'.');
if !scheme_shaped {
return true;
}
ALLOWED_URL_SCHEMES.contains(&scheme.to_ascii_lowercase().as_str())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rejects_script_bearing_tags() {
for tag in [
"script", "style", "iframe", "object", "embed", "base", "link", "meta", "svg", "math",
"template", "noscript", "frame", "frameset", "applet", "html", "head", "body", "title",
] {
assert!(!is_allowed_tag(tag), "{tag} must not be allowed");
}
}
#[test]
fn tag_check_is_case_insensitive() {
assert!(!is_allowed_tag("SCRIPT"));
assert!(!is_allowed_tag("ScRiPt"));
assert!(is_allowed_tag("DIV"));
}
#[test]
fn rejects_every_event_handler_attribute() {
for attr in [
"onclick",
"onerror",
"onload",
"onfocus",
"onmouseover",
"onanimationstart",
"ONCLICK",
"onwebkitanimationend",
] {
assert!(!is_allowed_attr(attr), "{attr} must not be allowed");
}
}
#[test]
fn jsx_spellings_canonicalize_to_the_html_attribute_they_mean() {
assert_eq!(canonical_attr("className"), "class");
assert_eq!(canonical_attr("htmlFor"), "for");
assert_eq!(canonical_attr("ARIA-Label"), "aria-label");
assert!(is_allowed_attr(&canonical_attr("className")));
}
#[test]
fn rejects_style_and_unknown_attributes() {
assert!(!is_allowed_attr("style"));
assert!(!is_allowed_attr("xmlns"));
assert!(!is_allowed_attr("http-equiv"));
assert!(!is_allowed_attr("srcdoc"));
assert!(!is_allowed_attr("formmethod"));
}
#[test]
fn allows_data_and_aria_families_but_not_bare_prefixes() {
assert!(is_allowed_attr("data-testid"));
assert!(is_allowed_attr("aria-label"));
assert!(!is_allowed_attr("data-"));
assert!(!is_allowed_attr("aria-"));
assert!(!is_allowed_attr("data-a b"));
assert!(!is_allowed_attr("data-a\"b"));
}
#[test]
fn event_names_reduce_to_attribute_safe_text_or_to_nothing() {
assert_eq!(canonical_event("click"), "click");
assert_eq!(canonical_event("Click"), "click");
assert_eq!(canonical_event("a b=\"c\""), "abc");
assert_eq!(canonical_event("_"), "");
assert_eq!(canonical_event("🎉"), "");
}
#[test]
fn the_renderer_namespace_is_reserved() {
assert!(is_reserved_attr("data-constela-on-click"));
assert!(is_reserved_attr("DATA-CONSTELA-REF"));
assert!(!is_reserved_attr("data-constellation"));
assert!(!is_reserved_attr("data-testid"));
}
#[test]
fn relative_urls_are_allowed() {
for url in [
"",
"/",
"/dashboard",
"./a",
"../a",
"#anchor",
"?q=1",
"posts/1",
"/a:b",
"//example.com/x",
] {
assert!(is_allowed_url(url), "{url:?} must be allowed");
}
}
#[test]
fn dangerous_schemes_are_rejected_including_obfuscated_spellings() {
for url in [
"javascript:alert(1)",
"JaVaScRiPt:alert(1)",
"java\tscript:alert(1)",
"java\nscript:alert(1)",
" javascript:alert(1)",
"\u{1}javascript:alert(1)",
"vbscript:msgbox(1)",
"data:text/html;base64,PHNjcmlwdD4=",
"file:///etc/passwd",
"blob:https://example.com/x",
] {
assert!(!is_allowed_url(url), "{url:?} must be rejected");
}
}
#[test]
fn allowed_schemes_pass() {
for url in [
"http://example.com",
"https://example.com/a?b=c#d",
"HTTPS://EXAMPLE.COM",
"mailto:someone@example.com",
"tel:+15550100",
] {
assert!(is_allowed_url(url), "{url:?} must be allowed");
}
}
#[test]
fn allowlists_have_no_duplicates() {
for (label, list) in [
("ALLOWED_TAGS", ALLOWED_TAGS),
("ALLOWED_ATTRS", ALLOWED_ATTRS),
("URL_ATTRS", URL_ATTRS),
("ID_REF_ATTRS", ID_REF_ATTRS),
("VOID_TAGS", VOID_TAGS),
] {
let mut sorted = list.to_vec();
sorted.sort_unstable();
let before = sorted.len();
sorted.dedup();
assert_eq!(before, sorted.len(), "{label} has a duplicate entry");
}
}
#[test]
fn every_void_tag_is_an_allowed_tag() {
for tag in VOID_TAGS {
assert!(is_allowed_tag(tag), "{tag} is void but not allowed");
}
}
}