use serde::{Deserialize, Serialize};
pub const ALLOWED_TAGS: &[&str] = &[
"a", "abbr", "b", "br", "code", "dd", "div", "dl", "dt", "em", "h3", "h4", "h5", "h6", "hr",
"i", "li", "ol", "p", "pre", "small", "span", "strong", "table", "tbody", "td", "tfoot", "th",
"thead", "time", "tr", "ul",
];
pub const VOID_TAGS: &[&str] = &["br", "hr"];
pub const ALLOWED_ATTRIBUTES: &[(&str, &str)] = &[
("a", "href"),
("a", "title"),
("time", "datetime"),
("td", "colspan"),
("th", "colspan"),
("td", "rowspan"),
("th", "rowspan"),
("*", "class"),
("*", "lang"),
("*", "dir"),
];
pub const MAX_DEPTH: usize = 8;
pub const MAX_NODES: usize = 512;
pub const MAX_ATTRIBUTES: usize = 32;
pub const MAX_TEXT_BYTES: usize = 8 * 1024;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "node", rename_all = "snake_case", deny_unknown_fields)]
#[non_exhaustive]
pub enum FragmentNode {
Text {
text: String,
},
Element {
tag: String,
#[serde(
default,
deserialize_with = "super::bounded_vec::<_, _, MAX_ATTRIBUTES>"
)]
attributes: Vec<(String, String)>,
#[serde(default, deserialize_with = "super::bounded_subtree")]
children: Vec<Self>,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum RenderError {
SlotNotGranted(String),
ForbiddenTag(String),
ForbiddenAttribute {
tag: String,
name: String,
},
InvalidAttributeValue {
name: String,
reason: &'static str,
},
TooDeep,
TooManyNodes,
TooManyAttributes(usize),
TextTooLong(usize),
TooLarge {
found: usize,
max: usize,
},
}
impl std::fmt::Display for RenderError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::SlotNotGranted(slot) => {
write!(f, "`[grants].slots` does not name {slot:?}")
}
Self::ForbiddenTag(tag) => write!(
f,
"<{tag}> is not one of the structural tags a render hook may use"
),
Self::ForbiddenAttribute { tag, name } => {
write!(f, "attribute `{name}` is not allowed on <{tag}>")
}
Self::InvalidAttributeValue { name, reason } => {
write!(f, "attribute `{name}`: {reason}")
}
Self::TooDeep => write!(f, "a fragment may nest at most {MAX_DEPTH} deep"),
Self::TooManyNodes => write!(f, "a fragment may carry at most {MAX_NODES} nodes"),
Self::TooManyAttributes(found) => write!(
f,
"an element may carry at most {MAX_ATTRIBUTES} attributes; this one carries {found}"
),
Self::TextTooLong(found) => write!(
f,
"a text run or attribute value of {found} bytes is over the \
{MAX_TEXT_BYTES}-byte ceiling"
),
Self::TooLarge { found, max } => write!(
f,
"the rendered fragment came to {found} bytes, over the {max}-byte `render_bytes` \
quota"
),
}
}
}
impl std::error::Error for RenderError {}
pub fn render(nodes: &[FragmentNode], max_bytes: usize) -> Result<String, RenderError> {
let mut out = String::new();
let mut budget = MAX_NODES;
for node in nodes {
write_node(&mut out, node, 0, &mut budget, max_bytes)?;
}
if out.len() > max_bytes {
return Err(RenderError::TooLarge {
found: out.len(),
max: max_bytes,
});
}
Ok(out)
}
fn write_node(
out: &mut String,
node: &FragmentNode,
depth: usize,
budget: &mut usize,
max_bytes: usize,
) -> Result<(), RenderError> {
if depth > MAX_DEPTH {
return Err(RenderError::TooDeep);
}
*budget = budget.checked_sub(1).ok_or(RenderError::TooManyNodes)?;
if out.len() > max_bytes {
return Err(RenderError::TooLarge {
found: out.len(),
max: max_bytes,
});
}
match node {
FragmentNode::Text { text } => {
if text.len() > MAX_TEXT_BYTES {
return Err(RenderError::TextTooLong(text.len()));
}
escape_into(out, text);
}
FragmentNode::Element {
tag,
attributes,
children,
} => {
let Some(tag) = ALLOWED_TAGS
.iter()
.find(|known| known.eq_ignore_ascii_case(tag))
else {
return Err(RenderError::ForbiddenTag(super::super::manifest::rejected(
tag,
)));
};
let tag = (*tag).to_owned();
if attributes.len() > MAX_ATTRIBUTES {
return Err(RenderError::TooManyAttributes(attributes.len()));
}
out.push('<');
out.push_str(&tag);
for (name, value) in attributes {
write_attribute(out, &tag, name, value)?;
if out.len() > max_bytes {
return Err(RenderError::TooLarge {
found: out.len(),
max: max_bytes,
});
}
}
if VOID_TAGS.contains(&tag.as_str()) {
out.push_str(" />");
return Ok(());
}
out.push('>');
for child in children {
write_node(out, child, depth.saturating_add(1), budget, max_bytes)?;
}
out.push_str("</");
out.push_str(&tag);
out.push('>');
}
}
Ok(())
}
fn write_attribute(
out: &mut String,
tag: &str,
name: &str,
value: &str,
) -> Result<(), RenderError> {
let Some((_, known)) = ALLOWED_ATTRIBUTES
.iter()
.find(|(on, known)| known.eq_ignore_ascii_case(name) && (*on == "*" || *on == tag))
else {
return Err(RenderError::ForbiddenAttribute {
tag: tag.to_owned(),
name: super::super::manifest::rejected(name),
});
};
let name = (*known).to_owned();
if value.len() > MAX_TEXT_BYTES {
return Err(RenderError::TextTooLong(value.len()));
}
match name.as_str() {
"href" => check_href(value)?,
"class" | "lang" | "dir" | "datetime" => {
if !value.bytes().all(|byte| {
byte.is_ascii_alphanumeric()
|| matches!(byte, b' ' | b'-' | b'_' | b':' | b'.' | b'+')
}) {
return Err(RenderError::InvalidAttributeValue {
name: name.clone(),
reason: "expected letters, digits, spaces and `-_:.+`",
});
}
}
"colspan" | "rowspan"
if value.is_empty()
|| value.len() > 3
|| !value.bytes().all(|byte| byte.is_ascii_digit()) =>
{
return Err(RenderError::InvalidAttributeValue {
name: name.clone(),
reason: "expected one to three digits",
});
}
_ => {}
}
out.push(' ');
out.push_str(&name);
out.push_str("=\"");
escape_into(out, value);
out.push('"');
Ok(())
}
fn check_href(value: &str) -> Result<(), RenderError> {
if value.contains('\\') {
return Err(RenderError::InvalidAttributeValue {
name: "href".to_owned(),
reason: "may not carry a backslash: a browser reads it as a path \
separator, so `/\\host` is `//host` by another spelling",
});
}
let shaped = (value.starts_with('/') && !value.starts_with("//"))
|| value.starts_with('#')
|| value.starts_with('?');
if !shaped {
return Err(RenderError::InvalidAttributeValue {
name: "href".to_owned(),
reason: "expected a same-origin link: `/path`, `?query` or `#fragment`",
});
}
if value.chars().any(char::is_control) {
return Err(RenderError::InvalidAttributeValue {
name: "href".to_owned(),
reason: "may not carry control characters",
});
}
Ok(())
}
fn escape_into(out: &mut String, text: &str) {
for ch in text.chars() {
match ch {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
other => out.push(other),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn text(value: &str) -> FragmentNode {
FragmentNode::Text {
text: value.to_owned(),
}
}
fn element(
tag: &str,
attributes: &[(&str, &str)],
children: Vec<FragmentNode>,
) -> FragmentNode {
FragmentNode::Element {
tag: tag.to_owned(),
attributes: attributes
.iter()
.map(|(name, value)| ((*name).to_owned(), (*value).to_owned()))
.collect(),
children,
}
}
#[test]
fn a_fragment_renders_to_escaped_html() {
let nodes = vec![element(
"p",
&[("class", "panel")],
vec![text("3 orders <b>& counting</b>")],
)];
assert_eq!(
render(&nodes, 4096),
Ok(r#"<p class="panel">3 orders <b>& counting</b></p>"#.to_owned())
);
}
#[test]
fn a_void_tag_never_carries_children_into_the_page() {
let nodes = vec![element("br", &[], vec![text("smuggled")])];
assert_eq!(render(&nodes, 4096), Ok("<br />".to_owned()));
}
#[test]
fn nothing_executable_can_be_expressed() {
for tag in [
"script", "style", "iframe", "object", "embed", "form", "input", "button", "img",
"svg", "math", "link", "meta", "base", "template", "noscript",
] {
assert!(
matches!(
render(&[element(tag, &[], Vec::new())], 4096),
Err(RenderError::ForbiddenTag(_))
),
"<{tag}> was rendered"
);
}
}
#[test]
fn no_event_handler_or_style_survives() {
for name in [
"onclick",
"onerror",
"onload",
"style",
"id",
"srcdoc",
"formaction",
"xlink:href",
] {
assert!(
matches!(
render(&[element("div", &[(name, "x")], Vec::new())], 4096),
Err(RenderError::ForbiddenAttribute { .. })
),
"{name} was rendered"
);
}
}
#[test]
fn an_href_may_only_point_back_at_the_host() {
for href in ["/orders/7", "?page=2", "#top"] {
assert!(
render(&[element("a", &[("href", href)], Vec::new())], 4096).is_ok(),
"{href}"
);
}
for href in [
"javascript:alert(1)",
"JavaScript:alert(1)",
"java\tscript:alert(1)",
"data:text/html,<script>alert(1)</script>",
"vbscript:msgbox",
"https://attacker.test/",
"//attacker.test/",
"/\\attacker.test/login",
"\\/attacker.test/login",
"\\\\attacker.test\\share",
"/path\\..\\..\\etc",
] {
assert!(
matches!(
render(&[element("a", &[("href", href)], Vec::new())], 4096),
Err(RenderError::InvalidAttributeValue { .. })
),
"{href} was rendered"
);
}
}
#[test]
fn an_attribute_value_cannot_break_out_of_its_quotes() {
let rendered = render(
&[element(
"a",
&[("title", "\" onmouseover=\"alert(1)")],
Vec::new(),
)],
4096,
)
.expect("title is free-form and escaped");
assert!(!rendered.contains("onmouseover=\"alert"), "{rendered}");
assert!(rendered.contains("""), "{rendered}");
}
#[test]
fn a_class_value_is_a_closed_charset_rather_than_an_escaped_one() {
assert!(render(&[element("div", &[("class", "a b-c_d")], Vec::new())], 4096).is_ok());
assert!(matches!(
render(&[element("div", &[("class", "a\"b")], Vec::new())], 4096),
Err(RenderError::InvalidAttributeValue { .. })
));
}
#[test]
fn a_tree_deeper_or_wider_than_the_ceilings_is_refused() {
let mut deep = text("leaf");
for _ in 0..(MAX_DEPTH + 2) {
deep = element("div", &[], vec![deep]);
}
assert_eq!(render(&[deep], 1 << 20), Err(RenderError::TooDeep));
let wide: Vec<FragmentNode> = (0..=MAX_NODES).map(|_| text("x")).collect();
assert_eq!(render(&wide, 1 << 20), Err(RenderError::TooManyNodes));
}
#[test]
fn an_overlong_tag_or_attribute_name_is_refused_without_being_copied() {
let huge = "p".repeat(512 * 1024);
assert!(matches!(
render(
&[FragmentNode::Element {
tag: huge.clone(),
attributes: Vec::new(),
children: Vec::new(),
}],
1 << 20
),
Err(RenderError::ForbiddenTag(_))
));
assert!(matches!(
render(
&[FragmentNode::Element {
tag: "p".to_owned(),
attributes: vec![(huge, "x".to_owned())],
children: Vec::new(),
}],
1 << 20
),
Err(RenderError::ForbiddenAttribute { .. })
));
assert_eq!(
render(
&[FragmentNode::Element {
tag: "P".to_owned(),
attributes: vec![("CLASS".to_owned(), "a".to_owned())],
children: Vec::new(),
}],
1 << 20
),
Ok("<p class=\"a\"></p>".to_owned())
);
}
#[test]
fn a_fragment_over_the_render_bytes_quota_is_refused_rather_than_truncated() {
let nodes: Vec<FragmentNode> = (0..64).map(|_| text(&"x".repeat(1024))).collect();
assert!(matches!(
render(&nodes, 4096),
Err(RenderError::TooLarge { .. })
));
}
#[test]
fn an_over_long_text_run_is_refused_before_it_is_escaped() {
let long = "x".repeat(MAX_TEXT_BYTES + 1);
assert_eq!(
render(&[text(&long)], 1 << 20),
Err(RenderError::TextTooLong(MAX_TEXT_BYTES + 1))
);
}
#[test]
fn every_allowed_tag_actually_renders() {
for tag in ALLOWED_TAGS {
assert!(
render(&[element(tag, &[], Vec::new())], 4096).is_ok(),
"<{tag}> is allowed but does not render"
);
}
}
#[test]
fn a_fragment_round_trips_through_the_wire_shape_a_guest_writes() {
let json = r#"[{"node":"element","tag":"p","children":[{"node":"text","text":"hi"}]}]"#;
let nodes: Vec<FragmentNode> = serde_json::from_str(json).expect("parses");
assert_eq!(render(&nodes, 4096), Ok("<p>hi</p>".to_owned()));
assert!(
serde_json::from_str::<Vec<FragmentNode>>(
r#"[{"node":"element","tag":"p","onclick":"x"}]"#
)
.is_err()
);
}
#[test]
fn one_element_may_not_carry_unbounded_attributes() {
let wide = FragmentNode::Element {
tag: "a".to_owned(),
attributes: (0..=MAX_ATTRIBUTES)
.map(|_| ("title".to_owned(), "x".to_owned()))
.collect(),
children: Vec::new(),
};
assert!(matches!(
render(&[wide], 1024 * 1024),
Err(RenderError::TooManyAttributes(_))
));
let heavy = FragmentNode::Element {
tag: "a".to_owned(),
attributes: (0..MAX_ATTRIBUTES)
.map(|_| ("title".to_owned(), "&".repeat(MAX_TEXT_BYTES)))
.collect(),
children: Vec::new(),
};
assert!(
matches!(render(&[heavy], 4096), Err(RenderError::TooLarge { .. })),
"a fragment far over the ceiling should be refused"
);
}
}