1use std::{borrow::Cow, convert::TryFrom, fmt, hash::Hash, path::PathBuf, sync::LazyLock};
3
4use bytes::Bytes;
5use chrono::{
6 FixedOffset, Utc,
7 format::{Item, strftime::StrftimeItems},
8};
9use http::Uri;
10use regex::Regex;
11use snafu::Snafu;
12use tracing::warn;
13use vector_lib::{
14 configurable::{ConfigurableNumber, ConfigurableString, NumberClass, configurable_component},
15 gauge,
16 internal_event::GaugeName,
17 lookup::lookup_v2::parse_target_path,
18};
19
20use crate::{
21 config::log_schema,
22 event::{EventRef, Metric, Value},
23};
24
25static RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\{\{(?P<key>[^\}]+)\}\}").unwrap());
26
27#[allow(missing_docs)]
29#[derive(Clone, Debug, Eq, PartialEq, Snafu)]
30pub enum TemplateParseError {
31 #[snafu(display("Invalid strftime item"))]
32 StrftimeError,
33 #[snafu(display(
34 "Invalid field path in template {:?} (see https://vector.dev/docs/reference/configuration/template-syntax/)",
35 path
36 ))]
37 InvalidPathSyntax { path: String },
38 #[snafu(display("Invalid numeric template"))]
39 InvalidNumericTemplate { template: String },
40}
41
42#[allow(missing_docs)]
44#[derive(Clone, Debug, Eq, PartialEq, Snafu)]
45pub enum TemplateRenderingError {
46 #[snafu(display("Missing fields on event: {:?}", missing_keys))]
47 MissingKeys { missing_keys: Vec<String> },
48 #[snafu(display("Not numeric: {:?}", input))]
49 NotNumeric { input: String },
50 #[snafu(display(
58 "rendered value ({rendered_len} bytes, preview {rendered_preview:?}) \
59 confined: {message}"
60 ))]
61 Confined {
62 rendered_preview: String,
63 rendered_len: usize,
64 message: String,
65 },
66}
67
68pub const CONFINED_PREVIEW_BYTES: usize = 32;
72
73pub fn confined_preview(rendered: &str) -> String {
76 if rendered.len() <= CONFINED_PREVIEW_BYTES {
77 return rendered.to_string();
78 }
79 let mut end = CONFINED_PREVIEW_BYTES;
80 while end > 0 && !rendered.is_char_boundary(end) {
81 end -= 1;
82 }
83 rendered.get(..end).unwrap_or("").to_string()
84}
85
86#[configurable_component]
98#[configurable(metadata(docs::templateable))]
99#[derive(Clone, Default)]
100#[serde(try_from = "String", into = "String")]
101pub struct Template {
102 src: String,
103
104 #[serde(skip)]
105 parts: Vec<Part>,
106
107 #[serde(skip)]
108 is_static: bool,
109
110 #[serde(skip)]
111 reserve_size: usize,
112
113 #[serde(skip)]
114 tz_offset: Option<FixedOffset>,
115
116 #[serde(skip)]
125 confinement: Option<ConfinementChecker>,
126}
127
128impl fmt::Debug for Template {
129 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
130 f.debug_struct("Template")
131 .field("src", &self.src)
132 .field("is_static", &self.is_static)
133 .field("tz_offset", &self.tz_offset)
134 .field("confinement", &self.confinement.as_ref().map(|_| "<fn>"))
135 .finish()
136 }
137}
138
139impl PartialEq for Template {
140 fn eq(&self, other: &Self) -> bool {
141 self.src == other.src && self.tz_offset == other.tz_offset
142 }
143}
144
145impl Eq for Template {}
146
147impl Hash for Template {
148 fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
149 self.src.hash(state);
150 self.tz_offset.hash(state);
151 }
152}
153
154impl TryFrom<&str> for Template {
155 type Error = TemplateParseError;
156
157 fn try_from(src: &str) -> Result<Self, Self::Error> {
158 Template::try_from(Cow::Borrowed(src))
159 }
160}
161
162impl TryFrom<String> for Template {
163 type Error = TemplateParseError;
164
165 fn try_from(src: String) -> Result<Self, Self::Error> {
166 Template::try_from(Cow::Owned(src))
167 }
168}
169
170impl TryFrom<PathBuf> for Template {
171 type Error = TemplateParseError;
172
173 fn try_from(p: PathBuf) -> Result<Self, Self::Error> {
174 Template::try_from(p.to_string_lossy().into_owned())
175 }
176}
177
178impl TryFrom<Cow<'_, str>> for Template {
179 type Error = TemplateParseError;
180
181 fn try_from(src: Cow<'_, str>) -> Result<Self, Self::Error> {
182 parse_template(&src).map(|parts| {
183 let is_static =
184 parts.is_empty() || (parts.len() == 1 && matches!(parts[0], Part::Literal(..)));
185
186 let reserve_size = parts
190 .iter()
191 .map(|part| match part {
192 Part::Literal(lit) => lit.len(),
193 Part::Reference(_path) => 1,
196 Part::Strftime(parsed) => parsed.reserve_size(),
197 })
198 .sum();
199
200 Template {
201 parts,
202 src: src.into_owned(),
203 is_static,
204 reserve_size,
205 tz_offset: None,
206 confinement: None,
207 }
208 })
209 }
210}
211
212impl From<Template> for String {
213 fn from(template: Template) -> String {
214 template.src
215 }
216}
217
218impl fmt::Display for Template {
219 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
220 self.src.fmt(f)
221 }
222}
223
224impl ConfigurableString for Template {}
226
227impl Template {
228 pub const fn with_tz_offset(mut self, tz_offset: Option<FixedOffset>) -> Self {
230 self.tz_offset = tz_offset;
231 self
232 }
233
234 pub(crate) fn with_confinement_checker(mut self, checker: ConfinementChecker) -> Self {
244 self.confinement = Some(checker);
245 self
246 }
247
248 pub fn confine(
260 self,
261 config: &ConfinementConfig,
262 component_name: &'static str,
263 field_name: &'static str,
264 ) -> crate::Result<Self> {
265 if config.dangerously_allow_unconfined_template_resolution {
270 ConfinementConfig::warn_unconfined_template("sink", component_name, field_name);
271 return Ok(self);
272 }
273 match ConfinementChecker::for_template(&self) {
274 Ok(Some(checker)) => Ok(self.with_confinement_checker(checker)),
275 Ok(None) => Ok(self),
277 Err(e) => Err(e.into()),
278 }
279 }
280
281 pub fn check_confinement(&self, rendered: &str) -> Result<(), TemplateRenderingError> {
293 if let Some(checker) = &self.confinement {
294 checker
295 .confine(rendered)
296 .map_err(|e| TemplateRenderingError::Confined {
297 rendered_preview: confined_preview(rendered),
298 rendered_len: rendered.len(),
299 message: e.to_string(),
300 })?;
301 }
302 Ok(())
303 }
304
305 pub fn render<'a>(
307 &self,
308 event: impl Into<EventRef<'a>>,
309 ) -> Result<Bytes, TemplateRenderingError> {
310 self.render_string(event.into()).map(Into::into)
311 }
312
313 pub fn render_string<'a>(
322 &self,
323 event: impl Into<EventRef<'a>>,
324 ) -> Result<String, TemplateRenderingError> {
325 let rendered = if self.is_static {
326 self.src.clone()
327 } else {
328 self.render_event(event.into())?
329 };
330 if let Some(checker) = &self.confinement {
331 checker
332 .confine(&rendered)
333 .map_err(|e| TemplateRenderingError::Confined {
334 rendered_preview: confined_preview(&rendered),
335 rendered_len: rendered.len(),
336 message: e.to_string(),
337 })?;
338 }
339 Ok(rendered)
340 }
341
342 fn render_event(&self, event: EventRef<'_>) -> Result<String, TemplateRenderingError> {
343 let mut missing_keys = Vec::new();
344 let mut out = String::with_capacity(self.reserve_size);
345 for part in &self.parts {
346 match part {
347 Part::Literal(lit) => out.push_str(lit),
348 Part::Strftime(items) => {
349 out.push_str(&render_timestamp(items, event, self.tz_offset))
350 }
351 Part::Reference(key) => {
352 out.push_str(
353 &match event {
354 EventRef::Log(log) => log
355 .parse_path_and_get_value(key)
356 .ok()
357 .and_then(|v| v.map(Value::to_string_lossy)),
358 EventRef::Metric(metric) => {
359 render_metric_field(key, metric).map(Cow::Borrowed)
360 }
361 EventRef::Trace(trace) => trace
362 .parse_path_and_get_value(key)
363 .ok()
364 .and_then(|v| v.map(Value::to_string_lossy)),
365 }
366 .unwrap_or_else(|| {
367 missing_keys.push(key.to_owned());
368 Cow::Borrowed("")
369 }),
370 );
371 }
372 }
373 }
374 if missing_keys.is_empty() {
375 Ok(out)
376 } else {
377 Err(TemplateRenderingError::MissingKeys { missing_keys })
378 }
379 }
380
381 pub fn get_fields(&self) -> Option<Vec<String>> {
383 let parts: Vec<_> = self
384 .parts
385 .iter()
386 .filter_map(|part| {
387 if let Part::Reference(r) = part {
388 Some(r.to_owned())
389 } else {
390 None
391 }
392 })
393 .collect();
394 (!parts.is_empty()).then_some(parts)
395 }
396
397 pub fn literal_prefix(&self) -> &str {
403 let bytes = self.src.as_bytes();
404 let mut i = 0;
405 while i < bytes.len() {
406 if bytes[i] == b'{' && bytes.get(i + 1) == Some(&b'{') {
408 break;
409 }
410 if bytes[i] == b'%' {
416 break;
417 }
418 i += 1;
419 }
420 self.src.split_at(i).0
421 }
422
423 #[allow(clippy::missing_const_for_fn)] pub fn get_ref(&self) -> &str {
426 &self.src
427 }
428
429 pub const fn is_empty(&self) -> bool {
431 self.src.is_empty()
432 }
433
434 pub const fn is_dynamic(&self) -> bool {
436 !self.is_static
437 }
438}
439
440#[derive(Clone, Debug, Eq, Hash, PartialEq)]
442#[configurable_component]
443#[serde(untagged)]
444enum UnsignedIntTemplateSource {
445 Number(u64),
447 String(String),
449}
450
451impl Default for UnsignedIntTemplateSource {
452 fn default() -> Self {
453 Self::Number(Default::default())
454 }
455}
456
457impl fmt::Display for UnsignedIntTemplateSource {
458 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
459 match self {
460 Self::Number(i) => i.fmt(f),
461 Self::String(s) => s.fmt(f),
462 }
463 }
464}
465
466#[configurable_component]
468#[configurable(metadata(docs::templateable))]
469#[derive(Clone, Debug, Default, Eq, Hash, PartialEq)]
470#[serde(
471 try_from = "UnsignedIntTemplateSource",
472 into = "UnsignedIntTemplateSource"
473)]
474pub struct UnsignedIntTemplate {
475 src: UnsignedIntTemplateSource,
476
477 #[serde(skip)]
478 parts: Vec<Part>,
479
480 #[serde(skip)]
481 tz_offset: Option<FixedOffset>,
482}
483
484impl TryFrom<UnsignedIntTemplateSource> for UnsignedIntTemplate {
485 type Error = TemplateParseError;
486
487 fn try_from(src: UnsignedIntTemplateSource) -> Result<Self, Self::Error> {
488 match src {
489 UnsignedIntTemplateSource::Number(num) => Ok(UnsignedIntTemplate {
490 src: UnsignedIntTemplateSource::Number(num),
491 parts: Vec::new(),
492 tz_offset: None,
493 }),
494 UnsignedIntTemplateSource::String(s) => UnsignedIntTemplate::try_from(s),
495 }
496 }
497}
498
499impl From<UnsignedIntTemplate> for UnsignedIntTemplateSource {
500 fn from(template: UnsignedIntTemplate) -> UnsignedIntTemplateSource {
501 template.src
502 }
503}
504
505impl TryFrom<&str> for UnsignedIntTemplate {
506 type Error = TemplateParseError;
507
508 fn try_from(src: &str) -> Result<Self, Self::Error> {
509 UnsignedIntTemplate::try_from(Cow::Borrowed(src))
510 }
511}
512
513impl TryFrom<String> for UnsignedIntTemplate {
514 type Error = TemplateParseError;
515
516 fn try_from(src: String) -> Result<Self, Self::Error> {
517 UnsignedIntTemplate::try_from(Cow::Owned(src))
518 }
519}
520
521impl From<u64> for UnsignedIntTemplate {
522 fn from(num: u64) -> UnsignedIntTemplate {
523 UnsignedIntTemplate {
524 src: UnsignedIntTemplateSource::Number(num),
525 parts: Vec::new(),
526 tz_offset: None,
527 }
528 }
529}
530
531impl TryFrom<Cow<'_, str>> for UnsignedIntTemplate {
532 type Error = TemplateParseError;
533
534 fn try_from(src: Cow<'_, str>) -> Result<Self, Self::Error> {
535 parse_template(&src).and_then(|parts| {
536 let is_static =
537 parts.is_empty() || (parts.len() == 1 && matches!(parts[0], Part::Literal(..)));
538
539 if is_static {
540 match src.parse::<u64>() {
541 Ok(num) => Ok(UnsignedIntTemplate {
542 src: UnsignedIntTemplateSource::Number(num),
543 parts,
544 tz_offset: None,
545 }),
546 Err(_) => Err(TemplateParseError::InvalidNumericTemplate {
547 template: src.into_owned(),
548 }),
549 }
550 } else {
551 Ok(UnsignedIntTemplate {
552 parts,
553 src: UnsignedIntTemplateSource::String(src.into_owned()),
554 tz_offset: None,
555 })
556 }
557 })
558 }
559}
560
561impl From<UnsignedIntTemplate> for String {
562 fn from(template: UnsignedIntTemplate) -> String {
563 template.src.to_string()
564 }
565}
566
567impl fmt::Display for UnsignedIntTemplate {
568 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
569 self.src.fmt(f)
570 }
571}
572
573impl ConfigurableString for UnsignedIntTemplate {}
574impl ConfigurableNumber for UnsignedIntTemplate {
575 type Numeric = u64;
576
577 fn class() -> NumberClass {
578 NumberClass::Unsigned
579 }
580}
581
582impl UnsignedIntTemplate {
583 pub fn render<'a>(
585 &self,
586 event: impl Into<EventRef<'a>>,
587 ) -> Result<u64, TemplateRenderingError> {
588 match self.src {
589 UnsignedIntTemplateSource::Number(num) => Ok(num),
590 UnsignedIntTemplateSource::String(_) => self.render_event(event.into()),
591 }
592 }
593
594 pub const fn with_tz_offset(mut self, tz_offset: Option<FixedOffset>) -> Self {
596 self.tz_offset = tz_offset;
597 self
598 }
599
600 fn render_event(&self, event: EventRef<'_>) -> Result<u64, TemplateRenderingError> {
601 let mut missing_keys = Vec::new();
602 let mut out = String::with_capacity(20);
603 for part in &self.parts {
604 match part {
605 Part::Literal(lit) => out.push_str(lit),
606 Part::Reference(key) => {
607 out.push_str(
608 &match event {
609 EventRef::Log(log) => log
610 .parse_path_and_get_value(key)
611 .ok()
612 .and_then(|v| v.map(Value::to_string_lossy)),
613 EventRef::Metric(metric) => {
614 render_metric_field(key, metric).map(Cow::Borrowed)
615 }
616 EventRef::Trace(trace) => trace
617 .parse_path_and_get_value(key)
618 .ok()
619 .and_then(|v| v.map(Value::to_string_lossy)),
620 }
621 .unwrap_or_else(|| {
622 missing_keys.push(key.to_owned());
623 Cow::Borrowed("")
624 }),
625 );
626 }
627 Part::Strftime(items) => {
628 out.push_str(&render_timestamp(items, event, self.tz_offset))
629 }
630 }
631 }
632 if missing_keys.is_empty() {
633 out.parse::<u64>()
634 .map_err(|_| TemplateRenderingError::NotNumeric { input: out })
635 } else {
636 Err(TemplateRenderingError::MissingKeys { missing_keys })
637 }
638 }
639
640 pub fn get_fields(&self) -> Option<Vec<String>> {
642 let parts: Vec<_> = self
643 .parts
644 .iter()
645 .filter_map(|part| {
646 if let Part::Reference(r) = part {
647 Some(r.to_owned())
648 } else {
649 None
650 }
651 })
652 .collect();
653 (!parts.is_empty()).then_some(parts)
654 }
655}
656
657#[derive(Clone, Debug, Eq, Hash, PartialEq)]
659enum Part {
660 Literal(String),
662 Strftime(ParsedStrftime),
664 Reference(String),
666}
667
668#[derive(Clone, Debug, Eq, Hash, PartialEq)]
670struct ParsedStrftime(Box<[Item<'static>]>);
671
672impl ParsedStrftime {
673 fn parse(fmt: &str) -> Result<Self, TemplateParseError> {
674 Ok(Self(
675 StrftimeItems::new(fmt)
676 .map(|item| match item {
677 Item::Space(space) => Item::OwnedSpace(space.into()),
679 Item::Literal(lit) => Item::OwnedLiteral(lit.into()),
680 Item::Fixed(f) => Item::Fixed(f),
682 Item::Numeric(num, pad) => Item::Numeric(num, pad),
683 Item::Error => Item::Error,
684 Item::OwnedSpace(space) => Item::OwnedSpace(space),
685 Item::OwnedLiteral(lit) => Item::OwnedLiteral(lit),
686 })
687 .map(|item| {
688 matches!(item, Item::Error)
689 .then(|| Err(TemplateParseError::StrftimeError))
690 .unwrap_or(Ok(item))
691 })
692 .collect::<Result<Vec<_>, _>>()?
693 .into(),
694 ))
695 }
696
697 fn is_dynamic(&self) -> bool {
698 self.0.iter().any(|item| match item {
699 Item::Fixed(_) => true,
700 Item::Numeric(_, _) => true,
701 Item::Error
702 | Item::Space(_)
703 | Item::OwnedSpace(_)
704 | Item::Literal(_)
705 | Item::OwnedLiteral(_) => false,
706 })
707 }
708
709 fn as_items(&self) -> impl Iterator<Item = &Item<'static>> + Clone {
710 self.0.iter()
711 }
712
713 fn reserve_size(&self) -> usize {
714 self.0
715 .iter()
716 .map(|item| match item {
717 Item::Literal(lit) => lit.len(),
718 Item::OwnedLiteral(lit) => lit.len(),
719 Item::Space(space) => space.len(),
720 Item::OwnedSpace(space) => space.len(),
721 Item::Error => 0,
722 Item::Numeric(_, _) => 2,
723 Item::Fixed(_) => 2,
724 })
725 .sum()
726 }
727}
728
729fn parse_literal(src: &str) -> Result<Part, TemplateParseError> {
730 let parsed = ParsedStrftime::parse(src)?;
731 Ok(if parsed.is_dynamic() {
732 Part::Strftime(parsed)
733 } else {
734 Part::Literal(src.to_string())
735 })
736}
737
738fn parse_template(src: &str) -> Result<Vec<Part>, TemplateParseError> {
740 let mut last_end = 0;
741 let mut parts = Vec::new();
742 for cap in RE.captures_iter(src) {
743 let all = cap.get(0).expect("Capture 0 is always defined");
744 if all.start() > last_end {
745 #[expect(
746 clippy::string_slice,
747 reason = "indices come from regex match positions, always char boundaries"
748 )]
749 parts.push(parse_literal(&src[last_end..all.start()])?);
750 }
751
752 let path = cap[1].trim().to_owned();
753
754 if parse_target_path(&path).is_err() {
757 return Err(TemplateParseError::InvalidPathSyntax { path });
758 }
759
760 parts.push(Part::Reference(path));
761 last_end = all.end();
762 }
763 if src.len() > last_end {
764 #[expect(
765 clippy::string_slice,
766 reason = "last_end comes from a regex match end position, always a char boundary"
767 )]
768 parts.push(parse_literal(&src[last_end..])?);
769 }
770
771 Ok(parts)
772}
773
774fn render_metric_field<'a>(key: &str, metric: &'a Metric) -> Option<&'a str> {
775 match key {
776 "name" => Some(metric.name()),
777 "namespace" => metric.namespace(),
778 _ if let Some(tag_key) = key.strip_prefix("tags.") => {
779 metric.tags().and_then(|tags| tags.get(tag_key))
780 }
781 _ => None,
782 }
783}
784
785fn render_timestamp(
786 items: &ParsedStrftime,
787 event: EventRef<'_>,
788 tz_offset: Option<FixedOffset>,
789) -> String {
790 let timestamp = match event {
791 EventRef::Log(log) => log.get_timestamp().and_then(Value::as_timestamp).copied(),
792 EventRef::Metric(metric) => metric.timestamp(),
793 EventRef::Trace(trace) => {
794 log_schema()
795 .timestamp_key_target_path()
796 .and_then(|timestamp_key| {
797 trace
798 .get(timestamp_key)
799 .and_then(Value::as_timestamp)
800 .copied()
801 })
802 }
803 }
804 .unwrap_or_else(Utc::now);
805
806 match tz_offset {
807 Some(offset) => timestamp
808 .with_timezone(&offset)
809 .format_with_items(items.as_items())
810 .to_string(),
811 None => timestamp
812 .with_timezone(&chrono::Utc)
813 .format_with_items(items.as_items())
814 .to_string(),
815 }
816}
817
818use crate::sinks::util::path_confinement::MAX_RENDERED_PATH_LEN;
819
820#[derive(Debug, Snafu)]
821#[snafu(module(build_error))]
822pub(crate) enum BuildError {
823 #[snafu(display(
825 "template references event fields ({fields:?}) but has no \
826 literal string prefix to derive a confinement base from. Add a static \
827 prefix to your template, or set \
828 `dangerously_allow_unconfined_template_resolution: true` to opt out."
829 ))]
830 NoDerivableBase {
831 fields: Vec<String>,
833 },
834
835 #[snafu(display(
838 "template has only `\"/\"` as its literal prefix (from {prefix:?}), \
839 which would permit writes anywhere in the namespace root. Add a \
840 non-root static prefix to your template, or set \
841 `dangerously_allow_unconfined_template_resolution: true` to opt out."
842 ))]
843 DerivedBaseIsRoot {
844 prefix: String,
846 },
847
848 #[snafu(display(
853 "HTTP/HTTPS template {prefix:?} has no static authority (host): the \
854 destination host would be fully event-controlled. Add a static host to \
855 your URI template, or set \
856 `dangerously_allow_unconfined_template_resolution: true` to opt out."
857 ))]
858 NoStaticUriAuthority {
859 prefix: String,
861 },
862
863 #[snafu(display(
867 "HTTP/HTTPS URI prefix {prefix:?} contains %2F, %5C, or a raw backslash \
868 in the static portion. Use a literal `/` in the path instead."
869 ))]
870 EncodedSeparatorInUriPrefix {
871 prefix: String,
873 },
874
875 #[snafu(display(
882 "HTTP/HTTPS template {template:?} mixes `{{{{ field }}}}` references \
883 with `?` or `#`, which cannot be confined. Move event-driven routing \
884 into the URL path, or set \
885 `dangerously_allow_unconfined_template_resolution: true` to opt out."
886 ))]
887 DynamicUriQueryOrFragment {
888 template: String,
890 },
891}
892
893#[derive(Debug, Snafu)]
894#[snafu(module(confine_error))]
895pub(crate) enum ConfineError {
896 #[snafu(display("rendered value contains a NUL byte"))]
898 NulByte,
899
900 #[snafu(display("rendered value is {len} bytes; maximum allowed is {max}"))]
902 TooLong {
903 len: usize,
905 max: usize,
907 },
908
909 #[snafu(display("rendered value {rendered:?} does not start with the base prefix {base:?}"))]
911 OutsideBase {
912 rendered: String,
914 base: String,
916 },
917
918 #[snafu(display("rendered value {rendered:?} contains a `..` path segment"))]
922 DotDotSegment {
923 rendered: String,
925 },
926
927 #[snafu(display("rendered value {rendered:?} is not a valid URI"))]
929 UriParseFailed {
930 rendered: String,
932 },
933
934 #[snafu(display(
939 "rendered URI {rendered:?} has authority {actual:?} but the confined base \
940 requires {expected:?}"
941 ))]
942 UriAuthorityMismatch {
943 rendered: String,
945 expected: String,
947 actual: String,
949 },
950}
951
952#[derive(Clone, Debug)]
958pub(crate) enum ConfinementChecker {
959 Prefix(PrefixChecker),
960 Uri(UriChecker),
961}
962
963impl ConfinementChecker {
964 pub(crate) fn for_template(tpl: &Template) -> Result<Option<Self>, BuildError> {
965 let fields = match tpl.get_fields() {
966 Some(f) => f,
967 None => return Ok(None),
968 };
969 let prefix = tpl.literal_prefix();
970 if prefix.is_empty() {
971 return Err(BuildError::NoDerivableBase { fields });
972 }
973 if prefix == "/" {
974 return Err(BuildError::DerivedBaseIsRoot {
975 prefix: prefix.to_string(),
976 });
977 }
978 let lp = prefix.to_ascii_lowercase();
982 if lp.starts_with("http://") || lp.starts_with("https://") {
983 let src = tpl.get_ref();
992 if src.contains('?') || src.contains('#') {
993 return Err(BuildError::DynamicUriQueryOrFragment {
994 template: src.to_string(),
995 });
996 }
997 UriChecker::from_prefix(prefix).map(|c| Some(Self::Uri(c)))
998 } else {
999 Ok(Some(Self::Prefix(PrefixChecker {
1000 base: prefix.to_string(),
1001 })))
1002 }
1003 }
1004
1005 pub(crate) fn confine(&self, rendered: &str) -> Result<(), ConfineError> {
1006 if rendered.contains('\0') {
1007 return Err(ConfineError::NulByte);
1008 }
1009 if rendered.len() > MAX_RENDERED_PATH_LEN {
1010 return Err(ConfineError::TooLong {
1011 len: rendered.len(),
1012 max: MAX_RENDERED_PATH_LEN,
1013 });
1014 }
1015 match self {
1016 Self::Prefix(c) => c.confine(rendered),
1017 Self::Uri(c) => c.confine(rendered),
1018 }
1019 }
1020}
1021
1022#[derive(Clone, Debug)]
1027pub(crate) struct PrefixChecker {
1028 base: String,
1029}
1030
1031impl PrefixChecker {
1032 pub(crate) fn confine(&self, rendered: &str) -> Result<(), ConfineError> {
1033 if rendered.split('/').any(|seg| seg == "..") {
1037 return Err(ConfineError::DotDotSegment {
1038 rendered: rendered.to_string(),
1039 });
1040 }
1041 if !rendered.starts_with(&self.base) {
1042 return Err(ConfineError::OutsideBase {
1043 rendered: rendered.to_string(),
1044 base: self.base.clone(),
1045 });
1046 }
1047 Ok(())
1048 }
1049}
1050
1051#[derive(Clone, Debug)]
1078pub(crate) struct UriChecker {
1079 scheme: String,
1081 authority: String,
1083 path_prefix: String,
1085}
1086
1087impl UriChecker {
1088 pub(crate) fn from_prefix(prefix: &str) -> Result<Self, BuildError> {
1089 let uri = prefix
1090 .parse::<Uri>()
1091 .map_err(|_| BuildError::NoStaticUriAuthority {
1092 prefix: prefix.to_string(),
1093 })?;
1094 let scheme = uri
1095 .scheme_str()
1096 .expect("scheme present because prefix starts with http(s)://")
1097 .to_ascii_lowercase();
1098 let path = uri.path().to_ascii_lowercase();
1099 if path.contains("%2f")
1104 || path.contains("%5c")
1105 || path.contains("%25")
1106 || uri.path().contains('\\')
1107 {
1108 return Err(BuildError::EncodedSeparatorInUriPrefix {
1109 prefix: prefix.to_string(),
1110 });
1111 }
1112 match uri.authority() {
1113 Some(auth) if !auth.as_str().is_empty() => Ok(Self {
1114 scheme,
1115 authority: auth.as_str().to_ascii_lowercase(),
1116 path_prefix: uri.path().to_string(),
1117 }),
1118 _ => Err(BuildError::NoStaticUriAuthority {
1119 prefix: prefix.to_string(),
1120 }),
1121 }
1122 }
1123
1124 pub(crate) fn confine(&self, rendered: &str) -> Result<(), ConfineError> {
1125 let uri = rendered
1128 .parse::<Uri>()
1129 .map_err(|_| ConfineError::UriParseFailed {
1130 rendered: rendered.to_string(),
1131 })?;
1132
1133 let actual_scheme = uri.scheme_str().unwrap_or("").to_ascii_lowercase();
1137 let actual_authority = uri
1138 .authority()
1139 .map(|a| a.as_str().to_ascii_lowercase())
1140 .unwrap_or_default();
1141 if actual_scheme != self.scheme || actual_authority != self.authority {
1142 return Err(ConfineError::UriAuthorityMismatch {
1143 rendered: rendered.to_string(),
1144 expected: format!("{}://{}", self.scheme, self.authority),
1145 actual: format!("{actual_scheme}://{actual_authority}"),
1146 });
1147 }
1148
1149 let path = uri.path();
1152 if !path.starts_with(&self.path_prefix) {
1153 return Err(ConfineError::OutsideBase {
1154 rendered: rendered.to_string(),
1155 base: format!("{}://{}{}", self.scheme, self.authority, self.path_prefix),
1156 });
1157 }
1158
1159 for segment in path.split('/') {
1163 if segment == ".."
1164 || segment.eq_ignore_ascii_case("%2e%2e")
1165 || segment.eq_ignore_ascii_case(".%2e")
1166 || segment.eq_ignore_ascii_case("%2e.")
1167 {
1168 return Err(ConfineError::DotDotSegment {
1169 rendered: rendered.to_string(),
1170 });
1171 }
1172 }
1173
1174 let path_lc = path.to_ascii_lowercase();
1186 if path_lc.contains("%2f")
1187 || path_lc.contains("%5c")
1188 || path_lc.contains("%25")
1189 || path.contains('\\')
1190 {
1191 return Err(ConfineError::DotDotSegment {
1192 rendered: rendered.to_string(),
1193 });
1194 }
1195
1196 if uri.query().is_some() {
1202 return Err(ConfineError::OutsideBase {
1203 rendered: rendered.to_string(),
1204 base: format!("{}://{}{}", self.scheme, self.authority, self.path_prefix),
1205 });
1206 }
1207 Ok(())
1208 }
1209}
1210
1211#[configurable_component]
1217#[derive(Clone, Debug, Default)]
1218pub struct ConfinementConfig {
1219 #[serde(default)]
1230 pub dangerously_allow_unconfined_template_resolution: bool,
1231}
1232
1233impl ConfinementConfig {
1234 pub fn warn_unconfined_template(
1238 component_kind: &'static str,
1239 component_type: &'static str,
1240 field: &'static str,
1241 ) {
1242 warn!(
1243 message = "SECURITY: component has `dangerously_allow_unconfined_template_resolution` \
1244 enabled — template is NOT confined. A log producer that controls any \
1245 field used in the template can write to arbitrary keys.",
1246 component_kind, component_type, field,
1247 );
1248 }
1249
1250 pub fn set_confinement_gauge(
1263 &self,
1264 component_kind: &'static str,
1265 component_type: &'static str,
1266 ) {
1267 let value = if self.dangerously_allow_unconfined_template_resolution {
1268 1.0
1269 } else {
1270 0.0
1271 };
1272 gauge!(
1273 GaugeName::SecurityConfinementDisabled,
1274 "component_kind" => component_kind,
1275 "component_type" => component_type,
1276 )
1277 .set(value);
1278 }
1279}
1280
1281#[cfg(test)]
1282mod tests {
1283 use chrono::{Offset, TimeZone, Utc};
1284 use chrono_tz::Tz;
1285 use vector_lib::{
1286 config::LogNamespace,
1287 lookup::{PathPrefix, metadata_path},
1288 metric_tags,
1289 };
1290 use vrl::event_path;
1291
1292 use super::*;
1293 use crate::event::{Event, LogEvent, MetricKind, MetricValue};
1294
1295 #[test]
1296 fn get_fields() {
1297 let f1 = Template::try_from("{{ foo }}")
1298 .unwrap()
1299 .get_fields()
1300 .unwrap();
1301 let f2 = Template::try_from("{{ foo }}-{{ bar }}")
1302 .unwrap()
1303 .get_fields()
1304 .unwrap();
1305 let f3 = Template::try_from("nofield").unwrap().get_fields();
1306 let f4 = Template::try_from("%F").unwrap().get_fields();
1307 let f5 = UnsignedIntTemplate::try_from("{{ foo }}-{{ bar }}")
1308 .unwrap()
1309 .get_fields()
1310 .unwrap();
1311 let f6 = UnsignedIntTemplate::from(123u64).get_fields();
1312 let f7 = UnsignedIntTemplate::try_from("%s").unwrap().get_fields();
1313
1314 assert_eq!(f1, vec!["foo"]);
1315 assert_eq!(f2, vec!["foo", "bar"]);
1316 assert_eq!(f3, None);
1317 assert_eq!(f4, None);
1318 assert_eq!(f5, vec!["foo", "bar"]);
1319 assert_eq!(f6, None);
1320 assert_eq!(f7, None);
1321 }
1322
1323 #[test]
1324 fn literal_prefix() {
1325 let cases = [
1326 ("/var/log/app.log", "/var/log/app.log"),
1327 ("/var/log/{{ host }}/app.log", "/var/log/"),
1328 ("/var/log/%Y/{{ host }}.log", "/var/log/"),
1329 ("/srv-{{ id }}.log", "/srv-"),
1330 ("{{ full_path }}", ""),
1331 ("/{{ tenant }}/app.log", "/"),
1332 ("100%%-literal/{{ x }}", "100"),
1336 ("no-template-at-all", "no-template-at-all"),
1337 ("only-strftime-%F.log", "only-strftime-"),
1338 ("a{b/{{ c }}", "a{b/"),
1340 ];
1341 for (src, expected) in cases {
1342 let tpl = Template::try_from(src).unwrap();
1343 assert_eq!(tpl.literal_prefix(), expected, "src = {src:?}");
1344 }
1345 }
1346
1347 #[test]
1348 fn is_dynamic() {
1349 assert!(Template::try_from("/kube-demo/%F").unwrap().is_dynamic());
1350 assert!(!Template::try_from("/kube-demo/echo").unwrap().is_dynamic());
1351 assert!(
1352 Template::try_from("/kube-demo/{{ foo }}")
1353 .unwrap()
1354 .is_dynamic()
1355 );
1356 assert!(
1357 Template::try_from("/kube-demo/{{ foo }}/%F")
1358 .unwrap()
1359 .is_dynamic()
1360 );
1361 }
1362
1363 #[test]
1364 fn render_log_static() {
1365 let event = Event::Log(LogEvent::from("hello world"));
1366 let template = Template::try_from("foo").unwrap();
1367
1368 assert_eq!(Ok(Bytes::from("foo")), template.render(&event))
1369 }
1370
1371 #[test]
1372 fn render_log_unsigned_number() {
1373 let event = Event::Log(LogEvent::from("hello world"));
1374 let template = UnsignedIntTemplate::from(123);
1375
1376 assert_eq!(Ok(123), template.render(&event))
1377 }
1378
1379 #[test]
1380 fn render_log_unsigned_number_dynamic() {
1381 let mut event = Event::Log(LogEvent::from("hello world"));
1382 event.as_mut_log().insert(event_path!("foo"), 123);
1383
1384 let template = UnsignedIntTemplate::try_from("{{ foo }}").unwrap();
1385 assert_eq!(Ok(123), template.render(&event))
1386 }
1387
1388 #[test]
1389 fn render_log_dynamic() {
1390 let mut event = Event::Log(LogEvent::from("hello world"));
1391 event
1392 .as_mut_log()
1393 .insert(event_path!("log_stream"), "stream");
1394 let template = Template::try_from("{{log_stream}}").unwrap();
1395
1396 assert_eq!(Ok(Bytes::from("stream")), template.render(&event))
1397 }
1398
1399 #[test]
1400 fn render_log_metadata() {
1401 let mut event = Event::Log(LogEvent::from("hello world"));
1402 event
1403 .as_mut_log()
1404 .insert(metadata_path!("metadata_key"), "metadata_value");
1405 let template = Template::try_from("{{%metadata_key}}").unwrap();
1406
1407 assert_eq!(Ok(Bytes::from("metadata_value")), template.render(&event))
1408 }
1409
1410 #[test]
1411 fn render_log_dynamic_with_prefix() {
1412 let mut event = Event::Log(LogEvent::from("hello world"));
1413 event
1414 .as_mut_log()
1415 .insert(event_path!("log_stream"), "stream");
1416 let template = Template::try_from("abcd-{{log_stream}}").unwrap();
1417
1418 assert_eq!(Ok(Bytes::from("abcd-stream")), template.render(&event))
1419 }
1420
1421 #[test]
1422 fn render_log_dynamic_with_postfix() {
1423 let mut event = Event::Log(LogEvent::from("hello world"));
1424 event
1425 .as_mut_log()
1426 .insert(event_path!("log_stream"), "stream");
1427 let template = Template::try_from("{{log_stream}}-abcd").unwrap();
1428
1429 assert_eq!(Ok(Bytes::from("stream-abcd")), template.render(&event))
1430 }
1431
1432 #[test]
1433 fn render_log_dynamic_missing_key() {
1434 let event = Event::Log(LogEvent::from("hello world"));
1435 let template = Template::try_from("{{log_stream}}-{{foo}}").unwrap();
1436
1437 assert_eq!(
1438 Err(TemplateRenderingError::MissingKeys {
1439 missing_keys: vec!["log_stream".to_string(), "foo".to_string()]
1440 }),
1441 template.render(&event)
1442 );
1443 }
1444
1445 #[test]
1446 fn render_log_dynamic_multiple_keys() {
1447 let mut event = Event::Log(LogEvent::from("hello world"));
1448 event.as_mut_log().insert(event_path!("foo"), "bar");
1449 event.as_mut_log().insert(event_path!("baz"), "quux");
1450 let template = Template::try_from("stream-{{foo}}-{{baz}}.log").unwrap();
1451
1452 assert_eq!(
1453 Ok(Bytes::from("stream-bar-quux.log")),
1454 template.render(&event)
1455 )
1456 }
1457
1458 #[test]
1459 fn render_log_dynamic_weird_junk() {
1460 let mut event = Event::Log(LogEvent::from("hello world"));
1461 event.as_mut_log().insert(event_path!("foo"), "bar");
1462 event.as_mut_log().insert(event_path!("baz"), "quux");
1463 let template = Template::try_from(r"{stream}{\{{}}}-{{foo}}-{{baz}}.log").unwrap();
1464
1465 assert_eq!(
1466 Ok(Bytes::from(r"{stream}{\{{}}}-bar-quux.log")),
1467 template.render(&event)
1468 )
1469 }
1470
1471 #[test]
1472 fn render_log_timestamp_strftime_style() {
1473 let ts = Utc
1474 .with_ymd_and_hms(2001, 2, 3, 4, 5, 6)
1475 .single()
1476 .expect("invalid timestamp");
1477
1478 let mut event = Event::Log(LogEvent::from("hello world"));
1479 event
1480 .as_mut_log()
1481 .insert(log_schema().timestamp_key_target_path().unwrap(), ts);
1482
1483 let template = Template::try_from("abcd-%F").unwrap();
1484
1485 assert_eq!(Ok(Bytes::from("abcd-2001-02-03")), template.render(&event))
1486 }
1487
1488 #[test]
1489 fn render_log_timestamp_strftime_style_namespace() {
1490 let ts = Utc
1491 .with_ymd_and_hms(2001, 2, 3, 4, 5, 6)
1492 .single()
1493 .expect("invalid timestamp");
1494
1495 let mut event = Event::Log(LogEvent::from("hello world"));
1496 event.as_mut_log().insert(event_path!("@timestamp"), ts);
1497 LogNamespace::Vector.insert_vector_metadata(
1499 event.as_mut_log(),
1500 Some(vrl::path!("foo")),
1501 vrl::path!("foo"),
1502 "bar",
1503 );
1504 let new_schema = event
1505 .as_mut_log()
1506 .metadata()
1507 .schema_definition()
1508 .as_ref()
1509 .clone()
1510 .with_meaning(parse_target_path("@timestamp").unwrap(), "timestamp");
1511 event
1512 .as_mut_log()
1513 .metadata_mut()
1514 .set_schema_definition(&std::sync::Arc::new(new_schema));
1515
1516 let template = Template::try_from("abcd-%F").unwrap();
1517
1518 assert_eq!(Ok(Bytes::from("abcd-2001-02-03")), template.render(&event))
1519 }
1520
1521 #[test]
1522 fn render_log_timestamp_multiple_strftime_style() {
1523 let ts = Utc
1524 .with_ymd_and_hms(2001, 2, 3, 4, 5, 6)
1525 .single()
1526 .expect("invalid timestamp");
1527
1528 let mut event = Event::Log(LogEvent::from("hello world"));
1529 event
1530 .as_mut_log()
1531 .insert(log_schema().timestamp_key_target_path().unwrap(), ts);
1532
1533 let template = Template::try_from("abcd-%F_%T").unwrap();
1534
1535 assert_eq!(
1536 Ok(Bytes::from("abcd-2001-02-03_04:05:06")),
1537 template.render(&event)
1538 )
1539 }
1540
1541 #[test]
1542 fn render_log_dynamic_with_strftime() {
1543 let ts = Utc
1544 .with_ymd_and_hms(2001, 2, 3, 4, 5, 6)
1545 .single()
1546 .expect("invalid timestamp");
1547
1548 let mut event = Event::Log(LogEvent::from("hello world"));
1549 event.as_mut_log().insert(event_path!("foo"), "butts");
1550 event.as_mut_log().insert(
1551 (PathPrefix::Event, log_schema().timestamp_key().unwrap()),
1552 ts,
1553 );
1554
1555 let template = Template::try_from("{{ foo }}-%F_%T").unwrap();
1556
1557 assert_eq!(
1558 Ok(Bytes::from("butts-2001-02-03_04:05:06")),
1559 template.render(&event)
1560 )
1561 }
1562
1563 #[test]
1564 fn render_log_dynamic_with_nested_strftime() {
1565 let ts = Utc
1566 .with_ymd_and_hms(2001, 2, 3, 4, 5, 6)
1567 .single()
1568 .expect("invalid timestamp");
1569
1570 let mut event = Event::Log(LogEvent::from("hello world"));
1571 event.as_mut_log().insert(event_path!("format"), "%F");
1572 event.as_mut_log().insert(
1573 (PathPrefix::Event, log_schema().timestamp_key().unwrap()),
1574 ts,
1575 );
1576
1577 let template = Template::try_from("nested {{ format }} %T").unwrap();
1578
1579 assert_eq!(
1580 Ok(Bytes::from("nested %F 04:05:06")),
1581 template.render(&event)
1582 )
1583 }
1584
1585 #[test]
1586 fn render_log_dynamic_with_reverse_nested_strftime() {
1587 let ts = Utc
1588 .with_ymd_and_hms(2001, 2, 3, 4, 5, 6)
1589 .single()
1590 .expect("invalid timestamp");
1591
1592 let mut event = Event::Log(LogEvent::from("hello world"));
1593 event
1594 .as_mut_log()
1595 .insert(&parse_target_path("\"%F\"").unwrap(), "foo");
1596 event.as_mut_log().insert(
1597 (PathPrefix::Event, log_schema().timestamp_key().unwrap()),
1598 ts,
1599 );
1600
1601 let template = Template::try_from("nested {{ \"%F\" }} %T").unwrap();
1602
1603 assert_eq!(
1604 Ok(Bytes::from("nested foo 04:05:06")),
1605 template.render(&event)
1606 )
1607 }
1608
1609 #[test]
1610 fn render_metric_timestamp() {
1611 let template = Template::try_from("timestamp %F %T").unwrap();
1612
1613 assert_eq!(
1614 Ok(Bytes::from("timestamp 2002-03-04 05:06:07")),
1615 template.render(&sample_metric())
1616 );
1617 }
1618
1619 #[test]
1620 fn render_metric_with_tags() {
1621 let template = Template::try_from("name={{name}} component={{tags.component}}").unwrap();
1622 let metric = sample_metric().with_tags(Some(metric_tags!(
1623 "test" => "true",
1624 "component" => "template",
1625 )));
1626 assert_eq!(
1627 Ok(Bytes::from("name=a-counter component=template")),
1628 template.render(&metric)
1629 );
1630 }
1631
1632 #[test]
1633 fn render_metric_without_tags() {
1634 let template = Template::try_from("name={{name}} component={{tags.component}}").unwrap();
1635 assert_eq!(
1636 Err(TemplateRenderingError::MissingKeys {
1637 missing_keys: vec!["tags.component".into()]
1638 }),
1639 template.render(&sample_metric())
1640 );
1641 }
1642
1643 #[test]
1644 fn render_metric_with_namespace() {
1645 let template = Template::try_from("namespace={{namespace}} name={{name}}").unwrap();
1646 let metric = sample_metric().with_namespace(Some("vector-test"));
1647 assert_eq!(
1648 Ok(Bytes::from("namespace=vector-test name=a-counter")),
1649 template.render(&metric)
1650 );
1651 }
1652
1653 #[test]
1654 fn render_metric_without_namespace() {
1655 let template = Template::try_from("namespace={{namespace}} name={{name}}").unwrap();
1656 let metric = sample_metric();
1657 assert_eq!(
1658 Err(TemplateRenderingError::MissingKeys {
1659 missing_keys: vec!["namespace".into()]
1660 }),
1661 template.render(&metric)
1662 );
1663 }
1664
1665 #[test]
1666 fn render_log_with_timezone() {
1667 let ts = Utc.with_ymd_and_hms(2001, 2, 3, 4, 5, 6).unwrap();
1668
1669 let template = Template::try_from("vector-%Y-%m-%d-%H.log").unwrap();
1670 let mut event = Event::Log(LogEvent::from("hello world"));
1671 event.as_mut_log().insert(
1672 (PathPrefix::Event, log_schema().timestamp_key().unwrap()),
1673 ts,
1674 );
1675
1676 let tz: Tz = "Asia/Singapore".parse().unwrap();
1677 let offset = Some(Utc::now().with_timezone(&tz).offset().fix());
1678 assert_eq!(
1679 Ok(Bytes::from("vector-2001-02-03-12.log")),
1680 template.with_tz_offset(offset).render(&event)
1681 );
1682 }
1683
1684 #[test]
1685 fn render_log_unsigned_int_with_timezone() {
1686 let ts = Utc.with_ymd_and_hms(2001, 2, 3, 4, 5, 6).unwrap();
1687
1688 let template = UnsignedIntTemplate::try_from("%Y%m%d%H").unwrap();
1689 let mut event = Event::Log(LogEvent::from("hello world"));
1690 event.as_mut_log().insert(event_path!("timestamp"), ts);
1691
1692 let tz: Tz = "Asia/Singapore".parse().unwrap();
1693 let offset = Some(Utc::now().with_timezone(&tz).offset().fix());
1694
1695 assert_eq!(
1696 Ok(2001020312),
1697 template.with_tz_offset(offset).render(&event)
1698 );
1699 }
1700
1701 fn sample_metric() -> Metric {
1702 Metric::new(
1703 "a-counter",
1704 MetricKind::Absolute,
1705 MetricValue::Counter { value: 1.1 },
1706 )
1707 .with_timestamp(Some(
1708 Utc.with_ymd_and_hms(2002, 3, 4, 5, 6, 7)
1709 .single()
1710 .expect("invalid timestamp"),
1711 ))
1712 }
1713
1714 #[test]
1715 fn strftime_error() {
1716 assert_eq!(
1717 Template::try_from("%E").unwrap_err(),
1718 TemplateParseError::StrftimeError
1719 );
1720 }
1721
1722 #[test]
1723 fn strftime_non_int_result() {
1724 let template = UnsignedIntTemplate::try_from("a-%s").unwrap();
1725 let ts = Utc.with_ymd_and_hms(2001, 2, 3, 4, 5, 6).unwrap();
1726
1727 let mut event = Event::Log(LogEvent::from("hello world"));
1728 event.as_mut_log().insert(event_path!("timestamp"), ts);
1729
1730 assert_eq!(
1731 Err(TemplateRenderingError::NotNumeric {
1732 input: "a-981173106".to_owned()
1733 }),
1734 template.render(&event)
1735 );
1736 }
1737
1738 #[test]
1739 fn dotdot_bypass_rejected() {
1740 let tpl = Template::try_from("safe/{{ tenant }}/").unwrap();
1744 let c = ConfinementChecker::for_template(&tpl).unwrap().unwrap();
1745
1746 assert!(c.confine("safe/legit/").is_ok());
1747 assert!(matches!(
1748 c.confine("safe/../../escape/").unwrap_err(),
1749 ConfineError::DotDotSegment { .. }
1750 ));
1751 assert!(matches!(
1752 c.confine("safe/../escape/").unwrap_err(),
1753 ConfineError::DotDotSegment { .. }
1754 ));
1755 assert!(c.confine("safe/not..dotdot/").is_ok());
1757 }
1758
1759 #[test]
1760 fn uri_path_traversal_rejected() {
1761 let tpl = Template::try_from("https://api.internal/ingest/{{ tenant }}").unwrap();
1765 let c = ConfinementChecker::for_template(&tpl).unwrap().unwrap();
1766
1767 assert!(c.confine("https://api.internal/ingest/acme").is_ok());
1768 assert!(matches!(
1769 c.confine("https://api.internal/ingest/../../admin")
1770 .unwrap_err(),
1771 ConfineError::DotDotSegment { .. }
1772 ));
1773 assert!(matches!(
1775 c.confine("https://api.internal/admin/secret").unwrap_err(),
1776 ConfineError::OutsideBase { .. }
1777 ));
1778 }
1779
1780 #[test]
1781 fn uri_percent_encoded_dotdot_rejected() {
1782 let tpl = Template::try_from("https://api.internal/ingest/{{ tenant }}").unwrap();
1786 let c = ConfinementChecker::for_template(&tpl).unwrap().unwrap();
1787
1788 assert!(c.confine("https://api.internal/ingest/legit").is_ok());
1789 assert!(matches!(
1790 c.confine("https://api.internal/ingest/%2e%2e/admin")
1791 .unwrap_err(),
1792 ConfineError::DotDotSegment { .. }
1793 ));
1794 assert!(matches!(
1795 c.confine("https://api.internal/ingest/%2E%2E/admin")
1796 .unwrap_err(),
1797 ConfineError::DotDotSegment { .. }
1798 ));
1799 assert!(matches!(
1800 c.confine("https://api.internal/ingest/.%2e/admin")
1801 .unwrap_err(),
1802 ConfineError::DotDotSegment { .. }
1803 ));
1804 assert!(matches!(
1805 c.confine("https://api.internal/ingest/%2e./admin")
1806 .unwrap_err(),
1807 ConfineError::DotDotSegment { .. }
1808 ));
1809 }
1810
1811 #[test]
1812 fn uri_encoded_slash_traversal_rejected() {
1813 let tpl = Template::try_from("https://api.internal/ingest/{{ tenant }}").unwrap();
1820 let c = ConfinementChecker::for_template(&tpl).unwrap().unwrap();
1821
1822 assert!(matches!(
1823 c.confine("https://api.internal/ingest/%2e%2e%2fadmin")
1824 .unwrap_err(),
1825 ConfineError::DotDotSegment { .. }
1826 ));
1827 assert!(matches!(
1828 c.confine("https://api.internal/ingest/%2e%2e%2Fadmin")
1829 .unwrap_err(),
1830 ConfineError::DotDotSegment { .. }
1831 ));
1832 assert!(matches!(
1833 c.confine("https://api.internal/ingest/safe%2fpath")
1834 .unwrap_err(),
1835 ConfineError::DotDotSegment { .. }
1836 ));
1837 assert!(matches!(
1838 c.confine("https://api.internal/ingest/safe%5cpath")
1839 .unwrap_err(),
1840 ConfineError::DotDotSegment { .. }
1841 ));
1842 }
1843
1844 #[test]
1845 fn uri_authority_mismatch_rejected() {
1846 let tpl = Template::try_from("https://trusted.example.com{{ path }}").unwrap();
1847 let c = ConfinementChecker::for_template(&tpl).unwrap().unwrap();
1848
1849 assert!(c.confine("https://trusted.example.com/api/v1").is_ok());
1851
1852 assert!(matches!(
1855 c.confine("https://trusted.example.com@evil.com/steal")
1856 .unwrap_err(),
1857 ConfineError::UriAuthorityMismatch { .. }
1858 ));
1859
1860 assert!(matches!(
1863 c.confine("https://trusted.example.com.evil.com/steal")
1864 .unwrap_err(),
1865 ConfineError::UriAuthorityMismatch { .. }
1866 ));
1867
1868 assert!(
1870 c.confine("https://trusted.example.com/path/%40user")
1871 .is_ok()
1872 );
1873 assert!(c.confine("https://trusted.example.com/path/@user").is_ok());
1874
1875 assert!(matches!(
1877 c.confine("http://trusted.example.com/api/v1").unwrap_err(),
1878 ConfineError::UriAuthorityMismatch { .. }
1879 ));
1880 }
1881
1882 #[test]
1883 fn uri_path_field_cannot_smuggle_backslash() {
1884 let tpl = Template::try_from("https://api.internal/ingest/{{ tenant }}").unwrap();
1888 let c = ConfinementChecker::for_template(&tpl).unwrap().unwrap();
1889 assert!(matches!(
1890 c.confine("https://api.internal/ingest/..\\admin")
1891 .unwrap_err(),
1892 ConfineError::DotDotSegment { .. }
1893 ));
1894 assert!(matches!(
1895 c.confine("https://api.internal/ingest/foo\\..\\admin")
1896 .unwrap_err(),
1897 ConfineError::DotDotSegment { .. }
1898 ));
1899 }
1900
1901 #[test]
1902 fn uri_path_field_cannot_smuggle_double_encoded_traversal() {
1903 let tpl = Template::try_from("https://api.internal/ingest/{{ tenant }}").unwrap();
1907 let c = ConfinementChecker::for_template(&tpl).unwrap().unwrap();
1908 assert!(matches!(
1909 c.confine("https://api.internal/ingest/%252e%252e%252fadmin")
1910 .unwrap_err(),
1911 ConfineError::DotDotSegment { .. }
1912 ));
1913 }
1914
1915 #[test]
1916 fn uri_path_field_cannot_smuggle_query() {
1917 let tpl = Template::try_from("https://api.internal/ingest/{{ tenant }}").unwrap();
1921 let c = ConfinementChecker::for_template(&tpl).unwrap().unwrap();
1922 assert!(matches!(
1923 c.confine("https://api.internal/ingest/ok?tenant=evil")
1924 .unwrap_err(),
1925 ConfineError::OutsideBase { .. }
1926 ));
1927 assert!(c.confine("https://api.internal/ingest/ok#frag").is_ok());
1930 }
1931
1932 #[test]
1933 fn uri_template_with_query_or_fragment_rejected_at_build() {
1934 for template_str in &[
1941 "https://api.internal/ingest?tenant={{ tenant }}",
1942 "https://api.internal/ingest?tenant=team-{{ tenant }}",
1943 "https://api.internal/{{ path }}?tenant={{ tenant }}",
1944 "https://api.internal/ingest/{{ path }}?time=%Y",
1945 "https://api.internal/ingest/{{ tenant }}?source=vector",
1946 "https://api.internal/base/{{ tenant }}/ingest#frag",
1947 "https://api.internal/base/{{ tenant }}#frag",
1948 ] {
1949 let tpl = Template::try_from(*template_str).unwrap();
1950 assert!(
1951 matches!(
1952 ConfinementChecker::for_template(&tpl).unwrap_err(),
1953 BuildError::DynamicUriQueryOrFragment { .. }
1954 ),
1955 "expected DynamicUriQueryOrFragment for {template_str}"
1956 );
1957 }
1958 }
1959
1960 #[test]
1961 fn static_uri_with_query_allowed() {
1962 let tpl = Template::try_from("https://api.internal/ingest?source=vector").unwrap();
1966 assert!(ConfinementChecker::for_template(&tpl).unwrap().is_none());
1967 }
1968
1969 #[test]
1970 fn no_static_uri_authority_rejected() {
1971 for template_str in &["https://{{ host }}/ingest", "http://{{ host }}/path"] {
1974 let tpl = Template::try_from(*template_str).unwrap();
1975 assert!(
1976 matches!(
1977 ConfinementChecker::for_template(&tpl).unwrap_err(),
1978 BuildError::NoStaticUriAuthority { .. }
1979 ),
1980 "expected NoStaticUriAuthority for {template_str}"
1981 );
1982 }
1983 let tpl = Template::try_from("https://trusted.example.com{{ path }}").unwrap();
1985 assert!(ConfinementChecker::for_template(&tpl).unwrap().is_some());
1986 }
1987
1988 #[test]
1989 fn root_only_prefix_rejected() {
1990 let tpl = Template::try_from("/{{ tenant }}/").unwrap();
1993 assert!(matches!(
1994 ConfinementChecker::for_template(&tpl).unwrap_err(),
1995 BuildError::DerivedBaseIsRoot { .. }
1996 ));
1997 }
1998
1999 #[test]
2000 fn non_root_slash_prefix_accepted() {
2001 let tpl = Template::try_from("/data/{{ tenant }}/").unwrap();
2003 let checker = ConfinementChecker::for_template(&tpl).unwrap().unwrap();
2004 assert!(checker.confine("/data/tenant-a/").is_ok());
2005 assert!(checker.confine("/other/tenant-a/").is_err());
2006 }
2007}