mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-10-07 16:19:07 +00:00
Reject sibling-prefix path escapes in private storage and log WeekRolled dates as int components to clear CodeQL log-injection findings.
129 lines
5 KiB
C#
129 lines
5 KiB
C#
using Microsoft.AspNetCore.Hosting;
|
|
using SeaHaven.Services.Helpers;
|
|
|
|
namespace Api.SeaHavenIndustries.Helper
|
|
{
|
|
public interface IWorkOrderPrivateFileStorage
|
|
{
|
|
Task<string> SaveAsync(int workOrderId, IFormFile file, CancellationToken cancellationToken = default);
|
|
bool TryResolvePhysicalPath(string? storedValue, out string physicalPath);
|
|
void TryDelete(string? storedValue);
|
|
string ToDownloadApiPath(int workOrderId, string kind, int? mediaId = null);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Stores board completion/media files under ContentRoot/App_Data (not wwwroot).
|
|
/// DB values are relative keys: workorder-files/{workOrderId}/{guid}{ext}
|
|
/// </summary>
|
|
public sealed class WorkOrderPrivateFileStorage : IWorkOrderPrivateFileStorage
|
|
{
|
|
public const string RelativeRoot = "workorder-files";
|
|
private readonly IWebHostEnvironment _env;
|
|
|
|
public WorkOrderPrivateFileStorage(IWebHostEnvironment env)
|
|
{
|
|
_env = env;
|
|
}
|
|
|
|
private string RootPath => Path.GetFullPath(Path.Combine(_env.ContentRootPath, "App_Data", RelativeRoot));
|
|
|
|
public async Task<string> SaveAsync(int workOrderId, IFormFile file, CancellationToken cancellationToken = default)
|
|
{
|
|
var clientName = file.FileName;
|
|
if (string.IsNullOrWhiteSpace(clientName) && !string.IsNullOrWhiteSpace(file.ContentDisposition))
|
|
{
|
|
try
|
|
{
|
|
clientName = System.Net.Http.Headers.ContentDispositionHeaderValue
|
|
.Parse(file.ContentDisposition).FileName?.Trim('"');
|
|
}
|
|
catch
|
|
{
|
|
clientName = null;
|
|
}
|
|
}
|
|
|
|
if (!WorkOrderUploadFileRules.TryGetAllowedExtension(clientName, out var extension, out var error))
|
|
throw new InvalidOperationException(error ?? "Invalid file.");
|
|
|
|
var serverName = WorkOrderUploadFileRules.BuildServerFileName(extension);
|
|
var relativeKey = $"{RelativeRoot}/{workOrderId}/{serverName}".Replace('\\', '/');
|
|
var directory = Path.Combine(RootPath, workOrderId.ToString());
|
|
var fullPath = Path.GetFullPath(Path.Combine(directory, serverName));
|
|
|
|
if (!IsPathUnderRoot(fullPath, RootPath))
|
|
throw new InvalidOperationException("Resolved path escapes storage root.");
|
|
|
|
Directory.CreateDirectory(directory);
|
|
await using (var stream = System.IO.File.Create(fullPath))
|
|
{
|
|
await file.CopyToAsync(stream, cancellationToken);
|
|
}
|
|
|
|
return relativeKey;
|
|
}
|
|
|
|
public bool TryResolvePhysicalPath(string? storedValue, out string physicalPath)
|
|
{
|
|
physicalPath = string.Empty;
|
|
if (string.IsNullOrWhiteSpace(storedValue))
|
|
return false;
|
|
|
|
// Accept relative keys; reject absolute URLs / wwwroot legacy paths here.
|
|
var normalized = storedValue.Replace('\\', '/').TrimStart('/');
|
|
if (!normalized.StartsWith(RelativeRoot + "/", StringComparison.OrdinalIgnoreCase))
|
|
return false;
|
|
|
|
var relative = normalized[(RelativeRoot.Length + 1)..];
|
|
var candidate = Path.GetFullPath(Path.Combine(RootPath, relative));
|
|
if (!IsPathUnderRoot(candidate, RootPath))
|
|
return false;
|
|
|
|
if (!System.IO.File.Exists(candidate))
|
|
return false;
|
|
|
|
physicalPath = candidate;
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Separator-aware containment check so sibling prefixes like
|
|
/// <c>.../workorder-files-secret</c> do not match <c>.../workorder-files</c>.
|
|
/// </summary>
|
|
private static bool IsPathUnderRoot(string candidate, string root)
|
|
{
|
|
var fullRoot = Path.GetFullPath(root);
|
|
var fullCandidate = Path.GetFullPath(candidate);
|
|
|
|
if (string.Equals(fullCandidate, fullRoot, StringComparison.OrdinalIgnoreCase))
|
|
return true;
|
|
|
|
var rootPrefix = fullRoot.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)
|
|
+ Path.DirectorySeparatorChar;
|
|
return fullCandidate.StartsWith(rootPrefix, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
public void TryDelete(string? storedValue)
|
|
{
|
|
if (!TryResolvePhysicalPath(storedValue, out var path))
|
|
return;
|
|
|
|
try
|
|
{
|
|
System.IO.File.Delete(path);
|
|
}
|
|
catch
|
|
{
|
|
// best-effort cleanup
|
|
}
|
|
}
|
|
|
|
public string ToDownloadApiPath(int workOrderId, string kind, int? mediaId = null)
|
|
=> kind switch
|
|
{
|
|
"completion" => $"/api/workorders/{workOrderId}/completion-doc/file",
|
|
"media" when mediaId.HasValue => $"/api/workorders/{workOrderId}/media/{mediaId.Value}/file",
|
|
_ => $"/api/workorders/{workOrderId}/media/file"
|
|
};
|
|
}
|
|
}
|