vertigo_cmark/policy.rs
1//! What of raw HTML and link destinations gets from markdown into the rendered tree.
2//!
3//! Markdown allows raw HTML and links to any destination, and vertigo-cmark builds DOM nodes
4//! from them directly — there is no HTML string to clean afterwards with a sanitizer like
5//! `ammonia`. So every element and attribute of raw HTML and every destination of a link
6//! or an image goes through an [`HtmlPolicy`] first.
7//!
8//! [`SafeHtml`], used by [`to_vertigo`](crate::to_vertigo) and its variants, lets through
9//! what markdown authors usually need and nothing that runs scripts: the same whitelist
10//! as `ammonia`'s defaults. [`TrustedHtml`] renders everything as written — only for content
11//! you control. For anything in between implement [`HtmlPolicy`] yourself, delegating
12//! to [`SafeHtml`] what you don't change:
13//!
14//! ```
15//! use std::borrow::Cow;
16//! use vertigo_cmark::{ElementAction, HtmlPolicy, SafeHtml};
17//!
18//! /// Safe HTML plus YouTube players.
19//! struct WithVideos;
20//!
21//! impl HtmlPolicy for WithVideos {
22//! fn element(&self, name: &str) -> ElementAction {
23//! match name {
24//! "iframe" => ElementAction::Keep,
25//! name => SafeHtml.element(name),
26//! }
27//! }
28//!
29//! fn attribute<'v>(&self, element: &str, name: &str, value: &'v str) -> Option<Cow<'v, str>> {
30//! match (element, name) {
31//! ("iframe", "src") => value
32//! .starts_with("https://www.youtube.com/embed/")
33//! .then_some(Cow::Borrowed(value)),
34//! ("iframe", "allowfullscreen") => Some(Cow::Borrowed(value)),
35//! _ => SafeHtml.attribute(element, name, value),
36//! }
37//! }
38//!
39//! fn destination<'u>(&self, url: &'u str, image: bool) -> Option<Cow<'u, str>> {
40//! SafeHtml.destination(url, image)
41//! }
42//! }
43//! ```
44//!
45//! Heading attributes (`{#id .class}`) and footnote labels are not checked: they end up
46//! only in `id` and `class` and can't run anything.
47
48use std::borrow::Cow;
49
50/// What happens to an element of raw HTML.
51#[derive(Clone, Copy, PartialEq, Eq, Debug)]
52pub enum ElementAction {
53 /// The element stays, with the attributes [`HtmlPolicy::attribute`] lets through.
54 Keep,
55 /// Only the tag goes away, its content stays in place.
56 Unwrap,
57 /// The element goes away together with its content — like `<script>`.
58 Remove,
59}
60
61/// Decides what of raw HTML and link destinations gets into the rendered tree.
62pub trait HtmlPolicy {
63 /// What happens to an element of raw HTML. The name is lowercase.
64 fn element(&self, name: &str) -> ElementAction;
65
66 /// The value an attribute of a kept element gets — the same one or changed —
67 /// or `None`, which drops the attribute. Names are lowercase and character references
68 /// in values are already resolved (`j` is `j`).
69 fn attribute<'v>(&self, element: &str, name: &str, value: &'v str) -> Option<Cow<'v, str>>;
70
71 /// The destination of a markdown link or image (`image`), autolinks included, or `None`,
72 /// which leaves the link text without a link and the image replaced by its description.
73 fn destination<'u>(&self, url: &'u str, image: bool) -> Option<Cow<'u, str>>;
74}
75
76/// Elements and attributes from `ammonia`'s default whitelist, links and images only with safe
77/// destinations ([`is_safe_url`]). Scripts, styles, event handlers, frames, forms
78/// and `javascript:` addresses don't get through. Elements outside the whitelist lose just
79/// their tags; `<script>` and `<style>` go away together with their content.
80#[derive(Clone, Copy, Default, Debug)]
81pub struct SafeHtml;
82
83/// Everything as written, scripts included — only for content you control.
84#[derive(Clone, Copy, Default, Debug)]
85pub struct TrustedHtml;
86
87/// Elements [`SafeHtml`] keeps.
88const ELEMENTS: &[&str] = &[
89 "a",
90 "abbr",
91 "acronym",
92 "area",
93 "article",
94 "aside",
95 "b",
96 "bdi",
97 "bdo",
98 "blockquote",
99 "br",
100 "caption",
101 "center",
102 "cite",
103 "code",
104 "col",
105 "colgroup",
106 "data",
107 "dd",
108 "del",
109 "details",
110 "dfn",
111 "div",
112 "dl",
113 "dt",
114 "em",
115 "figcaption",
116 "figure",
117 "footer",
118 "h1",
119 "h2",
120 "h3",
121 "h4",
122 "h5",
123 "h6",
124 "header",
125 "hgroup",
126 "hr",
127 "i",
128 "img",
129 "ins",
130 "kbd",
131 "li",
132 "map",
133 "mark",
134 "nav",
135 "ol",
136 "p",
137 "pre",
138 "q",
139 "rp",
140 "rt",
141 "rtc",
142 "ruby",
143 "s",
144 "samp",
145 "small",
146 "span",
147 "strike",
148 "strong",
149 "sub",
150 "summary",
151 "sup",
152 "table",
153 "tbody",
154 "td",
155 "th",
156 "thead",
157 "time",
158 "tr",
159 "tt",
160 "u",
161 "ul",
162 "var",
163 "wbr",
164];
165
166/// Elements [`SafeHtml`] removes together with their content.
167const REMOVED_WITH_CONTENT: &[&str] = &["script", "style"];
168
169/// Attributes [`SafeHtml`] keeps on every element.
170const GENERIC_ATTRIBUTES: &[&str] = &["lang", "title"];
171
172/// Attributes [`SafeHtml`] keeps on particular elements.
173const ELEMENT_ATTRIBUTES: &[(&str, &[&str])] = &[
174 ("a", &["href", "hreflang"]),
175 ("bdo", &["dir"]),
176 ("blockquote", &["cite"]),
177 ("col", &["align", "char", "charoff", "span"]),
178 ("colgroup", &["align", "char", "charoff", "span"]),
179 ("del", &["cite", "datetime"]),
180 ("hr", &["align", "size", "width"]),
181 ("img", &["align", "alt", "height", "src", "width"]),
182 ("ins", &["cite", "datetime"]),
183 ("ol", &["start"]),
184 ("q", &["cite"]),
185 ("table", &["align", "char", "charoff", "summary"]),
186 ("tbody", &["align", "char", "charoff"]),
187 (
188 "td",
189 &["align", "char", "charoff", "colspan", "headers", "rowspan"],
190 ),
191 ("tfoot", &["align", "char", "charoff"]),
192 (
193 "th",
194 &[
195 "align", "char", "charoff", "colspan", "headers", "rowspan", "scope",
196 ],
197 ),
198 ("thead", &["align", "char", "charoff"]),
199 ("tr", &["align", "char", "charoff"]),
200];
201
202/// Attributes with an address the browser opens or loads.
203const URL_ATTRIBUTES: &[&str] = &[
204 "action",
205 "background",
206 "cite",
207 "data",
208 "formaction",
209 "href",
210 "longdesc",
211 "ping",
212 "poster",
213 "src",
214 "xlink:href",
215];
216
217/// Schemes [`is_safe_url`] accepts — the same as `ammonia`'s defaults.
218const SAFE_SCHEMES: &[&str] = &[
219 "bitcoin",
220 "ftp",
221 "ftps",
222 "geo",
223 "http",
224 "https",
225 "im",
226 "irc",
227 "ircs",
228 "magnet",
229 "mailto",
230 "mms",
231 "mx",
232 "news",
233 "nntp",
234 "openpgp4fpr",
235 "sip",
236 "sms",
237 "smsto",
238 "ssh",
239 "tel",
240 "url",
241 "webcal",
242 "wtai",
243 "xmpp",
244];
245
246impl HtmlPolicy for SafeHtml {
247 fn element(&self, name: &str) -> ElementAction {
248 if REMOVED_WITH_CONTENT.contains(&name) {
249 ElementAction::Remove
250 } else if ELEMENTS.contains(&name) {
251 ElementAction::Keep
252 } else {
253 ElementAction::Unwrap
254 }
255 }
256
257 fn attribute<'v>(&self, element: &str, name: &str, value: &'v str) -> Option<Cow<'v, str>> {
258 let listed = GENERIC_ATTRIBUTES.contains(&name)
259 || ELEMENT_ATTRIBUTES
260 .iter()
261 .any(|(tag, attributes)| *tag == element && attributes.contains(&name));
262 let safe = !URL_ATTRIBUTES.contains(&name) || is_safe_url(value);
263 (listed && safe).then_some(Cow::Borrowed(value))
264 }
265
266 fn destination<'u>(&self, url: &'u str, _image: bool) -> Option<Cow<'u, str>> {
267 is_safe_url(url).then_some(Cow::Borrowed(url))
268 }
269}
270
271impl HtmlPolicy for TrustedHtml {
272 fn element(&self, _name: &str) -> ElementAction {
273 ElementAction::Keep
274 }
275
276 fn attribute<'v>(&self, _element: &str, _name: &str, value: &'v str) -> Option<Cow<'v, str>> {
277 Some(Cow::Borrowed(value))
278 }
279
280 fn destination<'u>(&self, url: &'u str, _image: bool) -> Option<Cow<'u, str>> {
281 Some(Cow::Borrowed(url))
282 }
283}
284
285/// Whether the address is relative (`/page`, `#note`, `image.png`) or has one of the schemes
286/// `ammonia` accepts by default (`https:`, `mailto:`…) — so never `javascript:`, `vbscript:`,
287/// `data:` or `file:`. The scheme is read the way browsers read it: without spaces and control
288/// characters around the address, without tabs and newlines inside (`java\tscript:`)
289/// and regardless of case.
290pub fn is_safe_url(url: &str) -> bool {
291 scheme(url).is_none_or(|scheme| SAFE_SCHEMES.contains(&scheme.as_str()))
292}
293
294/// Scheme of the address in lowercase, `None` for a relative address.
295fn scheme(url: &str) -> Option<String> {
296 let cleaned: String = url
297 .trim_matches(|character: char| character <= ' ')
298 .chars()
299 .filter(|character| !matches!(character, '\t' | '\n' | '\r'))
300 .collect();
301 let (candidate, _) = cleaned.split_once(':')?;
302 let mut characters = candidate.chars();
303 let first = characters.next()?;
304 let is_scheme = first.is_ascii_alphabetic()
305 && characters.all(|character| {
306 character.is_ascii_alphanumeric() || matches!(character, '+' | '-' | '.')
307 });
308 is_scheme.then(|| candidate.to_ascii_lowercase())
309}