Skip to content

Commit c3ba20a

Browse files
committed
fix(template): reject URI query at build and render
UriChecker had no query handling. Two attack surfaces: 1. Build: a template like ?tenant={{ tenant }} lets an attacker supply a value containing & to append arbitrary key=value pairs. Every attempt to reason about & boundaries and multi-field patterns was either bypassable or false-positive on operator- authored static suffixes. Reject any URI template whose literal prefix crosses into the query at build time; operators can put dynamic routing in the path instead. 2. Render: with the build check in place, a URI checker only exists for pathful templates. So any rendered URI with a query means a field value smuggled ?... into the path. Example: template .../ingest/{{ tenant }} with tenant = 'ok?tenant=evil' renders to .../ingest/ok?tenant=evil — same authority, same path prefix, attacker-controlled query. Reject any rendered URI with a non-empty query.
1 parent 8837039 commit c3ba20a

2 files changed

Lines changed: 82 additions & 15 deletions

File tree

src/sinks/elasticsearch/config.rs

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -614,7 +614,13 @@ where
614614
/// value violating those constraints is either malicious or would be
615615
/// rejected on ingest.
616616
fn is_valid_data_stream_component(s: &str) -> bool {
617-
if s.is_empty() || s.len() > 255 {
617+
// Empty is legitimate: `DataStreamConfig::index` filters empty parts
618+
// out of the joined `type-dataset-namespace` name, so an event field
619+
// that explicitly overrides one of the parts to `""` just skips it.
620+
if s.is_empty() {
621+
return true;
622+
}
623+
if s.len() > 255 {
618624
return false;
619625
}
620626
if s.contains('\0') || s.contains('/') || s.contains('\\') {
@@ -623,7 +629,9 @@ fn is_valid_data_stream_component(s: &str) -> bool {
623629
// Reject any path segment that is exactly `.` or `..` to block
624630
// filesystem-style traversal even if the surrounding value looks
625631
// otherwise identifier-like.
626-
if s.split(&['/', '\\'][..]).any(|seg| seg == ".." || seg == ".") {
632+
if s.split(&['/', '\\'][..])
633+
.any(|seg| seg == ".." || seg == ".")
634+
{
627635
return false;
628636
}
629637
// Control characters have no place in a routing identifier.
@@ -743,7 +751,8 @@ mod tests {
743751
assert!(!is_valid_data_stream_component("logs/tenant"));
744752
assert!(!is_valid_data_stream_component("logs\\tenant"));
745753
// Empty + length cap.
746-
assert!(!is_valid_data_stream_component(""));
754+
// Empty is accepted — DataStreamConfig::index filters empty parts.
755+
assert!(is_valid_data_stream_component(""));
747756
assert!(!is_valid_data_stream_component(&"x".repeat(256)));
748757
// NUL + control chars.
749758
assert!(!is_valid_data_stream_component("logs\0"));

src/template.rs

Lines changed: 70 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1022,6 +1022,17 @@ impl UriChecker {
10221022
.scheme_str()
10231023
.expect("scheme present because prefix starts with http(s)://")
10241024
.to_ascii_lowercase();
1025+
// Reject URI templates whose literal prefix has already crossed
1026+
// into the query. Composing a robust prefix pattern from operator-
1027+
// authored `?tenant={{ tenant }}` shapes is fragile — every
1028+
// starts_with/`&`-boundary combination has either a bypass or a
1029+
// false-positive on operator-authored static suffixes. Force
1030+
// dynamic routing into the path instead.
1031+
if uri.query().is_some() {
1032+
return Err(BuildError::NoStaticUriAuthority {
1033+
prefix: prefix.to_string(),
1034+
});
1035+
}
10251036
match uri.authority() {
10261037
Some(auth) if !auth.as_str().is_empty() => Ok(Self {
10271038
scheme,
@@ -1096,6 +1107,20 @@ impl UriChecker {
10961107
});
10971108
}
10981109

1110+
// 5. Reject any rendered query. `from_prefix` rejects URI templates
1111+
// whose literal prefix crosses into the query, so a URI checker is
1112+
// only ever built for pathful templates. A query at render time
1113+
// means a field value smuggled `?...` into the path — for
1114+
// example `.../ingest/{{ tenant }}` with a tenant value of
1115+
// `ok?tenant=evil` renders to `.../ingest/ok` with query
1116+
// `tenant=evil`; same authority + same path prefix, but the
1117+
// destination now has an attacker-controlled routing parameter.
1118+
if uri.query().is_some() {
1119+
return Err(ConfineError::OutsideBase {
1120+
rendered: rendered.to_string(),
1121+
base: format!("{}://{}{}", self.scheme, self.authority, self.path_prefix),
1122+
});
1123+
}
10991124
Ok(())
11001125
}
11011126
}
@@ -1657,18 +1682,6 @@ mod tests {
16571682
));
16581683
}
16591684

1660-
#[test]
1661-
fn uri_query_only_suffix_accepted() {
1662-
// `https://host{{ query }}` with `query = "?x=1"` should pass.
1663-
let tpl = Template::try_from("https://api.internal{{ query }}").unwrap();
1664-
let c = ConfinementChecker::for_template(&tpl).unwrap().unwrap();
1665-
assert!(c.confine("https://api.internal?x=1").is_ok());
1666-
// http::Uri silently strips fragments per RFC 7230 (fragments are not sent
1667-
// to servers), so the fragment is invisible to the authority + path checks
1668-
// and the rendered value is accepted.
1669-
assert!(c.confine("https://api.internal#fragment").is_ok());
1670-
}
1671-
16721685
#[test]
16731686
fn uri_percent_encoded_dotdot_rejected() {
16741687
// Servers that decode percent-encoding before path resolution can be
@@ -1771,6 +1784,51 @@ mod tests {
17711784
));
17721785
}
17731786

1787+
#[test]
1788+
fn uri_path_field_cannot_smuggle_query() {
1789+
// The build-time check rejects URI templates that put a field in
1790+
// the query, so UriChecker only exists for pathful templates. If a
1791+
// rendered URI has any query at all, a field value must have
1792+
// smuggled it in through the path. Example: template
1793+
// `https://api.internal/ingest/{{ tenant }}` + tenant value
1794+
// `ok?tenant=evil` renders to `.../ingest/ok?tenant=evil` — same
1795+
// authority and path prefix, attacker-controlled query.
1796+
let tpl = Template::try_from("https://api.internal/ingest/{{ tenant }}").unwrap();
1797+
let c = ConfinementChecker::for_template(&tpl).unwrap().unwrap();
1798+
assert!(matches!(
1799+
c.confine("https://api.internal/ingest/ok?tenant=evil")
1800+
.unwrap_err(),
1801+
ConfineError::OutsideBase { .. }
1802+
));
1803+
// Fragments are stripped by `http::Uri` before reaching the server,
1804+
// so a rendered `#frag` doesn't count as a query and is accepted.
1805+
assert!(c.confine("https://api.internal/ingest/ok#frag").is_ok());
1806+
}
1807+
1808+
#[test]
1809+
fn uri_field_in_query_rejected_at_build() {
1810+
// URIs whose template puts a field in the query are fragile to
1811+
// confine: every attempt at reasoning about `&` boundaries, static
1812+
// suffixes, and multi-field patterns has been either bypassable
1813+
// (a value like `a&tenant=other` sneaking through a bare prefix
1814+
// check) or false-positive (a legitimate operator-authored
1815+
// `&source=vector` static suffix). Reject at build.
1816+
for template_str in &[
1817+
"https://api.internal/ingest?tenant={{ tenant }}",
1818+
"https://api.internal/ingest?tenant=team-{{ tenant }}",
1819+
"https://api.internal/ingest?apiVersion=1.0&tenant={{ tenant }}",
1820+
] {
1821+
let tpl = Template::try_from(*template_str).unwrap();
1822+
assert!(
1823+
matches!(
1824+
ConfinementChecker::for_template(&tpl).unwrap_err(),
1825+
BuildError::NoStaticUriAuthority { .. }
1826+
),
1827+
"expected NoStaticUriAuthority for {template_str}"
1828+
);
1829+
}
1830+
}
1831+
17741832
#[test]
17751833
fn no_static_uri_authority_rejected() {
17761834
// `https://{{ host }}/ingest` has prefix `https://` — any host can be

0 commit comments

Comments
 (0)