mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-10-07 00:02:10 +00:00
feat(work-orders): stream stored WO media safely
This commit is contained in:
parent
bb651bb547
commit
a25fd0bd5d
2 changed files with 85 additions and 20 deletions
56
Api.SeaHavenIndustries.Tests/FileStorageAdapterTests.cs
Normal file
56
Api.SeaHavenIndustries.Tests/FileStorageAdapterTests.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -46,18 +46,9 @@ namespace Api.SeaHavenIndustries.Infrastructure
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (!Uri.TryCreate(fileUrl, UriKind.Absolute, out var uri))
|
if (!TryResolveDocumentPath(fileUrl, out var fullPath))
|
||||||
return false;
|
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))
|
if (!System.IO.File.Exists(fullPath))
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
|
|
@ -77,18 +68,9 @@ namespace Api.SeaHavenIndustries.Infrastructure
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (!Uri.TryCreate(fileUrl, UriKind.Absolute, out var uri))
|
if (!TryResolveDocumentPath(fileUrl, out var fullPath))
|
||||||
return null;
|
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;
|
return System.IO.File.Exists(fullPath) ? System.IO.File.OpenRead(fullPath) : null;
|
||||||
}
|
}
|
||||||
catch
|
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()
|
private string ResolveWebRoot()
|
||||||
=> _webHostEnvironment.WebRootPath
|
=> _webHostEnvironment.WebRootPath
|
||||||
?? Path.Combine(_webHostEnvironment.ContentRootPath, "wwwroot");
|
?? Path.Combine(_webHostEnvironment.ContentRootPath, "wwwroot");
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue