@@ -60,39 +60,106 @@ internal Request(RequestContext requestContext)
6060
6161 PathBase = string . Empty ;
6262 Path = originalPath ;
63+ var prefix = requestContext . Server . Options . UrlPrefixes . GetPrefix ( ( int ) requestContext . UrlContext ) ;
6364
6465 // 'OPTIONS * HTTP/1.1'
6566 if ( KnownMethod == HttpApiTypes . HTTP_VERB . HttpVerbOPTIONS && string . Equals ( RawUrl , "*" , StringComparison . Ordinal ) )
6667 {
6768 PathBase = string . Empty ;
6869 Path = string . Empty ;
6970 }
70- else
71+ // Prefix may be null if the requested has been transfered to our queue
72+ else if ( prefix is not null )
7173 {
72- var prefix = requestContext . Server . Options . UrlPrefixes . GetPrefix ( ( int ) requestContext . UrlContext ) ;
73- // Prefix may be null if the requested has been transfered to our queue
74- if ( ! ( prefix is null ) )
74+ var pathBase = prefix . PathWithoutTrailingSlash ;
75+
76+ // url: /base/path, prefix: /base/, base: /base, path: /path
77+ // url: /, prefix: /, base: , path: /
78+ if ( originalPath . Equals ( pathBase , StringComparison . Ordinal ) )
7579 {
76- if ( originalPath . Length == prefix . PathWithoutTrailingSlash . Length )
77- {
78- // They matched exactly except for the trailing slash.
79- PathBase = originalPath ;
80- Path = string . Empty ;
81- }
82- else
83- {
84- // url: /base/path, prefix: /base/, base: /base, path: /path
85- // url: /, prefix: /, base: , path: /
86- PathBase = originalPath . Substring ( 0 , prefix . PathWithoutTrailingSlash . Length ) ; // Preserve the user input casing
87- Path = originalPath . Substring ( prefix . PathWithoutTrailingSlash . Length ) ;
88- }
80+ // Exact match, no need to preserve the casing
81+ PathBase = pathBase ;
82+ Path = string . Empty ;
8983 }
90- else if ( requestContext . Server . Options . UrlPrefixes . TryMatchLongestPrefix ( IsHttps , cookedUrl . GetHost ( ) ! , originalPath , out var pathBase , out var path ) )
84+ else if ( originalPath . Equals ( pathBase , StringComparison . OrdinalIgnoreCase ) )
9185 {
86+ // Preserve the user input casing
87+ PathBase = originalPath ;
88+ Path = string . Empty ;
89+ }
90+ else if ( originalPath . StartsWith ( prefix . Path , StringComparison . Ordinal ) )
91+ {
92+ // Exact match, no need to preserve the casing
9293 PathBase = pathBase ;
93- Path = path ;
94+ Path = originalPath [ pathBase . Length ..] ;
95+ }
96+ else if ( originalPath . StartsWith ( prefix . Path , StringComparison . OrdinalIgnoreCase ) )
97+ {
98+ // Preserve the user input casing
99+ PathBase = originalPath [ ..pathBase . Length ] ;
100+ Path = originalPath [ pathBase . Length ..] ;
101+ }
102+ else
103+ {
104+ // Http.Sys path base matching is based on the cooked url which applies some non-standard normalizations that we don't use
105+ // like collapsing duplicate slashes "//", converting '\' to '/', and un-escaping "%2F" to '/'. Find the right split and
106+ // ignore the normalizations.
107+ var originalOffset = 0 ;
108+ var baseOffset = 0 ;
109+ while ( originalOffset < originalPath . Length && baseOffset < pathBase . Length )
110+ {
111+ var baseValue = pathBase [ baseOffset ] ;
112+ var offsetValue = originalPath [ originalOffset ] ;
113+ if ( baseValue == offsetValue
114+ || char . ToUpperInvariant ( baseValue ) == char . ToUpperInvariant ( offsetValue ) )
115+ {
116+ // case-insensitive match, continue
117+ originalOffset ++ ;
118+ baseOffset ++ ;
119+ }
120+ else if ( baseValue == '/' && offsetValue == '\\ ' )
121+ {
122+ // Http.Sys considers these equivalent
123+ originalOffset ++ ;
124+ baseOffset ++ ;
125+ }
126+ else if ( baseValue == '/' && originalPath . AsSpan ( originalOffset ) . StartsWith ( "%2F" , StringComparison . OrdinalIgnoreCase ) )
127+ {
128+ // Http.Sys un-escapes this
129+ originalOffset += 3 ;
130+ baseOffset ++ ;
131+ }
132+ else if ( baseOffset > 0 && pathBase [ baseOffset - 1 ] == '/'
133+ && ( offsetValue == '/' || offsetValue == '\\ ' ) )
134+ {
135+ // Duplicate slash, skip
136+ originalOffset ++ ;
137+ }
138+ else if ( baseOffset > 0 && pathBase [ baseOffset - 1 ] == '/'
139+ && originalPath . AsSpan ( originalOffset ) . StartsWith ( "%2F" , StringComparison . OrdinalIgnoreCase ) )
140+ {
141+ // Duplicate slash equivalent, skip
142+ originalOffset += 3 ;
143+ }
144+ else
145+ {
146+ // Mismatch, fall back
147+ // The failing test case here is "/base/call//../bat//path1//path2", reduced to "/base/call/bat//path1//path2",
148+ // where http.sys collapses "//" before "../", but we do "../" first. We've lost the context that there were dot segments,
149+ // or duplicate slashes, how do we figure out that "call/" can be eliminated?
150+ originalOffset = 0 ;
151+ break ;
152+ }
153+ }
154+ PathBase = originalPath [ ..originalOffset ] ;
155+ Path = originalPath [ originalOffset ..] ;
94156 }
95157 }
158+ else if ( requestContext . Server . Options . UrlPrefixes . TryMatchLongestPrefix ( IsHttps , cookedUrl . GetHost ( ) ! , originalPath , out var pathBase , out var path ) )
159+ {
160+ PathBase = pathBase ;
161+ Path = path ;
162+ }
96163
97164 ProtocolVersion = RequestContext . GetVersion ( ) ;
98165
0 commit comments