diff --git a/Api.SeaHavenIndustries.Tests/FileStorageAdapterTests.cs b/Api.SeaHavenIndustries.Tests/FileStorageAdapterTests.cs new file mode 100644 index 0000000..04126b8 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/FileStorageAdapterTests.cs @@ -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(); + 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)); + + 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); + } +} diff --git a/Api.SeaHavenIndustries/Infrastructure/PortAdapters.cs b/Api.SeaHavenIndustries/Infrastructure/PortAdapters.cs index a3ebbf3..8b4aad2 100644 --- a/Api.SeaHavenIndustries/Infrastructure/PortAdapters.cs +++ b/Api.SeaHavenIndustries/Infrastructure/PortAdapters.cs @@ -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");