Skip to main content

vector/sinks/util/
path_confinement.rs

1//! Shared infrastructure for confining templated sink outputs to an
2//! operator-authored boundary.
3//!
4//! Sinks that render templates into security-relevant identifiers (paths,
5//! keys, URIs, …) use the helpers in this module to ensure the rendered
6//! value cannot escape the literal portion the operator wrote.
7
8use std::path::{Component, Path, PathBuf};
9
10use snafu::Snafu;
11use tokio::fs as tokio_fs;
12
13use crate::template::Template;
14
15/// Maximum byte length of a rendered path before it is rejected.
16///
17/// Bounds per-event cost (path canonicalization, directory creation) and
18/// provides a coarse cap on memory blow-up from attacker-controlled fields.
19pub const MAX_RENDERED_PATH_LEN: usize = 1024;
20
21/// Errors raised while building a [`PathConfinement`] from a template.
22#[derive(Debug, Snafu)]
23pub enum BuildError {
24    #[snafu(display(
25        "path template references event fields ({fields:?}) but has no \
26         literal directory prefix to derive a base directory from. Set \
27         `base_dir` explicitly, or set \
28         `dangerously_allow_unconfined_template_resolution: true` to opt out of path \
29         confinement (not recommended)."
30    ))]
31    NoDerivableBase { fields: Vec<String> },
32
33    #[snafu(display(
34        "path template literal prefix {prefix:?} normalizes to a filesystem \
35         root, which would permit writes anywhere on disk. Set `base_dir` \
36         explicitly (for example `base_dir: /var/log/vector`), or set \
37         `dangerously_allow_unconfined_template_resolution: true` to opt out of path \
38         confinement (not recommended)."
39    ))]
40    DerivedBaseIsRoot { prefix: String },
41
42    #[snafu(display("`base_dir` must be an absolute path, got {path:?}"))]
43    BaseNotAbsolute { path: PathBuf },
44
45    #[snafu(display(
46        "static path {path:?} is outside the configured `base_dir` {base:?}; \
47         this configuration would drop every event at runtime. \
48         Either align the path with `base_dir`, or remove `base_dir`."
49    ))]
50    StaticPathOutsideBase { path: PathBuf, base: PathBuf },
51}
52
53/// Errors raised while confining a rendered path against a base directory.
54#[derive(Debug, Snafu)]
55pub enum ConfineError {
56    #[snafu(display("rendered path contains a NUL byte"))]
57    NulByte,
58
59    #[snafu(display(
60        "rendered path {rendered:?} resolves outside the configured base \
61         directory {base:?}"
62    ))]
63    OutsideBase { rendered: PathBuf, base: PathBuf },
64
65    #[snafu(display("rendered path is {len} bytes; maximum allowed is {max}"))]
66    TooLong { len: usize, max: usize },
67
68    #[cfg(windows)]
69    #[snafu(display("rendered path contains a forbidden Windows component: {component:?}"))]
70    ForbiddenComponent { component: String },
71
72    #[snafu(display(
73        "rendered path {parent:?} resolves outside the base directory \
74         {base:?} after symlink resolution"
75    ))]
76    SymlinkEscape { parent: PathBuf, base: PathBuf },
77
78    #[snafu(display("I/O error while resolving base directory {path:?}: {source}"))]
79    BaseIo {
80        path: PathBuf,
81        source: std::io::Error,
82    },
83}
84
85/// Lexically resolve `.` and `..` in a path without touching the
86/// filesystem.
87///
88/// This is pure: it never follows symlinks, never reads the FS, and never
89/// pops past a root or prefix component. The result has the same root /
90/// prefix as the input.
91pub fn normalize_lexically(p: &Path) -> PathBuf {
92    let mut out: Vec<Component<'_>> = Vec::new();
93    for component in p.components() {
94        match component {
95            Component::Prefix(_) | Component::RootDir => {
96                out.push(component);
97            }
98            Component::CurDir => {}
99            Component::ParentDir => {
100                // Only pop if the last pushed component is a normal segment.
101                // Never pop past a root or prefix. If there is no normal
102                // segment to pop and no root anchor, retain the `..`
103                // (relative path).
104                let pop_idx = out.iter().rposition(|c| matches!(c, Component::Normal(_)));
105                match pop_idx {
106                    Some(idx) if idx == out.len() - 1 => {
107                        out.pop();
108                    }
109                    _ => {
110                        // No trailing Normal to pop.
111                        let has_anchor = out
112                            .iter()
113                            .any(|c| matches!(c, Component::Prefix(_) | Component::RootDir));
114                        if !has_anchor {
115                            out.push(component);
116                        }
117                    }
118                }
119            }
120            Component::Normal(_) => {
121                out.push(component);
122            }
123        }
124    }
125    let mut buf = PathBuf::new();
126    for c in out {
127        buf.push(c.as_os_str());
128    }
129    if buf.as_os_str().is_empty() {
130        buf.push(".");
131    }
132    buf
133}
134
135/// Returns `true` if `p` is exactly a filesystem root (no normal segments
136/// below the root or drive prefix).
137fn is_filesystem_root(p: &Path) -> bool {
138    let mut had_anchor = false;
139    for c in p.components() {
140        match c {
141            Component::Prefix(_) | Component::RootDir => had_anchor = true,
142            Component::CurDir => {}
143            _ => return false,
144        }
145    }
146    had_anchor
147}
148
149/// Truncate a literal-prefix string to the last path-separator boundary so
150/// that the returned slice is a clean directory prefix (no trailing partial
151/// component like `srv-` in `"/srv-{{id}}"`).
152fn truncate_to_separator(prefix: &str) -> &str {
153    let bytes = prefix.as_bytes();
154    let mut cut = 0usize;
155    for (i, b) in bytes.iter().enumerate() {
156        if *b == b'/' || (cfg!(windows) && *b == b'\\') {
157            cut = i + 1;
158        }
159    }
160    prefix.split_at(cut).0
161}
162
163/// Confines a rendered filesystem path to a base directory derived from a
164/// template's literal prefix.
165///
166/// Build with [`PathConfinement::for_template`] at sink construction time
167/// (no FS I/O). Use [`PathConfinement::confine`] before any FS mutation,
168/// and [`PathConfinement::verify_parent`] after `create_dir_all` to catch
169/// intermediate symlinks.
170#[derive(Debug)]
171pub struct PathConfinement {
172    base_lexical: PathBuf,
173    base_canonical: Option<PathBuf>,
174}
175
176impl PathConfinement {
177    /// Build a confinement for `tpl`. Returns:
178    /// - `Ok(Some(_))` with a base derived from `explicit` (if set) or from
179    ///   the template's literal prefix.
180    /// - `Ok(None)` only when the template has no field references AND no
181    ///   explicit base was set — nothing to confine.
182    /// - `Err(_)` if no usable base can be derived and `explicit` is unset.
183    ///
184    /// Performs no filesystem I/O.
185    pub fn for_template(
186        tpl: &Template,
187        explicit: Option<&Path>,
188    ) -> Result<Option<Self>, BuildError> {
189        // A static template with no explicit base has nothing to confine.
190        // But if `explicit` is set, the operator asked for confinement, so
191        // we still want to enforce `base_dir` + `O_NOFOLLOW` on the
192        // rendered path.
193        let fields = match tpl.get_fields() {
194            Some(f) => f,
195            None if explicit.is_none() => return Ok(None),
196            None => Vec::new(),
197        };
198
199        let base_path = match explicit {
200            Some(p) => {
201                if !p.is_absolute() {
202                    return Err(BuildError::BaseNotAbsolute {
203                        path: p.to_path_buf(),
204                    });
205                }
206                normalize_lexically(p)
207            }
208            None => {
209                let raw = tpl.literal_prefix();
210                let dir_prefix = truncate_to_separator(raw);
211                if dir_prefix.is_empty() {
212                    return Err(BuildError::NoDerivableBase { fields });
213                }
214                let candidate = normalize_lexically(Path::new(dir_prefix));
215                if !candidate.is_absolute() {
216                    return Err(BuildError::NoDerivableBase { fields });
217                }
218                if is_filesystem_root(&candidate) {
219                    return Err(BuildError::DerivedBaseIsRoot {
220                        prefix: dir_prefix.to_owned(),
221                    });
222                }
223                candidate
224            }
225        };
226
227        if explicit.is_some() && is_filesystem_root(&base_path) {
228            warn!(
229                message = "Configured `base_dir` is a filesystem root; path \
230                           confinement is effectively disabled.",
231                base_dir = ?base_path,
232            );
233        }
234
235        // Static template with an explicit base: the rendered path is known
236        // now, so validate it immediately rather than silently dropping every
237        // event at runtime.  Mirror the runtime join logic in `confine()`:
238        // relative paths are resolved against the base before comparison.
239        if fields.is_empty() {
240            let raw = Path::new(tpl.get_ref());
241            let resolved = if raw.is_absolute() {
242                normalize_lexically(raw)
243            } else {
244                normalize_lexically(&base_path.join(raw))
245            };
246            if !resolved.starts_with(&base_path) {
247                return Err(BuildError::StaticPathOutsideBase {
248                    path: resolved,
249                    base: base_path,
250                });
251            }
252        }
253
254        Ok(Some(Self {
255            base_lexical: base_path,
256            base_canonical: None,
257        }))
258    }
259
260    /// The lexical base directory used for containment checks.
261    pub fn base_dir(&self) -> &Path {
262        &self.base_lexical
263    }
264
265    /// Apply lexical confinement to a rendered path. Pure — runs before
266    /// any FS mutation.
267    pub fn confine(&self, rendered: &Path) -> Result<PathBuf, ConfineError> {
268        let raw_bytes = path_bytes(rendered);
269        if raw_bytes.contains(&0) {
270            return Err(ConfineError::NulByte);
271        }
272        if raw_bytes.len() > MAX_RENDERED_PATH_LEN {
273            return Err(ConfineError::TooLong {
274                len: raw_bytes.len(),
275                max: MAX_RENDERED_PATH_LEN,
276            });
277        }
278
279        let absolute = if rendered.is_absolute() {
280            rendered.to_path_buf()
281        } else {
282            self.base_lexical.join(rendered)
283        };
284        let normalized = normalize_lexically(&absolute);
285
286        #[cfg(windows)]
287        {
288            for c in normalized.components() {
289                if let Component::Normal(os) = c {
290                    let s = os.to_string_lossy();
291                    if s.contains(':') {
292                        return Err(ConfineError::ForbiddenComponent {
293                            component: s.into_owned(),
294                        });
295                    }
296                    if is_windows_reserved_name(&s) {
297                        return Err(ConfineError::ForbiddenComponent {
298                            component: s.into_owned(),
299                        });
300                    }
301                }
302            }
303        }
304
305        if !normalized.starts_with(&self.base_lexical) {
306            return Err(ConfineError::OutsideBase {
307                rendered: normalized,
308                base: self.base_lexical.clone(),
309            });
310        }
311
312        Ok(normalized)
313    }
314
315    /// Verify that `parent` (typically the parent directory of the file
316    /// about to be opened) canonicalizes to a location inside the confined
317    /// base directory.
318    ///
319    /// Catches intermediate symlinks placed by an **event-field attacker**
320    /// (a log producer that controls field values but cannot write to the
321    /// filesystem). A local attacker who can write inside `base_dir` could
322    /// race between this call and the subsequent `open` to swap a directory
323    /// for a symlink; closing that gap requires fd-based traversal
324    /// (`openat`/`cap-std`), which is Phase 1b scope.
325    pub async fn verify_parent(&mut self, parent: &Path) -> Result<PathBuf, ConfineError> {
326        if self.base_canonical.is_none() {
327            tokio_fs::create_dir_all(&self.base_lexical)
328                .await
329                .map_err(|source| ConfineError::BaseIo {
330                    path: self.base_lexical.clone(),
331                    source,
332                })?;
333            let canonical = tokio_fs::canonicalize(&self.base_lexical)
334                .await
335                .map_err(|source| ConfineError::BaseIo {
336                    path: self.base_lexical.clone(),
337                    source,
338                })?;
339            self.base_canonical = Some(canonical);
340        }
341        let base_canonical = self.base_canonical.as_ref().expect("just set");
342
343        let parent_canonical =
344            tokio_fs::canonicalize(parent)
345                .await
346                .map_err(|source| ConfineError::BaseIo {
347                    path: parent.to_path_buf(),
348                    source,
349                })?;
350
351        if !parent_canonical.starts_with(base_canonical) {
352            return Err(ConfineError::SymlinkEscape {
353                parent: parent_canonical,
354                base: base_canonical.clone(),
355            });
356        }
357
358        Ok(parent_canonical)
359    }
360}
361
362#[cfg(unix)]
363fn path_bytes(p: &Path) -> &[u8] {
364    use std::os::unix::ffi::OsStrExt;
365    p.as_os_str().as_bytes()
366}
367
368#[cfg(not(unix))]
369fn path_bytes(p: &Path) -> &[u8] {
370    // On Windows OsStr is WTF-8; falling back to to_string_lossy is fine
371    // for length and NUL-byte checks because a real NUL survives lossy
372    // conversion.
373    p.as_os_str().to_str().map(str::as_bytes).unwrap_or(&[])
374}
375
376#[cfg(windows)]
377fn is_windows_reserved_name(name: &str) -> bool {
378    // Strip extension for the check.
379    let stem = name
380        .rsplit_once('.')
381        .map(|(stem, _)| stem)
382        .unwrap_or(name)
383        .to_ascii_uppercase();
384    matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL")
385        || (stem.starts_with("COM")
386            && stem.len() == 4
387            && matches!(stem.as_bytes()[3], b'0'..=b'9' | 0xB9 | 0xB2 | 0xB3))
388        || (stem.starts_with("LPT")
389            && stem.len() == 4
390            && matches!(stem.as_bytes()[3], b'0'..=b'9' | 0xB9 | 0xB2 | 0xB3))
391}
392
393#[cfg(test)]
394mod tests {
395    use super::*;
396
397    fn pb(s: &str) -> PathBuf {
398        PathBuf::from(s)
399    }
400
401    #[test]
402    fn normalize_lexically_cases() {
403        let cases: &[(&str, &str)] = &[
404            ("/a/b/../c", "/a/c"),
405            ("/a/./b", "/a/b"),
406            ("/a//b", "/a/b"),
407            ("/..", "/"),
408            ("/../../etc", "/etc"),
409            ("../a", "../a"),
410            ("a/../../b", "../b"),
411        ];
412        for (input, expected) in cases {
413            assert_eq!(
414                normalize_lexically(&pb(input)),
415                pb(expected),
416                "input = {input:?}"
417            );
418        }
419    }
420
421    // Path-shape cases hard-code Unix absolute paths (`/var/log/...`) so the
422    // absolute/root/derived-base semantics apply. On Windows `/foo` is not
423    // absolute and would surface `NoDerivableBase` for cases that expect
424    // `DerivedBaseIsRoot`. The confinement logic itself is platform-neutral;
425    // only the string fixtures are Unix-shaped.
426    #[cfg(unix)]
427    #[test]
428    fn for_template_cases() {
429        enum Expected {
430            Static,
431            Base(&'static str),
432            ErrNoDerivable,
433            ErrDerivedIsRoot,
434            ErrNotAbsolute,
435            ErrStaticOutside,
436        }
437        use Expected::*;
438        let cases: &[(&str, Option<&str>, Expected)] = &[
439            // no field refs → no confinement needed
440            ("/var/log/app.log", None, Static),
441            // auto-derives base from literal prefix
442            ("/var/log/{{ host }}/app.log", None, Base("/var/log")),
443            // partial component before field → truncates to root → rejected
444            ("/srv-{{ id }}.log", None, ErrDerivedIsRoot),
445            // no literal prefix at all
446            ("{{ full_path }}", None, ErrNoDerivable),
447            // only `/` before first field
448            ("/{{ tenant }}/app.log", None, ErrDerivedIsRoot),
449            // explicit base overrides auto-derived
450            (
451                "/var/log/{{ host }}/app.log",
452                Some("/srv/tenants"),
453                Base("/srv/tenants"),
454            ),
455            // `%%` stops scan conservatively; base truncates to /tmp
456            ("/tmp/100%%/{{ x }}.log", None, Base("/tmp")),
457            // relative explicit base is always rejected
458            ("{{ x }}", Some("relative/dir"), ErrNotAbsolute),
459            // static path inside explicit base_dir → confinement applies
460            (
461                "/var/log/vector/out.log",
462                Some("/var/log/vector"),
463                Base("/var/log/vector"),
464            ),
465            // static path outside explicit base_dir → rejected at build time
466            ("/tmp/out.log", Some("/var/log/vector"), ErrStaticOutside),
467            // relative static path + explicit base → resolved and accepted
468            ("out.log", Some("/var/log/vector"), Base("/var/log/vector")),
469            // relative static path that traverses outside base → rejected
470            (
471                "../../etc/passwd",
472                Some("/var/log/vector"),
473                ErrStaticOutside,
474            ),
475        ];
476        for (tpl_src, explicit, expected) in cases {
477            let tpl = Template::try_from(*tpl_src).unwrap();
478            let explicit_path = explicit.map(Path::new);
479            let result = PathConfinement::for_template(&tpl, explicit_path);
480            match expected {
481                Static => assert!(result.unwrap().is_none(), "expected None for {tpl_src:?}"),
482                Base(b) => assert_eq!(
483                    result.unwrap().unwrap().base_dir(),
484                    pb(b),
485                    "tpl = {tpl_src:?}"
486                ),
487                ErrNoDerivable => assert!(
488                    matches!(result.unwrap_err(), BuildError::NoDerivableBase { .. }),
489                    "tpl = {tpl_src:?}"
490                ),
491                ErrDerivedIsRoot => assert!(
492                    matches!(result.unwrap_err(), BuildError::DerivedBaseIsRoot { .. }),
493                    "tpl = {tpl_src:?}"
494                ),
495                ErrNotAbsolute => assert!(
496                    matches!(result.unwrap_err(), BuildError::BaseNotAbsolute { .. }),
497                    "tpl = {tpl_src:?}"
498                ),
499                ErrStaticOutside => assert!(
500                    matches!(
501                        result.unwrap_err(),
502                        BuildError::StaticPathOutsideBase { .. }
503                    ),
504                    "tpl = {tpl_src:?}"
505                ),
506            }
507        }
508    }
509
510    // Same reasoning as `for_template_cases` — Unix-shaped fixtures.
511    #[cfg(unix)]
512    #[test]
513    fn confine_cases() {
514        // (template, rendered_path, expect_ok)
515        let cases: &[(&str, &str, bool)] = &[
516            // legitimate sub-path
517            (
518                "/var/log/{{ host }}/app.log",
519                "/var/log/host-a/app.log",
520                true,
521            ),
522            // `..` escape
523            (
524                "/var/log/apps/{{ s }}/app.log",
525                "/var/log/apps/../../../etc/cron.d/app.log",
526                false,
527            ),
528            // absolute path outside base
529            ("/var/log/{{ host }}/app.log", "/etc/passwd", false),
530            // string-prefix confusion: /var/logs ≠ /var/log
531            ("/var/log/{{ x }}", "/var/logs/x", false),
532            // %% base is /tmp; both %% and % variants fall under it
533            ("/tmp/100%%/{{ x }}.log", "/tmp/100%%/value.log", true),
534            ("/tmp/100%%/{{ x }}.log", "/tmp/100%/value.log", true),
535        ];
536        for (tpl_src, rendered, expect_ok) in cases {
537            let tpl = Template::try_from(*tpl_src).unwrap();
538            let c = PathConfinement::for_template(&tpl, None).unwrap().unwrap();
539            let result = c.confine(Path::new(rendered));
540            assert_eq!(
541                result.is_ok(),
542                *expect_ok,
543                "tpl={tpl_src:?} rendered={rendered:?} → {result:?}"
544            );
545        }
546    }
547
548    #[cfg(unix)]
549    #[test]
550    fn confine_blocks_nul_byte() {
551        use std::ffi::OsStr;
552        use std::os::unix::ffi::OsStrExt;
553        let tpl = Template::try_from("/var/log/{{ x }}").unwrap();
554        let c = PathConfinement::for_template(&tpl, None).unwrap().unwrap();
555        let p = Path::new(OsStr::from_bytes(b"/var/log/abc\0def"));
556        assert!(matches!(c.confine(p).unwrap_err(), ConfineError::NulByte));
557    }
558}