From f516322dabdd1d52375c80108fe82fa90fdb92be Mon Sep 17 00:00:00 2001 From: fh-adliance Date: Fri, 12 Jun 2026 10:17:19 +0200 Subject: [PATCH 1/7] ContainerService is now scoped --- src/Adliance.AzureBlobSimulator/Program.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Adliance.AzureBlobSimulator/Program.cs b/src/Adliance.AzureBlobSimulator/Program.cs index aad1064..ad3c98c 100644 --- a/src/Adliance.AzureBlobSimulator/Program.cs +++ b/src/Adliance.AzureBlobSimulator/Program.cs @@ -5,7 +5,7 @@ var builder = WebApplication.CreateBuilder(args); builder.Services.Configure(builder.Configuration.GetSection(StorageOptions.SectionName)); -builder.Services.AddSingleton(); +builder.Services.AddScoped(); builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddLogging(); From e4cce9483b5133e13f6662e1b33d5b6dbcb05da1 Mon Sep 17 00:00:00 2001 From: fh-adliance Date: Fri, 12 Jun 2026 11:26:22 +0200 Subject: [PATCH 2/7] Refactor blob storage system Improve structure and reliability of blob handling by: - Introducing scoped container loading - Adding support for subdirectory blob organization - Ensuring directory cleanup on blob deletion - Strengthening traversal validation and error handling - Centralizing shared logic - Applying code style fixes --- .../Attributes/BlobNameAttribute.cs | 35 ++- .../Controllers/BlobController.cs | 74 +++++-- .../Controllers/BlobsController.cs | 73 ++++--- .../Controllers/ContainersController.cs | 11 +- .../Models/BlobMetadata.cs | 15 ++ .../Services/ContainerService.cs | 206 +++++++++++++++--- 6 files changed, 327 insertions(+), 87 deletions(-) create mode 100644 src/Adliance.AzureBlobSimulator/Models/BlobMetadata.cs diff --git a/src/Adliance.AzureBlobSimulator/Attributes/BlobNameAttribute.cs b/src/Adliance.AzureBlobSimulator/Attributes/BlobNameAttribute.cs index 140dbef..9695393 100644 --- a/src/Adliance.AzureBlobSimulator/Attributes/BlobNameAttribute.cs +++ b/src/Adliance.AzureBlobSimulator/Attributes/BlobNameAttribute.cs @@ -6,17 +6,36 @@ public class BlobNameAttribute : ValidationAttribute { public override bool IsValid(object? value) { - if (value is string valueAsString) + if (value is not string s) { - if (valueAsString.Length <= 0) return false; - if (valueAsString.Length > 1024) return false; - if (valueAsString.Contains('/')) return false; - if (valueAsString.Contains('\\')) return false; - if (valueAsString.Contains("..")) return false; + return false; + } + + if (string.IsNullOrWhiteSpace(s)) + { + return false; + } + + if (s.Length > 1024) + { + return false; + } - return true; + if (s.StartsWith('/') || s.StartsWith('\\')) + { + return false; + } + + if (s.Contains("..", StringComparison.Ordinal)) + { + return false; + } + + if (s.Contains('\\')) + { + return false; } - return false; + return true; } } diff --git a/src/Adliance.AzureBlobSimulator/Controllers/BlobController.cs b/src/Adliance.AzureBlobSimulator/Controllers/BlobController.cs index 12ead08..a762f98 100644 --- a/src/Adliance.AzureBlobSimulator/Controllers/BlobController.cs +++ b/src/Adliance.AzureBlobSimulator/Controllers/BlobController.cs @@ -13,30 +13,53 @@ public class BlobController(ContainerService containerService) : ControllerBase public async Task WriteBlob([FromRoute, ContainerName] string container, [FromRoute, BlobName] string blob) { var blobType = Request.Headers["x-ms-blob-type"].ToString(); - if (!string.Equals(blobType, "BlockBlob", StringComparison.OrdinalIgnoreCase)) return BadRequest("Only BlockBlob uploads are supported."); + if (!string.Equals(blobType, "BlockBlob", StringComparison.OrdinalIgnoreCase)) + { + return BadRequest("Only BlockBlob uploads are supported."); + } - if (!containerService.DoesContainerExist(container)) return NotFound($"Container \"{container}\" not found."); + if (!containerService.DoesContainerExist(container)) + { + return NotFound($"Container \"{container}\" not found."); + } var containerPath = containerService.GetContainerPath(container); var targetPath = Path.Combine(containerPath, blob); + + if (!targetPath.StartsWith(containerPath, StringComparison.Ordinal)) + { + return BadRequest("Invalid blob path."); + } + + var directory = Path.GetDirectoryName(targetPath); + if (!string.IsNullOrEmpty(directory)) + { + Directory.CreateDirectory(directory); + } + await using var fs = new FileStream(targetPath, FileMode.Create, FileAccess.Write); await Request.Body.CopyToAsync(fs); await fs.FlushAsync(); var fileInfo = new FileInfo(targetPath); Response.Headers["x-ms-version"] = Constants.MsVersion; - Response.Headers["ETag"] = Guid.NewGuid().ToString(); - Response.Headers["Last-Modified"] = fileInfo.LastWriteTimeUtc.ToString("R"); Response.Headers["x-ms-request-server-encrypted"] = "false"; + Response.Headers.ETag = containerService.GetBlobETag(container, blob); + Response.Headers.LastModified = fileInfo.LastWriteTimeUtc.ToString("R"); return StatusCode(201); } [HttpGet("/{container}/{*blob}")] public IActionResult ReadBlob([FromRoute, ContainerName] string container, [FromRoute, BlobName] string blob) { - if (!containerService.DoesBlobExist(container, blob)) return NotFound($"Blob \"{container}/{blob}\" not found."); + var path = containerService.GetBlobPath(container, blob); + + if (!System.IO.File.Exists(path)) + { + return NotFound($"Blob \"{container}/{blob}\" not found."); + } - var fileInfo = new FileInfo(containerService.GetBlobPath(container, blob)); + var fileInfo = new FileInfo(path); var contentTypeProvider = new FileExtensionContentTypeProvider(); var contentType = contentTypeProvider.TryGetContentType(fileInfo.Name, out var c) ? c : "application/octet-stream"; @@ -44,24 +67,29 @@ public IActionResult ReadBlob([FromRoute, ContainerName] string container, [From Response.Headers["x-ms-blob-type"] = "BlockBlob"; Response.Headers["x-ms-lease-status"] = "unlocked"; Response.Headers["x-ms-lease-state"] = "available"; - Response.Headers["Last-Modified"] = fileInfo.LastWriteTimeUtc.ToString("R"); - Response.Headers["ETag"] = Guid.NewGuid().ToString(); Response.Headers["Content-Length"] = fileInfo.Length.ToString(CultureInfo.InvariantCulture); + Response.Headers.ETag = containerService.GetBlobETag(container, blob); + Response.Headers.LastModified = fileInfo.LastWriteTimeUtc.ToString("R"); - // support custom ms range header, see https://learn.microsoft.com/en-us/rest/api/storageservices/get-blob?tabs=microsoft-entra-id#request - if (Request.Headers.TryGetValue("x-ms-range", out var requestedRange)) - Request.Headers["Range"] = requestedRange; + // // support custom ms range header, see https://learn.microsoft.com/en-us/rest/api/storageservices/get-blob?tabs=microsoft-entra-id#request + // if (Request.Headers.TryGetValue("x-ms-range", out var requestedRange)) + // Request.Headers.Range = requestedRange; - var fileStream = new FileStream(fileInfo.FullName, FileMode.Open, FileAccess.Read); + var fileStream = new FileStream(fileInfo.FullName, FileMode.Open, FileAccess.Read, FileShare.Read); return File(fileStream, contentType, enableRangeProcessing: true); } [HttpHead("/{container}/{*blob}")] public IActionResult GetBlobProperties([FromRoute, ContainerName] string container, [FromRoute, BlobName] string blob) { - if (!containerService.DoesBlobExist(container, blob)) return NotFound($"Blob \"{container}/{blob}\" not found."); + var path = containerService.GetBlobPath(container, blob); + + if (!System.IO.File.Exists(path)) + { + return NotFound($"Blob \"{container}/{blob}\" not found."); + } - var fileInfo = new FileInfo(containerService.GetBlobPath(container, blob)); + var fileInfo = new FileInfo(path); var contentTypeProvider = new FileExtensionContentTypeProvider(); var contentType = contentTypeProvider.TryGetContentType(fileInfo.FullName, out var detectedContentType) ? detectedContentType : "application/octet-stream"; @@ -69,12 +97,12 @@ public IActionResult GetBlobProperties([FromRoute, ContainerName] string contain Response.Headers["x-ms-blob-type"] = "BlockBlob"; Response.Headers["x-ms-lease-status"] = "unlocked"; Response.Headers["x-ms-lease-state"] = "available"; - Response.Headers["Last-Modified"] = fileInfo.LastWriteTimeUtc.ToString("R"); - Response.Headers["ETag"] = Guid.NewGuid().ToString(); Response.Headers["Content-Length"] = fileInfo.Length.ToString(CultureInfo.InvariantCulture); - Response.Headers["Content-Type"] = contentType; - Response.Headers["Accept-Ranges"] = "bytes"; Response.Headers["x-ms-server-encrypted"] = "false"; + Response.Headers.AcceptRanges = "bytes"; + Response.Headers.ContentType = contentType; + Response.Headers.ETag = containerService.GetBlobETag(container, blob); + Response.Headers.LastModified = fileInfo.LastWriteTimeUtc.ToString("R"); return Ok(); } @@ -83,12 +111,16 @@ public IActionResult DeleteBlob( [FromRoute, ContainerName] string container, [FromRoute, BlobName] string blob) { - if (!containerService.DoesBlobExist(container, blob)) + var path = containerService.GetBlobPath(container, blob); + + if (!System.IO.File.Exists(path)) + { return NotFound($"Blob \"{container}/{blob}\" not found."); + } - var blobPath = containerService.GetBlobPath(container, blob); + System.IO.File.Delete(path); - System.IO.File.Delete(blobPath); + containerService.CleanupBlobDirectoryChain(path, containerService.GetContainerPath(container)); Response.Headers["x-ms-version"] = Constants.MsVersion; Response.Headers["x-ms-delete-type-permanent"] = "true"; diff --git a/src/Adliance.AzureBlobSimulator/Controllers/BlobsController.cs b/src/Adliance.AzureBlobSimulator/Controllers/BlobsController.cs index 88d20b1..0c0a2b4 100644 --- a/src/Adliance.AzureBlobSimulator/Controllers/BlobsController.cs +++ b/src/Adliance.AzureBlobSimulator/Controllers/BlobsController.cs @@ -11,36 +11,42 @@ public class BlobsController(ContainerService containerService) : ControllerBase { [HttpGet("/{container}")] public IActionResult HandleGetRequest([FromRoute, ContainerName] string container, [FromQuery] string? comp, [FromQuery] string? restype) - { - if (restype == "container" && comp == "list") return ListBlobs(container); - if (restype == "container" && comp == null) return GetContainerProperties(container); - return BadRequest("Unsupported operation on GET /."); - } + => restype switch + { + "container" when comp == "list" => ListBlobs(container), + "container" when comp == null => GetContainerProperties(container), + _ => BadRequest("Unsupported operation on GET /.") + }; private IActionResult ListBlobs(string container) { - if (!containerService.DoesContainerExist(container)) return NotFound($"Container \"{container}\" not found."); + if (!containerService.DoesContainerExist(container)) + { + return NotFound($"Container \"{container}\" not found."); + } var contentTypeProvider = new FileExtensionContentTypeProvider(); - var filePaths = containerService.GetFilePaths(container); - var blobs = filePaths.Select(filePath => - { - var fileInfo = new FileInfo(filePath); - return new Blob + var blobs = containerService.GetBlobNames(container) + .Select(blobName => { - Name = fileInfo.Name, - Properties = new BlobProperties + var meta = containerService.GetBlobMetadata(container, blobName); + + return new Blob { - LastModified = fileInfo.LastWriteTimeUtc.ToString("R"), - Created = fileInfo.CreationTimeUtc.ToString("R"), - Etag = Guid.NewGuid().ToString(), - ContentLength = fileInfo.Length, - ContentType = contentTypeProvider.TryGetContentType(fileInfo.Name, out var contentType) ? contentType : "application/octet-stream", - BlobType = "BlockBlob" - } - }; - }); + // we can suppress this warning because we know that the name and ContentType are not null + Name = meta.Name!, + Properties = new BlobProperties + { + LastModified = meta.LastModified.ToString("R"), + Created = meta.Created.ToString("R"), + Etag = containerService.GetBlobETag(container, blobName), + ContentLength = meta.ContentLength, + ContentType = meta.ContentType!, + BlobType = "BlockBlob" + } + }; + }); var response = new BlobEnumerationResults { @@ -49,25 +55,36 @@ private IActionResult ListBlobs(string container) Blobs = new BlobsCollection(blobs.ToList()) }; - var responseXml = XmlSerializationService.SerializeToXml(response); - Response.Headers["x-ms-version"] = Constants.MsVersion; - return Content(responseXml, "application/xml"); + + return Content(XmlSerializationService.SerializeToXml(response), "application/xml"); } private IActionResult GetContainerProperties(string container) { - if (!containerService.DoesContainerExist(container)) return NotFound($"Container \"{container}\" not found."); + if (!containerService.DoesContainerExist(container)) + { + return NotFound($"Container \"{container}\" not found."); + } Response.Headers["x-ms-version"] = Constants.MsVersion; - Response.Headers["Last-Modified"] = DateTime.UtcNow.ToString("R"); - Response.Headers["ETag"] = $"\"{Guid.NewGuid()}\""; Response.Headers["x-ms-lease-status"] = "unlocked"; Response.Headers["x-ms-lease-state"] = "available"; Response.Headers["x-ms-has-immutability-policy"] = "false"; Response.Headers["x-ms-has-legal-hold"] = "false"; Response.Headers["x-ms-default-encryption-scope"] = "$account-encryption-key"; Response.Headers["x-ms-deny-encryption-scope-override"] = "false"; + Response.Headers.ETag = containerService.GetContainerETag(container); + + var containerPath = containerService.GetContainerPath(container); + + var lastModifiedTicks = Directory + .GetFiles(containerPath, "*", SearchOption.AllDirectories) + .Select(f => new FileInfo(f).LastWriteTimeUtc.Ticks) + .DefaultIfEmpty(DateTime.UtcNow.Ticks) + .Max(); + + Response.Headers.LastModified = new DateTime(lastModifiedTicks, DateTimeKind.Utc).ToString("R"); return Ok(); } diff --git a/src/Adliance.AzureBlobSimulator/Controllers/ContainersController.cs b/src/Adliance.AzureBlobSimulator/Controllers/ContainersController.cs index 915ead8..033a0fd 100644 --- a/src/Adliance.AzureBlobSimulator/Controllers/ContainersController.cs +++ b/src/Adliance.AzureBlobSimulator/Controllers/ContainersController.cs @@ -9,11 +9,12 @@ public class ContainersController(ContainerService containerService) : Controlle { [HttpGet("/")] public IActionResult HandleGetRequest([FromQuery] string comp, [FromQuery] string? restype) - { - if (comp == "properties" && restype == "account") return GetAccountProperties(); - if (comp == "list") return ListContainers(); - return BadRequest("Unsupported operation on GET /."); - } + => comp switch + { + "properties" when restype == "account" => GetAccountProperties(), + "list" => ListContainers(), + _ => BadRequest("Unsupported operation on GET /.") + }; private IActionResult GetAccountProperties() { diff --git a/src/Adliance.AzureBlobSimulator/Models/BlobMetadata.cs b/src/Adliance.AzureBlobSimulator/Models/BlobMetadata.cs new file mode 100644 index 0000000..0d04cc3 --- /dev/null +++ b/src/Adliance.AzureBlobSimulator/Models/BlobMetadata.cs @@ -0,0 +1,15 @@ +namespace Adliance.AzureBlobSimulator.Models; + +public class BlobMetadata +{ + public string? Path { get; set; } + public string? Name { get; set; } + + public long ContentLength { get; set; } + public string? ContentType { get; set; } + + public DateTime LastModified { get; set; } + public DateTime Created { get; set; } + + public string? ETag { get; set; } +} diff --git a/src/Adliance.AzureBlobSimulator/Services/ContainerService.cs b/src/Adliance.AzureBlobSimulator/Services/ContainerService.cs index a609d76..fd888b2 100644 --- a/src/Adliance.AzureBlobSimulator/Services/ContainerService.cs +++ b/src/Adliance.AzureBlobSimulator/Services/ContainerService.cs @@ -1,33 +1,202 @@ using Adliance.AzureBlobSimulator.Models; +using Microsoft.AspNetCore.StaticFiles; using Microsoft.Extensions.Options; namespace Adliance.AzureBlobSimulator.Services; public class ContainerService(IOptions options) { + private Dictionary? _containers; + + public Dictionary GetContainers() + => _containers ??= BuildContainers(); + public bool DoesContainerExist(string container) { return GetContainers().ContainsKey(container); } - public bool DoesBlobExist(string container, string blob) + public IEnumerable GetBlobNames(string container) { - if (!DoesContainerExist(container)) return false; - var filePath = Path.Combine(GetContainerPath(container), blob); - return File.Exists(filePath); + if (!GetContainers().TryGetValue(container, out var containerPath)) + { + return new List(); + } + + return Directory + .GetFiles(containerPath, "*", SearchOption.AllDirectories) + .Select(f => Path.GetRelativePath(containerPath, f).Replace("\\", "/")); } - public IEnumerable GetFilePaths(string container) + public string GetContainerPath(string container) { if (GetContainers().TryGetValue(container, out var containerPath)) { - return Directory.GetFiles(containerPath, "*", SearchOption.TopDirectoryOnly); + return Path.GetFullPath(containerPath); } - return new List(); + throw new DirectoryNotFoundException($"Container \"{container}\" not found."); } - public Dictionary GetContainers() + public string GetBlobPath(string container, string blob) + { + if (!GetContainers().TryGetValue(container, out var containerPath)) + { + throw new DirectoryNotFoundException($"Container \"{container}\" not found."); + } + + return ResolveBlobPath(containerPath, blob); + } + + public string GetBlobETag(string container, string blob) + { + var path = GetBlobPath(container, blob); + var fileInfo = new FileInfo(path); + + return GetBlobETagByFileInfo(fileInfo); + } + + public string GetContainerETag(string container) + { + var containerPath = GetContainerPath(container); + + var files = Directory.GetFiles(containerPath, "*", SearchOption.AllDirectories); + + if (files.Length == 0) + { + return "\"0-0\""; + } + + var hash = files + .Select(f => new FileInfo(f)) + .Aggregate(0L, (acc, fi) => + HashCode.Combine(acc, fi.Length, fi.LastWriteTimeUtc.Ticks)); + + return $"\"{files.Length}-{hash}\""; + } + + public BlobMetadata GetBlobMetadata(string container, string blob) + { + var filePath = GetBlobPath(container, blob); + + if (!File.Exists(filePath)) + { + throw new FileNotFoundException($"Blob \"{container}/{blob}\" not found."); + } + + var fileInfo = new FileInfo(filePath); + + var contentTypeProvider = new FileExtensionContentTypeProvider(); + var contentType = + contentTypeProvider.TryGetContentType(fileInfo.Name, out var ct) + ? ct + : "application/octet-stream"; + + return new BlobMetadata + { + Path = filePath, + Name = blob, + + ContentLength = fileInfo.Length, + ContentType = contentType, + + LastModified = fileInfo.LastWriteTimeUtc, + Created = fileInfo.CreationTimeUtc, + + ETag = GetBlobETagByFileInfo(fileInfo) + }; + } + + public void CleanupBlobDirectoryChain(string filePath, string containerRoot) + { + var root = Path.GetFullPath(containerRoot); + var current = Path.GetFullPath(Path.GetDirectoryName(filePath)!); + + while (true) + { + // stop at container root (never delete container) + if (string.Equals( + current.TrimEnd(Path.DirectorySeparatorChar), + root.TrimEnd(Path.DirectorySeparatorChar), + StringComparison.OrdinalIgnoreCase)) + { + break; + } + + if (!Directory.Exists(current)) + { + break; + } + + // if directory is NOT empty → stop + if (Directory.EnumerateFileSystemEntries(current).Any()) + { + break; + } + + Directory.Delete(current); + + current = Path.GetDirectoryName(current)!; + } + } + + private static string ResolveBlobPath(string containerPath, string blob) + { + if (string.IsNullOrWhiteSpace(blob)) + { + throw new InvalidOperationException("Blob name is empty."); + } + + // 1. Normalize container root + var rootPath = Path.GetFullPath(containerPath); + + // ensure trailing separator to avoid prefix issues + rootPath = rootPath.TrimEnd(Path.DirectorySeparatorChar) + + Path.DirectorySeparatorChar; + + // 2. Normalize blob separators (Azure style) + blob = blob.Replace('\\', '/'); + + // 3. Split into segments (prevents traversal tricks like "..") + var segments = blob.Split('/', StringSplitOptions.RemoveEmptyEntries); + + var safeSegments = new List(segments.Length); + + foreach (var segment in segments) + { + if (segment is "." or "..") + { + throw new InvalidOperationException($"Invalid blob segment. Segment: {segment}."); + } + + // optional: block weird control chars + if (segment.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0) + { + throw new InvalidOperationException("Invalid blob segment."); + } + + safeSegments.Add(segment); + } + + // 4. Rebuild safe relative path + var safeRelativePath = Path.Combine(safeSegments.ToArray()); + + // 5. Combine with container root + var fullPath = Path.GetFullPath(Path.Combine(rootPath, safeRelativePath)); + + // 6. HARD boundary check + if (!fullPath.StartsWith(rootPath, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException("Path traversal detected."); + } + + return fullPath; + } + + private static string GetBlobETagByFileInfo(FileInfo fileInfo) + => $"{fileInfo.Length}-{fileInfo.LastWriteTimeUtc.Ticks}"; + + private Dictionary BuildContainers() { var result = new Dictionary { @@ -37,7 +206,10 @@ public Dictionary GetContainers() foreach (var d in options.Value.Containers) { - if (Directory.Exists(d.LocalPath)) result.Add(d.Name, d.LocalPath); + if (Directory.Exists(d.LocalPath)) + { + result.Add(d.Name, d.LocalPath); + } } if (Directory.Exists(options.Value.LocalPath)) @@ -50,20 +222,4 @@ public Dictionary GetContainers() return result; } - - public string GetContainerPath(string container) - { - if (GetContainers().TryGetValue(container, out var containerPath)) return containerPath; - throw new Exception($"Container \"{container}\" not found."); - } - - public string GetBlobPath(string container, string blob) - { - if (GetContainers().TryGetValue(container, out var containerPath)) - { - var filePath = Path.Combine(containerPath, blob); - if (File.Exists(filePath)) return filePath; - } - throw new Exception($"Blob \"{container}/{blob}\" not found."); - } } From 68563d7d554cc560c39fe4989c95200cbb4c06d3 Mon Sep 17 00:00:00 2001 From: fh-adliance Date: Mon, 15 Jun 2026 11:54:56 +0200 Subject: [PATCH 3/7] Optimized Invalid Segment error message --- src/Adliance.AzureBlobSimulator/Services/ContainerService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Adliance.AzureBlobSimulator/Services/ContainerService.cs b/src/Adliance.AzureBlobSimulator/Services/ContainerService.cs index fd888b2..bd1178b 100644 --- a/src/Adliance.AzureBlobSimulator/Services/ContainerService.cs +++ b/src/Adliance.AzureBlobSimulator/Services/ContainerService.cs @@ -166,7 +166,7 @@ private static string ResolveBlobPath(string containerPath, string blob) { if (segment is "." or "..") { - throw new InvalidOperationException($"Invalid blob segment. Segment: {segment}."); + throw new InvalidOperationException($"Invalid blob segment. Segment: '{segment}'"); } // optional: block weird control chars From 0191a93ed699ac51341aa7954fc8d6f2c1b3ac45 Mon Sep 17 00:00:00 2001 From: fh-adliance Date: Mon, 15 Jun 2026 11:55:26 +0200 Subject: [PATCH 4/7] Added try-catch block for DirectoryNotFoundException in GetBlobProperties and ReadBlob methods --- .../Controllers/BlobController.cs | 100 ++++++++++-------- 1 file changed, 57 insertions(+), 43 deletions(-) diff --git a/src/Adliance.AzureBlobSimulator/Controllers/BlobController.cs b/src/Adliance.AzureBlobSimulator/Controllers/BlobController.cs index a762f98..2b429ae 100644 --- a/src/Adliance.AzureBlobSimulator/Controllers/BlobController.cs +++ b/src/Adliance.AzureBlobSimulator/Controllers/BlobController.cs @@ -52,58 +52,72 @@ public async Task WriteBlob([FromRoute, ContainerName] string con [HttpGet("/{container}/{*blob}")] public IActionResult ReadBlob([FromRoute, ContainerName] string container, [FromRoute, BlobName] string blob) { - var path = containerService.GetBlobPath(container, blob); - - if (!System.IO.File.Exists(path)) + try { - return NotFound($"Blob \"{container}/{blob}\" not found."); + var path = containerService.GetBlobPath(container, blob); + + if (!System.IO.File.Exists(path)) + { + return NotFound($"Blob \"{container}/{blob}\" not found."); + } + + var fileInfo = new FileInfo(path); + var contentTypeProvider = new FileExtensionContentTypeProvider(); + var contentType = contentTypeProvider.TryGetContentType(fileInfo.Name, out var c) ? c : "application/octet-stream"; + + Response.Headers["x-ms-version"] = Constants.MsVersion; + Response.Headers["x-ms-blob-type"] = "BlockBlob"; + Response.Headers["x-ms-lease-status"] = "unlocked"; + Response.Headers["x-ms-lease-state"] = "available"; + Response.Headers["Content-Length"] = fileInfo.Length.ToString(CultureInfo.InvariantCulture); + Response.Headers.ETag = containerService.GetBlobETag(container, blob); + Response.Headers.LastModified = fileInfo.LastWriteTimeUtc.ToString("R"); + + // // support custom ms range header, see https://learn.microsoft.com/en-us/rest/api/storageservices/get-blob?tabs=microsoft-entra-id#request + // if (Request.Headers.TryGetValue("x-ms-range", out var requestedRange)) + // Request.Headers.Range = requestedRange; + + var fileStream = new FileStream(fileInfo.FullName, FileMode.Open, FileAccess.Read, FileShare.Read); + return File(fileStream, contentType, enableRangeProcessing: true); + } + catch (DirectoryNotFoundException directoryNotFoundException) + { + return NotFound(directoryNotFoundException.Message); } - - var fileInfo = new FileInfo(path); - var contentTypeProvider = new FileExtensionContentTypeProvider(); - var contentType = contentTypeProvider.TryGetContentType(fileInfo.Name, out var c) ? c : "application/octet-stream"; - - Response.Headers["x-ms-version"] = Constants.MsVersion; - Response.Headers["x-ms-blob-type"] = "BlockBlob"; - Response.Headers["x-ms-lease-status"] = "unlocked"; - Response.Headers["x-ms-lease-state"] = "available"; - Response.Headers["Content-Length"] = fileInfo.Length.ToString(CultureInfo.InvariantCulture); - Response.Headers.ETag = containerService.GetBlobETag(container, blob); - Response.Headers.LastModified = fileInfo.LastWriteTimeUtc.ToString("R"); - - // // support custom ms range header, see https://learn.microsoft.com/en-us/rest/api/storageservices/get-blob?tabs=microsoft-entra-id#request - // if (Request.Headers.TryGetValue("x-ms-range", out var requestedRange)) - // Request.Headers.Range = requestedRange; - - var fileStream = new FileStream(fileInfo.FullName, FileMode.Open, FileAccess.Read, FileShare.Read); - return File(fileStream, contentType, enableRangeProcessing: true); } [HttpHead("/{container}/{*blob}")] public IActionResult GetBlobProperties([FromRoute, ContainerName] string container, [FromRoute, BlobName] string blob) { - var path = containerService.GetBlobPath(container, blob); - - if (!System.IO.File.Exists(path)) + try { - return NotFound($"Blob \"{container}/{blob}\" not found."); + var path = containerService.GetBlobPath(container, blob); + + if (!System.IO.File.Exists(path)) + { + return NotFound($"Blob \"{container}/{blob}\" not found."); + } + + var fileInfo = new FileInfo(path); + var contentTypeProvider = new FileExtensionContentTypeProvider(); + + var contentType = contentTypeProvider.TryGetContentType(fileInfo.FullName, out var detectedContentType) ? detectedContentType : "application/octet-stream"; + Response.Headers["x-ms-version"] = Constants.MsVersion; + Response.Headers["x-ms-blob-type"] = "BlockBlob"; + Response.Headers["x-ms-lease-status"] = "unlocked"; + Response.Headers["x-ms-lease-state"] = "available"; + Response.Headers["Content-Length"] = fileInfo.Length.ToString(CultureInfo.InvariantCulture); + Response.Headers["x-ms-server-encrypted"] = "false"; + Response.Headers.AcceptRanges = "bytes"; + Response.Headers.ContentType = contentType; + Response.Headers.ETag = containerService.GetBlobETag(container, blob); + Response.Headers.LastModified = fileInfo.LastWriteTimeUtc.ToString("R"); + return Ok(); + } + catch (DirectoryNotFoundException directoryNotFoundException) + { + return NotFound(directoryNotFoundException.Message); } - - var fileInfo = new FileInfo(path); - var contentTypeProvider = new FileExtensionContentTypeProvider(); - - var contentType = contentTypeProvider.TryGetContentType(fileInfo.FullName, out var detectedContentType) ? detectedContentType : "application/octet-stream"; - Response.Headers["x-ms-version"] = Constants.MsVersion; - Response.Headers["x-ms-blob-type"] = "BlockBlob"; - Response.Headers["x-ms-lease-status"] = "unlocked"; - Response.Headers["x-ms-lease-state"] = "available"; - Response.Headers["Content-Length"] = fileInfo.Length.ToString(CultureInfo.InvariantCulture); - Response.Headers["x-ms-server-encrypted"] = "false"; - Response.Headers.AcceptRanges = "bytes"; - Response.Headers.ContentType = contentType; - Response.Headers.ETag = containerService.GetBlobETag(container, blob); - Response.Headers.LastModified = fileInfo.LastWriteTimeUtc.ToString("R"); - return Ok(); } [HttpDelete("/{container}/{*blob}")] From b33acf164b5297dd3ffd181cb5aff1c2934904aa Mon Sep 17 00:00:00 2001 From: fh-adliance Date: Mon, 15 Jun 2026 11:56:09 +0200 Subject: [PATCH 5/7] Added traversal/path enumeration tests --- .../Controllers/BlobControllerTests.cs | 252 +++++++++++++++++- 1 file changed, 245 insertions(+), 7 deletions(-) diff --git a/test/Adliance.AzureBlobSimulator.Tests/Controllers/BlobControllerTests.cs b/test/Adliance.AzureBlobSimulator.Tests/Controllers/BlobControllerTests.cs index 57bebdb..40da1bd 100644 --- a/test/Adliance.AzureBlobSimulator.Tests/Controllers/BlobControllerTests.cs +++ b/test/Adliance.AzureBlobSimulator.Tests/Controllers/BlobControllerTests.cs @@ -1,4 +1,11 @@ +using System.Text; +using Adliance.AzureBlobSimulator.Controllers; +using Adliance.AzureBlobSimulator.Models; +using Adliance.AzureBlobSimulator.Services; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.Options; namespace Adliance.AzureBlobSimulator.Tests.Controllers; @@ -95,18 +102,249 @@ public async Task Will_Get_404_when_Getting_Properties_of_Blob_that_does_not_Exi } [Fact] - public async Task Will_Get_400_when_Getting_Blob_with_Invalid_Name() + public async Task Will_Get_400_when_Getting_Blob_in_Container_with_Invalid_Name() { - var containerClient = BlobServiceClient.GetBlobContainerClient("test-container"); - var blobClient = containerClient.GetBlobClient("test/-blob"); + var containerClient = BlobServiceClient.GetBlobContainerClient("_testcontainer"); + var blobClient = containerClient.GetBlobClient("test-blob"); await CustomAssert.RequestError(400, () => blobClient.GetPropertiesAsync()); } [Fact] - public async Task Will_Get_400_when_Getting_Blob_in_Container_with_Invalid_Name() + public async Task Can_Upload_Blob_Into_Subdirectory() { - var containerClient = BlobServiceClient.GetBlobContainerClient("_testcontainer"); - var blobClient = containerClient.GetBlobClient("test-blob"); - await CustomAssert.RequestError(400, () => blobClient.GetPropertiesAsync()); + const string containerName = "upload-test-container"; + const string blobName = "folder1/folder2/uploaded-file.txt"; + + Directory.CreateDirectory( + Path.Combine(TestStoragePath, containerName)); + + var blobClient = BlobServiceClient + .GetBlobContainerClient(containerName) + .GetBlobClient(blobName); + + await blobClient.UploadAsync( + BinaryData.FromString("Hello Subdirectory!")); + + var download = await blobClient.DownloadContentAsync(); + + Assert.Equal( + "Hello Subdirectory!", + download.Value.Content.ToString()); + + Assert.True(File.Exists( + Path.Combine( + TestStoragePath, + containerName, + "folder1", + "folder2", + "uploaded-file.txt"))); + } + + [Fact] + public async Task Can_Get_Blob_From_Subdirectory() + { + const string containerName = "test-container"; + const string blobName = "folder1/folder2/test.txt"; + + var filePath = Path.Combine( + TestStoragePath, + containerName, + "folder1", + "folder2", + "test.txt"); + + Directory.CreateDirectory( + Path.GetDirectoryName(filePath)!); + + await File.WriteAllTextAsync(filePath, "Hello World!"); + + var blobClient = BlobServiceClient + .GetBlobContainerClient(containerName) + .GetBlobClient(blobName); + + var response = await blobClient.DownloadContentAsync(); + + Assert.Equal( + "Hello World!", + response.Value.Content.ToString()); + } + + [Fact] + public async Task Can_Delete_Blob_From_Subdirectory() + { + const string containerName = "test-container"; + const string blobName = "folder1/folder2/test.txt"; + + var filePath = Path.Combine( + TestStoragePath, + containerName, + "folder1", + "folder2", + "test.txt"); + + Directory.CreateDirectory( + Path.GetDirectoryName(filePath)!); + + await File.WriteAllTextAsync(filePath, "Delete Me"); + + var blobClient = BlobServiceClient + .GetBlobContainerClient(containerName) + .GetBlobClient(blobName); + + await blobClient.DeleteAsync(); + + Assert.False(File.Exists(filePath)); + } + + [Theory] + [InlineData("../evil.txt")] + [InlineData("../../evil.txt")] + [InlineData("folder/../evil.txt")] + [InlineData(@"..\evil.txt")] + [InlineData(@"folder\..\evil.txt")] + public async Task WriteBlob_Should_Reject_Path_Traversal(string blobName) + { + const string containerName = "upload-test-container"; + var controller = CreateBlobController(containerName); + + var context = new DefaultHttpContext(); + context.Request.Headers["x-ms-blob-type"] = "BlockBlob"; + context.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes("evil")); + + controller.ControllerContext = new ControllerContext + { + HttpContext = context + }; + + try + { + await controller.WriteBlob(containerName, blobName); + } + catch (Exception ex) + { + var badRequest = Assert.IsType(ex); + Assert.Contains("Invalid blob segment.", badRequest.Message); + return; + } + + Assert.Fail("Should have thrown an InvalidOperationException with message containing 'Invalid blob segment.'"); + } + + [Theory] + [InlineData("../evil.txt")] + [InlineData("../../evil.txt")] + [InlineData("folder/../evil.txt")] + [InlineData(@"..\evil.txt")] + public void ReadBlob_Should_Reject_Path_Traversal(string blobName) + { + const string containerName = "test-container"; + var controller = CreateBlobController(containerName); + + try + { + controller.ReadBlob(containerName, blobName); + } + catch (Exception ex) + { + var badRequest = Assert.IsType(ex); + Assert.Contains("Invalid blob segment.", badRequest.Message); + return; + } + + Assert.Fail("Should have thrown InvalidOperationException with 'Invalid blob segment.'"); + } + + [Theory] + [InlineData("../evil.txt")] + [InlineData("../../evil.txt")] + [InlineData("folder/../evil.txt")] + [InlineData(@"..\evil.txt")] + public void GetProperties_Should_Reject_Path_Traversal(string blobName) + { + const string containerName = "test-container"; + var controller = CreateBlobController(containerName); + + try + { + controller.GetBlobProperties(containerName, blobName); + } + catch (Exception ex) + { + var badRequest = Assert.IsType(ex); + Assert.Contains("Invalid blob segment.", badRequest.Message); + return; + } + + Assert.Fail("Should have thrown InvalidOperationException with 'Invalid blob segment.'"); + } + + [Theory] + [InlineData("../evil.txt")] + [InlineData("../../evil.txt")] + [InlineData("folder/../evil.txt")] + [InlineData(@"..\evil.txt")] + public void Delete_Should_Reject_Path_Traversal(string blobName) + { + const string containerName = "test-container"; + var controller = CreateBlobController(containerName); + + try + { + controller.DeleteBlob(containerName, blobName); + } + catch (Exception ex) + { + var badRequest = Assert.IsType(ex); + Assert.Contains("Invalid blob segment.", badRequest.Message); + return; + } + + Assert.Fail("Should have thrown InvalidOperationException with 'Invalid blob segment.'"); + } + + [Fact] + public async Task Delete_Cannot_Delete_File_Outside_Container() + { + const string containerName = "test-container"; + + var outsideFile = Path.Combine( + TestStoragePath, + "outside.txt"); + + await File.WriteAllTextAsync( + outsideFile, + "secret"); + + var blobClient = BlobServiceClient + .GetBlobContainerClient(containerName) + .GetBlobClient("../outside.txt"); + + await CustomAssert.RequestError( + 400, + () => blobClient.DeleteAsync()); + + Assert.True(File.Exists(outsideFile)); + } + + private BlobController CreateBlobController(string containerName) + { + // We need to create the directory manually because container creation is not supported + var containerPath = Path.Combine(TestStoragePath, containerName); + Directory.CreateDirectory(containerPath); + + var containerService = new ContainerService(Options.Create(new StorageOptions + { + LocalPath = TestStoragePath, + Containers = + [ + new ContainerOptions + { + Name = containerName, + LocalPath = containerPath + } + ] + })); + + return new BlobController(containerService); } } From e2de208e204ecae6560295f43ec42ca15ea38c99 Mon Sep 17 00:00:00 2001 From: fh-adliance Date: Mon, 15 Jun 2026 11:56:56 +0200 Subject: [PATCH 6/7] Lint --- .../Controllers/VersionController.cs | 2 +- src/Adliance.AzureBlobSimulator/Models/BlobMetadata.cs | 2 +- .../Controllers/VersionControllerTests.cs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Adliance.AzureBlobSimulator/Controllers/VersionController.cs b/src/Adliance.AzureBlobSimulator/Controllers/VersionController.cs index a0a7c8a..6deddcd 100644 --- a/src/Adliance.AzureBlobSimulator/Controllers/VersionController.cs +++ b/src/Adliance.AzureBlobSimulator/Controllers/VersionController.cs @@ -1,4 +1,4 @@ -using System.Reflection; +using System.Reflection; using Microsoft.AspNetCore.Mvc; namespace Adliance.AzureBlobSimulator.Controllers; diff --git a/src/Adliance.AzureBlobSimulator/Models/BlobMetadata.cs b/src/Adliance.AzureBlobSimulator/Models/BlobMetadata.cs index 0d04cc3..4ef9aa0 100644 --- a/src/Adliance.AzureBlobSimulator/Models/BlobMetadata.cs +++ b/src/Adliance.AzureBlobSimulator/Models/BlobMetadata.cs @@ -1,4 +1,4 @@ -namespace Adliance.AzureBlobSimulator.Models; +namespace Adliance.AzureBlobSimulator.Models; public class BlobMetadata { diff --git a/test/Adliance.AzureBlobSimulator.Tests/Controllers/VersionControllerTests.cs b/test/Adliance.AzureBlobSimulator.Tests/Controllers/VersionControllerTests.cs index 13e25a5..fec7269 100644 --- a/test/Adliance.AzureBlobSimulator.Tests/Controllers/VersionControllerTests.cs +++ b/test/Adliance.AzureBlobSimulator.Tests/Controllers/VersionControllerTests.cs @@ -1,4 +1,4 @@ -using System.Net; +using System.Net; using Microsoft.AspNetCore.Mvc.Testing; namespace Adliance.AzureBlobSimulator.Tests.Controllers; From e3332fcd1b57a2c39d9ca6bdfb25bb9e85a43de2 Mon Sep 17 00:00:00 2001 From: fh-adliance Date: Mon, 15 Jun 2026 13:12:20 +0200 Subject: [PATCH 7/7] Reactivated Request.Headers.Range assignment in ReadBlob --- .../Controllers/BlobController.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Adliance.AzureBlobSimulator/Controllers/BlobController.cs b/src/Adliance.AzureBlobSimulator/Controllers/BlobController.cs index 2b429ae..058af93 100644 --- a/src/Adliance.AzureBlobSimulator/Controllers/BlobController.cs +++ b/src/Adliance.AzureBlobSimulator/Controllers/BlobController.cs @@ -74,8 +74,10 @@ public IActionResult ReadBlob([FromRoute, ContainerName] string container, [From Response.Headers.LastModified = fileInfo.LastWriteTimeUtc.ToString("R"); // // support custom ms range header, see https://learn.microsoft.com/en-us/rest/api/storageservices/get-blob?tabs=microsoft-entra-id#request - // if (Request.Headers.TryGetValue("x-ms-range", out var requestedRange)) - // Request.Headers.Range = requestedRange; + if (Request.Headers.TryGetValue("x-ms-range", out var requestedRange)) + { + Request.Headers.Range = requestedRange; + } var fileStream = new FileStream(fileInfo.FullName, FileMode.Open, FileAccess.Read, FileShare.Read); return File(fileStream, contentType, enableRangeProcessing: true);