diff --git a/MagicBytesValidator.Tests/DocXlsDisambiguationTests.cs b/MagicBytesValidator.Tests/DocXlsDisambiguationTests.cs new file mode 100644 index 0000000..aa1e2aa --- /dev/null +++ b/MagicBytesValidator.Tests/DocXlsDisambiguationTests.cs @@ -0,0 +1,103 @@ +namespace MagicBytesValidator.Tests; + +public class DocXlsDisambiguationTests +{ + private static readonly byte[] OleHeader = + [ + 0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1 + ]; + + [Fact] + public async Task XlsWorkbookStreamName_ShouldMatchXls_AndNotDoc() + { + var validator = new Validator(); + var xls = new Xls(); + var doc = new Doc(); + + using var stream = BuildOleLikeStreamWithUtf16Marker("Workbook"); + + var isXls = await validator.IsValidAsync(stream, xls, CancellationToken.None); + stream.Position = 0; + var isDoc = await validator.IsValidAsync(stream, doc, CancellationToken.None); + + Assert.True(isXls); + Assert.False(isDoc); + } + + [Fact] + public async Task DocWordDocumentStreamName_ShouldMatchDoc_AndNotXls() + { + var validator = new Validator(); + var doc = new Doc(); + var xls = new Xls(); + + using var stream = BuildOleLikeStreamWithUtf16Marker("WordDocument"); + + var isDoc = await validator.IsValidAsync(stream, doc, CancellationToken.None); + stream.Position = 0; + var isXls = await validator.IsValidAsync(stream, xls, CancellationToken.None); + + Assert.True(isDoc); + Assert.False(isXls); + } + + [Fact] + public async Task LegacyXlsBookStreamName_ShouldMatchXls() + { + var validator = new Validator(); + var xls = new Xls(); + + using var stream = BuildOleLikeStreamWithUtf16Marker("Book"); + + var isXls = await validator.IsValidAsync(stream, xls, CancellationToken.None); + + Assert.True(isXls); + } + + [Fact] + public async Task ClassicDocOffset512Marker_ShouldMatchDoc() + { + var validator = new Validator(); + var doc = new Doc(); + + using var stream = BuildOleLikeStreamWithOffset512Marker([0xEC, 0xA5, 0xC1, 0x00]); + + var isDoc = await validator.IsValidAsync(stream, doc, CancellationToken.None); + + Assert.True(isDoc); + } + + [Fact] + public async Task ClassicXlsOffset512Marker_ShouldMatchXls() + { + var validator = new Validator(); + var xls = new Xls(); + + using var stream = BuildOleLikeStreamWithOffset512Marker([0xFD, 0xFF, 0xFF, 0xFF, 0x24, 0x00]); + + var isXls = await validator.IsValidAsync(stream, xls, CancellationToken.None); + + Assert.True(isXls); + } + + private static MemoryStream BuildOleLikeStreamWithUtf16Marker(string marker) + { + var bytes = new byte[4096]; + var markerBytes = System.Text.Encoding.Unicode.GetBytes(marker); + + Array.Copy(OleHeader, 0, bytes, 0, OleHeader.Length); + Array.Copy(markerBytes, 0, bytes, 1536, markerBytes.Length); + + return new MemoryStream(bytes); + } + + private static MemoryStream BuildOleLikeStreamWithOffset512Marker(byte[] marker) + { + var bytes = new byte[2048]; + + Array.Copy(OleHeader, 0, bytes, 0, OleHeader.Length); + Array.Copy(marker, 0, bytes, 512, marker.Length); + + return new MemoryStream(bytes); + } +} \ No newline at end of file diff --git a/MagicBytesValidator.Tests/Http/FindValidatedTypeAsync.cs b/MagicBytesValidator.Tests/Http/FindValidatedTypeAsync.cs index c4b3cea..98e360b 100644 --- a/MagicBytesValidator.Tests/Http/FindValidatedTypeAsync.cs +++ b/MagicBytesValidator.Tests/Http/FindValidatedTypeAsync.cs @@ -2,6 +2,11 @@ public class FindValidatedTypeAsync { + private static readonly byte[] OleHeader = + [ + 0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1 + ]; + [Fact] public async Task Should_find_by_extension() { @@ -82,14 +87,120 @@ await sut.FindValidatedTypeAsync( ); } - private static IFormFile ProvideGifFile(string name, string contentType) + [Fact] + public async Task Should_validate_legacy_doc() { - byte[] gifSequence = [0x47, 0x49, 0x46, 0x38, 0x39, 0x61]; - var fileContents = gifSequence.Concat(new byte[] { 0x11, 0x12 }).ToArray(); - var fileStream = new MemoryStream(fileContents.ToArray()); + var formFile = ProvideFile("legacy.doc", "application/msword", BuildLegacyDocBytes()); + + var sut = new FormFileTypeProvider(); + + var result = await sut.FindValidatedTypeAsync( + formFile, + null, + CancellationToken.None + ); + + Assert.IsType(result); + } + + [Fact] + public async Task Should_validate_legacy_xls() + { + var formFile = ProvideFile("legacy.xls", "application/msexcel", BuildLegacyXlsBytes()); + + var sut = new FormFileTypeProvider(); + + var result = await sut.FindValidatedTypeAsync( + formFile, + null, + CancellationToken.None + ); + + Assert.IsType(result); + } + + [Fact] + public async Task Should_validate_legacy_ppt() + { + var formFile = ProvideFile("legacy.ppt", "application/vnd.ms-powerpoint", BuildLegacyPptBytes()); + + var sut = new FormFileTypeProvider(); + + var result = await sut.FindValidatedTypeAsync( + formFile, + null, + CancellationToken.None + ); + + Assert.IsType(result); + } + + [Fact] + public async Task Should_validate_modern_docx() + { + var formFile = ProvideFile( + "modern.docx", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + BuildOpenXmlBytes("word/_rels/document.xml.rels") + ); + + var sut = new FormFileTypeProvider(); + + var result = await sut.FindValidatedTypeAsync( + formFile, + null, + CancellationToken.None + ); + + Assert.IsType(result); + } + + [Fact] + public async Task Should_validate_modern_xlsx() + { + var formFile = ProvideFile( + "modern.xlsx", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + BuildOpenXmlBytes("xl/_rels/workbook.xml.rels") + ); + + var sut = new FormFileTypeProvider(); + + var result = await sut.FindValidatedTypeAsync( + formFile, + null, + CancellationToken.None + ); + + Assert.IsType(result); + } + + [Fact] + public async Task Should_validate_modern_pptx() + { + var formFile = ProvideFile( + "modern.pptx", + "application/vnd.openxmlformats-officedocument.presentationml.presentation", + BuildOpenXmlBytes("ppt/_rels/presentation.xml.rels") + ); + + var sut = new FormFileTypeProvider(); + + var result = await sut.FindValidatedTypeAsync( + formFile, + null, + CancellationToken.None + ); + + Assert.IsType(result); + } + + private static IFormFile ProvideFile(string name, string contentType, byte[] fileContents) + { + var fileStream = new MemoryStream(fileContents); return new FormFile( - new MemoryStream(fileContents.ToArray()), + new MemoryStream(fileContents), 0, fileStream.Length, name, @@ -102,4 +213,81 @@ private static IFormFile ProvideGifFile(string name, string contentType) } }; } + + private static IFormFile ProvideGifFile(string name, string contentType) + { + byte[] gifSequence = [0x47, 0x49, 0x46, 0x38, 0x39, 0x61]; + var fileContents = gifSequence.Concat(new byte[] { 0x11, 0x12 }).ToArray(); + + return ProvideFile(name, contentType, fileContents); + } + + private static byte[] BuildLegacyDocBytes() + { + var bytes = new byte[4096]; + var markerBytes = System.Text.Encoding.Unicode.GetBytes("WordDocument"); + + Array.Copy(OleHeader, 0, bytes, 0, OleHeader.Length); + Array.Copy(markerBytes, 0, bytes, 1536, markerBytes.Length); + + return bytes; + } + + private static byte[] BuildLegacyXlsBytes() + { + var bytes = new byte[4096]; + var markerBytes = System.Text.Encoding.Unicode.GetBytes("Workbook"); + + Array.Copy(OleHeader, 0, bytes, 0, OleHeader.Length); + Array.Copy(markerBytes, 0, bytes, 1536, markerBytes.Length); + + return bytes; + } + + private static byte[] BuildLegacyPptBytes() + { + var bytes = new byte[2048]; + var marker = new byte[] { 0xA0, 0x46, 0x1D, 0xF0 }; + + Array.Copy(OleHeader, 0, bytes, 0, OleHeader.Length); + Array.Copy(marker, 0, bytes, 512, marker.Length); + + return bytes; + } + + private static byte[] BuildOpenXmlBytes(string marker) + { + var bytes = BuildZipLikeBytesWithEocd(0x12345678); + var markerBytes = System.Text.Encoding.ASCII.GetBytes(marker); + + Array.Copy(markerBytes, 0, bytes, 64, markerBytes.Length); + + return bytes; + } + + private static byte[] BuildZipLikeBytesWithEocd(uint centralDirectoryOffset) + { + var bytes = new byte[512]; + + bytes[0] = 0x50; + bytes[1] = 0x4B; + bytes[2] = 0x03; + bytes[3] = 0x04; + + var eocdStart = bytes.Length - 22; + bytes[eocdStart + 0] = 0x50; + bytes[eocdStart + 1] = 0x4B; + bytes[eocdStart + 2] = 0x05; + bytes[eocdStart + 3] = 0x06; + + bytes[eocdStart + 16] = (byte)(centralDirectoryOffset & 0xFF); + bytes[eocdStart + 17] = (byte)((centralDirectoryOffset >> 8) & 0xFF); + bytes[eocdStart + 18] = (byte)((centralDirectoryOffset >> 16) & 0xFF); + bytes[eocdStart + 19] = (byte)((centralDirectoryOffset >> 24) & 0xFF); + + bytes[eocdStart + 20] = 0x00; + bytes[eocdStart + 21] = 0x00; + + return bytes; + } } \ No newline at end of file diff --git a/MagicBytesValidator.Tests/OfficeLegacyAndModernRecognitionTests.cs b/MagicBytesValidator.Tests/OfficeLegacyAndModernRecognitionTests.cs new file mode 100644 index 0000000..6cdb4cd --- /dev/null +++ b/MagicBytesValidator.Tests/OfficeLegacyAndModernRecognitionTests.cs @@ -0,0 +1,169 @@ +namespace MagicBytesValidator.Tests; + +public class OfficeLegacyAndModernRecognitionTests +{ + private static readonly byte[] OleHeader = + [ + 0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1 + ]; + + [Fact] + public async Task LegacyDocOle_ShouldMatchDoc_AndNotDocx() + { + var validator = new Validator(); + var doc = new Doc(); + var docx = new Docx(); + + using var stream = BuildOleLikeStreamWithUtf16Marker("WordDocument"); + + var isDoc = await validator.IsValidAsync(stream, doc, CancellationToken.None); + stream.Position = 0; + var isDocx = await validator.IsValidAsync(stream, docx, CancellationToken.None); + + Assert.True(isDoc); + Assert.False(isDocx); + } + + [Fact] + public async Task LegacyXlsOle_ShouldMatchXls_AndNotXlsx() + { + var validator = new Validator(); + var xls = new Xls(); + var xlsx = new Xlsx(); + + using var stream = BuildOleLikeStreamWithUtf16Marker("Workbook"); + + var isXls = await validator.IsValidAsync(stream, xls, CancellationToken.None); + stream.Position = 0; + var isXlsx = await validator.IsValidAsync(stream, xlsx, CancellationToken.None); + + Assert.True(isXls); + Assert.False(isXlsx); + } + + [Fact] + public async Task LegacyPptOle_ShouldMatchPpt_AndNotPptx() + { + var validator = new Validator(); + var ppt = new Ppt(); + var pptx = new Pptx(); + + using var stream = BuildOleLikeStreamWithOffset512Marker([0xA0, 0x46, 0x1D, 0xF0]); + + var isPpt = await validator.IsValidAsync(stream, ppt, CancellationToken.None); + stream.Position = 0; + var isPptx = await validator.IsValidAsync(stream, pptx, CancellationToken.None); + + Assert.True(isPpt); + Assert.False(isPptx); + } + + [Fact] + public async Task ModernDocxZip_ShouldMatchDocx_AndNotDoc() + { + var validator = new Validator(); + var docx = new Docx(); + var doc = new Doc(); + + using var stream = BuildOfficeOpenXmlLikeZipStream("word/_rels/document.xml.rels"); + + var isDocx = await validator.IsValidAsync(stream, docx, CancellationToken.None); + stream.Position = 0; + var isDoc = await validator.IsValidAsync(stream, doc, CancellationToken.None); + + Assert.True(isDocx); + Assert.False(isDoc); + } + + [Fact] + public async Task ModernXlsxZip_ShouldMatchXlsx_AndNotXls() + { + var validator = new Validator(); + var xlsx = new Xlsx(); + var xls = new Xls(); + + using var stream = BuildOfficeOpenXmlLikeZipStream("xl/_rels/workbook.xml.rels"); + + var isXlsx = await validator.IsValidAsync(stream, xlsx, CancellationToken.None); + stream.Position = 0; + var isXls = await validator.IsValidAsync(stream, xls, CancellationToken.None); + + Assert.True(isXlsx); + Assert.False(isXls); + } + + [Fact] + public async Task ModernPptxZip_ShouldMatchPptx_AndNotPpt() + { + var validator = new Validator(); + var pptx = new Pptx(); + var ppt = new Ppt(); + + using var stream = BuildOfficeOpenXmlLikeZipStream("ppt/_rels/presentation.xml.rels"); + + var isPptx = await validator.IsValidAsync(stream, pptx, CancellationToken.None); + stream.Position = 0; + var isPpt = await validator.IsValidAsync(stream, ppt, CancellationToken.None); + + Assert.True(isPptx); + Assert.False(isPpt); + } + + private static MemoryStream BuildOleLikeStreamWithUtf16Marker(string marker) + { + var bytes = new byte[4096]; + var markerBytes = System.Text.Encoding.Unicode.GetBytes(marker); + + Array.Copy(OleHeader, 0, bytes, 0, OleHeader.Length); + Array.Copy(markerBytes, 0, bytes, 1536, markerBytes.Length); + + return new MemoryStream(bytes); + } + + private static MemoryStream BuildOleLikeStreamWithOffset512Marker(byte[] marker) + { + var bytes = new byte[2048]; + + Array.Copy(OleHeader, 0, bytes, 0, OleHeader.Length); + Array.Copy(marker, 0, bytes, 512, marker.Length); + + return new MemoryStream(bytes); + } + + private static MemoryStream BuildOfficeOpenXmlLikeZipStream(string marker) + { + var bytes = BuildZipLikeBytesWithEocd(0x12345678).ToArray(); + var markerBytes = System.Text.Encoding.ASCII.GetBytes(marker); + + Array.Copy(markerBytes, 0, bytes, 64, markerBytes.Length); + + return new MemoryStream(bytes); + } + + private static MemoryStream BuildZipLikeBytesWithEocd(uint centralDirectoryOffset) + { + var bytes = new byte[512]; + + bytes[0] = 0x50; + bytes[1] = 0x4B; + bytes[2] = 0x03; + bytes[3] = 0x04; + + var eocdStart = bytes.Length - 22; + bytes[eocdStart + 0] = 0x50; + bytes[eocdStart + 1] = 0x4B; + bytes[eocdStart + 2] = 0x05; + bytes[eocdStart + 3] = 0x06; + + bytes[eocdStart + 16] = (byte)(centralDirectoryOffset & 0xFF); + bytes[eocdStart + 17] = (byte)((centralDirectoryOffset >> 8) & 0xFF); + bytes[eocdStart + 18] = (byte)((centralDirectoryOffset >> 16) & 0xFF); + bytes[eocdStart + 19] = (byte)((centralDirectoryOffset >> 24) & 0xFF); + + bytes[eocdStart + 20] = 0x00; + bytes[eocdStart + 21] = 0x00; + + return new MemoryStream(bytes); + } +} + diff --git a/MagicBytesValidator/Formats/Doc.cs b/MagicBytesValidator/Formats/Doc.cs index 240b93e..3257776 100644 --- a/MagicBytesValidator/Formats/Doc.cs +++ b/MagicBytesValidator/Formats/Doc.cs @@ -1,16 +1,28 @@ namespace MagicBytesValidator.Formats; -// TODO: Add sub header check (512 byte offset: EC A5 C1 00) - /// /// public class Doc : FileByteFilter { + private static readonly byte?[] WordDocumentStreamName = ToUtf16LePattern("WordDocument"); + public Doc() : base( ["application/msword"], ["doc", "dot"] ) { - StartsWith([0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1]); + StartsWith([0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1]) + .AnywhereAnyOf([ + [0xEC, 0xA5, 0xC1, 0x00], + WordDocumentStreamName + ]); + } + + private static byte?[] ToUtf16LePattern(string value) + { + return System.Text.Encoding.Unicode + .GetBytes(value) + .Select(static currentByte => (byte?)currentByte) + .ToArray(); } } \ No newline at end of file diff --git a/MagicBytesValidator/Formats/Xls.cs b/MagicBytesValidator/Formats/Xls.cs index 53aa35e..3127aea 100644 --- a/MagicBytesValidator/Formats/Xls.cs +++ b/MagicBytesValidator/Formats/Xls.cs @@ -4,17 +4,30 @@ namespace MagicBytesValidator.Formats; /// public class Xls : FileByteFilter { + private static readonly byte?[] WorkbookStreamName = ToUtf16LePattern("Workbook"); + private static readonly byte?[] BookStreamName = ToUtf16LePattern("Book"); + public Xls() : base( ["application/msexcel"], ["xls", "xla"] ) { StartsWith([0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1]) - .SpecificAnyOf([ - new ByteCheck(512, [0xFD, 0xFF, 0xFF, 0xFF, null, 0x00]), - new ByteCheck(512, [0xFD, 0xFF, 0xFF, 0xFF, null, 0x02]), - new ByteCheck(512, [0xFD, 0xFF, 0xFF, 0xFF, 0x20, 0x00, 0x00, 0x00]), - new ByteCheck(512, [0x09, 0x08, 0x10, 0x00, 0x00, 0x06, 0x05, 0x00]) + .AnywhereAnyOf([ + [0xFD, 0xFF, 0xFF, 0xFF, null, 0x00], + [0xFD, 0xFF, 0xFF, 0xFF, null, 0x02], + [0xFD, 0xFF, 0xFF, 0xFF, 0x20, 0x00, 0x00, 0x00], + [0x09, 0x08, 0x10, 0x00, 0x00, 0x06, 0x05, 0x00], + WorkbookStreamName, + BookStreamName ]); } + + private static byte?[] ToUtf16LePattern(string value) + { + return System.Text.Encoding.Unicode + .GetBytes(value) + .Select(static currentByte => (byte?)currentByte) + .ToArray(); + } } \ No newline at end of file diff --git a/MagicBytesValidator/Models/FileByteFilter.cs b/MagicBytesValidator/Models/FileByteFilter.cs index f99ace6..1c0bedf 100644 --- a/MagicBytesValidator/Models/FileByteFilter.cs +++ b/MagicBytesValidator/Models/FileByteFilter.cs @@ -44,18 +44,21 @@ private sealed class FileByteCheck public List Needed { get; } = []; public List AnyOf { get; } = []; public List Anywhere { get; } = []; + public List AnywhereAnyOf { get; } = []; public List TailContains { get; } = []; /* A file matches only if: - every Needed check matches at its fixed offset, - for each AnyOf-group at least one alternative matches, - every Anywhere-pattern occurs somewhere in the stream (null bytes act as wildcards), + - for each AnywhereAnyOf-group at least one anywhere-pattern occurs in the stream, - every TailContains check finds its pattern within the last bytes. */ public bool Matches(byte[] fileByteStream) { return Needed.All(check => CheckBytes(check, fileByteStream)) && AnyOf.All(group => group.Any(check => CheckBytes(check, fileByteStream))) && Anywhere.All(pattern => ContainsPatternAnywhere(pattern, fileByteStream)) + && AnywhereAnyOf.All(group => group.Any(pattern => ContainsPatternAnywhere(pattern, fileByteStream))) && TailContains.All(check => CheckTailContains(check, fileByteStream)); } } @@ -141,6 +144,21 @@ public FileByteFilter Anywhere( return this; } + public FileByteFilter AnywhereAnyOf( + byte?[][] bytesToCheck, + FileByteType? type = null) + { + ArgumentNullException.ThrowIfNull(bytesToCheck); + + if (!bytesToCheck.Any()) + { + throw new ArgumentEmptyException($"{nameof(bytesToCheck)} cannot be null or empty"); + } + + GetChecksByType(type).AnywhereAnyOf.Add(bytesToCheck); + return this; + } + public FileByteFilter Specific( ByteCheck bytesToCheck, FileByteType? type = null)