Skip to main content

rumtk_web/utils/
sanitize_html.rs

1/*
2 *     rumtk attempts to implement HL7 and medical protocols for interoperability in medicine.
3 *     This toolkit aims to be reliable, simple, performant, and standards compliant.
4 *     Copyright (C) 2026  Luis M. Santos, M.D. <lsantos@medicalmasses.com>
5 *     Copyright (C) 2026  MedicalMasses L.L.C. <contact@medicalmasses.com>
6 *
7 *     This program is free software: you can redistribute it and/or modify
8 *     it under the terms of the GNU General Public License as published by
9 *     the Free Software Foundation, either version 3 of the License, or
10 *     (at your option) any later version.
11 *
12 *     This program is distributed in the hope that it will be useful,
13 *     but WITHOUT ANY WARRANTY; without even the implied warranty of
14 *     MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15 *     GNU General Public License for more details.
16 *
17 *     You should have received a copy of the GNU General Public License
18 *     along with this program.  If not, see <https://www.gnu.org/licenses/>.
19 */
20use ammonia::Builder;
21use rumtk_core::dependencies::maplit::{hashmap, hashset};
22use rumtk_core::strings::RUMString;
23use std::borrow::Borrow;
24use std::collections::{HashMap, HashSet};
25use std::iter::IntoIterator;
26use std::sync::LazyLock;
27
28type AllowedSet = LazyLock<HashSet<&'static str>>;
29type AllowedMap = LazyLock<HashMap<&'static str, HashSet<&'static str>>>;
30
31static CLEAN_CONTENT_TAGS: AllowedSet = LazyLock::new(|| hashset!["script", "style"]);
32static CLEAN_CONTENT_TAGS_RELAXED: AllowedSet = LazyLock::new(|| hashset![]);
33static ALLOWED_URL_SCHEMES: AllowedSet = LazyLock::new(|| hashset![
34    "https", "mailto", "data"
35]);
36static ALLOWED_URL_SCHEMES_RELAXED: AllowedSet = LazyLock::new(|| hashset![
37    "http", "https", "mailto", "data"
38]);
39const ALLOWED_LINK_POLICY: Option<&str> = Some("noopener noreferrer");
40static ALLOWED_TAGS: AllowedSet = LazyLock::new(|| hashset![
41    "animate", "video", "object", "img", "noscript", "meta", "link",
42    "title", "form", "input", "select", "option", "textarea",
43    "button", "label", "fieldset", "legend",
44
45    "svg", "circle", "path", "polygon"
46]);
47static ALLOWED_TAGS_RELAXED: AllowedSet = LazyLock::new(|| hashset![
48    "script", "html", "head", "body", "header", "main", "footer", "style"
49]);
50static ALLOWED_GENERIC_ATTR: AllowedSet = LazyLock::new(|| hashset![
51    "class", "open", "hidden", "alt", "type", "height", "width", "href", "id", "data",
52    "action", "formaction",
53    "name", "for", "value", "max", "placeholder", "accept", "alt", "pattern",
54    "maxlength", "minlength", "autocapitalize",
55    "autocomplete", "autocorrect", "autofocus", "disabled", "hidden", "required",
56    "content"
57]);
58static ALLOWED_GENERIC_ATTR_RELAXED: AllowedSet = LazyLock::new(|| hashset![
59    "onload", "onerror", "style", "src", "srcset", "sizes", "width", "height",
60    "fetchpriority", "defer", "role", "as", "rel", "lang",
61    "onload", "onerror", "onclick", "ondblclick", "onmouseover", "onmouseout",
62    "onmousedown", "onmouseup", "onwheel", "onkeydown", "onkeyup", "onkeypress",
63    "onchange", "onfocus", "onblur", "oninput", "onsubmit", "onreset", "onunload",
64    "onresize", "onhashchange", "onplay", "onpause", "onended", "onvolumechange",
65    "ontimeupdate"
66]);
67static ALLOWED_HTMX_ATTR: AllowedSet = LazyLock::new(|| hashset![
68    //Core attributes
69    "hx-get", "hx-post", "hx-put", "hx-patch", "hx-delete", "hx-on", "hx-push-url",
70    "hx-select", "hx-select-oob", "hx-swap", "hx-swap-oob", "hx-target", "hx-trigger",
71    "hx-vals",
72    //Additional attributes
73    "hx-boost", "hx-confirm", "hx-disable", "hx-disable-elt", "hx-disinherit",
74    "hx-encoding", "hx-ext", "hx-headers", "hx-history", "hx-history-elt", "hx-include",
75    "hx-indicator", "hx-inherit", "hx-params", "hx-preserve", "hx-prompt", "hx-replace-url",
76    "hx-request", "hx-sync", "hx-validate", "hx-vars",
77]);
78static mut ALLOWED_ATTRS: AllowedMap = LazyLock::new(|| hashmap![
79            "a" => hashset![
80                "href", "hreflang"
81            ],
82            "bdo" => hashset![
83                "dir"
84            ],
85            "blockquote" => hashset![
86                "cite"
87            ],
88            "col" => hashset![
89                "align", "char", "charoff", "span"
90            ],
91            "colgroup" => hashset![
92                "align", "char", "charoff", "span"
93            ],
94            "del" => hashset![
95                "cite", "datetime"
96            ],
97            "hr" => hashset![
98                "align", "size", "width"
99            ],
100            "img" => hashset![
101                "align", "alt", "height", "src", "width"
102            ],
103            "ins" => hashset![
104                "cite", "datetime"
105            ],
106            "ol" => hashset![
107                "start"
108            ],
109            "q" => hashset![
110                "cite"
111            ],
112            "table" => hashset![
113                "align", "char", "charoff", "summary"
114            ],
115            "tbody" => hashset![
116                "align", "char", "charoff"
117            ],
118            "td" => hashset![
119                "align", "char", "charoff", "colspan", "headers", "rowspan"
120            ],
121            "tfoot" => hashset![
122                "align", "char", "charoff"
123            ],
124            "th" => hashset![
125                "align", "char", "charoff", "colspan", "headers", "rowspan", "scope"
126            ],
127            "thead" => hashset![
128                "align", "char", "charoff"
129            ],
130            "tr" => hashset![
131                "align", "char", "charoff"
132            ],
133
134
135            "object" => hashset![
136                "data", "type", "img"
137            ],
138            "path" => hashset![
139                "d", "stroke-linecap", "stroke-linejoin"
140            ],
141            "svg" => hashset![
142                "fill", "height", "opacity", "stroke", "stroke-width", "viewbox",
143                "width", "version", "baseProfile"
144            ],
145            "polygon" => hashset![
146                "points", "fill", "stroke", "stroke-width"
147            ],
148            "circle" => hashset![
149                "cx", "cy", "r", "fill", "stroke", "stroke-width"
150            ],
151            "video" => hashset![
152                "src", "controls", "autoplay", "loop", "muted", "width", "height"
153            ],
154            "animate" => hashset![
155                "attributeName", "from", "to", "dur", "fill", "begin", "repeatCount"
156            ],
157]);
158static mut ALLOWED_ATTRS_RELAXED: AllowedMap = LazyLock::new(|| hashmap![
159            "a" => hashset![
160                "href", "hreflang"
161            ],
162            "bdo" => hashset![
163                "dir"
164            ],
165            "blockquote" => hashset![
166                "cite"
167            ],
168            "col" => hashset![
169                "align", "char", "charoff", "span"
170            ],
171            "colgroup" => hashset![
172                "align", "char", "charoff", "span"
173            ],
174            "del" => hashset![
175                "cite", "datetime"
176            ],
177            "hr" => hashset![
178                "align", "size", "width"
179            ],
180            "img" => hashset![
181                "align", "alt", "height", "src", "width"
182            ],
183            "ins" => hashset![
184                "cite", "datetime"
185            ],
186            "ol" => hashset![
187                "start"
188            ],
189            "q" => hashset![
190                "cite"
191            ],
192            "table" => hashset![
193                "align", "char", "charoff", "summary"
194            ],
195            "tbody" => hashset![
196                "align", "char", "charoff"
197            ],
198            "td" => hashset![
199                "align", "char", "charoff", "colspan", "headers", "rowspan"
200            ],
201            "tfoot" => hashset![
202                "align", "char", "charoff"
203            ],
204            "th" => hashset![
205                "align", "char", "charoff", "colspan", "headers", "rowspan", "scope"
206            ],
207            "thead" => hashset![
208                "align", "char", "charoff"
209            ],
210            "tr" => hashset![
211                "align", "char", "charoff"
212            ],
213
214
215            "object" => hashset![
216                "data", "type", "img"
217            ],
218            "style" => hashset![
219            ],
220            "script" => hashset![
221                "src", "integrity", "crossorigin"
222            ],
223            "input" => hashset![
224                "id", "name", "for", "type", "value", "max", "placeholder",
225                "accept", "alt", "pattern", "maxlength", "minlength", "autocapitalize",
226                "autocomplete", "autocorrect", "autofocus", "disabled", "hidden", "required",
227                "onload", "onerror", "onclick", "ondblclick", "onmouseover", "onmouseout",
228                "onmousedown", "onmouseup", "onwheel", "onkeydown", "onkeyup", "onkeypress",
229                "onchange", "onfocus", "onblur", "oninput", "onsubmit", "onreset", "onunload",
230                "onresize", "onhashchange", "onplay", "onpause", "onended", "onvolumechange",
231                "ontimeupdate",
232            ],
233            "select" => hashset![
234                "id", "name", "for", "type", "value", "max", "placeholder",
235                "accept", "alt", "pattern", "maxlength", "minlength", "autocapitalize",
236                "autocomplete", "autocorrect", "autofocus", "disabled", "hidden", "required",
237                "onload", "onerror", "onclick", "ondblclick", "onmouseover", "onmouseout",
238                "onmousedown", "onmouseup", "onwheel", "onkeydown", "onkeyup", "onkeypress",
239                "onchange", "onfocus", "onblur", "oninput", "onsubmit", "onreset", "onunload",
240                "onresize", "onhashchange", "onplay", "onpause", "onended", "onvolumechange",
241                "ontimeupdate",
242            ],
243            "path" => hashset![
244                "d", "stroke-linecap", "stroke-linejoin"
245            ],
246            "svg" => hashset![
247                "fill", "height", "opacity", "stroke", "stroke-width", "viewbox",
248                "width", "version", "baseProfile"
249            ],
250            "polygon" => hashset![
251                "points", "fill", "stroke", "stroke-width"
252            ],
253            "circle" => hashset![
254                "cx", "cy", "r", "fill", "stroke", "stroke-width"
255            ],
256            "video" => hashset![
257                "src", "controls", "autoplay", "loop", "muted", "width", "height"
258            ],
259            "animate" => hashset![
260                "attributeName", "from", "to", "dur", "fill", "begin", "repeatCount"
261            ],
262]);
263static mut STRICT_SANITIZER: LazyLock<Builder> = LazyLock::new(|| unsafe {
264        let mut sanitizer = Builder::default();
265        default_init_sanitizer(&mut sanitizer, |sanitizer| { });
266        sanitizer
267    }
268);
269static mut RELAXED_SANITIZER: LazyLock<Builder> = LazyLock::new(|| unsafe {
270        let mut sanitizer = Builder::default();
271        default_init_sanitizer(&mut sanitizer, |sanitizer| {
272            sanitizer
273                .link_rel(None)
274                .add_tags((*ALLOWED_TAGS_RELAXED).clone())
275                .url_schemes((*ALLOWED_URL_SCHEMES_RELAXED).clone())
276                .add_generic_attributes((*ALLOWED_GENERIC_ATTR_RELAXED).clone())
277                .clean_content_tags(CLEAN_CONTENT_TAGS_RELAXED.clone())
278                ;
279            }
280        );
281        sanitizer
282    }
283);
284
285#[inline]
286unsafe fn default_init_sanitizer(builder: &mut Builder, init_closure: impl FnOnce(&mut Builder)) {
287    builder
288        .link_rel(ALLOWED_LINK_POLICY)
289        .url_schemes(ALLOWED_URL_SCHEMES.clone())
290        .add_tags((*ALLOWED_TAGS).clone())
291        .tag_attributes((*ALLOWED_ATTRS).clone())
292        .add_generic_attributes((*ALLOWED_GENERIC_ATTR).clone())
293        .add_generic_attributes((*ALLOWED_HTMX_ATTR).clone())
294        .clean_content_tags(CLEAN_CONTENT_TAGS.clone())
295        .strip_comments(true);
296    init_closure(builder);
297}
298
299#[inline]
300pub fn select_sanitizer<'a>(relaxed: bool) -> &'a Builder<'static> {
301    match relaxed {
302        true => unsafe {&*RELAXED_SANITIZER},
303        false => unsafe {&*STRICT_SANITIZER},
304    }
305}
306
307#[inline]
308pub fn select_sanitizer_mut<'a>(relaxed: bool) -> &'a mut Builder<'static> {
309    match relaxed {
310        true => unsafe {&mut *RELAXED_SANITIZER},
311        false => unsafe {&mut *STRICT_SANITIZER},
312    }
313}
314
315#[inline]
316pub fn sanitizer_update_attributes<T: 'static + ?Sized + Borrow<str>, I: IntoIterator<Item = &'static T>>(it: I, relaxed: bool) {
317    select_sanitizer_mut(relaxed).add_generic_attributes(it);
318}
319
320#[inline]
321pub fn sanitizer_update_tag_attributes<T: 'static + ?Sized + Borrow<str>, U: 'static + ?Sized + Borrow<str>, I: IntoIterator<Item = &'static T>>(tag: &'static U, it: I, relaxed: bool) {
322    select_sanitizer_mut(relaxed).add_tag_attributes(tag, it);
323}
324
325#[inline]
326pub fn sanitizers_update_attributes<T: 'static + ?Sized + Borrow<str>, I: IntoIterator<Item = &'static T> + Clone>(it: I) {
327    select_sanitizer_mut(false).add_generic_attributes(it.clone());
328    select_sanitizer_mut(true).add_generic_attributes(it);
329}
330
331#[inline]
332pub fn sanitizers_update_tag_attributes<T: 'static + ?Sized + Borrow<str>, I: IntoIterator<Item = &'static T> + Clone>(tag: &'static str, it: I) {
333    select_sanitizer_mut(false).add_tag_attributes(tag, it.clone());
334    select_sanitizer_mut(false).rm_clean_content_tags(&[tag]);
335    select_sanitizer_mut(true).add_tag_attributes(tag, it);
336    select_sanitizer_mut(true).rm_clean_content_tags(&[tag]);
337}
338
339#[inline]
340pub fn sanitize_html_strict(html: &str) -> RUMString {
341    select_sanitizer(false).clean(html).into()
342}
343
344#[inline]
345pub fn sanitize_html_relaxed(html: &str) -> RUMString {
346    select_sanitizer(true).clean(html).into()
347}
348
349#[inline]
350pub fn sanitize_html(html: &str, relaxed: bool) -> RUMString {
351    select_sanitizer(relaxed).clean(html).into()
352}