const URL_ATTRIBUTES: &[&str] = &[
"href",
"src",
"action",
"formaction",
"poster",
"cite",
"background",
"xlink:href",
"data",
];
const BLOCKED_SCHEMES: &[&str] = &["javascript:", "vbscript:", "data:text/html"];
pub const REPLACEMENT: &str = "#";
#[must_use]
pub fn is_blocked(key: &str, value: &str) -> bool {
if !is_url_attribute(key) {
return false;
}
if value.bytes().any(|byte| byte == b'&' || byte <= b' ') {
return has_blocked_scheme(&normalize(value));
}
has_blocked_scheme(value)
}
fn is_url_attribute(key: &str) -> bool {
URL_ATTRIBUTES
.iter()
.any(|name| eq_ignore_ascii_case(key, name))
}
fn eq_ignore_ascii_case(left: &str, right: &str) -> bool {
left.len() == right.len()
&& left
.bytes()
.zip(right.bytes())
.all(|(got, expected)| got.eq_ignore_ascii_case(&expected))
}
fn has_blocked_scheme(value: &str) -> bool {
BLOCKED_SCHEMES.iter().any(|scheme| {
let mut chars = value.chars();
scheme.chars().all(|expected| {
chars
.next()
.is_some_and(|got| got.eq_ignore_ascii_case(&expected))
})
})
}
fn normalize(value: &str) -> String {
let mut out = String::new();
let mut rest = value;
while let Some(first) = rest.chars().next() {
if first == '&' {
if let Some((decoded, consumed)) = decode_reference(rest) {
push_visible(&mut out, decoded);
rest = &rest[consumed..];
continue;
}
}
push_visible(&mut out, first);
rest = &rest[first.len_utf8()..];
}
out
}
fn push_visible(out: &mut String, scalar: char) {
if scalar > '\u{20}' {
out.push(scalar);
}
}
fn decode_reference(value: &str) -> Option<(char, usize)> {
let after = value.get(1..)?;
if let Some((scalar, used)) = numeric_reference(after) {
return Some((scalar, 1 + used));
}
named_reference(after).map(|(scalar, used)| (scalar, 1 + used))
}
fn numeric_reference(after: &str) -> Option<(char, usize)> {
let bytes = after.as_bytes();
if bytes.first().copied() != Some(b'#') {
return None;
}
let mut index = 1;
let hex = matches!(bytes.get(index).copied(), Some(b'x' | b'X'));
if hex {
index += 1;
}
let digits_start = index;
let radix: u32 = if hex { 16 } else { 10 };
let mut code: u32 = 0;
while index < bytes.len() {
let digit = match bytes[index] {
byte @ b'0'..=b'9' => u32::from(byte - b'0'),
byte @ b'a'..=b'f' if hex => u32::from(byte - b'a' + 10),
byte @ b'A'..=b'F' if hex => u32::from(byte - b'A' + 10),
_ => break,
};
code = code.wrapping_mul(radix).wrapping_add(digit).min(0x11_0000);
index += 1;
}
if index == digits_start {
return None;
}
if bytes.get(index).copied() == Some(b';') {
index += 1;
}
Some((char::from_u32(code).unwrap_or('\u{FFFD}'), index))
}
fn named_reference(after: &str) -> Option<(char, usize)> {
let end = after
.char_indices()
.nth(8)
.map_or(after.len(), |(index, _)| index);
let window = &after[..end];
let semi = window.find(';')?;
let scalar = match &window[..semi] {
"colon" => ':',
"Tab" => '\t',
"NewLine" => '\n',
_ => return None,
};
Some((scalar, semi + 1))
}
#[cfg(test)]
mod tests {
use crate::core::Attribute;
fn tag(name: &str, attribute: &Attribute) -> String {
let mut out = format!("<{name}");
attribute.write_into(&mut out);
out.push_str("></");
out.push_str(name);
out.push('>');
out
}
#[test]
fn script_urls_are_neutralised() {
for url in [
"javascript:alert(1)",
"JaVaScRiPt:alert(1)",
" javascript:x",
"java\tscript:x",
"java\nscript:x",
"\u{0001}javascript:x",
"vbscript:msgbox",
"data:text/html,<b>x</b>",
] {
assert_eq!(
tag("a", &Attribute::new("href", url)),
r##"<a href="#"></a>"##,
"{url:?}"
);
}
}
#[test]
fn other_urls_and_attributes_are_untouched() {
assert_eq!(
tag(
"a",
&Attribute::new("href", "https://swift.org/javascript:")
),
r#"<a href="https://swift.org/javascript:"></a>"#
);
assert_eq!(
tag("img", &Attribute::new("src", "data:image/png;base64,AAA")),
r#"<img src="data:image/png;base64,AAA"></img>"#
);
assert_eq!(
tag("div", &Attribute::new("title", "javascript:fine")),
r#"<div title="javascript:fine"></div>"#
);
assert_eq!(
tag("form", &Attribute::new("ACTION", "javascript:x")),
r##"<form ACTION="#"></form>"##
);
assert_eq!(
tag("button", &Attribute::boolean("formaction")),
"<button formaction></button>"
);
assert_eq!(
tag("div", &Attribute::new("data-href", "javascript:x")),
r#"<div data-href="javascript:x"></div>"#
);
}
#[test]
fn trusted_urls_render_as_given() {
assert_eq!(
tag("a", &Attribute::trusted_url("href", "javascript:print()")),
r#"<a href="javascript:print()"></a>"#
);
assert_eq!(
tag("a", &Attribute::trusted_url("href", "javascript:x")),
r#"<a href="javascript:x"></a>"#
);
}
#[test]
fn character_references_cannot_spell_a_script_url() {
for url in [
"javascript:alert(1)",
"javascript:x",
"javascript:x",
"javascript:x",
"javascript:x",
"javascript:x",
"java	script:x",
"java
script:x",
"javascript:x",
"javascript:x",
"java	script:x",
"vbscript:x",
] {
assert_eq!(
tag("a", &Attribute::raw("href", url)),
r##"<a href="#"></a>"##,
"{url:?}"
);
}
}
#[test]
fn character_references_in_safe_urls_are_kept() {
assert_eq!(
tag("a", &Attribute::new("href", "javascript:x")),
r#"<a href="javascript&#58;x"></a>"#
);
assert_eq!(
tag("a", &Attribute::raw("href", "/search?q=a&b=:")),
r#"<a href="/search?q=a&b=:"></a>"#
);
assert_eq!(
tag("a", &Attribute::raw("href", "java&COLON;x")),
r#"<a href="java&COLON;x"></a>"#
);
}
#[test]
fn every_url_attribute_is_covered_and_its_name_is_kept() {
for key in [
"href",
"src",
"action",
"formaction",
"poster",
"cite",
"background",
"xlink:href",
"data",
] {
let rendered = tag("a", &Attribute::new(key, "javascript:x"));
assert_eq!(rendered, format!(r##"<a {key}="#"></a>"##), "{key}");
}
}
#[test]
fn a_name_that_could_escape_its_quotes_is_not_emitted() {
for key in [
"a\" onclick=\"x",
"a'b",
"a=b",
"a<b",
"a>b",
"a/b",
"a b",
"a\nb",
"a\u{007f}b",
"",
] {
let mut out = String::from("<div");
Attribute::raw(key, "1").write_into(&mut out);
Attribute::new("id", "ok").write_into(&mut out);
out.push_str("></div>");
assert_eq!(out, r#"<div id="ok"></div>"#, "{key:?}");
}
}
}