Skip to main content

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 (`&#106;` 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}