Skip to main content

max / docengine

Add a chat render preset that cannot fetch livechat's message docs already named "docengine's chat preset" as the thing that renders a body at insert. It did not exist, so a host wiring up chat would have reached for render_strict and got forum rendering in a one-line room. The preset is strict plus an inline-only tag allowlist: headings, blockquotes, lists and tables keep their text and lose their structure, and pre is off the list so a fenced block collapses to a code span. List items get a separator inserted before the tags come off, since unwrapping li would fuse them. The security half is that a link renders as an anchor carrying nofollow noopener and nothing else. Nothing here resolves or previews a URL, so a message cannot make the server fetch an address its author chose. That is pinned two ways: per-message, the URL appears once and only as the href; and structurally, against the lockfile, since a preview fetcher cannot be wired in without an HTTP client landing in the dependency tree first. Minor rather than patch: SanitizePreset gains a variant.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-01 15:01 UTC
Signed with PGP, not checked
Commit: 1ce9df5744251f45f71d6a40d4b0898b1e55242e
Parent: 25fba1b
6 files changed, +222 insertions, -7 deletions
M Cargo.toml +1 -1
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "docengine"
3 - version = "0.3.5"
3 + version = "0.4.0"
4 4 edition = "2024"
5 5 license = "MIT"
6 6
M README.md +13 -3
@@ -11,22 +11,24 @@
11 11
12 12 ## Presets
13 13
14 - Four rendering presets, each with different security/feature tradeoffs:
14 + Five rendering presets, each with different security/feature tradeoffs:
15 15
16 16 | Preset | Use case | Tables | Images | Raw HTML | Dangerous scheme filter | Sanitization |
17 17 |--------|----------|:------:|:------:|:--------:|:-----------------------:|--------------|
18 18 | **Permissive** | Docs, blog posts (trusted) | Y | Y | Y | N | Default ammonia |
19 19 | **Standard** | App text fields (descriptions) | Y | N | Y | N | Default ammonia |
20 20 | **Strict** | User-generated content (forums) | N | N | N | Y | nofollow on links |
21 + | **Chat** | Chat messages | N | N | N | Y | nofollow on links, inline tags only |
21 22 | **Sanitize-only** | External HTML (RSS feeds) | -- | -- | -- | -- | Default ammonia, no markdown parsing |
22 23
23 24 ```rust
24 - use docengine::{render_permissive, render_standard, render_strict, sanitize_html};
25 + use docengine::{render_chat, render_permissive, render_standard, render_strict, sanitize_html};
25 26
26 27 // Convenience functions
27 28 let html = render_permissive("# Hello\n\n**Bold** text");
28 29 let html = render_standard("A description with [link](https://example.com)");
29 30 let html = render_strict("User post with @mentions and `code`");
31 + let html = render_chat("A chat message with a [link](https://example.com)");
30 32 let html = sanitize_html("<p>Pre-rendered</p><script>stripped</script>");
31 33
32 34 // Builder pattern for custom configurations
@@ -71,7 +73,7 @@
71 73
72 74 - **`Renderer`** -- configurable markdown renderer with builder pattern
73 75 - **`RenderResult`** -- rendered HTML plus `word_count` and `reading_time_minutes`
74 - - **`SanitizePreset`** -- `Permissive`, `Standard`, `Strict`, `Minimal`
76 + - **`SanitizePreset`** -- `Permissive`, `Standard`, `Strict`, `Minimal`, `Chat`
75 77 - **`TocEntry`** -- heading level, text, and anchor for table of contents
76 78
77 79 ### Functions
@@ -81,6 +83,7 @@
81 83 | `render_permissive(md)` | Render with full GFM features |
82 84 | `render_standard(md)` | Render without images |
83 85 | `render_strict(md)` | Render with all restrictions (UGC-safe) |
86 + | `render_chat(md)` | Render a chat message: strict, and inline only |
84 87 | `sanitize_html(html)` | Clean pre-rendered HTML without markdown parsing |
85 88 | `word_count(text)` | Count words in raw text |
86 89 | `reading_time_minutes(wc)` | Estimate reading time (200 wpm) |
@@ -129,6 +132,13 @@
129 132 - Replaces `javascript:`, `data:`, `vbscript:` URLs with `#`
130 133 - Adds `rel="noopener noreferrer nofollow"` to all links
131 134
135 + The chat preset is strict plus an inline-only tag allowlist, and it carries one
136 + guarantee the others do not state: rendering a message makes no outbound request.
137 + A link becomes an anchor and stays one. There is no preview fetch, no URL
138 + resolution, no metadata lookup, and no HTTP client anywhere in this crate's
139 + dependency tree -- `chat_render_path_cannot_fetch` asserts the last of those
140 + against the lockfile, so a fetcher cannot be added without a test failing first.
141 +
132 142 Zero unsafe code.
133 143
134 144 ## License
@@ -9,8 +9,8 @@
9 9 ```
10 10 src/
11 11 lib.rs Crate root, re-exports, convenience functions
12 - render.rs Renderer struct (builder pattern, 4 presets, render/render_with_meta)
13 - sanitize.rs SanitizePreset enum (Permissive, Standard, Strict, Minimal)
12 + render.rs Renderer struct (builder pattern, 5 presets, render/render_with_meta)
13 + sanitize.rs SanitizePreset enum (Permissive, Standard, Strict, Minimal, Chat)
14 14 text.rs Text utilities (word_count, reading_time, extract_title, strip_first_heading)
15 15 toc.rs Table of contents extraction and HTML rendering
16 16 escape.rs HTML entity escaping for safe string interpolation
M src/lib.rs +8 -1
@@ -3,10 +3,11 @@
3 3 //! Design + roadmap: maintainer wiki.
4 4 //! <!-- wiki: docengine-overview -->
5 5 //!
6 - //! Provides four rendering presets for different trust levels:
6 + //! Provides five rendering presets for different trust levels:
7 7 //! - **Permissive** -- full GFM (tables, footnotes, images, raw HTML). For trusted content.
8 8 //! - **Standard** -- GFM without images. For app text fields.
9 9 //! - **Strict** -- no images, no raw HTML, dangerous scheme filtering, nofollow. For UGC.
10 + //! - **Chat** -- Strict, plus block structure flattened to inline. For chat messages.
10 11 //! - **Sanitize-only** -- ammonia cleaning without markdown parsing. For external HTML.
11 12 //!
12 13 //! Optional features add document loading, TOML frontmatter, @mention resolution,
@@ -70,6 +71,12 @@
70 71 Renderer::strict().render(markdown)
71 72 }
72 73
74 + /// Render a chat message: inline only, no images, no raw HTML, links carry
75 + /// `nofollow noopener`, and nothing is fetched.
76 + pub fn render_chat(markdown: &str) -> String {
77 + Renderer::chat().render(markdown)
78 + }
79 +
73 80 /// Sanitize HTML without markdown parsing.
74 81 pub fn sanitize_html(html: &str) -> String {
75 82 Renderer::sanitize_only().sanitize_html(html)
M src/render.rs +181
@@ -39,6 +39,7 @@
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,6 +94,7 @@
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,6 +112,7 @@
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,10 +130,39 @@
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,6 +176,7 @@
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,6 +253,18 @@
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,6 +340,16 @@
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,6 +637,132 @@
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("![alt](https://example.com/tracker.png)");
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]
@@ -10,8 +10,18 @@
10 10 Strict,
11 11 /// Only allows p, em, strong, code, br, pre tags.
12 12 Minimal,
13 + /// Inline-only allowlist plus `rel="noopener noreferrer nofollow"`. For chat
14 + /// messages, where block structure is not wanted and a link must never
15 + /// become anything the server has to fetch.
16 + Chat,
13 17 }
14 18
19 + /// Tags a chat message may keep. Everything else is unwrapped to its text, so a
20 + /// heading or a list survives as words rather than as layout. `pre` is absent
21 + /// deliberately: a fenced block collapses to an inline `code` span, which is the
22 + /// most a one-line message should carry.
23 + const CHAT_TAGS: [&str; 6] = ["a", "code", "del", "em", "p", "strong"];
24 +
15 25 /// The single in-repo authority for permissive (creator long-form) sanitization.
16 26 ///
17 27 /// `ammonia::clean` is exactly `Builder::default().clean()`, so the XSS guarantee
@@ -58,6 +68,13 @@
58 68 b.tags(tags);
59 69 b
60 70 }
71 + SanitizePreset::Chat => {
72 + let tags: std::collections::HashSet<&str> = CHAT_TAGS.iter().copied().collect();
73 + let mut b = ammonia::Builder::default();
74 + b.tags(tags);
75 + b.link_rel(Some("noopener noreferrer nofollow"));
76 + b
77 + }
61 78 };
62 79 if allow_heading_ids {
63 80 for tag in ["h1", "h2", "h3", "h4", "h5", "h6"] {