@@ -74,7 +74,8 @@ public IEnumerable<string> AllowedUploadDirectories
7474 /// <remarks>
7575 /// Defaults to an empty collection (no paths allowed). Must be explicitly populated
7676 /// with trusted remote directory paths before any share-link operations will succeed.
77- /// Paths are normalized with forward slashes and dot-segments are collapsed before comparison.
77+ /// Paths are normalized with forward slashes and literal dot-segments are collapsed before comparison.
78+ /// Encoded dot-segments are rejected because URI construction could otherwise reinterpret the checked path.
7879 /// Matching is case-insensitive (OneDrive paths are case-insensitive).
7980 /// </remarks>
8081 public IEnumerable < string > AllowedSharePaths
@@ -91,7 +92,8 @@ public IEnumerable<string> AllowedSharePaths
9192 /// <remarks>
9293 /// Defaults to an empty collection (no paths allowed). Must be explicitly populated
9394 /// with trusted remote directory paths before any read operations will succeed.
94- /// Paths are normalized with forward slashes and dot-segments are collapsed before comparison.
95+ /// Paths are normalized with forward slashes and literal dot-segments are collapsed before comparison.
96+ /// Encoded dot-segments are rejected because URI construction could otherwise reinterpret the checked path.
9597 /// Matching is case-insensitive (OneDrive paths are case-insensitive).
9698 /// </remarks>
9799 public IEnumerable < string > AllowedReadPaths
@@ -108,7 +110,8 @@ public IEnumerable<string> AllowedReadPaths
108110 /// <remarks>
109111 /// Defaults to an empty collection (no paths allowed). Must be explicitly populated
110112 /// with trusted remote directory paths before any upload-destination operations will succeed.
111- /// Paths are normalized with forward slashes and dot-segments are collapsed before comparison.
113+ /// Paths are normalized with forward slashes and literal dot-segments are collapsed before comparison.
114+ /// Encoded dot-segments are rejected because URI construction could otherwise reinterpret the checked path.
112115 /// Matching is case-insensitive (OneDrive paths are case-insensitive).
113116 /// </remarks>
114117 public IEnumerable < string > AllowedUploadDestinationPaths
@@ -132,12 +135,12 @@ public IEnumerable<string> AllowedUploadDestinationPaths
132135
133136 Ensure . NotNullOrWhitespace ( filePath , nameof ( filePath ) ) ;
134137
135- if ( ! this . IsAllowedRemotePath ( filePath , this . _allowedReadPaths ) )
138+ if ( ! this . TryGetAllowedRemotePath ( filePath , this . _allowedReadPaths , out var normalizedPath ) )
136139 {
137140 throw new InvalidOperationException ( "Reading from the provided path is not allowed. Configure 'AllowedReadPaths' with trusted remote paths to enable reading." ) ;
138141 }
139142
140- Stream ? fileContentStream = await this . _connector . GetFileContentStreamAsync ( filePath , cancellationToken ) . ConfigureAwait ( false ) ;
143+ Stream ? fileContentStream = await this . _connector . GetFileContentStreamAsync ( normalizedPath , cancellationToken ) . ConfigureAwait ( false ) ;
141144
142145 if ( fileContentStream is null )
143146 {
@@ -170,7 +173,7 @@ public async Task UploadFileAsync(
170173 throw new ArgumentException ( "Variable was null or whitespace" , nameof ( destinationPath ) ) ;
171174 }
172175
173- if ( ! this . IsAllowedRemotePath ( destinationPath , this . _allowedUploadDestinationPaths ) )
176+ if ( ! this . TryGetAllowedRemotePath ( destinationPath , this . _allowedUploadDestinationPaths , out var normalizedDestinationPath ) )
174177 {
175178 throw new InvalidOperationException ( "Uploading to the provided remote destination is not allowed. Configure 'AllowedUploadDestinationPaths' with trusted remote paths to enable uploads." ) ;
176179 }
@@ -187,7 +190,7 @@ public async Task UploadFileAsync(
187190 using var content = SecureFileSystem . OpenRead ( canonicalPath , this . _allowedUploadDirectories , out _ ) ;
188191
189192 // TODO Add support for large file uploads (i.e. upload sessions)
190- await this . _connector . UploadSmallFileAsync ( content , destinationPath , cancellationToken ) . ConfigureAwait ( false ) ;
193+ await this . _connector . UploadSmallFileAsync ( content , normalizedDestinationPath , cancellationToken ) . ConfigureAwait ( false ) ;
191194 }
192195
193196 /// <summary>
@@ -207,12 +210,12 @@ public async Task<string> CreateLinkAsync(
207210
208211 Ensure . NotNullOrWhitespace ( filePath , nameof ( filePath ) ) ;
209212
210- if ( ! this . IsAllowedRemotePath ( filePath , this . _allowedSharePaths ) )
213+ if ( ! this . TryGetAllowedRemotePath ( filePath , this . _allowedSharePaths , out var normalizedPath ) )
211214 {
212215 throw new InvalidOperationException ( "Creating a share link for the provided path is not allowed. Configure 'AllowedSharePaths' with trusted remote paths to enable sharing." ) ;
213216 }
214217
215- return await this . _connector . CreateShareLinkAsync ( filePath , Type , Scope , cancellationToken ) . ConfigureAwait ( false ) ;
218+ return await this . _connector . CreateShareLinkAsync ( normalizedPath , Type , Scope , cancellationToken ) . ConfigureAwait ( false ) ;
216219 }
217220
218221 #region private
@@ -224,25 +227,34 @@ public async Task<string> CreateLinkAsync(
224227
225228 /// <summary>
226229 /// Checks whether the provided remote path falls within one of the allowed remote directory prefixes.
227- /// Paths are normalized with forward slashes, dot-segments are collapsed,
230+ /// Paths are normalized with forward slashes, literal dot-segments are collapsed,
231+ /// encoded dot-segments are rejected,
228232 /// and compared case-insensitively (OneDrive paths are case-insensitive).
229233 /// Subdirectories of allowed paths are permitted.
230234 /// </summary>
231- private bool IsAllowedRemotePath ( string path , HashSet < string > allowedPaths )
235+ private bool TryGetAllowedRemotePath ( string path , HashSet < string > allowedPaths , out string normalizedPath )
232236 {
233237 Ensure . NotNullOrWhitespace ( path , nameof ( path ) ) ;
238+ normalizedPath = string . Empty ;
234239
235240 if ( allowedPaths . Count == 0 )
236241 {
237242 return false ;
238243 }
239244
240- // Normalize to forward slashes and collapse dot-segments to prevent traversal bypass.
241- var normalizedPath = NormalizeRemotePath ( path ) ;
245+ if ( ! TryNormalizeRemotePath ( path , out normalizedPath ) )
246+ {
247+ return false ;
248+ }
242249
243250 foreach ( var allowedPath in allowedPaths )
244251 {
245- var normalizedAllowed = NormalizeRemotePath ( allowedPath ) ;
252+ if ( ! TryNormalizeRemotePath ( allowedPath , out var normalizedAllowed ) )
253+ {
254+ throw new InvalidOperationException (
255+ $ "Configured allowed remote path '{ allowedPath } ' contains an encoded dot-segment.") ;
256+ }
257+
246258 if ( ! normalizedAllowed . EndsWith ( "/" , StringComparison . Ordinal ) )
247259 {
248260 normalizedAllowed += "/" ;
@@ -267,14 +279,44 @@ private bool IsAllowedRemotePath(string path, HashSet<string> allowedPaths)
267279
268280 /// <summary>
269281 /// Normalizes a remote path by replacing backslashes with forward slashes
270- /// and collapsing "." and ".." segments to prevent traversal bypass .
282+ /// and collapsing literal "." and ".." segments.
271283 /// </summary>
272- private static string NormalizeRemotePath ( string path )
284+ private static bool TryNormalizeRemotePath ( string path , out string normalizedPath )
273285 {
274- var normalizedPath = path . Replace ( '\\ ' , '/' ) ;
286+ // URI construction can decode percent-encoded dots and separators after the
287+ // allow-list check. Reject any encoding that can reveal a structural dot-segment,
288+ // then reuse the returned path for the connector call.
289+ foreach ( var rawSegment in path . Split ( '/' ) )
290+ {
291+ var decoded = rawSegment ;
292+ for ( int i = 0 ; i < 5 ; i ++ )
293+ {
294+ var unescaped = Uri . UnescapeDataString ( decoded ) ;
295+ if ( string . Equals ( unescaped , decoded , StringComparison . Ordinal ) )
296+ {
297+ break ;
298+ }
299+
300+ decoded = unescaped ;
301+ }
302+
303+ if ( ! string . Equals ( decoded , rawSegment , StringComparison . Ordinal ) )
304+ {
305+ foreach ( var segment in decoded . Split ( '/' , '\\ ' ) )
306+ {
307+ if ( segment is "." or ".." )
308+ {
309+ normalizedPath = string . Empty ;
310+ return false ;
311+ }
312+ }
313+ }
314+ }
315+
316+ var pathWithNormalizedSeparators = path . Replace ( '\\ ' , '/' ) ;
275317
276- // Collapse ".." and "." segments to prevent traversal bypass .
277- var segments = normalizedPath . Split ( new [ ] { '/' } , StringSplitOptions . RemoveEmptyEntries ) ;
318+ // Collapse literal dot-segments so validation and connector use the same path .
319+ var segments = pathWithNormalizedSeparators . Split ( new [ ] { '/' } , StringSplitOptions . RemoveEmptyEntries ) ;
278320 var stack = new List < string > ( ) ;
279321 foreach ( var segment in segments )
280322 {
@@ -288,7 +330,8 @@ private static string NormalizeRemotePath(string path)
288330 }
289331 }
290332
291- return "/" + string . Join ( "/" , stack ) ;
333+ normalizedPath = "/" + string . Join ( "/" , stack ) ;
334+ return true ;
292335 }
293336
294337 /// <summary>
0 commit comments