1use std::path::{Component, Path, PathBuf};
9
10use snafu::Snafu;
11use tokio::fs as tokio_fs;
12
13use crate::template::Template;
14
15pub const MAX_RENDERED_PATH_LEN: usize = 1024;
20
21#[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#[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
85pub 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 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 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
135fn 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
149fn 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#[derive(Debug)]
171pub struct PathConfinement {
172 base_lexical: PathBuf,
173 base_canonical: Option<PathBuf>,
174}
175
176impl PathConfinement {
177 pub fn for_template(
186 tpl: &Template,
187 explicit: Option<&Path>,
188 ) -> Result<Option<Self>, BuildError> {
189 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 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 pub fn base_dir(&self) -> &Path {
262 &self.base_lexical
263 }
264
265 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 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 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 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 #[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 ("/var/log/app.log", None, Static),
441 ("/var/log/{{ host }}/app.log", None, Base("/var/log")),
443 ("/srv-{{ id }}.log", None, ErrDerivedIsRoot),
445 ("{{ full_path }}", None, ErrNoDerivable),
447 ("/{{ tenant }}/app.log", None, ErrDerivedIsRoot),
449 (
451 "/var/log/{{ host }}/app.log",
452 Some("/srv/tenants"),
453 Base("/srv/tenants"),
454 ),
455 ("/tmp/100%%/{{ x }}.log", None, Base("/tmp")),
457 ("{{ x }}", Some("relative/dir"), ErrNotAbsolute),
459 (
461 "/var/log/vector/out.log",
462 Some("/var/log/vector"),
463 Base("/var/log/vector"),
464 ),
465 ("/tmp/out.log", Some("/var/log/vector"), ErrStaticOutside),
467 ("out.log", Some("/var/log/vector"), Base("/var/log/vector")),
469 (
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 #[cfg(unix)]
512 #[test]
513 fn confine_cases() {
514 let cases: &[(&str, &str, bool)] = &[
516 (
518 "/var/log/{{ host }}/app.log",
519 "/var/log/host-a/app.log",
520 true,
521 ),
522 (
524 "/var/log/apps/{{ s }}/app.log",
525 "/var/log/apps/../../../etc/cron.d/app.log",
526 false,
527 ),
528 ("/var/log/{{ host }}/app.log", "/etc/passwd", false),
530 ("/var/log/{{ x }}", "/var/logs/x", false),
532 ("/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}