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+
241290fn 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