Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Terminal.Gui/FileServices/FileSystemTreeBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -72,5 +72,5 @@ private IEnumerable<IFileSystemInfo> TryGetChildren (IFileSystemInfo entry)
}
}

private static bool IsReparsePoint (IFileSystemInfo entry) => (entry.Attributes & FileAttributes.ReparsePoint) == FileAttributes.ReparsePoint;
internal static bool IsReparsePoint (IFileSystemInfo entry) => (entry.Attributes & FileAttributes.ReparsePoint) == FileAttributes.ReparsePoint;
}
9 changes: 9 additions & 0 deletions Terminal.Gui/Resources/Strings.Designer.cs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions Terminal.Gui/Resources/Strings.resx
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,9 @@
<data name="fdNewFailed" xml:space="preserve">
<value>New Failed</value>
</data>
<data name="fdPathTraversalError" xml:space="preserve">
<value>Name must not contain path separators or navigate outside the current directory.</value>
</data>
<data name="fdNewTitle" xml:space="preserve">
<value>New Folder</value>
</data>
Expand Down
87 changes: 83 additions & 4 deletions Terminal.Gui/Views/FileDialogs/DefaultFileOperations.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,36 @@ namespace Terminal.Gui.Views;
/// <summary>Default file operation handlers using modal dialogs.</summary>
public class DefaultFileOperations : IFileOperations
{
/// <summary>
/// Determines whether a candidate path is safely contained within the specified root directory.
/// Returns <see langword="false"/> if the name contains path-traversal sequences that escape the root.
/// </summary>
internal static bool IsContainedIn (string root, string candidate)
{
string rootFull = Path.GetFullPath (root)
.TrimEnd (Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)
+ Path.DirectorySeparatorChar;

string candidateFull = Path.GetFullPath (candidate);

return candidateFull.StartsWith (rootFull, StringComparison.Ordinal);
}

/// <summary>
/// Returns <see langword="true"/> if the name contains characters that are not valid in a file or directory name,
/// including path separators, null characters, and control characters.
/// </summary>
internal static bool ContainsInvalidNameCharacters (string name)
{
if (string.IsNullOrWhiteSpace (name))
{
return true;
}

char [] invalidChars = Path.GetInvalidFileNameChars ();

return name.IndexOfAny (invalidChars) >= 0;
}
/// <inheritdoc/>
public bool Delete (IApplication? app, IEnumerable<IFileSystemInfo> toDelete)
{
Expand Down Expand Up @@ -72,7 +102,24 @@ public bool Delete (IApplication? app, IEnumerable<IFileSystemInfo> toDelete)
{
if (toRename is IFileInfo f)
{
IFileInfo newLocation = fileSystem.FileInfo.New (Path.Combine (f.Directory?.FullName ?? throw new InvalidOperationException (), newName));
if (ContainsInvalidNameCharacters (newName))
{
MessageBox.ErrorQuery (app, Strings.fdRenameFailedTitle, Strings.fdPathTraversalError, Strings.btnOk);

return null;
}

string parentDir = f.Directory?.FullName ?? throw new InvalidOperationException ();
string combined = Path.Combine (parentDir, newName);

if (!IsContainedIn (parentDir, combined))
{
MessageBox.ErrorQuery (app, Strings.fdRenameFailedTitle, Strings.fdPathTraversalError, Strings.btnOk);

return null;
}

IFileInfo newLocation = fileSystem.FileInfo.New (combined);
f.MoveTo (newLocation.FullName);

return newLocation;
Expand All @@ -81,8 +128,24 @@ public bool Delete (IApplication? app, IEnumerable<IFileSystemInfo> toDelete)
{
var d = (IDirectoryInfo)toRename;

IDirectoryInfo newLocation =
fileSystem.DirectoryInfo.New (Path.Combine (d.Parent?.FullName ?? throw new InvalidOperationException (), newName));
if (ContainsInvalidNameCharacters (newName))
{
MessageBox.ErrorQuery (app, Strings.fdRenameFailedTitle, Strings.fdPathTraversalError, Strings.btnOk);

return null;
}

string parentDir = d.Parent?.FullName ?? throw new InvalidOperationException ();
string combined = Path.Combine (parentDir, newName);

if (!IsContainedIn (parentDir, combined))
{
MessageBox.ErrorQuery (app, Strings.fdRenameFailedTitle, Strings.fdPathTraversalError, Strings.btnOk);

return null;
}

IDirectoryInfo newLocation = fileSystem.DirectoryInfo.New (combined);
d.MoveTo (newLocation.FullName);

return newLocation;
Expand Down Expand Up @@ -113,7 +176,23 @@ public bool Delete (IApplication? app, IEnumerable<IFileSystemInfo> toDelete)

try
{
IDirectoryInfo newDir = fileSystem.DirectoryInfo.New (Path.Combine (inDirectory.FullName, result));
if (ContainsInvalidNameCharacters (result))
{
MessageBox.ErrorQuery (app, Strings.fdNewFailed, Strings.fdPathTraversalError, Strings.btnOk);

return null;
}

string combined = Path.Combine (inDirectory.FullName, result);

if (!IsContainedIn (inDirectory.FullName, combined))
{
MessageBox.ErrorQuery (app, Strings.fdNewFailed, Strings.fdPathTraversalError, Strings.btnOk);

return null;
}

IDirectoryInfo newDir = fileSystem.DirectoryInfo.New (combined);
newDir.Create ();

return newDir;
Expand Down
3 changes: 2 additions & 1 deletion Terminal.Gui/Views/FileDialogs/FileDialog.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.Collections.ObjectModel;
using System.IO.Abstractions;
using Terminal.Gui.FileServices;

namespace Terminal.Gui.Views;

Expand Down Expand Up @@ -522,7 +523,7 @@ private void RecursiveFind (IDirectoryInfo directory)
}
}

if (f.FileSystemInfo is IDirectoryInfo sub)
if (f.FileSystemInfo is IDirectoryInfo sub && !FileSystemTreeBuilder.IsReparsePoint (sub))
{
RecursiveFind (sub);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
// Copilot

namespace UnitTests.Views;

/// <summary>
/// Tests for path-traversal and invalid-name validation in <see cref="DefaultFileOperations"/>.
/// </summary>
public class DefaultFileOperationsSecurityTests
{
[Theory]
[InlineData ("/home/user/docs", "/home/user/docs/file.txt", true)]
[InlineData ("/home/user/docs", "/home/user/docs/sub/file.txt", true)]
[InlineData ("/home/user/docs", "/home/user/file.txt", false)]
[InlineData ("/home/user/docs", "/home/user/docs/../file.txt", false)]
[InlineData ("/home/user/docs", "/home/file.txt", false)]
public void IsContainedIn_DetectsPathTraversal_Unix (string root, string candidate, bool expected)
{
if (!OperatingSystem.IsLinux () && !OperatingSystem.IsMacOS ())
{
return;
}

bool result = DefaultFileOperations.IsContainedIn (root, candidate);
Assert.Equal (expected, result);
}

[Theory]
[InlineData ("C:\\Users\\docs", "C:\\Users\\docs\\file.txt", true)]
[InlineData ("C:\\Users\\docs", "C:\\Users\\docs\\sub\\file.txt", true)]
[InlineData ("C:\\Users\\docs", "C:\\Users\\file.txt", false)]
[InlineData ("C:\\Users\\docs", "C:\\Users\\docs\\..\\file.txt", false)]
public void IsContainedIn_DetectsPathTraversal_Windows (string root, string candidate, bool expected)
{
if (!OperatingSystem.IsWindows ())
{
return;
}

bool result = DefaultFileOperations.IsContainedIn (root, candidate);
Assert.Equal (expected, result);
}

[Theory]
[InlineData ("validname", false)]
[InlineData ("my-file.txt", false)]
[InlineData ("../escape", true)]
[InlineData ("sub/dir", true)]
[InlineData ("", true)]
[InlineData (" ", true)]
[InlineData ("file\0name", true)]
public void ContainsInvalidNameCharacters_DetectsInvalidNames (string name, bool expected)
{
bool result = DefaultFileOperations.ContainsInvalidNameCharacters (name);
Assert.Equal (expected, result);
}

[Fact]
public void IsContainedIn_RootIsContainedInItself_WhenSubPath ()
{
// A path that is exactly the root + separator + name should be contained
if (OperatingSystem.IsWindows ())
{
Assert.True (DefaultFileOperations.IsContainedIn ("C:\\root", "C:\\root\\child"));
}
else
{
Assert.True (DefaultFileOperations.IsContainedIn ("/root", "/root/child"));
}
}

[Fact]
public void IsContainedIn_RootItself_IsNotContained ()
{
// The root directory path itself (without trailing separator) is NOT considered "contained"
// because it's not a child path
if (OperatingSystem.IsWindows ())
{
Assert.False (DefaultFileOperations.IsContainedIn ("C:\\root", "C:\\root"));
}
else
{
Assert.False (DefaultFileOperations.IsContainedIn ("/root", "/root"));
}
}
}
Loading