@@ -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