feat(work-orders): stream stored WO media safely

This commit is contained in:
Arthur Bassi 2026-09-08 11:09:55 -03:00
parent bb651bb547
commit a25fd0bd5d
2 changed files with 85 additions and 20 deletions

View file

@ -0,0 +1,56 @@
using Api.SeaHavenIndustries.Infrastructure;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Moq;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
public sealed class FileStorageAdapterTests : IDisposable
{
private readonly string _webRoot = Path.Combine(Path.GetTempPath(), $"shoc-storage-{Guid.NewGuid():N}");
private readonly FileStorageAdapter _adapter;
public FileStorageAdapterTests()
{
Directory.CreateDirectory(_webRoot);
var environment = new Mock<IWebHostEnvironment>();
environment.SetupGet(candidate => candidate.WebRootPath).Returns(_webRoot);
environment.SetupGet(candidate => candidate.ContentRootPath).Returns(_webRoot);
_adapter = new FileStorageAdapter(environment.Object, new HttpContextAccessor());
}
[Fact]
public void OpenRead_ValidStoredUrl_ReturnsReadableStream()
{
var directory = Path.Combine(_webRoot, "Assets", "Documents");
Directory.CreateDirectory(directory);
File.WriteAllText(Path.Combine(directory, "report.pdf"), "stored");
using var stream = _adapter.OpenRead("https://example.test/Assets/Documents/report.pdf");
using var reader = new StreamReader(Assert.IsAssignableFrom<Stream>(stream));
Assert.Equal("stored", reader.ReadToEnd());
}
[Theory]
[InlineData("https://example.test/Assets/Images/report.pdf")]
[InlineData("https://example.test/Assets/Documents/../secret.txt")]
[InlineData("https://example.test/Assets/Documents/%2e%2e/secret.txt")]
public void OpenRead_UnsafeUrl_ReturnsNull(string fileUrl)
{
Assert.Null(_adapter.OpenRead(fileUrl));
}
[Fact]
public void OpenRead_MissingFile_ReturnsNull()
{
Assert.Null(_adapter.OpenRead("https://example.test/Assets/Documents/missing.pdf"));
}
public void Dispose()
{
if (Directory.Exists(_webRoot))
Directory.Delete(_webRoot, recursive: true);
}
}

View file

@ -46,18 +46,9 @@ namespace Api.SeaHavenIndustries.Infrastructure
try
{
if (!Uri.TryCreate(fileUrl, UriKind.Absolute, out var uri))
if (!TryResolveDocumentPath(fileUrl, out var fullPath))
return false;
var relativePath = uri.AbsolutePath.TrimStart('/');
if (string.IsNullOrWhiteSpace(relativePath)
|| relativePath.Contains("..", StringComparison.Ordinal)
|| !relativePath.StartsWith("Assets/Documents/", StringComparison.OrdinalIgnoreCase))
{
return false;
}
var fullPath = Path.Combine(ResolveWebRoot(), relativePath.Replace('/', Path.DirectorySeparatorChar));
if (!System.IO.File.Exists(fullPath))
return false;
@ -77,18 +68,9 @@ namespace Api.SeaHavenIndustries.Infrastructure
try
{
if (!Uri.TryCreate(fileUrl, UriKind.Absolute, out var uri))
if (!TryResolveDocumentPath(fileUrl, out var fullPath))
return null;
var relativePath = uri.AbsolutePath.TrimStart('/');
if (string.IsNullOrWhiteSpace(relativePath)
|| relativePath.Contains("..", StringComparison.Ordinal)
|| !relativePath.StartsWith("Assets/Documents/", StringComparison.OrdinalIgnoreCase))
{
return null;
}
var fullPath = Path.Combine(ResolveWebRoot(), relativePath.Replace('/', Path.DirectorySeparatorChar));
return System.IO.File.Exists(fullPath) ? System.IO.File.OpenRead(fullPath) : null;
}
catch
@ -97,6 +79,33 @@ namespace Api.SeaHavenIndustries.Infrastructure
}
}
private bool TryResolveDocumentPath(string fileUrl, out string fullPath)
{
fullPath = string.Empty;
if (!Uri.TryCreate(fileUrl, UriKind.Absolute, out var uri))
return false;
var relativePath = Uri.UnescapeDataString(uri.AbsolutePath).TrimStart('/');
if (string.IsNullOrWhiteSpace(relativePath)
|| relativePath.Contains("..", StringComparison.Ordinal)
|| !relativePath.StartsWith("Assets/Documents/", StringComparison.OrdinalIgnoreCase))
{
return false;
}
var webRoot = Path.GetFullPath(ResolveWebRoot());
var documentsRoot = Path.GetFullPath(Path.Combine(webRoot, "Assets", "Documents"))
.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)
+ Path.DirectorySeparatorChar;
var candidatePath = Path.GetFullPath(
Path.Combine(webRoot, relativePath.Replace('/', Path.DirectorySeparatorChar)));
if (!candidatePath.StartsWith(documentsRoot, StringComparison.OrdinalIgnoreCase))
return false;
fullPath = candidatePath;
return true;
}
private string ResolveWebRoot()
=> _webHostEnvironment.WebRootPath
?? Path.Combine(_webHostEnvironment.ContentRootPath, "wwwroot");