mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-10-01 08:43:15 +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
|
||||
{
|
||||
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");
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue