Harden legacy file paths and Twilio webhook validation
This commit is contained in:
@@ -14,6 +14,7 @@ namespace PowderCoating.Application.Services;
|
||||
/// </summary>
|
||||
public class FileService : IFileService
|
||||
{
|
||||
private const string UploadsRootFolder = "uploads";
|
||||
private readonly IWebHostEnvironment _environment;
|
||||
private readonly ILogger<FileService> _logger;
|
||||
|
||||
@@ -31,7 +32,9 @@ public class FileService : IFileService
|
||||
/// Validation order: null/empty check, size limit, then extension allowlist. The original file
|
||||
/// name is sanitised with <see cref="Path.GetFileName"/> to strip any directory components before
|
||||
/// prepending the GUID prefix, preventing path traversal if the browser supplies a name with
|
||||
/// slashes. Returns a relative path (from <c>wwwroot</c>) suitable for storing in the database.
|
||||
/// slashes. The target subfolder is resolved and confined under <c>wwwroot/uploads/</c> before
|
||||
/// any file system access occurs. Returns a relative path (from <c>wwwroot</c>) suitable for
|
||||
/// storing in the database.
|
||||
/// </summary>
|
||||
public async Task<(bool Success, string FilePath, string ErrorMessage)> SaveFileAsync(
|
||||
IFormFile file,
|
||||
@@ -65,7 +68,11 @@ public class FileService : IFileService
|
||||
// Create upload directory if it doesn't exist
|
||||
// NOTE: WebRootPath is read-only on Azure Linux App Service; this service is legacy
|
||||
// and should only be called for on-premises deployments. New uploads use Azure Blob.
|
||||
var uploadPath = Path.Combine(_environment.WebRootPath, "uploads", subfolder);
|
||||
if (!TryResolveUploadSubfolder(subfolder, out var uploadPath, out var relativeSubfolder, out var subfolderError))
|
||||
{
|
||||
return (false, string.Empty, subfolderError);
|
||||
}
|
||||
|
||||
if (!Directory.Exists(uploadPath))
|
||||
{
|
||||
try
|
||||
@@ -93,7 +100,7 @@ public class FileService : IFileService
|
||||
}
|
||||
|
||||
// Return relative path from wwwroot
|
||||
var relativePath = Path.Combine("uploads", subfolder, uniqueFileName).Replace("\\", "/");
|
||||
var relativePath = Path.Combine(UploadsRootFolder, relativeSubfolder, uniqueFileName).Replace("\\", "/");
|
||||
|
||||
_logger.LogInformation("File saved successfully: {FilePath}", relativePath);
|
||||
return (true, relativePath, string.Empty);
|
||||
@@ -108,8 +115,8 @@ public class FileService : IFileService
|
||||
/// <summary>
|
||||
/// Deletes a file given its relative path from <c>wwwroot</c>.
|
||||
/// Returns success if the file does not exist (idempotent) so that callers do not need to check
|
||||
/// existence before calling. The relative path is converted to an absolute path with
|
||||
/// <see cref="Path.Combine"/> rather than string concatenation to prevent directory traversal.
|
||||
/// existence before calling. The relative path is normalized and must remain under
|
||||
/// <c>wwwroot/uploads/</c>; paths outside that root are rejected.
|
||||
/// </summary>
|
||||
public async Task<(bool Success, string ErrorMessage)> DeleteFileAsync(string filePath)
|
||||
{
|
||||
@@ -120,7 +127,10 @@ public class FileService : IFileService
|
||||
return (false, "File path is required.");
|
||||
}
|
||||
|
||||
var fullPath = Path.Combine(_environment.WebRootPath, filePath.Replace('/', Path.DirectorySeparatorChar));
|
||||
if (!TryResolveLegacyUploadPath(filePath, out var fullPath, out var pathError))
|
||||
{
|
||||
return (false, pathError);
|
||||
}
|
||||
|
||||
if (!File.Exists(fullPath))
|
||||
{
|
||||
@@ -142,8 +152,8 @@ public class FileService : IFileService
|
||||
|
||||
/// <summary>
|
||||
/// Reads a file from disk and returns its raw bytes along with a derived MIME content type.
|
||||
/// Intended for serving files that are stored outside <c>wwwroot</c> (or otherwise not directly
|
||||
/// accessible via the static-files middleware) so controllers can stream them as file responses.
|
||||
/// Intended for serving files that are stored under the legacy <c>wwwroot/uploads/</c> path but
|
||||
/// are otherwise not directly exposed through the static-files middleware.
|
||||
/// </summary>
|
||||
public async Task<(bool Success, byte[] FileContent, string ContentType, string ErrorMessage)> GetFileAsync(string filePath)
|
||||
{
|
||||
@@ -154,7 +164,10 @@ public class FileService : IFileService
|
||||
return (false, Array.Empty<byte>(), string.Empty, "File path is required.");
|
||||
}
|
||||
|
||||
var fullPath = Path.Combine(_environment.WebRootPath, filePath.Replace('/', Path.DirectorySeparatorChar));
|
||||
if (!TryResolveLegacyUploadPath(filePath, out var fullPath, out var pathError))
|
||||
{
|
||||
return (false, Array.Empty<byte>(), string.Empty, pathError);
|
||||
}
|
||||
|
||||
if (!File.Exists(fullPath))
|
||||
{
|
||||
@@ -175,7 +188,7 @@ public class FileService : IFileService
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether a file exists at the given <c>wwwroot</c>-relative path without reading it.
|
||||
/// Checks whether a file exists at the given <c>wwwroot/uploads/</c>-relative path without reading it.
|
||||
/// Used by views and controllers to conditionally show download links only when the file is present.
|
||||
/// </summary>
|
||||
public bool FileExists(string filePath)
|
||||
@@ -185,7 +198,11 @@ public class FileService : IFileService
|
||||
return false;
|
||||
}
|
||||
|
||||
var fullPath = Path.Combine(_environment.WebRootPath, filePath.Replace('/', Path.DirectorySeparatorChar));
|
||||
if (!TryResolveLegacyUploadPath(filePath, out var fullPath, out _))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return File.Exists(fullPath);
|
||||
}
|
||||
|
||||
@@ -212,4 +229,96 @@ public class FileService : IFileService
|
||||
_ => "application/octet-stream"
|
||||
};
|
||||
}
|
||||
|
||||
private bool TryResolveUploadSubfolder(
|
||||
string subfolder,
|
||||
out string uploadPath,
|
||||
out string relativeSubfolder,
|
||||
out string errorMessage)
|
||||
{
|
||||
uploadPath = string.Empty;
|
||||
relativeSubfolder = string.Empty;
|
||||
errorMessage = string.Empty;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(subfolder))
|
||||
{
|
||||
errorMessage = "Upload subfolder is required.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!TryGetUploadsRootPath(out var uploadsRoot, out errorMessage))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var normalizedSubfolder = subfolder.Replace('\\', '/').Trim('/');
|
||||
var resolvedPath = Path.GetFullPath(
|
||||
Path.Combine(uploadsRoot, normalizedSubfolder.Replace('/', Path.DirectorySeparatorChar)));
|
||||
|
||||
if (!IsWithinDirectory(resolvedPath, uploadsRoot))
|
||||
{
|
||||
errorMessage = "Invalid upload subfolder.";
|
||||
_logger.LogWarning("Rejected upload subfolder outside uploads root: {Subfolder}", subfolder);
|
||||
return false;
|
||||
}
|
||||
|
||||
relativeSubfolder = Path.GetRelativePath(uploadsRoot, resolvedPath).Replace("\\", "/");
|
||||
uploadPath = resolvedPath;
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool TryResolveLegacyUploadPath(string filePath, out string fullPath, out string errorMessage)
|
||||
{
|
||||
fullPath = string.Empty;
|
||||
errorMessage = string.Empty;
|
||||
|
||||
if (!TryGetUploadsRootPath(out var uploadsRoot, out errorMessage))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var normalizedRelativePath = filePath.Replace('\\', '/').TrimStart('/');
|
||||
if (!normalizedRelativePath.StartsWith($"{UploadsRootFolder}/", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
errorMessage = "Invalid file path.";
|
||||
_logger.LogWarning("Rejected legacy file path outside uploads root: {FilePath}", filePath);
|
||||
return false;
|
||||
}
|
||||
|
||||
var resolvedPath = Path.GetFullPath(
|
||||
Path.Combine(_environment.WebRootPath, normalizedRelativePath.Replace('/', Path.DirectorySeparatorChar)));
|
||||
|
||||
if (!IsWithinDirectory(resolvedPath, uploadsRoot))
|
||||
{
|
||||
errorMessage = "Invalid file path.";
|
||||
_logger.LogWarning("Rejected path traversal attempt for legacy file path: {FilePath}", filePath);
|
||||
return false;
|
||||
}
|
||||
|
||||
fullPath = resolvedPath;
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool TryGetUploadsRootPath(out string uploadsRoot, out string errorMessage)
|
||||
{
|
||||
uploadsRoot = string.Empty;
|
||||
errorMessage = string.Empty;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_environment.WebRootPath))
|
||||
{
|
||||
errorMessage = "File storage is not available in this environment.";
|
||||
_logger.LogWarning("WebRootPath is not configured for the legacy file service.");
|
||||
return false;
|
||||
}
|
||||
|
||||
uploadsRoot = Path.GetFullPath(Path.Combine(_environment.WebRootPath, UploadsRootFolder));
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool IsWithinDirectory(string candidatePath, string rootPath)
|
||||
{
|
||||
var normalizedRoot = rootPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)
|
||||
+ Path.DirectorySeparatorChar;
|
||||
return candidatePath.StartsWith(normalizedRoot, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user