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::*;