diff --git a/BTCPayServer.Tests/FastTests.cs b/BTCPayServer.Tests/FastTests.cs index b1aaab609e..030d493125 100644 --- a/BTCPayServer.Tests/FastTests.cs +++ b/BTCPayServer.Tests/FastTests.cs @@ -581,6 +581,22 @@ private DateTimeOffset Date(int days) return new DateTimeOffset(1970, 1, 1, 0, 0, 0, TimeSpan.Zero) + TimeSpan.FromDays(days); } + [Fact] + public void CanDetectImage() + { + Assert.True(FileTypeDetector.IsPicture(new byte[] { 0x42, 0x4D }, "test.bmp")); + Assert.False(FileTypeDetector.IsPicture(new byte[] { 0x42, 0x4D }, ".bmp")); + Assert.False(FileTypeDetector.IsPicture(new byte[] { 0x42, 0x4D }, "test.svg")); + Assert.True(FileTypeDetector.IsPicture(new byte[] { 0xFF, 0xD8, 0xFF, 0xD9 }, "test.jpg")); + Assert.True(FileTypeDetector.IsPicture(new byte[] { 0xFF, 0xD8, 0xFF, 0xD9 }, "test.jpeg")); + Assert.False(FileTypeDetector.IsPicture(new byte[] { 0xFF, 0xD8, 0xFF, 0xDA }, "test.jpg")); + Assert.False(FileTypeDetector.IsPicture(new byte[] { 0xFF, 0xD8, 0xFF }, "test.jpg")); + Assert.True(FileTypeDetector.IsPicture(new byte[] { 0x3C, 0x73, 0x76, 0x67 }, "test.svg")); + Assert.False(FileTypeDetector.IsPicture(new byte[] { 0x3C, 0x73, 0x76, 0x67 }, "test.jpg")); + Assert.False(FileTypeDetector.IsPicture(new byte[] { 0xFF }, "e.jpg")); + Assert.False(FileTypeDetector.IsPicture(new byte[] { }, "empty.jpg")); + } + [Fact] public void RoundupCurrenciesCorrectly() { diff --git a/BTCPayServer/BufferizedFormFile.cs b/BTCPayServer/BufferizedFormFile.cs new file mode 100644 index 0000000000..a6848993a5 --- /dev/null +++ b/BTCPayServer/BufferizedFormFile.cs @@ -0,0 +1,62 @@ +using Microsoft.AspNetCore.Http; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace BTCPayServer +{ + public class BufferizedFormFile : IFormFile + { + private IFormFile _formFile; + private MemoryStream _content; + public byte[] Buffer { get; } + BufferizedFormFile(IFormFile formFile, byte[] content) + { + _formFile = formFile; + Buffer = content; + _content = new MemoryStream(content); + } + + public string ContentType => _formFile.ContentType; + + public string ContentDisposition => _formFile.ContentDisposition; + + public IHeaderDictionary Headers => _formFile.Headers; + + public long Length => _formFile.Length; + + public string Name => _formFile.Name; + + public string FileName => _formFile.FileName; + + public static async Task Bufferize(IFormFile formFile) + { + if (formFile is BufferizedFormFile b) + return b; + var content = new byte[formFile.Length]; + using var fs = formFile.OpenReadStream(); + await fs.ReadAsync(content, 0, content.Length); + return new BufferizedFormFile(formFile, content); + } + + public void CopyTo(Stream target) + { + _content.CopyTo(target); + } + + public Task CopyToAsync(Stream target, CancellationToken cancellationToken = default) + { + return _content.CopyToAsync(target, cancellationToken); + } + + public Stream OpenReadStream() + { + return _content; + } + + public void Rewind() + { + _content.Seek(0, SeekOrigin.Begin); + } + } +} diff --git a/BTCPayServer/Controllers/UIServerController.cs b/BTCPayServer/Controllers/UIServerController.cs index 002f3c61a3..72d3ae3272 100644 --- a/BTCPayServer/Controllers/UIServerController.cs +++ b/BTCPayServer/Controllers/UIServerController.cs @@ -1043,30 +1043,43 @@ public async Task Theme() if (model.LogoFile != null) { - if (model.LogoFile.ContentType.StartsWith("image/", StringComparison.InvariantCulture)) + if (model.LogoFile.Length > 1_000_000) { - // delete existing image - if (!string.IsNullOrEmpty(settings.LogoFileId)) - { - await _fileService.RemoveFile(settings.LogoFileId, userId); - } - - // add new image - try + TempData[WellKnownTempData.ErrorMessage] = "The uploaded logo file should be less than 1MB"; + } + else if (!model.LogoFile.ContentType.StartsWith("image/", StringComparison.InvariantCulture)) + { + TempData[WellKnownTempData.ErrorMessage] = "The uploaded logo file needs to be an image"; + } + else + { + var formFile = await model.LogoFile.Bufferize(); + if (!FileTypeDetector.IsPicture(formFile.Buffer, formFile.FileName)) { - var storedFile = await _fileService.AddFile(model.LogoFile, userId); - settings.LogoFileId = storedFile.Id; - settingsChanged = true; + TempData[WellKnownTempData.ErrorMessage] = "The uploaded logo file needs to be an image"; } - catch (Exception e) + else { - ModelState.AddModelError(nameof(settings.LogoFile), $"Could not save logo: {e.Message}"); + model.LogoFile = formFile; + // delete existing image + if (!string.IsNullOrEmpty(settings.LogoFileId)) + { + await _fileService.RemoveFile(settings.LogoFileId, userId); + } + + // add new image + try + { + var storedFile = await _fileService.AddFile(model.LogoFile, userId); + settings.LogoFileId = storedFile.Id; + settingsChanged = true; + } + catch (Exception e) + { + ModelState.AddModelError(nameof(settings.LogoFile), $"Could not save logo: {e.Message}"); + } } } - else - { - ModelState.AddModelError(nameof(settings.LogoFile), "The uploaded logo file needs to be an image"); - } } else if (RemoveLogoFile && !string.IsNullOrEmpty(settings.LogoFileId)) { diff --git a/BTCPayServer/Controllers/UIStoresController.cs b/BTCPayServer/Controllers/UIStoresController.cs index ec9e4dca58..8fc88d6e67 100644 --- a/BTCPayServer/Controllers/UIStoresController.cs +++ b/BTCPayServer/Controllers/UIStoresController.cs @@ -27,6 +27,7 @@ using BTCPayServer.Services.Wallets; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Rendering; @@ -658,29 +659,42 @@ public IActionResult GeneralSettings() if (model.LogoFile != null) { - if (model.LogoFile.ContentType.StartsWith("image/", StringComparison.InvariantCulture)) + if (model.LogoFile.Length > 1_000_000) { - // delete existing image - if (!string.IsNullOrEmpty(blob.LogoFileId)) - { - await _fileService.RemoveFile(blob.LogoFileId, userId); - } - - // add new image - try + TempData[WellKnownTempData.ErrorMessage] = "The uploaded logo file should be less than 1MB"; + } + else if (!model.LogoFile.ContentType.StartsWith("image/", StringComparison.InvariantCulture)) + { + TempData[WellKnownTempData.ErrorMessage] = "The uploaded logo file needs to be an image"; + } + else + { + var formFile = await model.LogoFile.Bufferize(); + if (!FileTypeDetector.IsPicture(formFile.Buffer, formFile.FileName)) { - var storedFile = await _fileService.AddFile(model.LogoFile, userId); - blob.LogoFileId = storedFile.Id; + TempData[WellKnownTempData.ErrorMessage] = "The uploaded logo file needs to be an image"; } - catch (Exception e) + else { - TempData[WellKnownTempData.ErrorMessage] = $"Could not save logo: {e.Message}"; + model.LogoFile = formFile; + // delete existing image + if (!string.IsNullOrEmpty(blob.LogoFileId)) + { + await _fileService.RemoveFile(blob.LogoFileId, userId); + } + + // add new image + try + { + var storedFile = await _fileService.AddFile(model.LogoFile, userId); + blob.LogoFileId = storedFile.Id; + } + catch (Exception e) + { + TempData[WellKnownTempData.ErrorMessage] = $"Could not save logo: {e.Message}"; + } } } - else - { - TempData[WellKnownTempData.ErrorMessage] = "The uploaded logo file needs to be an image"; - } } else if (RemoveLogoFile && !string.IsNullOrEmpty(blob.LogoFileId)) { @@ -691,7 +705,19 @@ public IActionResult GeneralSettings() if (model.CssFile != null) { - if (model.CssFile.ContentType.Equals("text/css", StringComparison.InvariantCulture)) + if (model.CssFile.Length > 1_000_000) + { + TempData[WellKnownTempData.ErrorMessage] = "The uploaded file should be less than 1MB"; + } + else if (!model.CssFile.ContentType.Equals("text/css", StringComparison.InvariantCulture)) + { + TempData[WellKnownTempData.ErrorMessage] = "The uploaded file needs to be a CSS file"; + } + else if (!model.CssFile.FileName.EndsWith(".css", StringComparison.OrdinalIgnoreCase)) + { + TempData[WellKnownTempData.ErrorMessage] = "The uploaded file needs to be a CSS file"; + } + else { // delete existing CSS file if (!string.IsNullOrEmpty(blob.CssFileId)) @@ -710,10 +736,6 @@ public IActionResult GeneralSettings() TempData[WellKnownTempData.ErrorMessage] = $"Could not save CSS file: {e.Message}"; } } - else - { - TempData[WellKnownTempData.ErrorMessage] = "The uploaded file needs to be a CSS file"; - } } else if (RemoveCssFile && !string.IsNullOrEmpty(blob.CssFileId)) { diff --git a/BTCPayServer/Extensions.cs b/BTCPayServer/Extensions.cs index 8789ca0bef..724bdae687 100644 --- a/BTCPayServer/Extensions.cs +++ b/BTCPayServer/Extensions.cs @@ -36,6 +36,10 @@ namespace BTCPayServer { public static class Extensions { + public static Task Bufferize(this IFormFile formFile) + { + return BufferizedFormFile.Bufferize(formFile); + } /// /// Unescape Uri string for %2F /// See details at: https://github.com/dotnet/aspnetcore/issues/14170#issuecomment-533342396 diff --git a/BTCPayServer/FileTypeDetector.cs b/BTCPayServer/FileTypeDetector.cs new file mode 100644 index 0000000000..4dff3934af --- /dev/null +++ b/BTCPayServer/FileTypeDetector.cs @@ -0,0 +1,92 @@ +#nullable enable +using System; +using System.Linq; +using Microsoft.EntityFrameworkCore; +using NBitcoin.DataEncoders; + +namespace BTCPayServer +{ + public class FileTypeDetector + { + // Thanks to https://www.garykessler.net/software/FileSigs_20220731.zip + + const string pictureSigs = + "JPEG2000 image files,00 00 00 0C 6A 50 20 20,JP2,Picture,0,(null)\n" + + "Bitmap image,42 4D,BMP|DIB,Picture,0,(null)\n" + + "GIF file,47 49 46 38,GIF,Picture,0,00 3B\n" + + "PNG image,89 50 4E 47 0D 0A 1A 0A,PNG|APNG,Picture,0,49 45 4E 44 AE 42 60 82\n" + + "Generic JPEGimage fil,FF D8,JPE|JPEG|JPG,Picture,0,FF D9\n" + + "JPEG-EXIF-SPIFF images,FF D8 FF,JFIF|JPE|JPEG|JPG,Picture,0,FF D9\n" + + "SVG images, 3C 73 76 67,SVG,Picture,0,(null)\n" + + "Google WebP image file, 52 49 46 46 XX XX XX XX 57 45 42 50,WEBP,Picture,0,(null)\n" + + "AVIF image file, XX XX XX XX 66 74 79 70,AVIF,Picture,0,(null)\n"; + + readonly static (int[] Header, int[]? Trailer, string[] Extensions)[] headerTrailers; + static FileTypeDetector() + { + var lines = pictureSigs.Split('\n', StringSplitOptions.RemoveEmptyEntries); + headerTrailers = new (int[] Header, int[]? Trailer, string[] Extensions)[lines.Length]; + for (int i = 0; i < lines.Length; i++) + { + var cells = lines[i].Split(','); + headerTrailers[i] = ( + DecodeData(cells[1]), + cells[^1] == "(null)" ? null : DecodeData(cells[^1]), + cells[2].Split('|').Select(p => $".{p}").ToArray() + ); + } + } + + private static int[] DecodeData(string pattern) + { + pattern = pattern.Replace(" ", ""); + int[] res = new int[pattern.Length / 2]; + for (int i = 0; i < pattern.Length; i+=2) + { + var b = pattern[i..(i + 2)]; + if (b == "XX") + res[i/2] = -1; + else + res[i/2] = byte.Parse(b, System.Globalization.NumberStyles.HexNumber); + } + return res; + } + + public static bool IsPicture(byte[] bytes, string? filename) + { + for (int i = 0; i < headerTrailers.Length; i++) + { + if (headerTrailers[i].Header is int[] header) + { + if (header.Length > bytes.Length) + goto next; + for (int x = 0; x < header.Length; x++) + { + if (bytes[x] != header[x] && header[x] != -1) + goto next; + } + } + if (headerTrailers[i].Trailer is int[] trailer) + { + if (trailer.Length > bytes.Length) + goto next; + for (int x = 0; x < trailer.Length; x++) + { + if (bytes[^(trailer.Length - x)] != trailer[x] && trailer[x] != -1) + goto next; + } + } + + if (filename is not null) + { + if (!headerTrailers[i].Extensions.Any(ext => filename.Length > ext.Length && filename.EndsWith(ext, StringComparison.OrdinalIgnoreCase))) + return false; + } + return true; +next: + ; + } + return false; + } + } +} diff --git a/BTCPayServer/Storage/StorageExtensions.cs b/BTCPayServer/Storage/StorageExtensions.cs index be3259fda0..8f2141334a 100644 --- a/BTCPayServer/Storage/StorageExtensions.cs +++ b/BTCPayServer/Storage/StorageExtensions.cs @@ -76,6 +76,7 @@ private static Action HandleStaticFileResponse() context.Context.Response.Headers["Content-Disposition"] = "attachment"; } context.Context.Response.Headers["Content-Security-Policy"] = "script-src ;"; + context.Context.Response.Headers["X-Content-Type-Options"] = "nosniff"; }; } }