Skip to content

Commit 93a974d

Browse files
committed
Add more improvements
1 parent d6299e4 commit 93a974d

3 files changed

Lines changed: 371 additions & 20 deletions

File tree

‎dotnet/src/Plugins/Plugins.MsGraph/CloudDrivePlugin.cs‎

Lines changed: 63 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -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>

‎dotnet/src/Plugins/Plugins.UnitTests/MsGraph/CloudDrivePluginTests.cs‎

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -658,6 +658,112 @@ public async Task UploadDoesNotFollowLinksOutsideAllowedDirectoryAsync()
658658
connector.VerifyNoOtherCalls();
659659
}
660660

661+
[Theory]
662+
[MemberData(nameof(DangerousRemotePaths))]
663+
public async Task RemoteOperationsRejectEncodedTraversalAsync(string operation, string remotePath)
664+
{
665+
var connector = new Mock<ICloudDriveConnector>(MockBehavior.Strict);
666+
var plugin = new CloudDrivePlugin(connector.Object)
667+
{
668+
AllowedReadPaths = ["/Uploads"],
669+
AllowedSharePaths = ["/Uploads"],
670+
AllowedUploadDestinationPaths = ["/Uploads"]
671+
};
672+
673+
await Assert.ThrowsAsync<InvalidOperationException>(
674+
() => InvokeRemoteOperationAsync(plugin, operation, remotePath, Path.Combine(this._tempDirectory, "source.txt")));
675+
connector.VerifyNoOtherCalls();
676+
}
677+
678+
[Theory]
679+
[MemberData(nameof(AllowedNormalizedRemotePaths))]
680+
public async Task RemoteOperationsUseTheCheckedNormalizedPathAsync(
681+
string operation,
682+
string suppliedPath,
683+
string expectedPath)
684+
{
685+
var sourcePath = Path.Combine(this._tempDirectory, "source.txt");
686+
await File.WriteAllTextAsync(sourcePath, "content");
687+
var connector = new Mock<ICloudDriveConnector>(MockBehavior.Strict);
688+
switch (operation)
689+
{
690+
case "read":
691+
connector.Setup(c => c.GetFileContentStreamAsync(expectedPath, It.IsAny<CancellationToken>()))
692+
.ReturnsAsync(new MemoryStream(Encoding.UTF8.GetBytes("content")));
693+
break;
694+
case "share":
695+
connector.Setup(c => c.CreateShareLinkAsync(expectedPath, "view", "organization", It.IsAny<CancellationToken>()))
696+
.ReturnsAsync("https://example.com/share");
697+
break;
698+
case "upload":
699+
connector.Setup(c => c.UploadSmallFileAsync(It.IsAny<Stream>(), expectedPath, It.IsAny<CancellationToken>()))
700+
.Returns(Task.CompletedTask);
701+
break;
702+
default:
703+
throw new InvalidOperationException($"Unknown test operation '{operation}'.");
704+
}
705+
706+
var plugin = new CloudDrivePlugin(connector.Object)
707+
{
708+
AllowedReadPaths = ["/Uploads"],
709+
AllowedSharePaths = ["/Uploads"],
710+
AllowedUploadDirectories = [this._tempDirectory],
711+
AllowedUploadDestinationPaths = ["/Uploads"]
712+
};
713+
714+
await InvokeRemoteOperationAsync(plugin, operation, suppliedPath, sourcePath);
715+
716+
connector.VerifyAll();
717+
}
718+
719+
[Theory]
720+
[InlineData("read")]
721+
[InlineData("share")]
722+
[InlineData("upload")]
723+
public async Task InvalidRemoteAllowListConfigurationRemainsVisibleAsync(string operation)
724+
{
725+
var connector = new Mock<ICloudDriveConnector>(MockBehavior.Strict);
726+
var plugin = new CloudDrivePlugin(connector.Object)
727+
{
728+
AllowedReadPaths = ["/Uploads/%2e%2e"],
729+
AllowedSharePaths = ["/Uploads/%2e%2e"],
730+
AllowedUploadDestinationPaths = ["/Uploads/%2e%2e"]
731+
};
732+
733+
var exception = await Assert.ThrowsAsync<InvalidOperationException>(
734+
() => InvokeRemoteOperationAsync(
735+
plugin,
736+
operation,
737+
"/Uploads/report.txt",
738+
Path.Combine(this._tempDirectory, "source.txt")));
739+
740+
Assert.Contains("Configured allowed remote path", exception.Message, StringComparison.Ordinal);
741+
connector.VerifyNoOtherCalls();
742+
}
743+
744+
[Theory]
745+
[InlineData("read")]
746+
[InlineData("share")]
747+
[InlineData("upload")]
748+
public async Task RemoteOperationsRejectMatchingNamePrefixOutsideAllowedFolderAsync(string operation)
749+
{
750+
var connector = new Mock<ICloudDriveConnector>(MockBehavior.Strict);
751+
var plugin = new CloudDrivePlugin(connector.Object)
752+
{
753+
AllowedReadPaths = ["/Uploads"],
754+
AllowedSharePaths = ["/Uploads"],
755+
AllowedUploadDestinationPaths = ["/Uploads"]
756+
};
757+
758+
await Assert.ThrowsAsync<InvalidOperationException>(
759+
() => InvokeRemoteOperationAsync(
760+
plugin,
761+
operation,
762+
"/UploadsOther/report.txt",
763+
Path.Combine(this._tempDirectory, "source.txt")));
764+
connector.VerifyNoOtherCalls();
765+
}
766+
661767
[SymbolicLinkTheory]
662768
[InlineData(false)]
663769
[InlineData(true)]
@@ -751,4 +857,70 @@ public void Dispose()
751857
Directory.Delete(this._tempDirectory, recursive: true);
752858
GC.SuppressFinalize(this);
753859
}
860+
861+
public static TheoryData<string, string> DangerousRemotePaths
862+
{
863+
get
864+
{
865+
string[] paths =
866+
[
867+
"/Uploads/%2e/Confidential/secret.txt",
868+
"/Uploads/%2e%2e/Confidential/secret.txt",
869+
"/Uploads/%2E%2E/Confidential/secret.txt",
870+
"/Uploads/%2e./Confidential/secret.txt",
871+
"/Uploads/.%2e/Confidential/secret.txt",
872+
"/Uploads/%252e%252e/Confidential/secret.txt",
873+
"/Uploads/%2e%2e%2fConfidential/secret.txt",
874+
"/Uploads/%2e%2e%5cConfidential/secret.txt",
875+
"/Uploads/%252f%252e%252e%252fConfidential/secret.txt"
876+
];
877+
TheoryData<string, string> data = [];
878+
foreach (var operation in new[] { "read", "share", "upload" })
879+
{
880+
foreach (var path in paths)
881+
{
882+
data.Add(operation, path);
883+
}
884+
}
885+
886+
return data;
887+
}
888+
}
889+
890+
public static TheoryData<string, string, string> AllowedNormalizedRemotePaths
891+
{
892+
get
893+
{
894+
TheoryData<string, string, string> data = [];
895+
foreach (var operation in new[] { "read", "share", "upload" })
896+
{
897+
data.Add(operation, "/Uploads/Sub/../report.txt", "/Uploads/report.txt");
898+
data.Add(operation, "/Uploads/a%20b.txt", "/Uploads/a%20b.txt");
899+
}
900+
901+
return data;
902+
}
903+
}
904+
905+
private static async Task InvokeRemoteOperationAsync(
906+
CloudDrivePlugin plugin,
907+
string operation,
908+
string remotePath,
909+
string localPath)
910+
{
911+
switch (operation)
912+
{
913+
case "read":
914+
await plugin.GetFileContentAsync(remotePath);
915+
break;
916+
case "share":
917+
await plugin.CreateLinkAsync(remotePath);
918+
break;
919+
case "upload":
920+
await plugin.UploadFileAsync(localPath, remotePath);
921+
break;
922+
default:
923+
throw new InvalidOperationException($"Unknown test operation '{operation}'.");
924+
}
925+
}
754926
}

0 commit comments

Comments
 (0)