| 78 |
78 |
|
Renderer::sanitize_only().sanitize_html(html)
|
| 79 |
79 |
|
}
|
| 80 |
80 |
|
|
|
81 |
+ |
/// Drop `src`/`poster`/`srcset` on media elements (`img`/`video`/`audio`/
|
|
82 |
+ |
/// `source`) that point at an off-platform host, so creator markdown cannot load
|
|
83 |
+ |
/// external images (tracking pixels: an external `<img>` leaks every viewer's
|
|
84 |
+ |
/// IP/UA/referrer to an arbitrary third party — contrary to the no-tracking
|
|
85 |
+ |
/// promise). Relative URLs and URLs under `allowed_host` are kept; external text
|
|
86 |
+ |
/// links are untouched (only media loads matter for the beacon). Run this on the
|
|
87 |
+ |
/// permissive-rendered HTML *before* `img_to_video`, since it works on `<img>`.
|
|
88 |
+ |
/// Additive and opt-in — existing `render_permissive` behaviour is unchanged.
|
|
89 |
+ |
pub fn restrict_media_hosts(html: &str, allowed_host: &str) -> String {
|
|
90 |
+ |
let allowed = allowed_host.trim_end_matches('/').to_string();
|
|
91 |
+ |
ammonia::Builder::default()
|
|
92 |
+ |
.attribute_filter(move |element, attribute, value| {
|
|
93 |
+ |
let is_media_src = matches!(
|
|
94 |
+ |
(element, attribute),
|
|
95 |
+ |
("img", "src" | "srcset")
|
|
96 |
+ |
| ("video", "src" | "poster")
|
|
97 |
+ |
| ("source", "src" | "srcset")
|
|
98 |
+ |
| ("audio", "src")
|
|
99 |
+ |
);
|
|
100 |
+ |
if is_media_src {
|
|
101 |
+ |
let v = value.trim();
|
|
102 |
+ |
let on_platform =
|
|
103 |
+ |
!v.contains("://") || v.starts_with('/') || v.starts_with(&allowed);
|
|
104 |
+ |
if !on_platform {
|
|
105 |
+ |
return None; // drop the attribute → no external media load
|
|
106 |
+ |
}
|
|
107 |
+ |
}
|
|
108 |
+ |
Some(std::borrow::Cow::Borrowed(value))
|
|
109 |
+ |
})
|
|
110 |
+ |
.clean(html)
|
|
111 |
+ |
.to_string()
|
|
112 |
+ |
}
|
|
113 |
+ |
|
| 81 |
114 |
|
#[cfg(test)]
|
| 82 |
115 |
|
mod top_level_tests {
|
| 83 |
116 |
|
use super::*;
|
| 87 |
120 |
|
// suite (the wrappers were untested at the function level).
|
| 88 |
121 |
|
|
| 89 |
122 |
|
#[test]
|
|
123 |
+ |
fn restrict_media_hosts_drops_external_img_keeps_cdn_and_links() {
|
|
124 |
+ |
let cdn = "https://cdn.makenot.work";
|
|
125 |
+ |
let html = concat!(
|
|
126 |
+ |
r#"<img src="https://tracker.evil/px.png?u=v">"#,
|
|
127 |
+ |
r#"<img src="https://cdn.makenot.work/u/a.png">"#,
|
|
128 |
+ |
r#"<img src="/rel/b.png">"#,
|
|
129 |
+ |
r#"<a href="https://example.com/post">link</a>"#,
|
|
130 |
+ |
);
|
|
131 |
+ |
let out = restrict_media_hosts(html, cdn);
|
|
132 |
+ |
assert!(!out.contains("tracker.evil"), "external img src must be dropped: {out}");
|
|
133 |
+ |
assert!(out.contains("cdn.makenot.work/u/a.png"), "cdn img kept: {out}");
|
|
134 |
+ |
assert!(out.contains("/rel/b.png"), "relative img kept: {out}");
|
|
135 |
+ |
assert!(out.contains(r#"href="https://example.com/post""#), "external text link kept: {out}");
|
|
136 |
+ |
}
|
|
137 |
+ |
|
|
138 |
+ |
#[test]
|
| 90 |
139 |
|
fn render_permissive_emits_paragraph_markup() {
|
| 91 |
140 |
|
let out = render_permissive("Hello **world**.");
|
| 92 |
141 |
|
assert!(out.contains("<p>"), "expected paragraph: {out:?}");
|