Skip to content

Commit 877ddba

Browse files
authored
fix(supervisor-network): canonicalize dot-segments before policy evaluation (#2699)
* fix(supervisor-network): strip path parameters before dot-segment resolution Signed-off-by: Adrien Langou <alangou@nvidia.com> * fix(supervisor-network): scope allow_encoded_slash to the matched L7 endpoint Signed-off-by: Adrien Langou <alangou@nvidia.com> * fix(supervisor-network): check the canonical target for encoded slashes Signed-off-by: Adrien Langou <alangou@nvidia.com> --------- Signed-off-by: Adrien Langou <alangou@nvidia.com>
1 parent 6340d18 commit 877ddba

3 files changed

Lines changed: 570 additions & 6 deletions

File tree

crates/openshell-supervisor-network/src/l7/path.rs

Lines changed: 172 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,11 @@
2222
//! request-target, raw non-ASCII bytes, and paths that cannot be parsed
2323
//! as origin-form.
2424
//! - Strip trailing `;params` from each segment by default (Tomcat-class
25-
//! `;jsessionid` ACL-bypass mitigation).
25+
//! `;jsessionid` ACL-bypass mitigation). Stripping happens *before*
26+
//! dot-segment resolution so that `..;` cannot evade the traversal guard
27+
//! and then revert to `..` on the way out.
28+
//! - Reject any `.`/`..` that survives to the end of canonicalization. The
29+
//! policy engine trusts this function to have removed them.
2630
//! - Reject `%2F` (encoded slash) inside a segment by default. Operators
2731
//! can opt in per-endpoint for APIs that rely on encoded slashes in
2832
//! slugs.
@@ -44,6 +48,8 @@ pub enum CanonicalizeError {
4448
NonAscii,
4549
#[error("request-target's `..` segment would escape the path root")]
4650
TraversalAboveRoot,
51+
#[error("request-target still contains a `.`/`..` segment after canonicalization")]
52+
ResidualDotSegment,
4753
#[error("request-target exceeds the configured maximum length")]
4854
PathTooLong,
4955
#[error("request-target is not a valid origin-form path")]
@@ -172,10 +178,32 @@ pub fn canonicalize_request_target(
172178
// with a real path separator.
173179
let decoded = percent_decode_with_sentinel(raw_path.as_bytes(), opts.allow_encoded_slash)?;
174180

175-
// 9. Split on literal `/` and resolve dot-segments.
181+
// 9. Split on literal `/`, then strip `;params` *before* resolving
182+
// dot-segments. Stripping afterwards would let a `..;` segment slip
183+
// past the traversal guard (it is not byte-equal to `..`) and then
184+
// revert to a bare `..` during reconstruction.
176185
let segments = split_path_segments(&decoded);
186+
let segments: Vec<&[u8]> = if opts.strip_path_parameters {
187+
segments.into_iter().map(strip_path_parameters).collect()
188+
} else {
189+
segments
190+
};
177191
let resolved = resolve_dot_segments(segments)?;
178192

193+
// 9b. Defense in depth: no `.`/`..` may survive canonicalization. The
194+
// policy engine trusts this function to have removed them, so a
195+
// residual dot-segment is always a bug or an attack. This also
196+
// catches a `..` hidden behind a `%2F` sentinel on endpoints that
197+
// opted into `allow_encoded_slash`, where the sentinel keeps the
198+
// dot-segment inside its segment and out of `resolve_dot_segments`.
199+
for seg in &resolved {
200+
for part in seg.split(|&b| b == ENCODED_SLASH_SENTINEL) {
201+
if part == b".." || part == b"." {
202+
return Err(CanonicalizeError::ResidualDotSegment);
203+
}
204+
}
205+
}
206+
179207
// 10. Reconstruct. Strip `;params` per segment if requested; re-encode
180208
// any byte that must be percent-encoded in the pchar set.
181209
let canonical = build_canonical_path(&resolved, decoded.last().copied() == Some(b'/'), *opts);
@@ -190,6 +218,20 @@ pub fn canonicalize_request_target(
190218
))
191219
}
192220

221+
/// Report whether a canonical path carries a `%2F` that survived
222+
/// canonicalization.
223+
///
224+
/// Callers that canonicalize before they know which endpoint config applies
225+
/// use this to re-check the result against the config that actually matched.
226+
///
227+
/// The test is exact: [`build_canonical_path`] emits the literal `%2F` only
228+
/// for the encoded-slash sentinel, and percent-encodes any other `%` byte as
229+
/// `%25`, so no `%2F` substring can reach the output by another route.
230+
#[must_use]
231+
pub fn canonical_path_has_encoded_slash(canonical_path: &str) -> bool {
232+
canonical_path.contains("%2F")
233+
}
234+
193235
// ---------------------------------------------------------------------------
194236
// Internals
195237
// ---------------------------------------------------------------------------
@@ -238,6 +280,13 @@ fn percent_decode_with_sentinel(
238280
Ok(out)
239281
}
240282

283+
/// Drop a trailing `;params` suffix from a single path segment.
284+
fn strip_path_parameters(seg: &[u8]) -> &[u8] {
285+
seg.iter()
286+
.position(|&b| b == b';')
287+
.map_or(seg, |pos| &seg[..pos])
288+
}
289+
241290
fn split_path_segments(decoded: &[u8]) -> Vec<&[u8]> {
242291
// decoded is guaranteed to start with `/`. Skip the leading `/` and
243292
// split on subsequent `/` bytes. The sentinel byte for encoded slashes
@@ -286,10 +335,7 @@ fn build_canonical_path(
286335
out.push('/');
287336
}
288337
let trimmed: &[u8] = if opts.strip_path_parameters {
289-
match seg.iter().position(|&b| b == b';') {
290-
Some(pos) => &seg[..pos],
291-
None => seg,
292-
}
338+
strip_path_parameters(seg)
293339
} else {
294340
seg
295341
};
@@ -582,4 +628,124 @@ mod tests {
582628
fn regression_dot_slash_dotdot() {
583629
assert_eq!(canon("/public/./../secret").unwrap(), "/secret");
584630
}
631+
632+
// ---------------------------------------------------------------------
633+
// A `;params` suffix on the dot segment itself must not let the segment
634+
// slip past the traversal guard. Stripping used to run after dot-segment
635+
// resolution, so `..;` was not byte-equal to `..` when the guard looked
636+
// at it, and reverted to a bare `..` during reconstruction — handing the
637+
// policy engine and the upstream a path that still escaped its prefix.
638+
// ---------------------------------------------------------------------
639+
640+
#[test]
641+
fn regression_dotdot_with_path_parameter_is_resolved_not_smuggled() {
642+
assert_eq!(canon("/public/..;/secret").unwrap(), "/secret");
643+
assert_eq!(canon("/public/..;x/secret").unwrap(), "/secret");
644+
assert_eq!(
645+
canon("/public/..;jsessionid=xyz/secret").unwrap(),
646+
"/secret"
647+
);
648+
assert_eq!(canon("/public/.;/secret").unwrap(), "/public/secret");
649+
assert_eq!(canon("/public/.;x/secret").unwrap(), "/public/secret");
650+
}
651+
652+
#[test]
653+
fn regression_percent_encoded_dotdot_with_path_parameter() {
654+
// `%2e%2e;` and `..%3B` both decode to `..;` before segmentation.
655+
assert_eq!(canon("/public/%2e%2e;/secret").unwrap(), "/secret");
656+
assert_eq!(canon("/public/..%3B/secret").unwrap(), "/secret");
657+
assert_eq!(canon("/public/..%3b/secret").unwrap(), "/secret");
658+
}
659+
660+
#[test]
661+
fn regression_chained_dotdot_with_path_parameters_hits_root_guard() {
662+
assert_eq!(
663+
canon("/public/..;/..;/secret"),
664+
Err(CanonicalizeError::TraversalAboveRoot)
665+
);
666+
assert_eq!(
667+
canon("/api/v1/..;/..;/..;/admin/keys"),
668+
Err(CanonicalizeError::TraversalAboveRoot)
669+
);
670+
assert_eq!(canon("/..;"), Err(CanonicalizeError::TraversalAboveRoot));
671+
}
672+
673+
#[test]
674+
fn regression_dotdot_behind_encoded_slash_is_rejected_when_opted_in() {
675+
// With `allow_encoded_slash`, the `%2F` sentinel keeps the dot-segment
676+
// inside its segment, so `resolve_dot_segments` never sees it. The
677+
// residual guard is what closes this.
678+
let opts = CanonicalizeOptions {
679+
allow_encoded_slash: true,
680+
..CanonicalizeOptions::default()
681+
};
682+
assert_eq!(
683+
canon_with("/public/..%2fsecret", opts),
684+
Err(CanonicalizeError::ResidualDotSegment)
685+
);
686+
assert_eq!(
687+
canon_with("/public/..%2f..%2fsecret", opts),
688+
Err(CanonicalizeError::ResidualDotSegment)
689+
);
690+
assert_eq!(
691+
canon_with("/public/.%2fsecret", opts),
692+
Err(CanonicalizeError::ResidualDotSegment)
693+
);
694+
// A legitimate encoded-slash slug still round-trips.
695+
assert_eq!(
696+
canon_with("/repos/group%2fproject/issues", opts).unwrap(),
697+
"/repos/group%2Fproject/issues"
698+
);
699+
}
700+
701+
#[test]
702+
fn encoded_slash_detection_on_canonical_paths_is_exact() {
703+
let opts = CanonicalizeOptions {
704+
allow_encoded_slash: true,
705+
..CanonicalizeOptions::default()
706+
};
707+
708+
// A surviving sentinel is detected.
709+
let slug = canon_with("/repos/group%2fproject/issues", opts).unwrap();
710+
assert_eq!(slug, "/repos/group%2Fproject/issues");
711+
assert!(canonical_path_has_encoded_slash(&slug));
712+
713+
// Ordinary paths are not.
714+
assert!(!canonical_path_has_encoded_slash(
715+
&canon("/public/secret").unwrap()
716+
));
717+
718+
// A literal `%` in the input is re-emitted as `%25`, so it cannot
719+
// fabricate a `%2F` substring — including when the input spells out
720+
// `%252F`, which decodes to the three characters `%`, `2`, `F`.
721+
let escaped = canon("/a/%252F/b").unwrap();
722+
assert_eq!(escaped, "/a/%252F/b");
723+
assert!(!canonical_path_has_encoded_slash(&escaped));
724+
725+
let percent = canon("/a/100%25/b").unwrap();
726+
assert_eq!(percent, "/a/100%25/b");
727+
assert!(!canonical_path_has_encoded_slash(&percent));
728+
}
729+
730+
#[test]
731+
fn canonical_output_never_contains_dot_segments() {
732+
// The contract the policy engine relies on: whatever comes back is
733+
// free of `.`/`..`, so rego never has to defend against them.
734+
for target in [
735+
"/public/..;/secret",
736+
"/public/.;/secret",
737+
"/public/%2e%2e;/secret",
738+
"/a;jsessionid=xyz/b",
739+
"/public//../secret",
740+
"/a/b/..",
741+
"/a/b/.",
742+
] {
743+
if let Ok(path) = canon(target) {
744+
assert!(
745+
!path.split('/').any(|seg| seg == ".." || seg == "."),
746+
"{target} canonicalized to {path}, which still has a dot-segment"
747+
);
748+
}
749+
}
750+
}
585751
}

0 commit comments

Comments
 (0)