Skip to main content

asciidoc_parser/parser/
safe_mode.rs

1use crate::document::InterpretedValue;
2
3/// Describes the safe mode under which a document is parsed and rendered.
4///
5/// Safe modes provide a security model that controls how much a document is
6/// allowed to reach outside of itself. They mirror the safe modes defined by
7/// [Ruby Asciidoctor], and the discriminant values are chosen so that the
8/// modes compare in order of increasing safety (`Unsafe` < `Safe` < `Server` <
9/// `Secure`). Features that could expose the host environment (for example,
10/// embedding the contents of a file directly in the output) are only enabled
11/// when the safe mode is below a threshold.
12///
13/// The default safe mode is [`SafeMode::Secure`], matching the most
14/// conservative setting. A client may relax it via
15/// [`Parser::with_safe_mode`](crate::Parser::with_safe_mode).
16///
17/// # This crate performs no path-jail enforcement
18///
19/// Unlike Ruby Asciidoctor, this crate performs **no filesystem I/O of its
20/// own**. Reading `include::` targets, images, and SVGs is delegated to the
21/// client via [`IncludeFileHandler`](crate::parser::IncludeFileHandler),
22/// [`ImageFileHandler`](crate::parser::ImageFileHandler), and
23/// [`SvgFileHandler`](crate::parser::SvgFileHandler). As a consequence, the
24/// path-traversal jail that Ruby Asciidoctor applies through
25/// `PathResolver#system_path` – rejecting or clamping `../`, absolute paths,
26/// `file://` URIs, and symlinks that escape a jail root – is **deliberately not
27/// ported** (see [`PathResolver`](crate::parser::PathResolver)). Below
28/// [`Secure`](Self::Secure), the raw include/image/SVG target is handed to the
29/// client handler verbatim, with no traversal check and without communicating
30/// any jail boundary.
31///
32/// **Enforcing a jail is therefore the client handler's responsibility.** A
33/// handler that resolves untrusted targets against the filesystem must itself
34/// reject `../`, absolute paths, and `file://` targets and resolve symlinks
35/// against its own jail root; the safe mode alone will not do this for it.
36///
37/// [Ruby Asciidoctor]: https://docs.asciidoctor.org/asciidoc/latest/safe-modes/
38#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)]
39pub enum SafeMode {
40    /// A safe mode level that disables any of the security features enforced by
41    /// Asciidoctor (Ruby or otherwise). This mode is intended for use when the
42    /// document is entirely trusted.
43    Unsafe = 0,
44
45    /// In Ruby Asciidoctor, this level parallels [`Unsafe`](Self::Unsafe)
46    /// except that it prevents access to files which reside outside of the
47    /// parent directory of the source file.
48    ///
49    /// **This crate does not enforce that jail.** Because path resolution is
50    /// delegated to the client handlers (see the [type-level
51    /// docs](SafeMode#this-crate-performs-no-path-jail-enforcement)), `Safe`
52    /// currently imposes no restriction beyond [`Unsafe`](Self::Unsafe): the
53    /// include/image/SVG handlers are consulted and their contents embedded
54    /// exactly as under `Unsafe`, and no `../`/absolute/`file://` traversal
55    /// check is applied. Keeping untrusted targets inside a directory is the
56    /// handler's responsibility.
57    Safe = 1,
58
59    /// A safe mode level intended for server deployments (hence the name).
60    ///
61    /// In this crate, `Server` masks host-revealing intrinsic attributes so
62    /// they cannot leak into rendered output: `docdir` reads as empty,
63    /// `docfile` is relativized against `docdir`, and `user-home` reads as `.`
64    /// rather than the real home directory.
65    ///
66    /// **`Server` does not by itself disable include or asset embedding.**
67    /// Unlike what its name might suggest, at `Server` (and every level below
68    /// [`Secure`](Self::Secure)) the include/image/SVG handlers *are* consulted
69    /// and file contents *are* embedded: `include::` directives pull in file
70    /// contents, `data-uri` images are base64-embedded, and inline/interactive
71    /// SVGs are embedded. Disabling that embedding – and applying any path jail
72    /// – happens only at [`Secure`](Self::Secure) (for embedding) or in the
73    /// client handler (for the jail). A server-side integrator that must not
74    /// embed arbitrary file contents should use [`Secure`](Self::Secure), not
75    /// `Server`.
76    Server = 10,
77
78    /// A safe mode level that disables the embedding of file contents into the
79    /// output.
80    ///
81    /// At `Secure` (and above), `include::` directives are converted to links
82    /// to their targets rather than embedding file contents, `data-uri` image
83    /// embedding is disabled, inline and interactive SVGs render as ordinary
84    /// `<img>` elements, and docinfo files are ignored. This is the level at
85    /// which the include/image/SVG handlers stop being consulted for embedding.
86    ///
87    /// This mode allows the AsciiDoc document to be processed in a shared,
88    /// server-side environment, such as a wiki, where the document should not
89    /// be able to embed the contents of arbitrary files. Note that `Secure`
90    /// still enforces no path-traversal jail of its own (there is nothing left
91    /// for a jail to guard, since embedding is off); a client that resolves
92    /// targets against the filesystem at a lower safe mode must jail them
93    /// itself (see the [type-level
94    /// docs](SafeMode#this-crate-performs-no-path-jail-enforcement)).
95    ///
96    /// This is the default safe mode.
97    #[default]
98    Secure = 20,
99}
100
101impl SafeMode {
102    /// The lowercase name of this safe mode (`unsafe`, `safe`, `server`,
103    /// `secure`).
104    ///
105    /// This is the value exposed through the `safe-mode-name` intrinsic
106    /// attribute and is also used to build the `safe-mode-<name>` flag
107    /// attribute. It matches the (lowercased) name reported by Ruby
108    /// Asciidoctor.
109    pub(crate) fn name(self) -> &'static str {
110        match self {
111            Self::Unsafe => "unsafe",
112            Self::Safe => "safe",
113            Self::Server => "server",
114            Self::Secure => "secure",
115        }
116    }
117
118    /// The numeric level of this safe mode (`0`, `1`, `10`, or `20`).
119    ///
120    /// This is the value exposed through the `safe-mode-level` intrinsic
121    /// attribute. Higher numbers indicate a more restrictive (safer) mode.
122    pub(crate) fn level(self) -> u8 {
123        self as u8
124    }
125}
126
127/// Applies Ruby Asciidoctor's `SafeMode::Server`-and-greater masking of the
128/// `docdir` / `docfile` intrinsic attributes for a *read*.
129///
130/// Returns `Some(masked)` only when `name` is `docdir` or `docfile` and that
131/// attribute is currently set to a plain value (as reported by `raw_set_value`,
132/// which yields the *unmasked* stored value or `None` when the attribute is
133/// unset):
134///
135/// * `docdir` is masked to an empty value, so the host directory never leaks
136///   into rendered output.
137/// * `docfile` is relativized against `docdir` (see [`relativize_docfile`]),
138///   matching Asciidoctor's `docfile[(docdir.length + 1)..]` for the usual case
139///   where `docfile` sits under `docdir`, and falling back to the base name
140///   otherwise.
141///
142/// Returns `None` for any other name, and for an *unset* `docdir` / `docfile`
143/// (so a reference to one still resolves as missing rather than empty). Because
144/// the computation reads the *raw* stored values, the API-provided attributes
145/// are left untouched – a non-`Server` parser still reads them back verbatim.
146///
147/// Both [`Parser`](crate::Parser) and its
148/// [`ResolvedAttributes`](crate::parser::ResolvedAttributes) snapshot funnel
149/// their `docdir` / `docfile` reads through this one function (after confirming
150/// `safe >= SafeMode::Server`), so the two report identical values.
151pub(crate) fn masked_doc_path(
152    name: &str,
153    raw_set_value: impl Fn(&str) -> Option<String>,
154) -> Option<InterpretedValue> {
155    match name {
156        // `docdir` is blanked whenever it is set, regardless of its value.
157        "docdir" => raw_set_value("docdir").map(|_| InterpretedValue::Value(String::new())),
158
159        "docfile" => {
160            let docfile = raw_set_value("docfile")?;
161            let relative = relativize_docfile(&docfile, raw_set_value("docdir").as_deref());
162            Some(InterpretedValue::Value(relative))
163        }
164
165        _ => None,
166    }
167}
168
169/// Relativizes `docfile` against `docdir` for `SafeMode::Server` masking.
170///
171/// When `docfile` sits directly under `docdir` – i.e. it begins with the exact
172/// `docdir` prefix followed by a path separator – the prefix and separator are
173/// stripped, matching Ruby Asciidoctor's `docfile[(docdir.length + 1)..-1]`
174/// (which keeps any intermediate sub-directories, not just the base name). This
175/// is the normal case, since Asciidoctor derives `docdir` from `docfile`.
176///
177/// A trailing separator on `docdir` (e.g. `/some/dir/`) is ignored so the match
178/// still lands on a path-component boundary and nested components are
179/// preserved.
180///
181/// Unlike Asciidoctor, this crate exposes `docdir` and `docfile` as independent
182/// API attributes, so a caller can pair them inconsistently. Rather than slice
183/// at an unrelated byte offset (truncating the path, or dropping the first byte
184/// when `docdir` is empty), any `docdir` that is absent, empty, or not an
185/// actual prefix falls back to the file's base name (its trailing path
186/// segment).
187fn relativize_docfile(docfile: &str, docdir: Option<&str>) -> String {
188    // Normalize away any trailing separator(s) on `docdir` so a directory
189    // written as `/some/dir/` matches at the same component boundary as
190    // `/some/dir`; without this the relative remainder loses its leading
191    // separator and nested components would collapse to the base name.
192    if let Some(docdir) = docdir.map(|d| d.trim_end_matches(['/', '\\']))
193        && !docdir.is_empty()
194        && let Some(rest) = docfile.strip_prefix(docdir)
195        && let Some(after) = rest.strip_prefix(['/', '\\'])
196    {
197        return after.to_owned();
198    }
199
200    // No usable `docdir` prefix: use the base name (trailing path segment).
201    docfile
202        .rsplit(['/', '\\'])
203        .next()
204        .unwrap_or(docfile)
205        .to_owned()
206}
207
208#[cfg(test)]
209mod tests {
210    use super::relativize_docfile;
211
212    #[test]
213    fn strips_exact_docdir_prefix() {
214        assert_eq!(
215            relativize_docfile("/some/dir/sample.adoc", Some("/some/dir")),
216            "sample.adoc"
217        );
218    }
219
220    #[test]
221    fn keeps_subdirectories_below_docdir() {
222        assert_eq!(
223            relativize_docfile("/some/dir/sub/sample.adoc", Some("/some/dir")),
224            "sub/sample.adoc"
225        );
226    }
227
228    #[test]
229    fn strips_a_backslash_separated_prefix() {
230        assert_eq!(
231            relativize_docfile(r"C:\some\dir\sample.adoc", Some(r"C:\some\dir")),
232            "sample.adoc"
233        );
234    }
235
236    #[test]
237    fn falls_back_to_base_name_when_docfile_is_not_under_docdir() {
238        // A `docfile` outside `docdir` must not be truncated at an unrelated
239        // offset; it relativizes to its base name instead.
240        assert_eq!(
241            relativize_docfile("/some/different/file.adoc", Some("/some/dir")),
242            "file.adoc"
243        );
244    }
245
246    #[test]
247    fn falls_back_to_base_name_when_prefix_is_not_separator_aligned() {
248        // `docdir` is a leading substring of `docfile` but not a path component
249        // (no separator follows), so the slice would corrupt the name.
250        assert_eq!(
251            relativize_docfile("/some/dirfile.adoc", Some("/some/dir")),
252            "dirfile.adoc"
253        );
254    }
255
256    #[test]
257    fn ignores_a_trailing_separator_on_docdir() {
258        // A `docdir` written with a trailing separator still relativizes to the
259        // same component boundary, preserving nested path components.
260        assert_eq!(
261            relativize_docfile("/some/dir/sub/sample.adoc", Some("/some/dir/")),
262            "sub/sample.adoc"
263        );
264        assert_eq!(
265            relativize_docfile("/some/dir/sample.adoc", Some("/some/dir/")),
266            "sample.adoc"
267        );
268
269        // Multiple trailing separators, and the Windows separator, too.
270        assert_eq!(
271            relativize_docfile("/some/dir/sub/sample.adoc", Some("/some/dir///")),
272            "sub/sample.adoc"
273        );
274        assert_eq!(
275            relativize_docfile(r"C:\some\dir\sub\sample.adoc", Some(r"C:\some\dir\")),
276            r"sub\sample.adoc"
277        );
278    }
279
280    #[test]
281    fn treats_empty_docdir_as_no_prefix() {
282        // An empty `docdir` must not drop the first byte of `docfile`.
283        assert_eq!(
284            relativize_docfile("/some/dir/sample.adoc", Some("")),
285            "sample.adoc"
286        );
287    }
288
289    #[test]
290    fn falls_back_to_base_name_without_a_docdir() {
291        assert_eq!(
292            relativize_docfile("/some/dir/sample.adoc", None),
293            "sample.adoc"
294        );
295    }
296
297    #[test]
298    fn returns_a_bare_docfile_unchanged() {
299        // A `docfile` with no directory component is its own base name.
300        assert_eq!(relativize_docfile("sample.adoc", None), "sample.adoc");
301        assert_eq!(
302            relativize_docfile("sample.adoc", Some("/some/dir")),
303            "sample.adoc"
304        );
305    }
306}