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
8 changes: 0 additions & 8 deletions .trivyignore
Original file line number Diff line number Diff line change
@@ -1,10 +1,2 @@
# Findings accepted after review. Each entry expires and is reported again
# on that date, so it has to be reviewed rather than forgotten.

# SixLabors.ImageSharp 3.1.12 (fixed in 4.1.2), a transitive dependency of
# elFinder.Net.Core 1.5.0, lifted from 2.0.0 by Grand.Web.AdminShared.
# GrandNode does not reference ImageSharp in code. 4.x is not verified with
# elFinder. Drop these entries once ImageSharp moves to 4.1.2 or elFinder goes.
CVE-2026-106110 exp:2027-01-16
CVE-2026-106113 exp:2027-01-16
CVE-2026-106115 exp:2027-01-16
5 changes: 0 additions & 5 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
<PackageVersion Include="Microsoft.Build.Tasks.Git" Version="10.0.401" />
<PackageVersion Include="HtmlSanitizer" Version="9.2.1039" />
<PackageVersion Include="MessagePack" Version="3.1.10" />
<PackageVersion Include="Microsoft.AspNetCore.Http" Version="2.3.13" />
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.12" />
<PackageVersion Include="Microsoft.FeatureManagement.AspNetCore" Version="4.8.0" />
<PackageVersion Include="Microsoft.OpenApi" Version="2.12.2" />
Expand Down Expand Up @@ -47,10 +46,6 @@
<PackageVersion Include="System.Security.Cryptography.Xml" Version="10.0.13" />
<PackageVersion Include="System.Xml.XPath.XmlDocument" Version="4.7.0" />
<PackageVersion Include="Stripe.net" Version="52.4.2" />
<PackageVersion Include="elFinder.Net.AspNetCore" Version="1.5.0" />
<PackageVersion Include="elFinder.Net.Core" Version="1.5.0" />
<PackageVersion Include="elFinder.Net.Drivers.FileSystem" Version="1.5.0" />
<PackageVersion Include="SixLabors.ImageSharp" Version="3.1.12" />
<PackageVersion Include="Azure.Extensions.AspNetCore.DataProtection.Blobs" Version="1.5.4" />
<PackageVersion Include="Azure.Extensions.AspNetCore.DataProtection.Keys" Version="1.6.4" />
<PackageVersion Include="Azure.Identity" Version="1.21.0" />
Expand Down
36 changes: 28 additions & 8 deletions src/Business/Grand.Business.Storage/Services/FileSystemStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,9 @@ public Task<bool> TryRenameDirectory(string path, string newName)
if (!Directory.Exists(physicalPath)) return Task.FromResult(false);
var directoryInfo = new DirectoryInfo(physicalPath);

var newphysicalPath = GetPhysicalPath(Path.Combine(directoryInfo.Parent.FullName, newName));
//the new path is built from the store path, not from Parent.FullName: an absolute path loses its leading
//slash in NormalizePath and lands under the root a second time on Linux
var newphysicalPath = GetPhysicalPath(SiblingPath(path, newName));

if (Directory.Exists(newphysicalPath)) return Task.FromResult(false);
directoryInfo.MoveTo(newphysicalPath);
Expand Down Expand Up @@ -165,17 +167,17 @@ public async Task CopyFile(string srcPath, string dstPath)
File.Copy(physicalSrcPath, physicalDstPath);
}

public async Task RenameFile(string file, string newName)
public Task RenameFile(string file, string newName)
{
var physicalSrcFile = GetPhysicalPath(file);

if (!File.Exists(physicalSrcFile)) throw new Exception($"The file '{file}' does not exist.");

var physicalFile = await GetFileInfo(file);

var newphysicalFile = GetPhysicalPath(Path.Combine(physicalFile.DirectoryPath, newName));
var newphysicalFile = GetPhysicalPath(SiblingPath(file, newName));
if (File.Exists(newphysicalFile)) throw new Exception($"The file '{newName}' does exist.");
File.Move(physicalSrcFile, newphysicalFile);

return Task.CompletedTask;
}

public Task<Stream> GetFileStream(string path)
Expand Down Expand Up @@ -250,12 +252,30 @@ private string GetPhysicalPath(string path)

var physicalPath = string.IsNullOrEmpty(path) ? _fileSystemPath : Path.Combine(_fileSystemPath, path);

// Verify that the resulting path is inside the root file system path.
var pathIsAllowed = Path.GetFullPath(physicalPath)
.StartsWith(_fileSystemPath, StringComparison.OrdinalIgnoreCase);
// Verify that the resulting path is inside the root file system path - the root itself or below a separator,
// so a sibling folder sharing the root's name as a prefix ("wwwroot-old") is outside.
var fullPath = Path.GetFullPath(physicalPath);
var root = Path.TrimEndingDirectorySeparator(_fileSystemPath);
var pathIsAllowed = fullPath.Equals(root, StringComparison.OrdinalIgnoreCase) ||
fullPath.StartsWith(root + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase);
if (!pathIsAllowed)
throw new Exception($"The path '{path}' resolves to a physical path outside the file system store root.");

return physicalPath;
}

/// <summary>
/// The store path of an entry named <paramref name="newName" /> next to <paramref name="path" />. The new name is
/// a single name: a separator or a dot segment would move the entry instead of renaming it.
/// </summary>
private string SiblingPath(string path, string newName)
{
if (string.IsNullOrWhiteSpace(newName) || newName is "." or ".." ||
newName.IndexOfAny(['/', '\\']) >= 0)
throw new ArgumentException($"'{newName}' is not a valid name.", nameof(newName));

var normalized = this.NormalizePath(path) ?? "";
var separator = normalized.LastIndexOf('/');
return separator < 0 ? newName : $"{normalized[..separator]}/{newName}";
}
}
5 changes: 0 additions & 5 deletions src/Core/Grand.Domain/Media/MediaSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,4 @@ public class MediaSettings : ISettings
public int ImageQuality { get; set; } = 80;

public string AllowedFileTypes { get; set; }

public string FileManagerEnabledCommands { get; set; } =
"abort,open,file,mkdir,mkfile,parents,tmb,dim,paste,duplicate,get,rm,ls,put,size,rename,tree,resize,search,upload";

public string FileManagerDisabledUICommands { get; set; } = "ping,hide,archive,extract,netmount,netunmount,zipdl";
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,13 @@ public static IList<string> GetAllowedMediaFileTypes(string allowedFileTypes)
{
if (string.IsNullOrEmpty(allowedFileTypes))
return new List<string> { ".gif", ".jpg", ".jpeg", ".png", ".bmp", ".webp" };
return allowedFileTypes.Split(',').Select(x => x.Trim().ToLowerInvariant()).ToList();
//a trailing or doubled comma leaves an empty entry, which would allow a file without an extension
return allowedFileTypes.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(x => x.ToLowerInvariant()).ToList();
}
public static bool IsAllowedMediaFileType(this IEnumerable<string> allowedFileTypes, string fileExtension)
{
return allowedFileTypes.Any(ft => ft.Equals(fileExtension, StringComparison.OrdinalIgnoreCase));
return !string.IsNullOrEmpty(fileExtension) &&
allowedFileTypes.Any(ft => ft.Equals(fileExtension, StringComparison.OrdinalIgnoreCase));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -68,16 +68,70 @@ public void TryCreateDirectoryTest()
}

[TestMethod]
[Ignore] //TMP
public async Task TryRenameDirectoryTest()
{
//Arrange
_store.TryCreateDirectory("Test");
await _store.TryDeleteDirectory("Test2");
await _store.TryDeleteDirectory("RenameTest");
await _store.TryDeleteDirectory("RenameTest2");
_store.TryCreateDirectory("RenameTest");
//Act
var result = await _store.TryRenameDirectory("RenameTest", "RenameTest2");
//Assert
Assert.IsTrue(result);
Assert.IsNotNull(_store.GetDirectoryInfo("RenameTest2"));
Assert.IsNull(_store.GetDirectoryInfo("RenameTest"));
}

[TestMethod]
public async Task TryRenameDirectory_Nested_StaysInItsParent()
{
//Arrange
await _store.TryDeleteDirectory("RenameNested");
_store.TryCreateDirectory("RenameNested/a/old");
//Act
var result = await _store.TryRenameDirectory("Test", "Test2");
var result = await _store.TryRenameDirectory("RenameNested/a/old", "new");
//Assert
Assert.IsTrue(result);
Assert.IsNotNull(_store.GetDirectoryInfo("RenameNested/a/new"));
await _store.TryDeleteDirectory("RenameNested");
}

[TestMethod]
[DataRow("../moved")]
[DataRow("a/b")]
[DataRow("a\\b")]
[DataRow("..")]
public async Task Rename_NewNameWithAPath_Throws(string newName)
{
//Arrange
await _store.TryDeleteDirectory("RenameGuard");
_store.TryCreateDirectory("RenameGuard/folder");
await _store.WriteAllText("RenameGuard/file.txt", "test");
//Act & Assert
await Assert.ThrowsAsync<ArgumentException>(() => _store.TryRenameDirectory("RenameGuard/folder", newName));
await Assert.ThrowsAsync<ArgumentException>(() => _store.RenameFile("RenameGuard/file.txt", newName));
await _store.TryDeleteDirectory("RenameGuard");
}

[TestMethod]
public void GetDirectoryInfo_SiblingSharingTheRootPrefix_Throws()
{
//Arrange - "<root>-old" starts with the root's path but lies outside it
var root = Path.Combine(Path.GetTempPath(), $"store-{Guid.NewGuid():N}");
Directory.CreateDirectory(root);
Directory.CreateDirectory(root + "-old");
try
{
var store = new FileSystemStore(root);
//Act & Assert
Assert.ThrowsExactly<Exception>(() => store.GetDirectoryInfo($"../{Path.GetFileName(root)}-old"));
Assert.IsNotNull(store.GetDirectoryInfo(""));
}
finally
{
Directory.Delete(root, true);
Directory.Delete(root + "-old", true);
}
}

[TestMethod]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ public void GetDirectoryInfo_OnlyInFallback_CreatesItInPrimary()
[TestMethod]
public void GetDirectoryInfo_UploadedShippedInWebRoot_FileManagerRootIsOnVolume()
{
//Arrange - wwwroot ships assets/images/uploaded with a placeholder.txt; elFinder takes PhysicalPath as its root
//Arrange - wwwroot ships assets/images/uploaded with a placeholder.txt; the file manager writes under it
Directory.CreateDirectory(Path.Combine(_webRoot, "assets", "images", "uploaded"));
File.WriteAllText(Path.Combine(_webRoot, "assets", "images", "uploaded", "legacy.jpg"), "old");
//Act
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,5 @@
ImageQuality: 80,
DefaultPictureZoomEnabled: false,
AutoCompleteSearchThumbPictureSize: 20,
FileManagerEnabledCommands: abort,open,file,mkdir,mkfile,parents,tmb,dim,paste,duplicate,get,rm,ls,put,size,rename,tree,resize,search,upload,
FileManagerDisabledUICommands: ping,hide,archive,extract,netmount,netunmount,zipdl,
DefaultImageName: no-image.png
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
using Grand.SharedKernel.Extensions;
using Microsoft.VisualStudio.TestTools.UnitTesting;

namespace Grand.SharedKernel.Tests.Extensions;

[TestClass]
public class FileExtensionsTests
{
[TestMethod]
public void GetAllowedMediaFileTypes_Empty_DefaultImageTypes()
{
var result = FileExtensions.GetAllowedMediaFileTypes(null);

CollectionAssert.AreEquivalent(new[] { ".gif", ".jpg", ".jpeg", ".png", ".bmp", ".webp" }, result.ToArray());
}

[TestMethod]
public void GetAllowedMediaFileTypes_TrailingAndDoubledComma_NoEmptyEntry()
{
var result = FileExtensions.GetAllowedMediaFileTypes(" .JPG, .png,, ,");

CollectionAssert.AreEqual(new[] { ".jpg", ".png" }, result.ToArray());
}

[TestMethod]
[DataRow(".jpg", true)]
[DataRow(".PNG", true)]
[DataRow(".svg", false)]
[DataRow("", false)]
[DataRow(null, false)]
public void IsAllowedMediaFileType_ChecksTheExtension(string extension, bool expected)
{
var allowed = FileExtensions.GetAllowedMediaFileTypes(".jpg,.png,");

Assert.AreEqual(expected, allowed.IsAllowedMediaFileType(extension));
}
}

This file was deleted.

Loading
Loading