case 4715 - download file names checked

report/download and export/download (any download key), the log-file routes and
backup download built a path from the file name in the URL with Path.Combine
and no check, so on Windows a name with a folder part or an absolute path could
reach files outside the folder (security review F21). New helper
FileUtil.FullPathInFolderOrNull accepts only a bare file name that resolves
directly inside the folder; TemporaryFileExists and BackupFileExists use it
(the download routes already call them first), and so do both log routes.
A refused name gets the same 404 as a missing file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-24 12:20:25 -07:00
parent 705a54f9d8
commit e58ab2c100
4 changed files with 31 additions and 17 deletions

View File

@@ -69,9 +69,10 @@ namespace AyaNova.Api.Controllers
//stream the file contents into a json object and return
//build the full path from the log file name and defined path
var logFilePath = System.IO.Path.Combine(ServerBootConfig.AYANOVA_LOG_PATH, logname);
//case 4715: only a bare file name inside the log folder
var logFilePath = FileUtil.FullPathInFolderOrNull(ServerBootConfig.AYANOVA_LOG_PATH, logname);
//does file exist?
if (!System.IO.File.Exists(logFilePath))
if (logFilePath == null || !System.IO.File.Exists(logFilePath))
{
return NotFound(new ApiErrorResponse(ApiErrorCode.NOT_FOUND));
}
@@ -149,8 +150,9 @@ namespace AyaNova.Api.Controllers
if (!Authorized.HasReadFullRole(user.Roles, AyaType.LogFile))
return StatusCode(403, new ApiNotAuthorizedResponse());
var logFilePath = System.IO.Path.Combine(ServerBootConfig.AYANOVA_LOG_PATH, logname);
if (!System.IO.File.Exists(logFilePath))
//case 4715: only a bare file name inside the log folder
var logFilePath = FileUtil.FullPathInFolderOrNull(ServerBootConfig.AYANOVA_LOG_PATH, logname);
if (logFilePath == null || !System.IO.File.Exists(logFilePath))
return NotFound(new ApiErrorResponse(ApiErrorCode.NOT_FOUND));
//Note: this works with new Nlog file locking for write so it must be opening it as read and shareable

View File

@@ -116,12 +116,27 @@ namespace AyaNova.Util
/// <returns>duh!</returns>
internal static bool TemporaryFileExists(string fileName)
{
if (string.IsNullOrWhiteSpace(fileName))
return false;
var FilePath = GetFullPathForTemporaryFile(fileName);
return File.Exists(FilePath);
//case 4715: only a bare file name inside the temporary files folder
var FilePath = FullPathInFolderOrNull(TemporaryFilesFolder, fileName);
return FilePath != null && File.Exists(FilePath);
}
/// <summary>
/// case 4715: full path of a file name that came from a request, or null unless it is a bare file name
/// (no folder part, no "..") that resolves to a file directly inside the folder
/// </summary>
internal static string FullPathInFolderOrNull(string folder, string fileName)
{
if (string.IsNullOrWhiteSpace(folder) || string.IsNullOrWhiteSpace(fileName))
return null;
if (fileName != Path.GetFileName(fileName) || fileName.Contains("..") || fileName.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0)
return null;
var folderFullPath = Path.GetFullPath(folder);
var fileFullPath = Path.GetFullPath(Path.Combine(folderFullPath, fileName));
var comparison = OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
if (!string.Equals(Path.GetDirectoryName(fileFullPath), folderFullPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar), comparison))
return null;
return fileFullPath;
}
/// <summary>
@@ -275,12 +290,9 @@ namespace AyaNova.Util
/// <returns>duh!</returns>
internal static bool BackupFileExists(string fileName)
{
if (string.IsNullOrWhiteSpace(fileName))
return false;
var utilityFilePath = GetFullPathForBackupFile(fileName);
return File.Exists(utilityFilePath);
//case 4715: only a bare file name inside the backup files folder
var utilityFilePath = FullPathInFolderOrNull(ServerBootConfig.AYANOVA_BACKUP_FILES_PATH, fileName);
return utilityFilePath != null && File.Exists(utilityFilePath);
}
// /// <summary>