diff --git a/src/sinks/file/mod.rs b/src/sinks/file/mod.rs index f07a4a907b..c95b34cac5 100644 --- a/src/sinks/file/mod.rs +++ b/src/sinks/file/mod.rs @@ -1,4 +1,5 @@ use std::convert::TryFrom; +use std::path::{Path, PathBuf}; use std::time::{Duration, Instant}; use async_compression::tokio::write::{GzipEncoder, ZstdEncoder}; @@ -33,7 +34,10 @@ use crate::{ internal_events::{ FileBytesSent, FileInternalMetricsConfig, FileIoError, FileOpen, TemplateRenderingError, }, - sinks::util::{timezone_to_offset, StreamSink}, + sinks::util::{ + path_confinement::{ConfineError, PathConfinement}, + timezone_to_offset, StreamSink, + }, template::Template, }; @@ -57,6 +61,29 @@ pub struct FileSinkConfig { #[configurable(metadata(docs::examples = "/tmp/vector-%Y-%m-%d.log.zst"))] pub path: Template, + /// Base directory used to confine templated `path` values. + /// + /// When `path` references event fields, Vector rejects any rendered path + /// that resolves outside of this directory (for example, via a `../` + /// sequence in the field's value), preventing writes outside the + /// intended log directory. If unset, the base directory is derived from + /// the literal prefix of the `path` template, up to the last `/` before + /// the first field reference. + #[configurable(metadata(docs::examples = "/var/log/vector"))] + #[serde(default)] + pub base_dir: Option, + + /// Disables confinement of templated `path` values to a base directory. + /// + /// This field only has an effect when `path` references event fields. + /// + /// **Warning**: enabling this allows any event field referenced by + /// `path` to place the output file anywhere on the filesystem the + /// Vector process can write to. Only enable this if every field + /// referenced in `path` is fully trusted. + #[serde(default)] + pub dangerously_allow_unconfined_template_resolution: bool, + /// The amount of time that a file can be idle and stay open. /// /// After not receiving any events in this amount of time, the file is flushed and closed. @@ -95,6 +122,8 @@ impl GenerateConfig for FileSinkConfig { fn generate_config() -> toml::Value { toml::Value::try_from(Self { path: Template::try_from("/tmp/vector-%Y-%m-%d.log").unwrap(), + base_dir: Default::default(), + dangerously_allow_unconfined_template_resolution: Default::default(), idle_timeout: default_idle_timeout(), encoding: (None::, TextSerializerConfig::default()).into(), compression: Default::default(), @@ -204,6 +233,7 @@ impl SinkConfig for FileSinkConfig { pub struct FileSink { path: Template, + path_confinement: Option, transformer: Transformer, encoder: Encoder, idle_timeout: Duration, @@ -224,8 +254,20 @@ impl FileSink { .or(cx.globals.timezone) .and_then(timezone_to_offset); + let path_confinement = if config.dangerously_allow_unconfined_template_resolution { + warn!( + message = "Path confinement is disabled for this file sink; templated \ + `path` values can write anywhere the Vector process has \ + filesystem access to.", + ); + None + } else { + PathConfinement::for_template(&config.path, config.base_dir.as_deref())? + }; + Ok(Self { path: config.path.clone().with_tz_offset(offset), + path_confinement, transformer, encoder, idle_timeout: config.idle_timeout, @@ -254,6 +296,18 @@ impl FileSink { Some(bytes) } + /// Confines the rendered `path` bytes to the sink's configured base + /// directory, if confinement is enabled. Returns an owned, normalized + /// path suitable for filesystem operations. + fn confine_path(&self, path: &Bytes) -> Result { + let bytes_path = BytesPath::new(path.clone()); + let rendered: &Path = bytes_path.as_ref(); + match &self.path_confinement { + Some(confinement) => confinement.confine(rendered), + None => Ok(rendered.to_path_buf()), + } + } + fn deadline_at(&self) -> Instant { Instant::now() .checked_add(self.idle_timeout) @@ -345,7 +399,24 @@ impl FileSink { file } else { trace!(message = "Opening new file.", ?path); - let file = match open_file(BytesPath::new(path.clone())).await { + let confined_path = match self.confine_path(&path) { + Ok(confined_path) => confined_path, + Err(error) => { + // The rendered path escapes the sink's confinement base + // directory (or otherwise fails validation); refuse to + // touch the filesystem and drop the event. + emit!(FileIoError { + code: "path_confinement_violation", + message: "Rendered path failed confinement check.", + error: std::io::Error::new(std::io::ErrorKind::InvalidInput, error), + path: &path, + dropped_events: 1, + }); + event.metadata().update_status(EventStatus::Errored); + return; + } + }; + let file = match open_file(confined_path).await { Ok(file) => file, Err(error) => { // We couldn't open the file for this event. @@ -399,6 +470,13 @@ impl FileSink { } } +// `path` is confined (see `FileSink::confine_path`), so this rejects `../` +// escapes. It still resolves through `std::fs`'s plain `open`/`create_dir_all` +// (not `openat`-relative), so a symlink planted at an intermediate component +// before `create_dir_all` runs would still be followed. Closing that +// TOCTOU gap would mean resolving relative to a directory handle instead of +// a path string — evaluate `cap-std` (https://github.com/bytecodealliance/cap-std) +// for that if it's ever worth the added (sync, spawn_blocking-bridged) dependency. async fn open_file(path: impl AsRef) -> std::io::Result { let parent = path.as_ref().parent(); @@ -448,7 +526,7 @@ mod tests { use similar_asserts::assert_eq; use vector_lib::{ codecs::JsonSerializerConfig, - event::{LogEvent, TraceEvent}, + event::{BatchNotifier, BatchStatus, LogEvent, TraceEvent}, sink::VectorSink, }; @@ -474,6 +552,8 @@ mod tests { let config = FileSinkConfig { path: template.clone().try_into().unwrap(), + base_dir: Default::default(), + dangerously_allow_unconfined_template_resolution: Default::default(), idle_timeout: default_idle_timeout(), encoding: (None::, TextSerializerConfig::default()).into(), compression: Compression::None, @@ -500,6 +580,8 @@ mod tests { let config = FileSinkConfig { path: template.clone().try_into().unwrap(), + base_dir: Default::default(), + dangerously_allow_unconfined_template_resolution: Default::default(), idle_timeout: default_idle_timeout(), encoding: (None::, TextSerializerConfig::default()).into(), compression: Compression::Gzip, @@ -526,6 +608,8 @@ mod tests { let config = FileSinkConfig { path: template.clone().try_into().unwrap(), + base_dir: Default::default(), + dangerously_allow_unconfined_template_resolution: Default::default(), idle_timeout: default_idle_timeout(), encoding: (None::, TextSerializerConfig::default()).into(), compression: Compression::Zstd, @@ -557,6 +641,8 @@ mod tests { let config = FileSinkConfig { path: template.try_into().unwrap(), + base_dir: Default::default(), + dangerously_allow_unconfined_template_resolution: Default::default(), idle_timeout: default_idle_timeout(), encoding: (None::, TextSerializerConfig::default()).into(), compression: Compression::None, @@ -639,6 +725,8 @@ mod tests { let config = FileSinkConfig { path: template.clone().try_into().unwrap(), + base_dir: Default::default(), + dangerously_allow_unconfined_template_resolution: Default::default(), idle_timeout: Duration::from_secs(1), encoding: (None::, TextSerializerConfig::default()).into(), compression: Compression::None, @@ -695,6 +783,8 @@ mod tests { let config = FileSinkConfig { path: template.clone().try_into().unwrap(), + base_dir: Default::default(), + dangerously_allow_unconfined_template_resolution: Default::default(), idle_timeout: default_idle_timeout(), encoding: (None::, TextSerializerConfig::default()).into(), compression: Compression::None, @@ -726,6 +816,8 @@ mod tests { let config = FileSinkConfig { path: template.try_into().unwrap(), + base_dir: Default::default(), + dangerously_allow_unconfined_template_resolution: Default::default(), idle_timeout: default_idle_timeout(), encoding: (None::, TextSerializerConfig::default()).into(), compression: Compression::None, @@ -777,6 +869,8 @@ mod tests { let config = FileSinkConfig { path: template.clone().try_into().unwrap(), + base_dir: Default::default(), + dangerously_allow_unconfined_template_resolution: Default::default(), idle_timeout: default_idle_timeout(), encoding: (None::, JsonSerializerConfig::default()).into(), compression: Compression::None, @@ -827,4 +921,161 @@ mod tests { }) .await; } + + fn confinement_test_config( + path: Template, + base_dir: Option, + ) -> FileSinkConfig { + FileSinkConfig { + path, + base_dir, + dangerously_allow_unconfined_template_resolution: false, + idle_timeout: default_idle_timeout(), + encoding: (None::, TextSerializerConfig::default()).into(), + compression: Compression::None, + acknowledgements: Default::default(), + timezone: Default::default(), + internal_metrics: FileInternalMetricsConfig { + include_file_tag: true, + }, + } + } + + #[tokio::test] + async fn path_confinement_rejects_traversal() { + trace_init(); + + // `allowed` is the derived confinement base (the template's literal + // prefix); `secret` is a sibling directory that a `../` escape would + // land in if confinement didn't reject it. + let scratch = temp_dir(); + let allowed = scratch.join("allowed"); + let secret = scratch.join("secret"); + + let mut template = allowed.to_string_lossy().to_string(); + template.push_str("/{{ appname }}.log"); + let config = confinement_test_config(template.try_into().unwrap(), None); + + let sink = FileSink::new(&config, SinkContext::default()).unwrap(); + + let (legit_batch, mut legit_receiver) = BatchNotifier::new_with_receiver(); + let mut legit_event = LogEvent::from("safe line").with_batch_notifier(&legit_batch); + legit_event.insert("appname", "safe"); + + let (evil_batch, mut evil_receiver) = BatchNotifier::new_with_receiver(); + let mut evil_event = LogEvent::from("evil line").with_batch_notifier(&evil_batch); + evil_event.insert("appname", "../secret/evil"); + + drop(legit_batch); + drop(evil_batch); + + let events = vec![Event::Log(legit_event), Event::Log(evil_event)]; + + VectorSink::from_event_streamsink(sink) + .run(Box::pin(stream::iter(events).map(Into::into))) + .await + .expect("Running sink failed"); + + assert_eq!(legit_receiver.try_recv(), Ok(BatchStatus::Delivered)); + assert_eq!(evil_receiver.try_recv(), Ok(BatchStatus::Errored)); + + assert_eq!( + lines_from_file(allowed.join("safe.log")), + vec!["safe line".to_string()] + ); + assert!( + !secret.exists(), + "confinement should have prevented the `../` escape from creating {secret:?}" + ); + } + + #[tokio::test] + async fn path_confinement_disabled_by_flag_allows_traversal() { + trace_init(); + + let scratch = temp_dir(); + let allowed = scratch.join("allowed"); + let secret = scratch.join("secret"); + + let mut template = allowed.to_string_lossy().to_string(); + template.push_str("/{{ appname }}.log"); + let mut config = confinement_test_config(template.try_into().unwrap(), None); + config.dangerously_allow_unconfined_template_resolution = true; + + let sink = FileSink::new(&config, SinkContext::default()).unwrap(); + + let (evil_batch, mut evil_receiver) = BatchNotifier::new_with_receiver(); + let mut evil_event = LogEvent::from("evil line").with_batch_notifier(&evil_batch); + evil_event.insert("appname", "../secret/evil"); + + drop(evil_batch); + + VectorSink::from_event_streamsink(sink) + .run(Box::pin( + stream::iter(vec![Event::Log(evil_event)]).map(Into::into), + )) + .await + .expect("Running sink failed"); + + assert_eq!(evil_receiver.try_recv(), Ok(BatchStatus::Delivered)); + assert_eq!( + lines_from_file(secret.join("evil.log")), + vec!["evil line".to_string()] + ); + } + + #[tokio::test] + async fn path_confinement_base_dir_override() { + trace_init(); + + let base = temp_dir(); + let config = confinement_test_config( + Template::try_from("{{ appname }}.log").unwrap(), + Some(base.clone()), + ); + + let sink = FileSink::new(&config, SinkContext::default()).unwrap(); + + let (batch, mut receiver) = BatchNotifier::new_with_receiver(); + let mut event = LogEvent::from("hello").with_batch_notifier(&batch); + event.insert("appname", "safe"); + + drop(batch); + + VectorSink::from_event_streamsink(sink) + .run(Box::pin( + stream::iter(vec![Event::Log(event)]).map(Into::into), + )) + .await + .expect("Running sink failed"); + + assert_eq!(receiver.try_recv(), Ok(BatchStatus::Delivered)); + assert_eq!( + lines_from_file(base.join("safe.log")), + vec!["hello".to_string()] + ); + } + + #[test] + fn path_confinement_build_error_without_derivable_base() { + let config = + confinement_test_config(Template::try_from("{{ appname }}.log").unwrap(), None); + + let error = FileSink::new(&config, SinkContext::default()) + .err() + .expect("expected sink construction to fail without a derivable base directory"); + assert!( + error.to_string().contains("no literal directory prefix"), + "unexpected error: {error}" + ); + } + + #[test] + fn dangerously_allow_unconfined_skips_confinement_build() { + let mut config = + confinement_test_config(Template::try_from("{{ appname }}.log").unwrap(), None); + config.dangerously_allow_unconfined_template_resolution = true; + + assert!(FileSink::new(&config, SinkContext::default()).is_ok()); + } } diff --git a/src/sinks/util/mod.rs b/src/sinks/util/mod.rs index 63bce66a13..f0b45974cc 100644 --- a/src/sinks/util/mod.rs +++ b/src/sinks/util/mod.rs @@ -13,9 +13,10 @@ pub mod http; pub mod metadata; pub mod normalizer; pub mod partitioner; +pub mod path_confinement; pub mod processed_event; -pub mod request_builder; pub mod rejection_report; +pub mod request_builder; pub mod retries; pub mod service; pub mod sink; @@ -45,13 +46,13 @@ pub use buffer::{ Buffer, Compression, PartitionBuffer, PartitionInnerBuffer, }; pub use builder::SinkBuilderExt; +pub use compressor::Compressor; +pub use compressor::Decompressor; pub use jwt_auth::AuthTokenConfig; #[cfg(feature = "sinks-vector")] pub use jwt_auth::{AuthState, AuthToken}; -pub use compressor::Compressor; -pub use compressor::Decompressor; -pub use rejection_report::{emit_rejection_error, RejectionContext, RejectionReport}; pub use normalizer::Normalizer; +pub use rejection_report::{emit_rejection_error, RejectionContext, RejectionReport}; pub use request_builder::{IncrementalRequestBuilder, RequestBuilder}; pub use service::{ Concurrency, ServiceBuilderExt, TowerBatchedSink, TowerPartitionSink, TowerRequestConfig, diff --git a/src/sinks/util/path_confinement.rs b/src/sinks/util/path_confinement.rs new file mode 100644 index 0000000000..454766266c --- /dev/null +++ b/src/sinks/util/path_confinement.rs @@ -0,0 +1,437 @@ +//! Shared infrastructure for confining templated sink outputs to an +//! operator-authored boundary. +//! +//! Sinks that render templates into filesystem paths use the helpers in this +//! module to ensure the rendered value cannot escape the literal portion the +//! operator wrote. + +use std::path::{Component, Path, PathBuf}; + +use snafu::Snafu; + +use crate::template::Template; + +/// Maximum byte length of a rendered path before it is rejected. +/// +/// Bounds per-event cost (path canonicalization, directory creation) and +/// provides a coarse cap on memory blow-up from attacker-controlled fields. +pub const MAX_RENDERED_PATH_LEN: usize = 1024; + +/// Errors raised while building a [`PathConfinement`] from a template. +#[derive(Debug, Snafu)] +pub enum BuildError { + #[snafu(display( + "path template references event fields ({fields:?}) but has no \ + literal directory prefix to derive a base directory from. Set \ + `base_dir` explicitly, or set \ + `dangerously_allow_unconfined_template_resolution: true` to opt out of path \ + confinement (not recommended)." + ))] + NoDerivableBase { fields: Vec }, + + #[snafu(display( + "path template literal prefix {prefix:?} normalizes to a filesystem \ + root, which would permit writes anywhere on disk. Set `base_dir` \ + explicitly (for example `base_dir: /var/log/vector`), or set \ + `dangerously_allow_unconfined_template_resolution: true` to opt out of path \ + confinement (not recommended)." + ))] + DerivedBaseIsRoot { prefix: String }, + + #[snafu(display("`base_dir` must be an absolute path, got {path:?}"))] + BaseNotAbsolute { path: PathBuf }, +} + +/// Errors raised while confining a rendered path against a base directory. +#[derive(Debug, Snafu)] +pub enum ConfineError { + #[snafu(display("rendered path contains a NUL byte"))] + NulByte, + + #[snafu(display( + "rendered path {rendered:?} resolves outside the configured base \ + directory {base:?}" + ))] + OutsideBase { rendered: PathBuf, base: PathBuf }, + + #[snafu(display("rendered path is {len} bytes; maximum allowed is {max}"))] + TooLong { len: usize, max: usize }, + + #[cfg(windows)] + #[snafu(display("rendered path contains a forbidden Windows component: {component:?}"))] + ForbiddenComponent { component: String }, +} + +/// Lexically resolve `.` and `..` in a path without touching the +/// filesystem. +/// +/// This is pure: it never follows symlinks, never reads the FS, and never +/// pops past a root or prefix component. The result has the same root / +/// prefix as the input. +pub fn normalize_lexically(p: &Path) -> PathBuf { + let mut out: Vec> = Vec::new(); + for component in p.components() { + match component { + Component::Prefix(_) | Component::RootDir => { + out.push(component); + } + Component::CurDir => {} + Component::ParentDir => { + let pop_idx = out.iter().rposition(|c| matches!(c, Component::Normal(_))); + match pop_idx { + Some(idx) if idx == out.len() - 1 => { + out.pop(); + } + _ => { + let has_anchor = out + .iter() + .any(|c| matches!(c, Component::Prefix(_) | Component::RootDir)); + if !has_anchor { + out.push(component); + } + } + } + } + Component::Normal(_) => { + out.push(component); + } + } + } + let mut buf = PathBuf::new(); + for c in out { + buf.push(c.as_os_str()); + } + if buf.as_os_str().is_empty() { + buf.push("."); + } + buf +} + +/// Returns `true` if `p` is exactly a filesystem root (no normal segments +/// below the root or drive prefix). +fn is_filesystem_root(p: &Path) -> bool { + let mut had_anchor = false; + for c in p.components() { + match c { + Component::Prefix(_) | Component::RootDir => had_anchor = true, + Component::CurDir => {} + _ => return false, + } + } + had_anchor +} + +/// Truncate a literal-prefix string to the last path-separator boundary so +/// that the returned slice is a clean directory prefix (no trailing partial +/// component like `srv-` in `"/srv-{{id}}"`). +fn truncate_to_separator(prefix: &str) -> &str { + let bytes = prefix.as_bytes(); + let mut cut = 0usize; + for (i, b) in bytes.iter().enumerate() { + if *b == b'/' || (cfg!(windows) && *b == b'\\') { + cut = i + 1; + } + } + prefix.split_at(cut).0 +} + +/// Confines a rendered filesystem path to a base directory derived from a +/// template's literal prefix. +/// +/// Build with [`PathConfinement::for_template`] at sink construction time +/// (no FS I/O). Use [`PathConfinement::confine`] before any FS mutation, +/// and [`PathConfinement::verify_parent`] after `create_dir_all` to catch +/// intermediate symlinks. +#[derive(Debug)] +pub struct PathConfinement { + base_lexical: PathBuf, +} + +impl PathConfinement { + /// Build a confinement for `tpl`. Returns: + /// - `Ok(None)` if the template has no field references (nothing to confine). + /// - `Ok(Some(_))` with a base derived from `explicit` (if set) or from + /// the template's literal prefix. + /// - `Err(_)` if no usable base can be derived and `explicit` is unset. + /// + /// Performs no filesystem I/O. + pub fn for_template( + tpl: &Template, + explicit: Option<&Path>, + ) -> Result, BuildError> { + let fields = match tpl.get_fields() { + Some(f) => f, + None => return Ok(None), + }; + + let base_path = match explicit { + Some(p) => { + if !p.is_absolute() { + return Err(BuildError::BaseNotAbsolute { + path: p.to_path_buf(), + }); + } + normalize_lexically(p) + } + None => { + let raw = tpl.literal_prefix(); + let dir_prefix = truncate_to_separator(raw); + if dir_prefix.is_empty() { + return Err(BuildError::NoDerivableBase { fields }); + } + let candidate = normalize_lexically(Path::new(dir_prefix)); + if !candidate.is_absolute() { + return Err(BuildError::NoDerivableBase { fields }); + } + if is_filesystem_root(&candidate) { + return Err(BuildError::DerivedBaseIsRoot { + prefix: dir_prefix.to_owned(), + }); + } + candidate + } + }; + + if explicit.is_some() && is_filesystem_root(&base_path) { + warn!( + message = "Configured `base_dir` is a filesystem root; path \ + confinement is effectively disabled.", + base_dir = ?base_path, + ); + } + + Ok(Some(Self { + base_lexical: base_path, + })) + } + + /// The lexical base directory used for containment checks. + pub fn base_dir(&self) -> &Path { + &self.base_lexical + } + + /// Apply lexical confinement to a rendered path. Pure — runs before + /// any FS mutation. + pub fn confine(&self, rendered: &Path) -> Result { + let raw_bytes = path_bytes(rendered); + if raw_bytes.contains(&0) { + return Err(ConfineError::NulByte); + } + if raw_bytes.len() > MAX_RENDERED_PATH_LEN { + return Err(ConfineError::TooLong { + len: raw_bytes.len(), + max: MAX_RENDERED_PATH_LEN, + }); + } + + let absolute = if rendered.is_absolute() { + rendered.to_path_buf() + } else { + self.base_lexical.join(rendered) + }; + let normalized = normalize_lexically(&absolute); + + #[cfg(windows)] + { + for c in normalized.components() { + if let Component::Normal(os) = c { + let s = os.to_string_lossy(); + if s.contains(':') { + return Err(ConfineError::ForbiddenComponent { + component: s.into_owned(), + }); + } + if is_windows_reserved_name(&s) { + return Err(ConfineError::ForbiddenComponent { + component: s.into_owned(), + }); + } + } + } + } + + if !normalized.starts_with(&self.base_lexical) { + return Err(ConfineError::OutsideBase { + rendered: normalized, + base: self.base_lexical.clone(), + }); + } + + Ok(normalized) + } + + /// Strip the base prefix from an already-confined absolute path to + /// produce a path suitable for cap-std relative operations. + pub fn relative_path<'a>(&self, confined: &'a Path) -> &'a Path { + confined + .strip_prefix(&self.base_lexical) + .unwrap_or(confined) + } +} + +#[cfg(unix)] +fn path_bytes(p: &Path) -> &[u8] { + use std::os::unix::ffi::OsStrExt; + p.as_os_str().as_bytes() +} + +#[cfg(not(unix))] +fn path_bytes(p: &Path) -> &[u8] { + p.as_os_str().to_str().map(str::as_bytes).unwrap_or(&[]) +} + +#[cfg(windows)] +fn is_windows_reserved_name(name: &str) -> bool { + let stem = name + .rsplit_once('.') + .map(|(stem, _)| stem) + .unwrap_or(name) + .to_ascii_uppercase(); + matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL") + || (stem.starts_with("COM") + && stem.len() == 4 + && matches!(stem.as_bytes()[3], b'0'..=b'9' | 0xB9 | 0xB2 | 0xB3)) + || (stem.starts_with("LPT") + && stem.len() == 4 + && matches!(stem.as_bytes()[3], b'0'..=b'9' | 0xB9 | 0xB2 | 0xB3)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn pb(s: &str) -> PathBuf { + PathBuf::from(s) + } + + #[test] + fn normalize_lexically_cases() { + let cases: &[(&str, &str)] = &[ + ("/a/b/../c", "/a/c"), + ("/a/./b", "/a/b"), + ("/a//b", "/a/b"), + ("/..", "/"), + ("/../../etc", "/etc"), + ("../a", "../a"), + ("a/../../b", "../b"), + ]; + for (input, expected) in cases { + assert_eq!( + normalize_lexically(&pb(input)), + pb(expected), + "input = {input:?}" + ); + } + } + + #[cfg(unix)] + #[test] + fn confine_blocks_dotdot_traversal() { + use crate::template::Template; + use std::convert::TryFrom; + let tpl = Template::try_from("/var/log/apps/{{ appname }}/%Y-%m-%d.log").unwrap(); + let pc = PathConfinement::for_template(&tpl, None).unwrap().unwrap(); + assert_eq!(pc.base_dir(), pb("/var/log/apps")); + + assert!(pc + .confine(&pb("/var/log/apps/myapp/2026-01-01.log")) + .is_ok()); + assert!(pc + .confine(&pb("/var/log/apps/../../../../etc/cron.d/v/2026-01-01.log")) + .is_err()); + assert!(pc + .confine(&pb("/var/log/apps/../etc/passwd/2026-01-01.log")) + .is_err()); + } + + #[cfg(unix)] + #[test] + fn confine_rejects_injected_separator() { + use crate::template::Template; + use std::convert::TryFrom; + let tpl = Template::try_from("/var/log/apps/{{ appname }}.log").unwrap(); + let pc = PathConfinement::for_template(&tpl, None).unwrap().unwrap(); + + assert!(pc.confine(&pb("/var/log/apps/safe.log")).is_ok()); + // A separator injected via appname still can't escape if it doesn't use .. + assert!(pc.confine(&pb("/var/log/apps/sub/safe.log")).is_ok()); + // But traversal with injected separator is blocked + assert!(pc + .confine(&pb("/var/log/apps/sub/../../etc/evil.log")) + .is_err()); + } + + #[cfg(unix)] + #[test] + fn no_derivable_base_for_relative_template() { + use crate::template::Template; + use std::convert::TryFrom; + let tpl = Template::try_from("{{ appname }}.log").unwrap(); + let err = PathConfinement::for_template(&tpl, None).unwrap_err(); + assert!(matches!(err, BuildError::NoDerivableBase { .. })); + } + + #[cfg(unix)] + #[test] + fn root_only_prefix_rejected() { + use crate::template::Template; + use std::convert::TryFrom; + let tpl = Template::try_from("/{{ tenant }}/app.log").unwrap(); + let err = PathConfinement::for_template(&tpl, None).unwrap_err(); + assert!(matches!(err, BuildError::DerivedBaseIsRoot { .. })); + } + + #[cfg(unix)] + #[test] + fn static_template_returns_none() { + use crate::template::Template; + use std::convert::TryFrom; + let tpl = Template::try_from("/var/log/vector/output.log").unwrap(); + let result = PathConfinement::for_template(&tpl, None).unwrap(); + assert!(result.is_none()); + } + + #[cfg(unix)] + #[test] + fn explicit_base_dir_overrides_prefix() { + use crate::template::Template; + use std::convert::TryFrom; + let tpl = Template::try_from("{{ appname }}.log").unwrap(); + let base = pb("/data/logs"); + let pc = PathConfinement::for_template(&tpl, Some(&base)) + .unwrap() + .unwrap(); + assert_eq!(pc.base_dir(), pb("/data/logs")); + } + + #[cfg(unix)] + #[test] + fn confine_rejects_too_long_path() { + use crate::template::Template; + use std::convert::TryFrom; + let tpl = Template::try_from("/var/log/apps/{{ appname }}.log").unwrap(); + let pc = PathConfinement::for_template(&tpl, None).unwrap().unwrap(); + + let long_component = "a".repeat(MAX_RENDERED_PATH_LEN + 1); + let long_path = pb(&format!("/var/log/apps/{long_component}.log")); + let err = pc.confine(&long_path).unwrap_err(); + assert!(matches!(err, ConfineError::TooLong { .. })); + } + + #[cfg(unix)] + #[test] + fn confine_rejects_nul_byte() { + use crate::template::Template; + use std::convert::TryFrom; + use std::ffi::OsStr; + use std::os::unix::ffi::OsStrExt; + + let tpl = Template::try_from("/var/log/apps/{{ appname }}.log").unwrap(); + let pc = PathConfinement::for_template(&tpl, None).unwrap().unwrap(); + + let raw = b"/var/log/apps/evil\0.log"; + let path = Path::new(OsStr::from_bytes(raw)); + let err = pc.confine(path).unwrap_err(); + assert!(matches!(err, ConfineError::NulByte)); + } +} diff --git a/src/template.rs b/src/template.rs index ca2378e007..9bf5259efb 100644 --- a/src/template.rs +++ b/src/template.rs @@ -220,6 +220,21 @@ impl Template { (!parts.is_empty()).then_some(parts) } + /// Returns the literal text that precedes the first field reference (or + /// time-format specifier) in this template. + /// + /// This is the longest prefix of the rendered output that is guaranteed + /// not to depend on the input event, and is used by sinks to derive a + /// confinement base directory for templated filesystem paths. A `{{ ... }}` + /// reference or a `%`-style strftime specifier both stop accumulation, + /// since neither is known statically. + pub fn literal_prefix(&self) -> &str { + match self.parts.first() { + Some(Part::Literal(lit)) => lit.as_str(), + _ => "", + } + } + /// Returns a reference to the template string. pub fn get_ref(&self) -> &str { &self.src