| 39 |
39 |
|
strip_raw_html: bool,
|
| 40 |
40 |
|
dangerous_scheme_filter: bool,
|
| 41 |
41 |
|
heading_ids: bool,
|
|
42 |
+ |
inline_only: bool,
|
| 42 |
43 |
|
sanitize: SanitizePreset,
|
| 43 |
44 |
|
}
|
| 44 |
45 |
|
|
| 93 |
94 |
|
strip_raw_html: false,
|
| 94 |
95 |
|
dangerous_scheme_filter: false,
|
| 95 |
96 |
|
heading_ids: false,
|
|
97 |
+ |
inline_only: false,
|
| 96 |
98 |
|
sanitize: SanitizePreset::Permissive,
|
| 97 |
99 |
|
}
|
| 98 |
100 |
|
}
|
| 110 |
112 |
|
strip_raw_html: false,
|
| 111 |
113 |
|
dangerous_scheme_filter: false,
|
| 112 |
114 |
|
heading_ids: false,
|
|
115 |
+ |
inline_only: false,
|
| 113 |
116 |
|
sanitize: SanitizePreset::Standard,
|
| 114 |
117 |
|
}
|
| 115 |
118 |
|
}
|
| 127 |
130 |
|
strip_raw_html: true,
|
| 128 |
131 |
|
dangerous_scheme_filter: true,
|
| 129 |
132 |
|
heading_ids: false,
|
|
133 |
+ |
inline_only: false,
|
| 130 |
134 |
|
sanitize: SanitizePreset::Strict,
|
| 131 |
135 |
|
}
|
| 132 |
136 |
|
}
|
| 133 |
137 |
|
|
|
138 |
+ |
/// Inline-only, no images, no raw HTML, dangerous scheme blocking, nofollow
|
|
139 |
+ |
/// on links. Suitable for chat messages.
|
|
140 |
+ |
///
|
|
141 |
+ |
/// Narrower than [`strict`](Self::strict) in two ways that are policy rather
|
|
142 |
+ |
/// than taste. Block structure is flattened, because a message is one line in
|
|
143 |
+ |
/// a scrolling room and a heading or a table in it is layout an author should
|
|
144 |
+ |
/// not get to impose on the room. And a link stays a link: it renders as an
|
|
145 |
+ |
/// anchor carrying `nofollow noopener` and nothing else. Nothing in this
|
|
146 |
+ |
/// crate resolves, expands, or previews a URL, so rendering a message cannot
|
|
147 |
+ |
/// make the server fetch an attacker-chosen address. That is the security
|
|
148 |
+ |
/// boundary the preset exists to hold, and `chat_render_path_cannot_fetch`
|
|
149 |
+ |
/// in this module pins it.
|
|
150 |
+ |
pub fn chat() -> Self {
|
|
151 |
+ |
Self {
|
|
152 |
+ |
tables: false,
|
|
153 |
+ |
strikethrough: true,
|
|
154 |
+ |
footnotes: false,
|
|
155 |
+ |
smart_punctuation: false,
|
|
156 |
+ |
tasklists: false,
|
|
157 |
+ |
strip_images: true,
|
|
158 |
+ |
strip_raw_html: true,
|
|
159 |
+ |
dangerous_scheme_filter: true,
|
|
160 |
+ |
heading_ids: false,
|
|
161 |
+ |
inline_only: true,
|
|
162 |
+ |
sanitize: SanitizePreset::Chat,
|
|
163 |
+ |
}
|
|
164 |
+ |
}
|
|
165 |
+ |
|
| 134 |
166 |
|
/// No markdown parsing, only ammonia sanitization. Suitable for HTML from
|
| 135 |
167 |
|
/// external sources (RSS feeds).
|
| 136 |
168 |
|
pub fn sanitize_only() -> Self {
|
| 144 |
176 |
|
strip_raw_html: false,
|
| 145 |
177 |
|
dangerous_scheme_filter: false,
|
| 146 |
178 |
|
heading_ids: false,
|
|
179 |
+ |
inline_only: false,
|
| 147 |
180 |
|
sanitize: SanitizePreset::Permissive,
|
| 148 |
181 |
|
}
|
| 149 |
182 |
|
}
|
| 220 |
253 |
|
self
|
| 221 |
254 |
|
}
|
| 222 |
255 |
|
|
|
256 |
+ |
/// Keep the text of block constructs but not their structure.
|
|
257 |
+ |
///
|
|
258 |
+ |
/// The stripping itself is the sanitizer's tag allowlist. This flag only
|
|
259 |
+ |
/// covers what the allowlist cannot: unwrapping `<li>a</li><li>b</li>` fuses
|
|
260 |
+ |
/// the items into `ab`, so list items get a separator inserted before the
|
|
261 |
+ |
/// tags come off.
|
|
262 |
+ |
#[must_use]
|
|
263 |
+ |
pub fn with_inline_only(mut self, enabled: bool) -> Self {
|
|
264 |
+ |
self.inline_only = enabled;
|
|
265 |
+ |
self
|
|
266 |
+ |
}
|
|
267 |
+ |
|
| 223 |
268 |
|
fn build_options(&self) -> Options {
|
| 224 |
269 |
|
let mut opts = Options::empty();
|
| 225 |
270 |
|
if self.tables {
|
| 295 |
340 |
|
other => Some(other),
|
| 296 |
341 |
|
});
|
| 297 |
342 |
|
|
|
343 |
+ |
let inline_only = self.inline_only;
|
|
344 |
+ |
let filtered = filtered.flat_map(move |event| match event {
|
|
345 |
+ |
// The `</li>` is about to be unwrapped, taking the only thing that
|
|
346 |
+ |
// separated one item's text from the next with it.
|
|
347 |
+ |
Event::End(TagEnd::Item) if inline_only => {
|
|
348 |
+ |
vec![Event::End(TagEnd::Item), Event::SoftBreak]
|
|
349 |
+ |
}
|
|
350 |
+ |
other => vec![other],
|
|
351 |
+ |
});
|
|
352 |
+ |
|
| 298 |
353 |
|
let mut output = String::new();
|
| 299 |
354 |
|
if self.heading_ids {
|
| 300 |
355 |
|
// Buffering is required: a heading's anchor depends on text that
|
| 582 |
637 |
|
assert_eq!(Renderer::strict().render(""), "");
|
| 583 |
638 |
|
}
|
| 584 |
639 |
|
|
|
640 |
+ |
// --- chat preset
|
|
641 |
+ |
|
|
642 |
+ |
#[test]
|
|
643 |
+ |
fn chat_links_are_nofollow_noopener_anchors() {
|
|
644 |
+ |
let html = Renderer::chat().render("see [example](https://example.com)");
|
|
645 |
+ |
assert!(
|
|
646 |
+ |
html.contains(r#"href="https://example.com""#),
|
|
647 |
+ |
"got: {html}"
|
|
648 |
+ |
);
|
|
649 |
+ |
assert!(html.contains("nofollow"), "got: {html}");
|
|
650 |
+ |
assert!(html.contains("noopener"), "got: {html}");
|
|
651 |
+ |
}
|
|
652 |
+ |
|
|
653 |
+ |
#[test]
|
|
654 |
+ |
fn chat_render_path_cannot_fetch() {
|
|
655 |
+ |
// The security boundary: rendering a message must not turn a URL an
|
|
656 |
+ |
// author chose into a request the server makes. Two halves, and both
|
|
657 |
+ |
// have to hold.
|
|
658 |
+ |
//
|
|
659 |
+ |
// Half one, per-message: a link renders as an anchor and nothing else.
|
|
660 |
+ |
// No title, no expansion, no preview markup, no second element carrying
|
|
661 |
+ |
// the URL anywhere the browser or the server would follow eagerly.
|
|
662 |
+ |
let html = Renderer::chat().render("[a](http://169.254.169.254/latest/meta-data/)");
|
|
663 |
+ |
assert_eq!(
|
|
664 |
+ |
html.matches("169.254.169.254").count(),
|
|
665 |
+ |
1,
|
|
666 |
+ |
"the URL must appear once, as the href, and nowhere else: {html}"
|
|
667 |
+ |
);
|
|
668 |
+ |
assert!(!html.contains("<img"), "no eager fetch via img: {html}");
|
|
669 |
+ |
assert!(!html.contains("<link"), "no eager fetch via link: {html}");
|
|
670 |
+ |
assert!(!html.contains("preload"), "no eager fetch hint: {html}");
|
|
671 |
+ |
|
|
672 |
+ |
// Half two, structural, and the one that actually keeps the boundary:
|
|
673 |
+ |
// this crate has no way to make a request at all. A preview fetcher
|
|
674 |
+ |
// cannot be wired into the render path without an HTTP client landing
|
|
675 |
+ |
// in the lockfile first, so pin the lockfile. If this fails, someone
|
|
676 |
+ |
// added a client — the question to answer is whether the chat path can
|
|
677 |
+ |
// still be shown not to reach it, not whether to relax the list.
|
|
678 |
+ |
let lock = include_str!("../Cargo.lock");
|
|
679 |
+ |
for client in [
|
|
680 |
+ |
"reqwest",
|
|
681 |
+ |
"hyper",
|
|
682 |
+ |
"ureq",
|
|
683 |
+ |
"isahc",
|
|
684 |
+ |
"curl",
|
|
685 |
+ |
"attohttpc",
|
|
686 |
+ |
"surf",
|
|
687 |
+ |
] {
|
|
688 |
+ |
assert!(
|
|
689 |
+ |
!lock.contains(&format!("name = \"{client}\"")),
|
|
690 |
+ |
"{client} is in docengine's dependency tree; the chat render path \
|
|
691 |
+ |
can no longer be shown to make no outbound request"
|
|
692 |
+ |
);
|
|
693 |
+ |
}
|
|
694 |
+ |
}
|
|
695 |
+ |
|
|
696 |
+ |
#[test]
|
|
697 |
+ |
fn chat_flattens_block_structure() {
|
|
698 |
+ |
let r = Renderer::chat();
|
|
699 |
+ |
let html = r.render("## Heading\n\ntext");
|
|
700 |
+ |
assert!(!html.contains("<h2"), "no headings in chat: {html}");
|
|
701 |
+ |
assert!(html.contains("Heading"), "heading text survives: {html}");
|
|
702 |
+ |
|
|
703 |
+ |
let html = r.render("> quoted");
|
|
704 |
+ |
assert!(!html.contains("<blockquote"), "no blockquotes: {html}");
|
|
705 |
+ |
assert!(html.contains("quoted"), "quote text survives: {html}");
|
|
706 |
+ |
|
|
707 |
+ |
let html = r.render("| a | b |\n|---|---|\n| 1 | 2 |");
|
|
708 |
+ |
assert!(!html.contains("<table"), "no tables: {html}");
|
|
709 |
+ |
}
|
|
710 |
+ |
|
|
711 |
+ |
#[test]
|
|
712 |
+ |
fn chat_list_items_do_not_fuse() {
|
|
713 |
+ |
// Unwrapping <li> would run "one" and "two" together into "onetwo".
|
|
714 |
+ |
let html = Renderer::chat().render("- one\n- two");
|
|
715 |
+ |
assert!(!html.contains("<ul"), "no lists: {html}");
|
|
716 |
+ |
assert!(
|
|
717 |
+ |
!html.contains("onetwo"),
|
|
718 |
+ |
"items must stay separated: {html}"
|
|
719 |
+ |
);
|
|
720 |
+ |
assert!(html.contains("one"), "got: {html}");
|
|
721 |
+ |
assert!(html.contains("two"), "got: {html}");
|
|
722 |
+ |
}
|
|
723 |
+ |
|
|
724 |
+ |
#[test]
|
|
725 |
+ |
fn chat_keeps_inline_emphasis_and_code() {
|
|
726 |
+ |
let html = Renderer::chat().render("**bold** *italic* `code()` ~~struck~~");
|
|
727 |
+ |
assert!(html.contains("<strong>bold</strong>"), "got: {html}");
|
|
728 |
+ |
assert!(html.contains("<em>italic</em>"), "got: {html}");
|
|
729 |
+ |
assert!(html.contains("<code>code()</code>"), "got: {html}");
|
|
730 |
+ |
assert!(html.contains("<del>struck</del>"), "got: {html}");
|
|
731 |
+ |
}
|
|
732 |
+ |
|
|
733 |
+ |
#[test]
|
|
734 |
+ |
fn chat_fenced_block_collapses_to_a_code_span() {
|
|
735 |
+ |
// `pre` is off the allowlist, so a fenced block keeps its code
|
|
736 |
+ |
// semantics without claiming a block of the room.
|
|
737 |
+ |
let html = Renderer::chat().render("```\nlet x = 1;\n```");
|
|
738 |
+ |
assert!(!html.contains("<pre"), "no pre in chat: {html}");
|
|
739 |
+ |
assert!(html.contains("<code>"), "code span survives: {html}");
|
|
740 |
+ |
assert!(html.contains("let x = 1;"), "got: {html}");
|
|
741 |
+ |
}
|
|
742 |
+ |
|
|
743 |
+ |
#[test]
|
|
744 |
+ |
fn chat_strips_images_and_raw_html() {
|
|
745 |
+ |
let r = Renderer::chat();
|
|
746 |
+ |
let html = r.render("");
|
|
747 |
+ |
assert!(!html.contains("<img"), "got: {html}");
|
|
748 |
+ |
assert!(!html.contains("tracker.png"), "no external src: {html}");
|
|
749 |
+ |
|
|
750 |
+ |
let html = r.render("hello <b>bold</b> <script>alert(1)</script>");
|
|
751 |
+ |
assert!(!html.contains("<b>"), "got: {html}");
|
|
752 |
+ |
assert!(!html.contains("<script"), "got: {html}");
|
|
753 |
+ |
}
|
|
754 |
+ |
|
|
755 |
+ |
#[test]
|
|
756 |
+ |
fn chat_neutralizes_dangerous_schemes() {
|
|
757 |
+ |
let html = Renderer::chat().render("[x](javascript:alert(1))");
|
|
758 |
+ |
assert!(!html.contains("javascript:"), "got: {html}");
|
|
759 |
+ |
}
|
|
760 |
+ |
|
|
761 |
+ |
#[test]
|
|
762 |
+ |
fn chat_empty_input() {
|
|
763 |
+ |
assert_eq!(Renderer::chat().render(""), "");
|
|
764 |
+ |
}
|
|
765 |
+ |
|
| 585 |
766 |
|
// --- Sanitize-only preset
|
| 586 |
767 |
|
|
| 587 |
768 |
|
#[test]
|