Skip to main content

vector/template/
confinement.rs

1#[derive(Debug, Snafu)]
2#[snafu(module(build_error))]
3pub(crate) enum BuildError {
4    /// Template has event-field references but no literal prefix to confine them to.
5    #[snafu(display(
6        "template references event fields ({fields:?}) but has no \
7         literal string prefix to derive a confinement base from. Add a static \
8         prefix to your template, or set \
9         `dangerously_allow_unconfined_template_resolution: true` to opt out."
10    ))]
11    NoDerivableBase {
12        /// The event fields referenced by the template.
13        fields: Vec<String>,
14    },
15
16    /// The only derivable prefix is a bare root (`/`), which would allow writes
17    /// to any path under the server's namespace root.
18    #[snafu(display(
19        "template has only `\"/\"` as its literal prefix (from {prefix:?}), \
20         which would permit writes anywhere in the namespace root. Add a \
21         non-root static prefix to your template, or set \
22         `dangerously_allow_unconfined_template_resolution: true` to opt out."
23    ))]
24    DerivedBaseIsRoot {
25        /// The literal prefix that resolved to root.
26        prefix: String,
27    },
28
29    /// The template is an HTTP/HTTPS URI but the static prefix ends before the
30    /// authority (host + optional port), so the rendered URL's destination host
31    /// is entirely event-controlled. Supply a static scheme + host, or set
32    /// `dangerously_allow_unconfined_template_resolution: true` to opt out.
33    #[snafu(display(
34        "HTTP/HTTPS template {prefix:?} has no static authority (host): the \
35         destination host would be fully event-controlled. Add a static host to \
36         your URI template, or set \
37         `dangerously_allow_unconfined_template_resolution: true` to opt out."
38    ))]
39    NoStaticUriAuthority {
40        /// The literal prefix that contained no host.
41        prefix: String,
42    },
43
44    /// The operator-authored URI prefix contains a percent-encoded path separator
45    /// (`%2f` or `%5c`) or a raw backslash. These would cause every rendered
46    /// event to be dropped at runtime; reject at build time instead.
47    #[snafu(display(
48        "HTTP/HTTPS URI prefix {prefix:?} contains %2F, %5C, or a raw backslash \
49         in the static portion. Use a literal `/` in the path instead."
50    ))]
51    EncodedSeparatorInUriPrefix {
52        /// The literal prefix that contained the encoded separator.
53        prefix: String,
54    },
55
56    #[snafu(display(
57        "HTTP/HTTPS template {prefix:?} has a `{{{{ field }}}}` reference inside \
58         the authority (host) component: the static prefix does not contain a \
59         `/` after the host, so the rendered host is partly event-controlled. \
60         Add a `/` after the static host in your URI template, or set \
61         `dangerously_allow_unconfined_template_resolution: true` to opt out."
62    ))]
63    PartialUriAuthority {
64        /// The literal prefix whose authority was left unterminated.
65        prefix: String,
66    },
67
68    /// The template is an HTTP/HTTPS URI containing `?` or `#` in combination
69    /// with `{{ field }}` references.
70    ///
71    /// A field-rendered value can inject a `?query` or a `#fragment` that
72    /// steers routing or (for `#`) silently drops any operator-authored
73    /// suffix through `http::Uri`'s fragment truncation.
74    #[snafu(display(
75        "HTTP/HTTPS template {template:?} mixes `{{{{ field }}}}` references \
76         with `?` or `#`, which cannot be confined. Move event-driven routing \
77         into the URL path, or set \
78         `dangerously_allow_unconfined_template_resolution: true` to opt out."
79    ))]
80    DynamicUriQueryOrFragment {
81        /// The full template source that mixed dynamic fields with `?` or `#`.
82        template: String,
83    },
84}
85
86#[derive(Debug, Snafu)]
87#[snafu(module(confine_error))]
88pub(crate) enum ConfineError {
89    /// Rendered value contains a NUL byte.
90    #[snafu(display("rendered value contains a NUL byte"))]
91    NulByte,
92
93    /// Rendered value exceeds the maximum allowed byte length.
94    #[snafu(display("rendered value is {len} bytes; maximum allowed is {max}"))]
95    TooLong {
96        /// Actual length of the rendered value in bytes.
97        len: usize,
98        /// Maximum allowed length in bytes.
99        max: usize,
100    },
101
102    /// Rendered value does not start with the required base prefix.
103    #[snafu(display(
104        "rendered value {:?} does not start with the base prefix {base:?}",
105        confined_preview(rendered)
106    ))]
107    OutsideBase {
108        /// The rendered value that failed confinement.
109        rendered: String,
110        /// The required base prefix.
111        base: String,
112    },
113
114    /// Rejected because a `..` segment could escape the namespace root on
115    /// filesystem-like protocols (e.g. WebHDFS) even when the string prefix
116    /// check passes (e.g. `safe/../../escape` starts with `safe/`).
117    #[snafu(display(
118        "rendered value {:?} contains a `..` path segment",
119        confined_preview(rendered)
120    ))]
121    DotDotSegment {
122        /// The rendered value that contained the `..` segment.
123        rendered: String,
124    },
125
126    /// Rendered URI could not be parsed.
127    #[snafu(display("rendered value {:?} is not a valid URI", confined_preview(rendered)))]
128    UriParseFailed {
129        /// The rendered value that could not be parsed.
130        rendered: String,
131    },
132
133    /// Rendered URI has a different scheme or authority (host + port) than the
134    /// operator-configured base. This covers both `@`-userinfo injection
135    /// (`trusted.host@evil.com`) and host-extension attacks
136    /// (`trusted.host.evil.com`).
137    #[snafu(display(
138        "rendered URI {:?} has authority {actual:?} but the confined base requires {expected:?}",
139        confined_preview(rendered)
140    ))]
141    UriAuthorityMismatch {
142        /// The rendered value that failed confinement.
143        rendered: String,
144        /// The authority that was required.
145        expected: String,
146        /// The authority that was actually present.
147        actual: String,
148    },
149}
150
151/// Confinement checker stored on a [`Template`] at build time.
152///
153/// Dispatches to `PrefixChecker` for non-URI fields (Kafka topics, Redis
154/// keys, tenant IDs, …) or `UriChecker` for HTTP/HTTPS URI fields. Common
155/// guards (NUL bytes, length limit) run before dispatching.
156#[derive(Clone, Debug, PartialEq, Eq, Hash)]
157pub(crate) enum ConfinementChecker {
158    Prefix(PrefixChecker),
159    Uri(UriChecker),
160}
161
162impl ConfinementChecker {
163    pub(crate) fn for_template(tpl: &Template) -> Result<Option<Self>, BuildError> {
164        let fields = match tpl.get_fields() {
165            Some(f) => f,
166            None => return Ok(None),
167        };
168        let prefix = tpl.literal_prefix();
169        if prefix.is_empty() {
170            return Err(BuildError::NoDerivableBase { fields });
171        }
172        if prefix == "/" {
173            return Err(BuildError::DerivedBaseIsRoot {
174                prefix: prefix.to_string(),
175            });
176        }
177        // Only treat as a URI if the prefix literally starts with http:// or https://.
178        // Testing for the scheme token alone (without "://") would misfire on
179        // templates like `http_{{tenant}}` whose prefix is `http_`.
180        let lp = prefix.to_ascii_lowercase();
181        if lp.starts_with("http://") || lp.starts_with("https://") {
182            // Reject URI templates that have field references AND `?` or `#`.
183            // A static query/fragment is safe (fixed value, not
184            // event-controlled). But once a `{{ field }}` is present, the
185            // rendered path segment can smuggle either:
186            //   - a `?extra=...` query string, or
187            //   - a `#frag` that `http::Uri` truncates before our checker
188            //     sees the path, silently dropping any operator-authored
189            //     suffix like `/ingest`.
190            let src = tpl.get_ref();
191            if src.contains('?') || src.contains('#') {
192                return Err(BuildError::DynamicUriQueryOrFragment {
193                    template: src.to_string(),
194                });
195            }
196            UriChecker::from_prefix(prefix).map(|c| Some(Self::Uri(c)))
197        } else {
198            Ok(Some(Self::Prefix(PrefixChecker {
199                base: prefix.to_string(),
200            })))
201        }
202    }
203
204    pub(crate) fn confine(&self, rendered: &str) -> Result<(), ConfineError> {
205        if rendered.contains('\0') {
206            return Err(ConfineError::NulByte);
207        }
208        if rendered.len() > MAX_RENDERED_PATH_LEN {
209            return Err(ConfineError::TooLong {
210                len: rendered.len(),
211                max: MAX_RENDERED_PATH_LEN,
212            });
213        }
214        match self {
215            Self::Prefix(c) => c.confine(rendered),
216            Self::Uri(c) => c.confine(rendered),
217        }
218    }
219}
220
221/// Confinement for non-URI templates (Kafka topics, Redis keys, tenant IDs, …).
222///
223/// Enforces that the rendered value starts with the operator-controlled literal
224/// prefix and contains no `..` path segments.
225#[derive(Clone, Debug, PartialEq, Eq, Hash)]
226pub(crate) struct PrefixChecker {
227    base: String,
228}
229
230impl PrefixChecker {
231    pub(crate) fn confine(&self, rendered: &str) -> Result<(), ConfineError> {
232        // Reject `..` segments: on filesystem-like protocols (e.g. WebHDFS) a
233        // value like `safe/../../escape` passes `starts_with("safe/")` but
234        // resolves outside the namespace root on the server.
235        if rendered.split('/').any(|seg| seg == "..") {
236            return Err(ConfineError::DotDotSegment {
237                rendered: rendered.to_string(),
238            });
239        }
240        if !rendered.starts_with(&self.base) {
241            return Err(ConfineError::OutsideBase {
242                rendered: rendered.to_string(),
243                base: self.base.clone(),
244            });
245        }
246        Ok(())
247    }
248}
249
250/// Confinement for HTTP/HTTPS URI templates.
251///
252/// At build time the operator-authored static prefix is parsed with
253/// `http::Uri` and its scheme, authority, and path are stored normalised
254/// (lowercased). At render time the rendered value is also parsed with
255/// `http::Uri` and the structured fields are compared, which avoids all the
256/// pitfalls of raw-string heuristics (case sensitivity, percent-encoding,
257/// `@`-injection inside the authority component, etc.).
258///
259/// Three checks run on every render:
260///
261/// 1. **Authority check** — the rendered URI's scheme and authority must match
262///    the operator-authored values exactly. This catches `@`-userinfo injection
263///    (`trusted.host@evil.com`) and host-extension attacks
264///    (`trusted.host.evil.com`).
265///
266/// 2. **Path-prefix check** — the rendered URI's path must start with the
267///    static path portion derived from the template prefix.
268///
269/// 3. **Dot-dot segment check** — no path segment may be `..`, `.%2e`,
270///    `%2e.`, or `%2e%2e` (case-insensitive). This catches path traversal
271///    within the same host even when the prefix check passes.
272///
273/// URI templates containing `?` are rejected at build time — query parameters
274/// cannot be safely confined to a static boundary and must not appear in
275/// dynamic URI templates.
276#[derive(Clone, Debug, PartialEq, Eq, Hash)]
277pub(crate) struct UriChecker {
278    /// Lowercased scheme, e.g. `"https"`.
279    scheme: String,
280    /// Lowercased authority (host + optional port), e.g. `"api.internal"`.
281    authority: String,
282    /// Static path portion from the template prefix, e.g. `"/ingest/"`.
283    path_prefix: String,
284}
285
286impl UriChecker {
287    pub(crate) fn from_prefix(prefix: &str) -> Result<Self, BuildError> {
288        let uri = prefix
289            .parse::<Uri>()
290            .map_err(|_| BuildError::NoStaticUriAuthority {
291                prefix: prefix.to_string(),
292            })?;
293        let scheme = uri
294            .scheme_str()
295            .expect("scheme present because prefix starts with http(s)://")
296            .to_ascii_lowercase();
297        let path = uri.path().to_ascii_lowercase();
298        // Reject encoded path separators, encoded percents, and raw
299        // backslashes in the operator-authored prefix. Any of these would
300        // cause every rendered URI to fail the render-time check, silently
301        // dropping all events; detect at build time instead.
302        if path.contains("%2f")
303            || path.contains("%5c")
304            || path.contains("%25")
305            || uri.path().contains('\\')
306        {
307            return Err(BuildError::EncodedSeparatorInUriPrefix {
308                prefix: prefix.to_string(),
309            });
310        }
311        let authority = match uri.authority() {
312            Some(auth) if !auth.as_str().is_empty() => auth.as_str().to_ascii_lowercase(),
313            _ => {
314                return Err(BuildError::NoStaticUriAuthority {
315                    prefix: prefix.to_string(),
316                });
317            }
318        };
319        // `http::Uri` normalises a missing path to `"/"`, so `uri.path()` can't
320        // tell us whether the prefix actually had a `/` closing off the host. Check
321        // the raw prefix instead: no `/` after `://` means the `{{ field }}`
322        // reference sits inside (or extends) the authority we just parsed.
323        let after_scheme = prefix
324            .split_once("://")
325            .map(|(_, rest)| rest)
326            .expect("\"://\" present because prefix starts with http(s)://");
327        if !after_scheme.contains('/') {
328            return Err(BuildError::PartialUriAuthority {
329                prefix: prefix.to_string(),
330            });
331        }
332        Ok(Self {
333            scheme,
334            authority,
335            path_prefix: uri.path().to_string(),
336        })
337    }
338
339    pub(crate) fn confine(&self, rendered: &str) -> Result<(), ConfineError> {
340        // Parse with http::Uri so all structural checks use the same tokeniser
341        // that built the baseline — no raw-string heuristics.
342        let uri = rendered
343            .parse::<Uri>()
344            .map_err(|_| ConfineError::UriParseFailed {
345                rendered: rendered.to_string(),
346            })?;
347
348        // 1. Authority check: scheme + host must exactly match the base.
349        //    Catches @-userinfo injection and host-extension attacks.
350        //    Both sides are lowercased so the comparison is case-insensitive.
351        let actual_scheme = uri.scheme_str().unwrap_or("").to_ascii_lowercase();
352        let actual_authority = uri
353            .authority()
354            .map(|a| a.as_str().to_ascii_lowercase())
355            .unwrap_or_default();
356        if actual_scheme != self.scheme || actual_authority != self.authority {
357            return Err(ConfineError::UriAuthorityMismatch {
358                rendered: rendered.to_string(),
359                expected: format!("{}://{}", self.scheme, self.authority),
360                actual: format!("{actual_scheme}://{actual_authority}"),
361            });
362        }
363
364        // 2. Path-prefix check: catches path escape when the template includes
365        //    a static path (e.g. `https://api.internal/ingest/{{ tenant }}`).
366        let path = uri.path();
367        if !path.starts_with(&self.path_prefix) {
368            return Err(ConfineError::OutsideBase {
369                rendered: rendered.to_string(),
370                base: format!("{}://{}{}", self.scheme, self.authority, self.path_prefix),
371            });
372        }
373
374        // 3. Dot-dot segment check: catches within-prefix path traversal.
375        //    Also rejects percent-encoded variants that some servers decode
376        //    before resolving the path (e.g. `/ingest/%2e%2e/admin`).
377        for segment in path.split('/') {
378            if segment == ".."
379                || segment.eq_ignore_ascii_case("%2e%2e")
380                || segment.eq_ignore_ascii_case(".%2e")
381                || segment.eq_ignore_ascii_case("%2e.")
382            {
383                return Err(ConfineError::DotDotSegment {
384                    rendered: rendered.to_string(),
385                });
386            }
387        }
388
389        // 4. Reject encoded path separators, raw backslashes, and encoded
390        //    percent signs.
391        //    `%2f` (encoded `/`) and `%5c` (encoded `\`) are decoded by many
392        //    servers before path normalization, turning an otherwise-safe
393        //    segment into a traversal vector.  Raw `\` is accepted by
394        //    `http::Uri` but treated as a path separator by Windows/IIS,
395        //    allowing `/ingest/..\admin` to escape the prefix on those hosts.
396        //    `%25` is the encoded form of `%`; a proxy that decodes once
397        //    turns `%252e%252e%252fadmin` into `%2e%2e%2fadmin`, which a
398        //    second decoder resolves to `../admin`. Rejecting `%25` closes
399        //    the double-encoding bypass.
400        let path_lc = path.to_ascii_lowercase();
401        if path_lc.contains("%2f")
402            || path_lc.contains("%5c")
403            || path_lc.contains("%25")
404            || path.contains('\\')
405        {
406            return Err(ConfineError::DotDotSegment {
407                rendered: rendered.to_string(),
408            });
409        }
410
411        // 4. Reject any rendered query. URI templates containing `?` are
412        //    rejected at build time, so a query at render time means a field
413        //    value smuggled `?...` into the path — e.g. tenant value
414        //    `ok?tenant=evil` renders `.../ingest/ok?tenant=evil`: same
415        //    authority and path prefix but an attacker-controlled query.
416        if uri.query().is_some() {
417            return Err(ConfineError::OutsideBase {
418                rendered: rendered.to_string(),
419                base: format!("{}://{}{}", self.scheme, self.authority, self.path_prefix),
420            });
421        }
422        Ok(())
423    }
424}
425
426impl ConfinementConfig {
427    /// Returns a `ConfinementConfig` that opts out of confinement.
428    ///
429    /// Use only in tests where templates intentionally have no literal prefix.
430    pub const fn unconfined() -> Self {
431        Self {
432            dangerously_allow_unconfined_template_resolution: true,
433        }
434    }
435
436    /// Logs a per-template SECURITY warning on the opt-out path.
437    ///
438    /// The `vector_security_confinement_disabled` gauge is owned by the topology
439    /// (see `RunningTopology::refresh_confinement_gauges`), which holds a handle
440    /// for the sink's lifetime so the metric matches the active topology and
441    /// never expires while the sink runs.
442    pub fn warn_unconfined_template(
443        component_kind: &'static str,
444        component_type: &'static str,
445        field: &'static str,
446    ) {
447        warn!(
448            message = "SECURITY: component has `dangerously_allow_unconfined_template_resolution` \
449                       enabled — template is NOT confined. A log producer that controls any \
450                       field used in the template can write to arbitrary keys.",
451            component_kind, component_type, field,
452        );
453    }
454}
455use super::*;