From bd43f1b6fc48647b91f9322280c07d742c34d2a9 Mon Sep 17 00:00:00 2001 From: detilium Date: Mon, 1 Dec 2025 13:47:12 +0100 Subject: [PATCH 01/30] bump dev version [skip ci] --- Directory.Version.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Version.props b/Directory.Version.props index faedbe2..a715999 100644 --- a/Directory.Version.props +++ b/Directory.Version.props @@ -1,5 +1,5 @@ - 1.1.0 + 1.1.1 \ No newline at end of file From 35acf296f59a9d8c46f75361a5d6e4feb7e830e5 Mon Sep 17 00:00:00 2001 From: Christian Haase Date: Fri, 19 Dec 2025 10:28:01 +0100 Subject: [PATCH 02/30] Use Stream in favour of byte array for optimised performance and memory usage --- src/ByteGuard.FileValidator/FileValidator.cs | 297 ++++++++++-------- .../Validators/PdfValidator.cs | 2 +- 2 files changed, 165 insertions(+), 134 deletions(-) diff --git a/src/ByteGuard.FileValidator/FileValidator.cs b/src/ByteGuard.FileValidator/FileValidator.cs index 1618896..ea32deb 100644 --- a/src/ByteGuard.FileValidator/FileValidator.cs +++ b/src/ByteGuard.FileValidator/FileValidator.cs @@ -282,15 +282,15 @@ public List GetSupportedFileTypes() } /// - /// Whether the given file is valid based on its type, signature, size, and potentially its internal structure (for Open XML documents). + /// Whether the given file is valid based on all parameters. /// /// File name including extension (e.g. my-file.jpg). - /// Byte array content of the file. - /// true if supported file type, valid signature, and valid Open XML (if the file type is expected to be an Open XML file), false otherwise, unless is enabled. + /// Stream content of the file. + /// true if valid based on all parameters, false otherwise, unless is enabled. /// Thrown if the file type is not supported and is enabled. /// Thrown if the file does not adhere to the expected file signature and is enabled. /// Thrown if the internal ZIP-archive structure does not adhere to the expected Open XML structure of the given file type and is enabled. - public bool IsValidFile(string fileName, byte[] content) + public bool IsValidFile(string fileName, Stream stream) { // Validate file type. if (!IsValidFileType(fileName)) @@ -304,7 +304,7 @@ public bool IsValidFile(string fileName, byte[] content) } // Validate file size. - if (!HasValidSize(content)) + if (!HasValidSize(stream)) { if (_configuration.ThrowExceptionOnInvalidFile) { @@ -315,7 +315,7 @@ public bool IsValidFile(string fileName, byte[] content) } // Validate file signature. - if (!HasValidSignature(fileName, content)) + if (!HasValidSignature(fileName, stream)) { if (_configuration.ThrowExceptionOnInvalidFile) { @@ -326,7 +326,7 @@ public bool IsValidFile(string fileName, byte[] content) } // Validate Open XML conformance for specific file types. - if (IsOpenXmlFormat(fileName) && !IsValidOpenXmlDocument(fileName, content)) + if (IsOpenXmlFormat(fileName) && !IsValidOpenXmlDocument(fileName, stream)) { if (_configuration.ThrowExceptionOnInvalidFile) { @@ -337,7 +337,7 @@ public bool IsValidFile(string fileName, byte[] content) } // Validate Open Document Format (ODF) for specific file types. - if (IsOpenDocumentFormat(fileName) && !IsValidOpenDocumentFormat(fileName, content)) + if (IsOpenDocumentFormat(fileName) && !IsValidOpenDocumentFormat(fileName, stream)) { if (_configuration.ThrowExceptionOnInvalidFile) { @@ -350,7 +350,7 @@ public bool IsValidFile(string fileName, byte[] content) // Validate antimalware scan if configured. if (_antimalwareScanner != null) { - var isClean = IsMalwareClean(fileName, content); + var isClean = IsMalwareClean(fileName, stream); if (!isClean) { if (_configuration.ThrowExceptionOnInvalidFile) @@ -366,32 +366,27 @@ public bool IsValidFile(string fileName, byte[] content) } /// - /// Whether the given file is valid based on its type, signature, and potentially its internal structure (for Open XML documents). + /// Whether the given file is valid based on all parameters. /// /// File name including extension (e.g. my-file.jpg). - /// Stream content of the file. - /// true if supported file type, valid signature, and valid Open XML (if the file type is expected to be an Open XML file), false otherwise, unless is enabled. + /// Byte array content of the file. + /// true if valid based on all parameters, false otherwise, unless is enabled. /// Thrown if the file type is not supported and is enabled. /// Thrown if the file does not adhere to the expected file signature and is enabled. /// Thrown if the internal ZIP-archive structure does not adhere to the expected Open XML structure of the given file type and is enabled. - public bool IsValidFile(string fileName, Stream stream) + public bool IsValidFile(string fileName, byte[] content) { - using (var memoryStream = new MemoryStream()) + using (var stream = new MemoryStream(content)) { - stream.CopyTo(memoryStream); - - memoryStream.Position = 0; - var content = memoryStream.ToArray(); - - return IsValidFile(fileName, content); + return IsValidFile(fileName, stream); } } /// - /// Whether the given file is valid based on its type, signature, and potentially its internal structure (for Open XML documents). + /// Whether the given file is valid based on all parameters. /// /// Full path to the file including filename and extension (e.g. C:\temp\my-file.jpg). - /// true if supported file type, valid signature, and valid Open XML (if the file type is expected to be an Open XML file), false otherwise, unless is enabled. + /// true if valid based on all parameters, false otherwise, unless is enabled. /// Thrown if the is null or whitespace. /// Thrown if the file type is not supported and is enabled. /// Thrown if the file does not adhere to the expected file signature and is enabled. @@ -448,18 +443,17 @@ public bool IsValidFileType(string fileName) /// /// WARNING: This does not check if the file type is supported according to the configuration of the FileValidator, /// but only validates if the provided file is adhering to the expected signature for its file type. - /// To completely validate the file based on all parameters, please use . + /// To completely validate the file based on all parameters, please use . /// File signatures (also known as "magic numbers") are specific sequences of bytes at the beginning of a file that indicate its format. /// Various file header signatures are sourced from Wikipedia. /// /// File name including extension (e.g. my-file.jpg). - /// Byte array content of the file. + /// Stream content of the file. /// true if the file signature matches one of the expected signatures for the file type, false otherwise, unless is enabled. - /// Thrown if the file name is null, empty, or whitespace, or if the byte content is null. - /// Thrown if unable to deduct file type (extension) from the given file name. /// Thrown if the file type is not supported and is enabled. /// Thrown if the file does not adhere to the expected file signature and is enabled. - public bool HasValidSignature(string fileName, byte[] content) + /// Thrown if the stream is not readable or seekable. + public bool HasValidSignature(string fileName, Stream stream) { if (string.IsNullOrWhiteSpace(fileName)) { @@ -473,13 +467,24 @@ public bool HasValidSignature(string fileName, byte[] content) throw new ArgumentException("Unable to deduct file type (extension) based on the file name.", nameof(fileName)); } - if (content is null || content.Length == 0) + if (stream is null || stream.Length == 0) { - throw new ArgumentNullException(nameof(content), "File content cannot be null or empty when validating file signature."); + throw new ArgumentNullException(nameof(stream), "Stream cannot be null or empty when validating file signature."); + } + + if (!stream.CanRead) + { + throw new InvalidOperationException("Stream is not readable."); + } + + if (!stream.CanSeek) + { + throw new InvalidOperationException("Stream is not seekable."); } var fileDefinition = SupportedFileDefinitions.FirstOrDefault(fd => fd.FileType.Equals(extension, StringComparison.InvariantCultureIgnoreCase)); + if (fileDefinition == null) { if (_configuration.ThrowExceptionOnInvalidFile) @@ -495,7 +500,7 @@ public bool HasValidSignature(string fileName, byte[] content) // for this purpose. if (fileDefinition.FileType.Equals(FileExtensions.Pdf)) { - using (var pdfValidator = new PdfValidator(content)) + using (var pdfValidator = new PdfValidator(stream)) { var isValidPdf = pdfValidator.IsValidPdfSignature(); if (!isValidPdf && _configuration.ThrowExceptionOnInvalidFile) @@ -508,11 +513,10 @@ public bool HasValidSignature(string fileName, byte[] content) } // Calculate signature start and end index. - var signatureStart = fileDefinition.SignatureOffset; var signatureEnd = fileDefinition.SignatureOffset + fileDefinition.ValidSignatures.Max(s => s.Length); // Check whether the content is valid according to the primary header signature length. - if (content.Length < signatureEnd) + if (stream.Length < signatureEnd) { if (_configuration.ThrowExceptionOnInvalidFile) { @@ -524,8 +528,16 @@ public bool HasValidSignature(string fileName, byte[] content) // Check primary header signature. var signatureLength = fileDefinition.ValidSignatures.Max(s => s.Length); - var headerBytes = content.Skip(signatureStart).Take(signatureLength).ToArray(); - var result = fileDefinition.ValidSignatures.Any(signature => headerBytes.Take(signature.Length).SequenceEqual(signature)); + + byte[] headerBytes = new byte[signatureLength]; + stream.Seek(fileDefinition.SignatureOffset, SeekOrigin.Begin); +#if NET8_0_OR_GREATER + stream.ReadExactly(headerBytes, 0, signatureLength); +#else + _ = stream.Read(headerBytes, 0, signatureLength); +#endif + + var result = fileDefinition.ValidSignatures.Any(signature => headerBytes.SequenceEqual(signature)); // Might as well return early as the subtype check is irrelevant if the primary signature is invalid. if (!result) @@ -541,12 +553,10 @@ public bool HasValidSignature(string fileName, byte[] content) // Check subtype header signature. if (fileDefinition.HasSubtype) { - // Calculate signature start and end index. - var subtypeSignatureStart = fileDefinition.SubtypeOffset; var subtypeSignatureEnd = fileDefinition.SubtypeOffset + fileDefinition.ValidSubtypeSignatures.Max(s => s.Length); // Check whether the content is valid according to the primary header signature length. - if (content.Length < subtypeSignatureEnd) + if (stream.Length < subtypeSignatureEnd) { if (_configuration.ThrowExceptionOnInvalidFile) { @@ -558,7 +568,15 @@ public bool HasValidSignature(string fileName, byte[] content) // Check subtype header signature. var subtypeSignatureLength = fileDefinition.ValidSubtypeSignatures.Max(s => s.Length); - var subtypeHeaderBytes = content.Skip(subtypeSignatureStart).Take(subtypeSignatureLength).ToArray(); + + var subtypeHeaderBytes = new byte[subtypeSignatureLength]; + stream.Seek(fileDefinition.SubtypeOffset, SeekOrigin.Begin); +#if NET8_0_OR_GREATER + stream.ReadExactly(subtypeHeaderBytes, 0, subtypeSignatureLength); +#else + _ = stream.Read(subtypeHeaderBytes, 0, subtypeSignatureLength); +#endif + result = fileDefinition.ValidSubtypeSignatures.Any(signature => subtypeHeaderBytes.Take(signature.Length).SequenceEqual(signature)); } @@ -576,23 +594,28 @@ public bool HasValidSignature(string fileName, byte[] content) /// /// WARNING: This does not check if the file type is supported according to the configuration of the FileValidator, /// but only validates if the provided file is adhering to the expected signature for its file type. - /// To completely validate the file based on all parameters, please use . + /// To completely validate the file based on all parameters, please use . /// File signatures (also known as "magic numbers") are specific sequences of bytes at the beginning of a file that indicate its format. /// Various file header signatures are sourced from Wikipedia. /// /// File name including extension (e.g. my-file.jpg). - /// Stream content of the file. + /// Byte array content of the file. /// true if the file signature matches one of the expected signatures for the file type, false otherwise, unless is enabled. + /// Thrown if the file name is null, empty, or whitespace, or if the byte content is null. + /// Thrown if unable to deduct file type (extension) from the given file name. /// Thrown if the file type is not supported and is enabled. /// Thrown if the file does not adhere to the expected file signature and is enabled. - public bool HasValidSignature(string fileName, Stream stream) + public bool HasValidSignature(string fileName, byte[] content) { - var maxHeaderLength = GetMaxHeaderLength(); - - var buffer = new byte[maxHeaderLength]; - _ = stream.Read(buffer, 0, maxHeaderLength); + if (content is null || content.Length == 0) + { + throw new ArgumentNullException(nameof(content), "File content cannot be null or empty when validating file signature."); + } - return HasValidSignature(fileName, buffer); + using (var stream = new MemoryStream(content)) + { + return HasValidSignature(fileName, stream); + } } /// @@ -631,14 +654,14 @@ public bool HasValidSignature(string filePath) /// /// WARNING: This does not check if the file type is supported according to the configuration of the FileValidator, /// but only validates if the provided file is adhering to the expected signature for its file type. - /// To completely validate the file based on all parameters, please use . + /// To completely validate the file based on all parameters, please use . /// - /// Byte array content of the file. + /// Stream content of the file. /// true if the file size is below the file size limit, false otherwise. /// >Thrown if the file size is greater than the configured file size limit and is enabled - public bool HasValidSize(byte[] content) + public bool HasValidSize(Stream stream) { - var isBelowLimit = content.Length <= _configuration.FileSizeLimit; + var isBelowLimit = stream.Length <= _configuration.FileSizeLimit; if (_configuration.ThrowExceptionOnInvalidFile && !isBelowLimit) { @@ -654,14 +677,14 @@ public bool HasValidSize(byte[] content) /// /// WARNING: This does not check if the file type is supported according to the configuration of the FileValidator, /// but only validates if the provided file is adhering to the expected signature for its file type. - /// To completely validate the file based on all parameters, please use . + /// To completely validate the file based on all parameters, please use . /// - /// Stream content of the file. + /// Byte array content of the file. /// true if the file size is below the file size limit, false otherwise. /// >Thrown if the file size is greater than the configured file size limit and is enabled - public bool HasValidSize(Stream stream) + public bool HasValidSize(byte[] content) { - var isBelowLimit = stream.Length <= _configuration.FileSizeLimit; + var isBelowLimit = content.Length <= _configuration.FileSizeLimit; if (_configuration.ThrowExceptionOnInvalidFile && !isBelowLimit) { @@ -702,15 +725,14 @@ public bool HasValidSize(string filePath) /// /// WARNING: This does not check if the file type is supported according to the configuration of the FileValidator, /// but only validates if the provided file is adhering the Open XML format. - /// To completely validate the file based on all parameters, please use . + /// To completely validate the file based on all parameters, please use . /// /// File name including extension (e.g. my-file.docx). - /// Byte content of the file. + /// Stream content of the file. /// true if the file is a valid Open XML file, false otherwise. - /// Thrown if the file name is null, empty, or whitespace, or if the byte content is null. - /// Thrown if unable to deduct file type (extension) from the given file name. /// Thrown if Open XML file is invalid based on the given file type and is enabled. - public bool IsValidOpenXmlDocument(string fileName, byte[] content) + /// Thrown if the stream is not readable. + public bool IsValidOpenXmlDocument(string fileName, Stream stream) { if (string.IsNullOrWhiteSpace(fileName)) { @@ -720,13 +742,17 @@ public bool IsValidOpenXmlDocument(string fileName, byte[] content) var extension = Path.GetExtension(fileName); if (string.IsNullOrWhiteSpace(extension)) { - throw new ArgumentException("Unable to deduct file type (extension) based on the file name.", - nameof(fileName)); + throw new ArgumentException("Unable to deduct file type (extension) based on the file name.", nameof(fileName)); } - if (content is null || content.Length == 0) + if (stream is null || stream.Length == 0) { - throw new ArgumentNullException(nameof(content), "File content cannot be null or empty when validating Open XML structure."); + throw new ArgumentNullException(nameof(stream), "Stream cannot be null or empty when validating file signature."); + } + + if (!stream.CanRead) + { + throw new InvalidOperationException("Stream is not readable."); } try @@ -743,29 +769,25 @@ public bool IsValidOpenXmlDocument(string fileName, byte[] content) } bool isValid; - - using (var memoryStream = new MemoryStream(content)) + switch (extension.ToLowerInvariant()) { - switch (extension.ToLowerInvariant()) - { - case FileExtensions.Docx: - { - isValid = OpenXmlFormatValidator.IsValidWordDocument(memoryStream); - break; - } - case FileExtensions.Xlsx: - { - isValid = OpenXmlFormatValidator.IsValidSpreadsheetDocument(memoryStream); - break; - } - case FileExtensions.Pptx: - { - isValid = OpenXmlFormatValidator.IsValidPresentationDocument(memoryStream); - break; - } - default: - throw new InvalidOpenXmlFormatException("The provided file extension is not recognized as an Open XML file."); - } + case FileExtensions.Docx: + { + isValid = OpenXmlFormatValidator.IsValidWordDocument(stream); + break; + } + case FileExtensions.Xlsx: + { + isValid = OpenXmlFormatValidator.IsValidSpreadsheetDocument(stream); + break; + } + case FileExtensions.Pptx: + { + isValid = OpenXmlFormatValidator.IsValidPresentationDocument(stream); + break; + } + default: + throw new InvalidOpenXmlFormatException("The provided file extension is not recognized as an Open XML file."); } if (_configuration.ThrowExceptionOnInvalidFile && !isValid) @@ -822,22 +844,24 @@ public bool IsValidOpenXmlDocument(string fileName, byte[] content) /// /// WARNING: This does not check if the file type is supported according to the configuration of the FileValidator, /// but only validates if the provided file is adhering the Open XML format. - /// To completely validate the file based on all parameters, please use . + /// To completely validate the file based on all parameters, please use . /// /// File name including extension (e.g. my-file.docx). - /// Stream content of the file. + /// Byte content of the file. /// true if the file is a valid Open XML file, false otherwise. + /// Thrown if the file name is null, empty, or whitespace, or if the byte content is null. + /// Thrown if unable to deduct file type (extension) from the given file name. /// Thrown if Open XML file is invalid based on the given file type and is enabled. - public bool IsValidOpenXmlDocument(string fileName, Stream stream) + public bool IsValidOpenXmlDocument(string fileName, byte[] content) { - using (var memoryStream = new MemoryStream()) + if (content is null || content.Length == 0) { - stream.CopyTo(memoryStream); - - memoryStream.Position = 0; - var content = memoryStream.ToArray(); + throw new ArgumentNullException(nameof(content), "File content cannot be null or empty when validating Open XML structure."); + } - return IsValidOpenXmlDocument(fileName, content); + using (var memoryStream = new MemoryStream(content)) + { + return IsValidOpenXmlDocument(fileName, memoryStream); } } @@ -874,15 +898,14 @@ public bool IsValidOpenXmlDocument(string filePath) /// /// WARNING: This does not check if the file type is supported according to the configuration of the FileValidator, /// but only validates if the provided file is adhering the Open Document Format specification. - /// To completely validate the file based on all parameters, please use . + /// To completely validate the file based on all parameters, please use . /// /// File name including extension (e.g. my-file.odt). - /// Byte content of the file. + /// Stream content of the file. /// true if the file is a valid Open Document Format (ODF) file, false otherwise. - /// Thrown if the file name is null, empty, or whitespace, or if the byte content is null. - /// Thrown if unable to deduct file type (extension) from the given file name. /// Thrown if Open Document Format (ODF) file is invalid based on the given file type and is enabled. - public bool IsValidOpenDocumentFormat(string fileName, byte[] content) + /// Thrown if the stream is not readable or seekable. + public bool IsValidOpenDocumentFormat(string fileName, Stream stream) { if (string.IsNullOrWhiteSpace(fileName)) { @@ -895,9 +918,14 @@ public bool IsValidOpenDocumentFormat(string fileName, byte[] content) throw new ArgumentException("Unable to deduct file type (extension) based on the file name.", nameof(fileName)); } - if (content is null || content.Length == 0) + if (stream is null || stream.Length == 0) { - throw new ArgumentNullException(nameof(content), "File content cannot be null or empty when validating Open Document Format structure."); + throw new ArgumentNullException(nameof(stream), "Stream cannot be null or empty when validating file signature."); + } + + if (!stream.CanRead) + { + throw new InvalidOperationException("Stream is not readable."); } try @@ -915,18 +943,15 @@ public bool IsValidOpenDocumentFormat(string fileName, byte[] content) bool isValid; - using (var memoryStream = new MemoryStream(content)) + switch (extension.ToLowerInvariant()) { - switch (extension.ToLowerInvariant()) - { - case FileExtensions.Odt: - { - isValid = OpenDocumentFormatValidator.IsValidOpenDocumentTextDocument(memoryStream); - break; - } - default: - throw new InvalidOpenDocumentFormatException("The provided file extension is not recognized as an Open Document Format file."); - } + case FileExtensions.Odt: + { + isValid = OpenDocumentFormatValidator.IsValidOpenDocumentTextDocument(stream); + break; + } + default: + throw new InvalidOpenDocumentFormatException("The provided file extension is not recognized as an Open Document Format file."); } if (_configuration.ThrowExceptionOnInvalidFile && !isValid) @@ -953,22 +978,24 @@ public bool IsValidOpenDocumentFormat(string fileName, byte[] content) /// /// WARNING: This does not check if the file type is supported according to the configuration of the FileValidator, /// but only validates if the provided file is adhering the Open Document Format specification. - /// To completely validate the file based on all parameters, please use . + /// To completely validate the file based on all parameters, please use . /// /// File name including extension (e.g. my-file.odt). - /// Stream content of the file. + /// Byte content of the file. /// true if the file is a valid Open Document Format (ODF) file, false otherwise. + /// Thrown if the file name is null, empty, or whitespace, or if the byte content is null. + /// Thrown if unable to deduct file type (extension) from the given file name. /// Thrown if Open Document Format (ODF) file is invalid based on the given file type and is enabled. - public bool IsValidOpenDocumentFormat(string fileName, Stream stream) + public bool IsValidOpenDocumentFormat(string fileName, byte[] content) { - using (var memoryStream = new MemoryStream()) + if (content is null || content.Length == 0) { - stream.CopyTo(memoryStream); - - memoryStream.Position = 0; - var content = memoryStream.ToArray(); + throw new ArgumentNullException(nameof(content), "File content cannot be null or empty when validating Open Document Format structure."); + } - return IsValidOpenDocumentFormat(fileName, content); + using (var memoryStream = new MemoryStream(content)) + { + return IsValidOpenDocumentFormat(fileName, memoryStream); } } @@ -1030,6 +1057,7 @@ public bool IsMalwareClean(string fileName, byte[] content) /// Thrown if no antimalware scanner has been configured for the FileValidator. /// Thrown if malware was detected in the file and is enabled. /// Thrown if the configured antimalware scanner encountered an error while scanning the file for malware. + /// Thrown if the stream is not readable or seekable. public bool IsMalwareClean(string fileName, Stream stream) { if (_antimalwareScanner is null) @@ -1037,6 +1065,21 @@ public bool IsMalwareClean(string fileName, Stream stream) throw new InvalidOperationException("No antimalware scanner has been configured for the FileValidator."); } + if (stream is null || stream.Length == 0) + { + throw new ArgumentNullException(nameof(stream), "Stream cannot be null or empty when validating file signature."); + } + + if (!stream.CanRead) + { + throw new InvalidOperationException("Stream is not readable."); + } + + if (!stream.CanSeek) + { + throw new InvalidOperationException("Stream is not seekable."); + } + stream.Seek(0, SeekOrigin.Begin); bool isClean; @@ -1122,17 +1165,5 @@ public bool IsOpenDocumentFormat(string fileName) var extensions = Path.GetExtension(fileName); return OpenDocumentFormats.Contains(extensions.ToLowerInvariant()); } - - /// - /// Calculate and retrieve the maximum possible header length based on the supported file definitions. - /// - /// Maximum possible header length - private int GetMaxHeaderLength() - { - return SupportedFileDefinitions.Max(f => - f.SignatureOffset - + (f.ValidSignatures.Count > 0 ? f.ValidSignatures.Max(s => s.Length) : 0) - + (f.ValidSubtypeSignatures.Count > 0 ? f.ValidSubtypeSignatures.Max(s => s.Length) : 0)); - } } } diff --git a/src/ByteGuard.FileValidator/Validators/PdfValidator.cs b/src/ByteGuard.FileValidator/Validators/PdfValidator.cs index e154104..cba1049 100644 --- a/src/ByteGuard.FileValidator/Validators/PdfValidator.cs +++ b/src/ByteGuard.FileValidator/Validators/PdfValidator.cs @@ -41,7 +41,7 @@ internal class PdfValidator : IDisposable /// Content stream /// Whether the stream should be closed during dispose. /// Thrown if the provided is null or empty. - public PdfValidator(Stream stream, bool leaveOpen = false) + public PdfValidator(Stream stream, bool leaveOpen = true) { if (stream == null || stream.Length == 0) { From 0bf1ac006dab56e87841a92687f4db741db4a9aa Mon Sep 17 00:00:00 2001 From: detilium Date: Fri, 19 Dec 2025 11:30:45 +0100 Subject: [PATCH 03/30] Bump version for dev [skip ci] --- Directory.Version.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Version.props b/Directory.Version.props index a715999..e92bfe7 100644 --- a/Directory.Version.props +++ b/Directory.Version.props @@ -1,5 +1,5 @@ - 1.1.1 + 1.1.2 \ No newline at end of file From fffd89092bacaa097aae3a38ae73e1b1763e137d Mon Sep 17 00:00:00 2001 From: Christian Haase Date: Sun, 21 Dec 2025 16:33:22 +0100 Subject: [PATCH 04/30] Add ZIP validation for ZIP-based file formats (OOXML + ODF) --- Directory.Version.props | 2 +- README.md | 134 ++++++++----- .../Configuration/ConfigurationValidator.cs | 65 +++++- .../FileValidatorConfiguration.cs | 5 + .../FileValidatorConfigurationBuilder.cs | 33 +++- .../ZipValidationConfiguration.cs | 132 +++++++++++++ .../Exceptions/InvalidZipArchiveException.cs | 25 +++ src/ByteGuard.FileValidator/FileValidator.cs | 56 +++++- .../Validators/ZipValidator.cs | 122 ++++++++++++ .../ByteGuard.FileValidator.Tests.Unit.csproj | 4 + .../ConfigurationValidatorTests.cs | 186 +++++++++++++++++- .../FileValidatorConfigurationBuilderTests.cs | 55 ++++++ .../FileValidatorTests.cs | 146 ++++++++++++++ .../TestHelpers/ZipTestFactory.cs | 88 +++++++++ 14 files changed, 988 insertions(+), 65 deletions(-) create mode 100644 src/ByteGuard.FileValidator/Configuration/ZipValidationConfiguration.cs create mode 100644 src/ByteGuard.FileValidator/Exceptions/InvalidZipArchiveException.cs create mode 100644 src/ByteGuard.FileValidator/Validators/ZipValidator.cs create mode 100644 tests/ByteGuard.FileValidator.Tests.Unit/TestHelpers/ZipTestFactory.cs diff --git a/Directory.Version.props b/Directory.Version.props index e92bfe7..8d2acde 100644 --- a/Directory.Version.props +++ b/Directory.Version.props @@ -1,5 +1,5 @@ - 1.1.2 + 1.2.0 \ No newline at end of file diff --git a/README.md b/README.md index 041fa58..ae3e4c4 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,16 @@ - # ByteGuard File Validator ![NuGet Version](https://img.shields.io/nuget/v/ByteGuard.FileValidator) **ByteGuard.FileValidator** is a lightweight security-focused library for validating user-supplied files in .NET applications. It helps you enforce consistent file upload rules by checking: -- Allowed file extensions -- File size limits -- File signatures (magic numbers) to detect spoofed types -- Specification conformance for Office Open XML / Open Document Formats (`.docx`, `.xlsx`, `.pptx`, `.odt`) -- Malware scan result using a varity of scanners (_requires the addition of a specific ByteGuard.FileValidator scanner package_) + +- Allowed file extensions +- File size limits +- File signatures (magic numbers) to detect spoofed types +- ZIP cotnaienr safety and specification conformance for Office Open XML / Open Document Formats (`.docx`, `.xlsx`, `.pptx`, `.odt`) +- Malware scan result using a varity of scanners (_requires the addition of a specific ByteGuard.FileValidator scanner package_) > ⚠️ **Important:** This package is one layer in a defense-in-depth strategy. -It does **not** replace endpoint protection, sandboxing, input validation, or other security controls. +> It does **not** replace endpoint protection, sandboxing, input validation, or other security controls. ## Features @@ -18,6 +18,7 @@ It does **not** replace endpoint protection, sandboxing, input validation, or ot - ✅ Validate files by **size** - ✅ Validate files by **signature (_magic-numbers_)** - ✅ Validate files by **specification conformance** for archive-based formats (_Open XML and Open Document Formats_) +- ✅ Validate **ZIP container safety** for ZIP-based formats (_Open XML and Open Document Formats_) to protect against decompression bombs and suspicious paths - ✅ **Ensure no malware** through a variety of antimalware scanners - ✅ Validate using file path, `Stream`, or `byte[]` - ✅ Configure which file types to support @@ -27,28 +28,41 @@ It does **not** replace endpoint protection, sandboxing, input validation, or ot ## Getting Started ### Installation + This package is published and installed via [NuGet](https://www.nuget.org/packages/ByteGuard.FileValidator). Reference the package in your project: + ```bash dotnet add package ByteGuard.FileValidator ``` ### Antimalware scanners -In order to use the antimalware scanning capabilities, ensure you have a ByteGuard.FileValidator antimalware package referenced as well. Youo can find the relevant scanner package on NuGet under the namespace `ByteGuard.FileValidator.Scanners`. + +In order to use the antimalware scanning capabilities, ensure you have a ByteGuard.FileValidator antimalware package referenced as well. You can find the relevant scanner package on [NuGet](https://www.nuget.org/packages?q=ByteGuard.FileValidator.Scanner.&includeComputedFrameworks=true&prerel=true&sortby=relevance) under the namespace `ByteGuard.FileValidator.Scanner`. + ## Usage ### Basic validation ```csharp -// Without antimalware scanner var configuration = new FileValidatorConfiguration { SupportedFileTypes = [FileExtensions.Pdf, FileExtensions.Jpg, FileExtensions.Png], FileSizeLimit = ByteSize.MegaBytes(25), - ThrowExceptionOnInvalidFile = false + ThrowExceptionOnInvalidFile = false, + ZipValidationConfiguration = new ZipValidationConfiguration + { + Enabled = true, + MaxEntries = 10_000, + TotalUncompressedSizeLimit = ByteSize.MegaBytes(512), + EntryUncompressedSizeLimit = ByteSize.MegaBytes(128), + CompresseionRateLimit = 200.0, + RejectSuspiciousPaths = true + } }; +// Without antimalware scanner var fileValidator = new FileValidator(configuration); var isValid = fileValidator.IsValidFile("example.pdf", fileStream); @@ -65,6 +79,15 @@ var configuration = new FileValidatorConfigurationBuilder() .AllowFileTypes(FileExtensions.Pdf, FileExtensions.Jpg, FileExtensions.Png) .SetFileSizeLimit(ByteSize.MegaBytes(25)) .SetThrowExceptionOnInvalidFile(false) + .ConfigureZipValidation(zipOptions => + { + zipOptions.Enabled = true; + zipOptions.MaxEntries = 10_000; + zipOptions.TotalUncompressedSizeLimit = ByteSize.MegaBytes(512); + zipOptions.EntryUncompressedSizeLimit = ByteSize.MegaBytes(128); + zipOptions.CompressionRateLimit = 200.0; + zipOptions.RejectSuspiciousPaths = true; + }) .Build(); var fileValidator = new FileValidator(configuration); @@ -76,12 +99,13 @@ var isValid = fileValidator.IsValidFile("example.pdf", fileStream); The `FileValidator` class provides methods to validate specific aspects of a file. > ⚠️ It’s recommended to use `IsValidFile` for comprehensive validation. -> -> `IsValidFile` performs, in order: -> 1. Extension validation -> 2. File size validation -> 3. Signature (magic-number) validation -> 4. Optional Open XML / Open Document Format specification conformance validation (for supported types) +> +> `IsValidFile` performs, in order: +> +> 1. Extension validation +> 2. File size validation +> 3. Signature (magic-number) validation +> 4. Optional Open XML / Open Document Format specification conformance validation (for supported types), including ZIP container safety > 5. Optional antimalware scanning with a compatible scanning package ```csharp @@ -94,6 +118,7 @@ bool isMalwareClean = fileValidator.IsMalwareClean(fileName, fileStream); ``` ### Example + ```csharp [HttpPost("upload")] public async Task Upload(IFormFile file) @@ -117,30 +142,21 @@ public async Task Upload(IFormFile file) } // Proceed with processing/saving... - + return Ok(); } ``` ## Supported File Extensions -The following file extensions are supported by the `FileValidator`: -- `.jpeg`, `.jpg` -- `.pdf` -- `.png` -- `.bmp` -- `.doc` -- `.docx` -- `.odt` -- `.rtf` -- `.xls` -- `.xlsx` -- `.pptx` -- `.m4a` -- `.mov` -- `.avi` -- `.mp3` -- `.mp4` -- `.wav` + +The following file types are supported by the `FileValidator`: + +| Category | Supported extensions | +| ------------- | ------------------------------------------------------------------ | +| **Documents** | `.doc`, `.docx`, `.xls`, `.xlsx`, `.pptx`, `.odt` , `.pdf`, `.rtf` | +| **Images** | `.jpg`, `.jpeg`, `.png,`, `.bmp` | +| **Video** | `.mov`, `.avi`, `.mp4` | +| **Audio** | `.m4a`, `.mp3`, `.wav` | ### Validation coverage per type @@ -153,14 +169,16 @@ The following file extensions are supported by the `FileValidator`: For some formats, additional checks are performed: -- **Office Open XML / Open Document Format** (`.docx`, `.xlsx`, `.pptx`, `.odt`): +- **Microsoft Office / Open Document Format** (`.docx`, `.xlsx`, `.pptx`, `.odt`): + - Extension - File size - Signature + - ZIP container safety - Specification conformance - Malware scan result -- **Other binary formats** (e.g. images, audio, video such as `.jpg`, `.png`, `.mp3`, `.mp4`): +- **Other binary formats**: - Extension - File size - Signature @@ -170,25 +188,38 @@ For some formats, additional checks are performed: The `FileValidatorConfiguration` supports: -| Setting | Required | Default | Description | -|--|--|--|--| -| `SupportedFileTypes` | Yes | N/A | A list of allowed file extensions (e.g., `.pdf`, `.jpg`).
Use the predefined constants in `FileExtensions` for supported types. | -| `FileSizeLimit` | Yes | N/A | Maximum permitted size of files.
Use the static `ByteSize` class provided with this package, to simplify your limit. | -| `ThrowExceptionOnInvalidFile` | No | `true` | Whether to throw an exception on invalid files or return `false`. | +| Setting | Required | Default | Description | +| ----------------------------- | -------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------- | +| `SupportedFileTypes` | Yes | N/A | A list of allowed file extensions (e.g., `.pdf`, `.jpg`).
Use the predefined constants in `FileExtensions` for supported types. | +| `FileSizeLimit` | Yes | N/A | Maximum permitted size of files.
Use the static `ByteSize` class provided with this package, to simplify your limit. | +| `ThrowExceptionOnInvalidFile` | No | `true` | Whether to throw an exception on invalid files or return `false`. | +| `ZipValidationConfiguration` | Yes | _See below_ | Specific configuration class to configure how ZIP validation is performed on ZIP-based file formats (_Open XML and Open Document Formats_). | + +The nested `ZipValidationConfiguration` supports: + +| Setting | Required | Default | Description | +| ---------------------------- | -------- | ------------- | -------------------------------------------------------------------------------------------------------------------------------- | +| `Enabled` | Yes | `true` | Whether ZIP validation is enabled. | +| `MaxEntries` | Yes | `10000` | The maximum allowed number of entries within the ZIP container. | +| `TotalUncompressedSizeLimit` | Yes | 512 MB | The total uncompressed size limit of the entire ZIP container. | +| `EntryUncompressedSizeLimit` | Yes | 128 MB | The maximum uncompressed size limit of individuel entries within the ZIP container. | +| `CompressionRateLimit` | Yes | `200` (200:1) | The maximum allowed compression rate (compressed size / uncompressed size). | +| `RejectSuspiciousPaths` | Yes | `true` | Whether files should be rejected if their full name contains suspicious paths (e.g. root paths, drive letters, path traversal.). | ### Exceptions When `ThrowExceptionOnInvalidFile` is set to `true`, validation functions will throw one of the appropriate exceptions defined below. However, when `ThrowExceptionOnInvalidFile` is set to `false`, all validation functions will either return `true` or `false`. -| Exception type | Scenario | -|--|--| -| `EmptyFileException` | Thrown when the file content is `null` or empty, indicating a file without any content. | -| `UnsupportedFileException` | Thrown when the file extension is not in the list of supported types. | -| `InvalidFileSizeException` | Thrown when the file size exceeds the configured file size limit. | -| `InvalidSignatureException` | Thrown when the file's signature does not match the expected signature for its type. | -| `InvalidOpenXmlFormatException` | Thrown when the internal structure of an Open XML file is invalid (`.docx`, `.xlsx`, `.pptx`, etc.). | +| Exception type | Scenario | +| ------------------------------------ | ---------------------------------------------------------------------------------------------------- | +| `EmptyFileException` | Thrown when the file content is `null` or empty, indicating a file without any content. | +| `UnsupportedFileException` | Thrown when the file extension is not in the list of supported types. | +| `InvalidFileSizeException` | Thrown when the file size exceeds the configured file size limit. | +| `InvalidSignatureException` | Thrown when the file's signature does not match the expected signature for its type. | +| `InvalidOpenXmlFormatException` | Thrown when the internal structure of an Open XML file is invalid (`.docx`, `.xlsx`, `.pptx`, etc.). | | `InvalidOpenDocumentFormatException` | Thrown when the specification conformance of an Open Document Format file is invalid (`.odt`, etc.). | -| `MalwareDetectedException` | Thrown when the configured antimalware scanner detected malware in the file from a scan result. | +| `InvalidZipArchiveException` | Thrown when the ZIP-baesd file format does not respect the ZIP validation rules. | +| `MalwareDetectedException` | Thrown when the configured antimalware scanner detected malware in the file from a scan result. | ## When to use this package @@ -197,4 +228,5 @@ When `ThrowExceptionOnInvalidFile` is set to `true`, validation functions will t - ✅ When you want **defense-in-depth** against spoofed or malicious files ## License -_ByteGuard FileValidator is Copyright © ByteGuard Contributors - Provided under the MIT license._ \ No newline at end of file + +_ByteGuard FileValidator is Copyright © ByteGuard Contributors - Provided under the MIT license._ diff --git a/src/ByteGuard.FileValidator/Configuration/ConfigurationValidator.cs b/src/ByteGuard.FileValidator/Configuration/ConfigurationValidator.cs index ff59372..1dc9a26 100644 --- a/src/ByteGuard.FileValidator/Configuration/ConfigurationValidator.cs +++ b/src/ByteGuard.FileValidator/Configuration/ConfigurationValidator.cs @@ -3,7 +3,7 @@ namespace ByteGuard.FileValidator.Configuration { /// - /// Class used to validate the given configuration. + /// Class used to validate a given file validator configuration instance. /// public static class ConfigurationValidator { @@ -11,8 +11,8 @@ public static class ConfigurationValidator /// Validate configuration and throw exceptions if invalid. ///
/// Configuration instance to validate. - /// Throw if the configuration instance is null. - /// Thrown is no supported file types have been set, there's an error with any of the provided file types (missing "." prefix), or the file size limit is less than or equal to 0. + /// Throw if any required objects on the configuration object is null, or if the configuration object itself is null. + /// Thrown if any of the configuration values are invalid. /// Thrown if any of the provided supported file types are unsupported by the file validator. public static void ThrowIfInvalid(FileValidatorConfiguration configuration) { @@ -45,6 +45,65 @@ public static void ThrowIfInvalid(FileValidatorConfiguration configuration) { throw new ArgumentException("File size limit must be greater than zero.", nameof(configuration.FileSizeLimit)); } + + ValidateZipValidationConfiguration(configuration); + } + + /// + /// Validate the ZIP validation options on the configuration object. + /// + /// File validator configuration object. + private static void ValidateZipValidationConfiguration(FileValidatorConfiguration configuration) + { + var zipConfig = configuration.ZipValidationConfiguration + ?? throw new ArgumentNullException( + nameof(configuration.ZipValidationConfiguration), + $"{nameof(configuration.ZipValidationConfiguration)} cannot be null. Disable ZIP validation using 'Enabled' if unwanted."); + + if (zipConfig.Enabled) + { + if (zipConfig.MaxEntries == 0 || zipConfig.MaxEntries < -1) + { + throw new ArgumentException("MaxEntries on ZIP validation configuration is invalid. Either set a valid positive value or use '-1' for no limit.", nameof(zipConfig.MaxEntries)); + } + + if (zipConfig.TotalUncompressedSizeLimit == 0 || zipConfig.TotalUncompressedSizeLimit < -1) + { + throw new ArgumentException( + "TotalUncompressedSizeLimit on ZIP validation configuration is invalid. Either set a valid positive value or use '-1' for no limit.", + nameof(zipConfig.TotalUncompressedSizeLimit)); + } + + if (zipConfig.EntryUncompressedSizeLimit == 0 || zipConfig.EntryUncompressedSizeLimit < -1) + { + throw new ArgumentException( + "EntryUncompressedSizeLimit on ZIP validation configuration is invalid. Either set a valid positive value or use '-1' for no limit.", + nameof(zipConfig.EntryUncompressedSizeLimit)); + } + + // Ensure EntryUncompressedSizeLimit isn't greater than the TotalUncompressedSizeLimit if defined. + if (zipConfig.EntryUncompressedSizeLimit != -1 && zipConfig.TotalUncompressedSizeLimit != -1 + && zipConfig.EntryUncompressedSizeLimit > zipConfig.TotalUncompressedSizeLimit) + { + throw new ArgumentException( + "EntryUncompressedSizeLimit cannot exceed TotalUncompressedSizeLimit.", + nameof(zipConfig.EntryUncompressedSizeLimit)); + } + + if (double.IsNaN(zipConfig.CompressionRateLimit) || double.IsInfinity(zipConfig.CompressionRateLimit)) + { + throw new ArgumentException( + "CompressionRateLimit must be a finite number. Either set a valid positive value or use '-1' for no limit.", + nameof(zipConfig.CompressionRateLimit)); + } + + if (zipConfig.CompressionRateLimit == 0 || zipConfig.CompressionRateLimit < -1) + { + throw new ArgumentException( + "CompressionRateLimit on ZIP validation configuration is invalid. Either set a valid positive value or use '-1' for no limit.", + nameof(zipConfig.CompressionRateLimit)); + } + } } } } diff --git a/src/ByteGuard.FileValidator/Configuration/FileValidatorConfiguration.cs b/src/ByteGuard.FileValidator/Configuration/FileValidatorConfiguration.cs index 262cf8e..3357aaa 100644 --- a/src/ByteGuard.FileValidator/Configuration/FileValidatorConfiguration.cs +++ b/src/ByteGuard.FileValidator/Configuration/FileValidatorConfiguration.cs @@ -29,5 +29,10 @@ public class FileValidatorConfiguration /// Whether to throw an exception if an unsupported/invalid file is encountered. Defaults to true. /// public bool ThrowExceptionOnInvalidFile { get; set; } = true; + + /// + /// ZIP validation configuration. + /// + public ZipValidationConfiguration ZipValidationConfiguration { get; set; } = new(); } } diff --git a/src/ByteGuard.FileValidator/Configuration/FileValidatorConfigurationBuilder.cs b/src/ByteGuard.FileValidator/Configuration/FileValidatorConfigurationBuilder.cs index 670785d..a1aa611 100644 --- a/src/ByteGuard.FileValidator/Configuration/FileValidatorConfigurationBuilder.cs +++ b/src/ByteGuard.FileValidator/Configuration/FileValidatorConfigurationBuilder.cs @@ -1,6 +1,4 @@ -using ByteGuard.FileValidator.Scanners; - -namespace ByteGuard.FileValidator.Configuration +namespace ByteGuard.FileValidator.Configuration { /// /// File validator configurations fluent API builder. @@ -10,6 +8,7 @@ public class FileValidatorConfigurationBuilder private readonly List supportedFileTypes = new List(); private bool throwOnInvalidFiles = true; private long fileSizeLimit = ByteSize.MegaBytes(25); + private ZipValidationConfiguration zipConfig = new(); /// /// Allow specific file types (extensions) to be validated. @@ -43,6 +42,22 @@ public FileValidatorConfigurationBuilder SetFileSizeLimit(long inFileSizeLimit) return this; } + /// + /// Configure the ZIP validation options. + /// + /// Configuration action. + public FileValidatorConfigurationBuilder ConfigureZipValidation(Action configure) + { + configure?.Invoke(zipConfig); + return this; + } + + /// + /// Disable ZIP validation. + /// + public FileValidatorConfigurationBuilder DisableZipValidation() + => ConfigureZipValidation(options => options.Enabled = false); + /// /// Build configuration. /// @@ -53,7 +68,17 @@ public FileValidatorConfiguration Build() { SupportedFileTypes = supportedFileTypes, ThrowExceptionOnInvalidFile = throwOnInvalidFiles, - FileSizeLimit = fileSizeLimit + FileSizeLimit = fileSizeLimit, + ZipValidationConfiguration = new() + { + Enabled = zipConfig.Enabled, + Scope = zipConfig.Scope, + MaxEntries = zipConfig.MaxEntries, + TotalUncompressedSizeLimit = zipConfig.TotalUncompressedSizeLimit, + EntryUncompressedSizeLimit = zipConfig.EntryUncompressedSizeLimit, + CompressionRateLimit = zipConfig.CompressionRateLimit, + RejectSuspiciousPaths = zipConfig.RejectSuspiciousPaths + } }; ConfigurationValidator.ThrowIfInvalid(configuration); diff --git a/src/ByteGuard.FileValidator/Configuration/ZipValidationConfiguration.cs b/src/ByteGuard.FileValidator/Configuration/ZipValidationConfiguration.cs new file mode 100644 index 0000000..638285e --- /dev/null +++ b/src/ByteGuard.FileValidator/Configuration/ZipValidationConfiguration.cs @@ -0,0 +1,132 @@ +namespace ByteGuard.FileValidator.Configuration +{ + /// + /// Defines the scope of ZIP validation within the file validator. + /// + /// + /// Internal for now as this is only used in preflight validation of ZIP-based file formats. + /// This enum allows for future expansion with a complete ZIP file validation procedure. + /// + [Flags] + internal enum ZipValidationScope + { + /// + /// No ZIP validation. + /// + None = 0, + + /// + /// Validate ZIP-based formats (.docx, .xlsx, .odt, etc). + /// + ZipBasedFormats = 1, + + /// + /// Validate ZIP files. + /// + ZipFiles = 2, + + /// + /// Validate both ZIP based formats (.docx, .xlsx, .odt, etc.) and ZIP files. + /// + All = ZipBasedFormats | ZipFiles + } + + /// + /// Configuration class for the internal ZIP validator. + /// + public class ZipValidationConfiguration + { + /// + /// Whether ZIP validation is enabled. + /// + public bool Enabled { get; set; } = true; + + /// + /// Scope of ZIP validation. + /// + /// + /// Defines the scope of ZIP validation. Defaults to . + /// Internal for now as this is only used in preflight validation of ZIP-based file formats. + /// This enum allows for future expansion with a complete ZIP file validation procedure. + /// + internal ZipValidationScope Scope { get; set; } = ZipValidationScope.All; + + /// + /// Max entries within a given ZIP-archive. + /// + /// + /// Defaults to 10.000. Use -1 for no limit. + /// + public int MaxEntries { get; set; } = 10_000; + + /// + /// Whether is enabled based on its value. + /// + /// + /// Will return false if is set to -1. + /// + internal bool MaxEntriesEnabled => MaxEntries > 0; + + /// + /// Max allowed total uncompressed size. + /// + /// + /// Defaults to 512MB. Use -1 for no limit. + /// + public long TotalUncompressedSizeLimit { get; set; } = ByteSize.MegaBytes(512); + + /// + /// Whether is enabled based on its value. + /// + /// + /// Will return false if is set to -1. + /// + internal bool TotalUncompressedSizeLimitEnabled => TotalUncompressedSizeLimit > 0; + + /// + /// Max allowed uncompressed size for each entry within the ZIP-archive. + /// + /// + /// Defaults to 128MB. Use -1 for no limit. + /// + public long EntryUncompressedSizeLimit { get; set; } = ByteSize.MegaBytes(128); + + /// + /// Whether is enabled based on its value. + /// + /// + /// Will return false if is set to -1. + /// + internal bool EntryUncompressedSizeLimitEnabled => EntryUncompressedSizeLimit > 0; + + /// + /// Max allowed compression rate. + /// + /// + /// Defaults to 200:1. Use -1 for no limit. + /// + public double CompressionRateLimit { get; set; } = 200.0; // 200:1 + + /// + /// Whether is enabled based on its value. + /// + /// + /// Will return false if is set to -1. + /// + internal bool CompressionRateLimitEnabled => CompressionRateLimit > 0; + + /// + /// Whether to reject suspicious paths within the ZIP-archive. + /// + /// + /// Will handle the following paths as being suspicious: + ///
    + ///
  • /
  • + ///
  • \\
  • + ///
  • Drive-letters (e.g. C: and D:)
  • + ///
  • Path traversal (e.g. ../, \\.., ..)
  • + ///
+ ///
+ public bool RejectSuspiciousPaths { get; set; } = true; + } +} diff --git a/src/ByteGuard.FileValidator/Exceptions/InvalidZipArchiveException.cs b/src/ByteGuard.FileValidator/Exceptions/InvalidZipArchiveException.cs new file mode 100644 index 0000000..7e48fb0 --- /dev/null +++ b/src/ByteGuard.FileValidator/Exceptions/InvalidZipArchiveException.cs @@ -0,0 +1,25 @@ +namespace ByteGuard.FileValidator.Exceptions; + +/// +/// Exception type used specifically when a given file is invalid based on the ZIP validation. +/// +public class InvalidZipArchiveException : Exception +{ + /// + /// Construct a new to indicate that the internal + /// ZIP-archive structure in the provided file is not valid based on the defined validation configurations. + /// + public InvalidZipArchiveException() + : base("ZIP-archive is invalid based on the defined validation configurations.") + { + } + + /// + /// Construct a new to indicate that the internal + /// ZIP-archive structure in the provided file is not valid based on the defined validation configurations. + /// + /// Custom exception message. + public InvalidZipArchiveException(string message) : base(message) + { + } +} diff --git a/src/ByteGuard.FileValidator/FileValidator.cs b/src/ByteGuard.FileValidator/FileValidator.cs index ea32deb..1096a1a 100644 --- a/src/ByteGuard.FileValidator/FileValidator.cs +++ b/src/ByteGuard.FileValidator/FileValidator.cs @@ -290,6 +290,9 @@ public List GetSupportedFileTypes() /// Thrown if the file type is not supported and is enabled. /// Thrown if the file does not adhere to the expected file signature and is enabled. /// Thrown if the internal ZIP-archive structure does not adhere to the expected Open XML structure of the given file type and is enabled. + /// Thrown if the internal ZIP-archive structure does not adhere to the expected ODF structure of the given file type and is enabled. + /// Thrown if antimalware scanner is enabled, malware has been detected in the file and is enabled. + /// Thrown if the ZIP archive validation fails according to the options and is enabled. public bool IsValidFile(string fileName, Stream stream) { // Validate file type. @@ -374,6 +377,9 @@ public bool IsValidFile(string fileName, Stream stream) /// Thrown if the file type is not supported and is enabled. /// Thrown if the file does not adhere to the expected file signature and is enabled. /// Thrown if the internal ZIP-archive structure does not adhere to the expected Open XML structure of the given file type and is enabled. + /// Thrown if the internal ZIP-archive structure does not adhere to the expected ODF structure of the given file type and is enabled. + /// Thrown if antimalware scanner is enabled, malware has been detected in the file and is enabled. + /// Thrown if the ZIP archive validation fails according to the options and is enabled. public bool IsValidFile(string fileName, byte[] content) { using (var stream = new MemoryStream(content)) @@ -391,6 +397,9 @@ public bool IsValidFile(string fileName, byte[] content) /// Thrown if the file type is not supported and is enabled. /// Thrown if the file does not adhere to the expected file signature and is enabled. /// Thrown if the internal ZIP-archive structure does not adhere to the expected Open XML structure of the given file type and is enabled. + /// Thrown if the internal ZIP-archive structure does not adhere to the expected ODF structure of the given file type and is enabled. + /// Thrown if antimalware scanner is enabled, malware has been detected in the file and is enabled. + /// Thrown if the ZIP archive validation fails according to the options and is enabled. public bool IsValidFile(string filePath) { if (string.IsNullOrWhiteSpace(filePath)) @@ -732,6 +741,7 @@ public bool HasValidSize(string filePath) /// true if the file is a valid Open XML file, false otherwise. /// Thrown if Open XML file is invalid based on the given file type and is enabled. /// Thrown if the stream is not readable. + /// Thrown if the ZIP archive validation fails according to the options and is enabled. public bool IsValidOpenXmlDocument(string fileName, Stream stream) { if (string.IsNullOrWhiteSpace(fileName)) @@ -768,6 +778,17 @@ public bool IsValidOpenXmlDocument(string fileName, Stream stream) return false; } + // Perform ZIP validation. + bool shouldRunZipValidation = _configuration.ZipValidationConfiguration.Enabled + && _configuration.ZipValidationConfiguration.Scope.HasFlag(ZipValidationScope.ZipBasedFormats); + + if (shouldRunZipValidation) + { + ZipValidator.Validate(stream, _configuration.ZipValidationConfiguration); + } + + stream.Seek(0, SeekOrigin.Begin); + bool isValid; switch (extension.ToLowerInvariant()) { @@ -834,6 +855,15 @@ public bool IsValidOpenXmlDocument(string fileName, Stream stream) throw; } + return false; + } + catch (InvalidZipArchiveException) + { + if (_configuration.ThrowExceptionOnInvalidFile) + { + throw; + } + return false; } } @@ -852,6 +882,7 @@ public bool IsValidOpenXmlDocument(string fileName, Stream stream) /// Thrown if the file name is null, empty, or whitespace, or if the byte content is null. /// Thrown if unable to deduct file type (extension) from the given file name. /// Thrown if Open XML file is invalid based on the given file type and is enabled. + /// Thrown if the ZIP archive validation fails according to the options and is enabled. public bool IsValidOpenXmlDocument(string fileName, byte[] content) { if (content is null || content.Length == 0) @@ -877,6 +908,7 @@ public bool IsValidOpenXmlDocument(string fileName, byte[] content) /// true if the file is a valid Open XML file, false otherwise. /// Thrown if the is null or whitespace. /// Thrown if Open XML file is invalid based on the given file type and is enabled. + /// Thrown if the ZIP archive validation fails according to the options and is enabled. public bool IsValidOpenXmlDocument(string filePath) { if (string.IsNullOrWhiteSpace(filePath)) @@ -905,6 +937,7 @@ public bool IsValidOpenXmlDocument(string filePath) /// true if the file is a valid Open Document Format (ODF) file, false otherwise. /// Thrown if Open Document Format (ODF) file is invalid based on the given file type and is enabled. /// Thrown if the stream is not readable or seekable. + /// Thrown if the ZIP archive validation fails according to the options and is enabled. public bool IsValidOpenDocumentFormat(string fileName, Stream stream) { if (string.IsNullOrWhiteSpace(fileName)) @@ -941,8 +974,18 @@ public bool IsValidOpenDocumentFormat(string fileName, Stream stream) return false; } - bool isValid; + // Perform ZIP validation. + bool shouldRunZipValidation = _configuration.ZipValidationConfiguration.Enabled + && _configuration.ZipValidationConfiguration.Scope.HasFlag(ZipValidationScope.ZipBasedFormats); + if (shouldRunZipValidation) + { + ZipValidator.Validate(stream, _configuration.ZipValidationConfiguration); + } + + stream.Seek(0, SeekOrigin.Begin); + + bool isValid; switch (extension.ToLowerInvariant()) { case FileExtensions.Odt: @@ -968,6 +1011,15 @@ public bool IsValidOpenDocumentFormat(string fileName, Stream stream) throw new InvalidOpenDocumentFormatException("The provided file is not a valid Open Document Format file. See inner exception for details.", e); } + return false; + } + catch (InvalidZipArchiveException) + { + if (_configuration.ThrowExceptionOnInvalidFile) + { + throw; + } + return false; } } @@ -986,6 +1038,7 @@ public bool IsValidOpenDocumentFormat(string fileName, Stream stream) /// Thrown if the file name is null, empty, or whitespace, or if the byte content is null. /// Thrown if unable to deduct file type (extension) from the given file name. /// Thrown if Open Document Format (ODF) file is invalid based on the given file type and is enabled. + /// Thrown if the ZIP archive validation fails according to the options and is enabled. public bool IsValidOpenDocumentFormat(string fileName, byte[] content) { if (content is null || content.Length == 0) @@ -1011,6 +1064,7 @@ public bool IsValidOpenDocumentFormat(string fileName, byte[] content) /// true if the file is a valid Open Document Format (ODF) file, false otherwise. /// Thrown if the is null or whitespace. /// Thrown if Open Document Format (ODF) file is invalid based on the given file type and is enabled. + /// Thrown if the ZIP archive validation fails according to the options and is enabled. public bool IsValidOpenDocumentFormat(string filePath) { if (string.IsNullOrWhiteSpace(filePath)) diff --git a/src/ByteGuard.FileValidator/Validators/ZipValidator.cs b/src/ByteGuard.FileValidator/Validators/ZipValidator.cs new file mode 100644 index 0000000..d064f92 --- /dev/null +++ b/src/ByteGuard.FileValidator/Validators/ZipValidator.cs @@ -0,0 +1,122 @@ +using System.IO.Compression; +using ByteGuard.FileValidator.Configuration; +using ByteGuard.FileValidator.Exceptions; + +namespace ByteGuard.FileValidator.Validators; + +internal static class ZipValidator +{ + /// + /// Validate ZIP archive. + /// + /// ZIP content stream. + /// ZIP validation configuration. + public static void Validate(Stream zipStream, ZipValidationConfiguration options) + { + // Only perform validation if enabled. + if (!options.Enabled) return; + + if (zipStream is null || zipStream.Length == 0) + { + throw new ArgumentNullException(nameof(zipStream), "Stream cannot be null or empty when validating file signature."); + } + + if (!zipStream.CanRead) + { + throw new InvalidOperationException("Stream is not readable."); + } + + if (!zipStream.CanSeek) + { + throw new InvalidOperationException("Stream is not seekable."); + } + + zipStream.Seek(0, SeekOrigin.Begin); + + using (var archive = new ZipArchive(zipStream, ZipArchiveMode.Read, leaveOpen: true)) + { + // Validat maximum number of entries. + if (options.MaxEntriesEnabled && archive.Entries.Count > options.MaxEntries) + { + throw new InvalidZipArchiveException($"The total count of entries exceeds the defined maximum of {options.MaxEntries}"); + } + + long totalUncompressed = 0; + foreach (var entry in archive.Entries) + { + // Validate suspicious paths. + if (options.RejectSuspiciousPaths && IsSuspiciousZipPath(entry.FullName)) + { + throw new InvalidZipArchiveException($"Suspicious ZIP entry path: '{entry.FullName}'"); + } + + long uncompressed = entry.Length; + long compressed = entry.CompressedLength; + + if (uncompressed < 0 || compressed < 0) + { + throw new InvalidZipArchiveException($"'{entry.FullName}' has invalid size metadata."); + } + + // Validate uncompressed size. + if (options.EntryUncompressedSizeLimitEnabled && uncompressed > options.EntryUncompressedSizeLimit) + { + throw new InvalidZipArchiveException($"'{entry.FullName}' too large uncompressed ({compressed} > {options.EntryUncompressedSizeLimit})."); + } + + // Validate total uncompressed size. + totalUncompressed += uncompressed; + if (options.TotalUncompressedSizeLimitEnabled && totalUncompressed > options.TotalUncompressedSizeLimit) + { + throw new InvalidZipArchiveException($"ZIP total uncompressed too large ({totalUncompressed} > {options.TotalUncompressedSizeLimit})"); + } + + // Validate compression rate. + if (uncompressed > 0) + { + if (compressed == 0) + { + throw new InvalidZipArchiveException($"Entry {entry.FullName} has 0 compressed bytes but {uncompressed} bytes."); + } + + var ratio = (double)uncompressed / compressed; + if (options.CompressionRateLimitEnabled && ratio > options.CompressionRateLimit) + { + throw new InvalidZipArchiveException($"Entry {entry.FullName} compression rate to high ({ratio:0.0}:1 > {options.CompressionRateLimit:0.0}:1)."); + } + } + } + } + } + + /// + /// Check whether the given name of a ZIP-archive entry looks suspicious. + /// + /// + /// Checks root (e.g. /, \\), drive letters (e.g. C:, D:) and path traversal (e.g., ../, \\.., ..) + /// + /// Entry fulle name. + /// true if the name looks suspicious, false otherwise. + private static bool IsSuspiciousZipPath(string fullName) + { + if (string.IsNullOrWhiteSpace(fullName)) return true; + + // Absolute paths. + if (fullName.StartsWith("/", StringComparison.Ordinal) || + fullName.StartsWith("\\", StringComparison.Ordinal)) + return true; + + // Drive letters (Windows). + if (fullName.Length >= 2 && char.IsLetter(fullName[0]) && fullName[1] == ':') + return true; + + // Path traversal. + var normalized = fullName.Replace('\\', '/'); + if (normalized.Contains("../", StringComparison.Ordinal) || + normalized.Contains("/..", StringComparison.Ordinal) || + normalized.StartsWith("..", StringComparison.Ordinal)) + return true; + + return false; + } +} diff --git a/tests/ByteGuard.FileValidator.Tests.Unit/ByteGuard.FileValidator.Tests.Unit.csproj b/tests/ByteGuard.FileValidator.Tests.Unit/ByteGuard.FileValidator.Tests.Unit.csproj index 340f3d1..f983057 100644 --- a/tests/ByteGuard.FileValidator.Tests.Unit/ByteGuard.FileValidator.Tests.Unit.csproj +++ b/tests/ByteGuard.FileValidator.Tests.Unit/ByteGuard.FileValidator.Tests.Unit.csproj @@ -44,4 +44,8 @@ + + + + diff --git a/tests/ByteGuard.FileValidator.Tests.Unit/ConfigurationValidatorTests.cs b/tests/ByteGuard.FileValidator.Tests.Unit/ConfigurationValidatorTests.cs index 62ee7e8..66eff48 100644 --- a/tests/ByteGuard.FileValidator.Tests.Unit/ConfigurationValidatorTests.cs +++ b/tests/ByteGuard.FileValidator.Tests.Unit/ConfigurationValidatorTests.cs @@ -37,7 +37,7 @@ public void ThrowIfInvalid_SupportedFileTypesIsEmpty_ShouldThrowArgumentExceptio // Arrange var config = new FileValidatorConfiguration { - SupportedFileTypes = new List() + SupportedFileTypes = new() }; // Act @@ -53,7 +53,7 @@ public void ThrowIfInvalid_FileTypeIsInvalid_ShouldThrowArgumentException() // Arrange var config = new FileValidatorConfiguration { - SupportedFileTypes = new List { "pdf", ".jpg" } // "pdf" is missing "." prefix. + SupportedFileTypes = new() { "pdf", ".jpg" } // "pdf" is missing "." prefix. }; // Act @@ -69,7 +69,7 @@ public void ThrowIfInvalid_FileTypeIsUnsupported_ShouldThrowUnsupportedFileExcep // Arrange var config = new FileValidatorConfiguration { - SupportedFileTypes = new List { ".unsupported", ".jpg" } + SupportedFileTypes = new() { ".unsupported", ".jpg" } }; // Act @@ -85,14 +85,190 @@ public void ThrowIfInvalid_FileSizeLimitIsLessThanOrEqualToZero_ShouldThrowArgum // Arrange var config = new FileValidatorConfiguration { - SupportedFileTypes = new List { ".jpg" } + SupportedFileTypes = new() { ".jpg" } }; // Act Action act = () => new FileValidator(config); - // Act & Assert + // Assert + Assert.Throws(act); + } + + [Fact(DisplayName = "ThrowIfInvalid should throw ArgumentNullException if the ZIP validatiomn options is null")] + public void ThrowIfInvalid_NullZipValidationConfiguration_ShouldThrowArgumentNullException() + { + // Arrange + var config = new FileValidatorConfiguration + { + SupportedFileTypes = new() { ".jpg" }, + FileSizeLimit = 25, + ZipValidationConfiguration = null! + }; + + // Act + Action act = () => new FileValidator(config); + + // Assert + Assert.Throws(act); + } + + [Fact(DisplayName = "ThrowIfInvalid should not throw any exception if the ZIP validation options is disabled though values are invalid")] + public void ThrowIfInvalid_ZipValidationNotEnabledWithIncorrectValues_ShouldPassValidation() + { + // Arrange + var config = new FileValidatorConfiguration + { + SupportedFileTypes = new() { ".jpg" }, + FileSizeLimit = 25, + ZipValidationConfiguration = new() + { + Enabled = false, + MaxEntries = -25, + TotalUncompressedSizeLimit = -25, + EntryUncompressedSizeLimit = -10, + CompressionRateLimit = double.PositiveInfinity + } + }; + + // Act + var exception = Record.Exception(() => new FileValidator(config)); + + // Assert + Assert.Null(exception); + } + + [Theory(DisplayName = "ThrowIfInvalid should throw ArgumentException if MaxEntries is invalid")] + [InlineData(0)] + [InlineData(-25)] + public void ThrowIfInvalid_MaxEntriesIsInvalid_ShouldThrowArgumentException(int value) + { + // Arrange + var config = new FileValidatorConfiguration + { + SupportedFileTypes = new() { ".jpg" }, + FileSizeLimit = 25, + ZipValidationConfiguration = new() + { + Enabled = true, + MaxEntries = value, + TotalUncompressedSizeLimit = -1, + EntryUncompressedSizeLimit = -1, + CompressionRateLimit = -1 + } + }; + + // Act + Action act = () => new FileValidator(config); + + // Assert + Assert.Throws(act); + } + + [Theory(DisplayName = "ThrowIfInvalid should throw ArgumentException if TotalUncompressedSizeLimit is invalid")] + [InlineData(0)] + [InlineData(-25)] + public void ThrowIfInvalid_TotalUncompressedSizeLimitIsInvalid_ShouldThrowArgumentException(long value) + { + // Arrange + var config = new FileValidatorConfiguration + { + SupportedFileTypes = new() { ".jpg" }, + FileSizeLimit = 25, + ZipValidationConfiguration = new() + { + Enabled = true, + MaxEntries = -1, + TotalUncompressedSizeLimit = value, + EntryUncompressedSizeLimit = -1, + CompressionRateLimit = -1 + } + }; + + // Act + Action act = () => new FileValidator(config); + + // Assert Assert.Throws(act); + } + [Theory(DisplayName = "ThrowIfInvalid should throw ArgumentException if EntryUncompressedSizeLimit is invalid")] + [InlineData(0)] + [InlineData(-25)] + public void ThrowIfInvalid_EntryUncompressedSizeLimitIsInvalid_ShouldThrowArgumentException(long value) + { + // Arrange + var config = new FileValidatorConfiguration + { + SupportedFileTypes = new() { ".jpg" }, + FileSizeLimit = 25, + ZipValidationConfiguration = new() + { + Enabled = true, + MaxEntries = -1, + TotalUncompressedSizeLimit = -1, + EntryUncompressedSizeLimit = value, + CompressionRateLimit = -1 + } + }; + + // Act + Action act = () => new FileValidator(config); + + // Assert + Assert.Throws(act); + } + + [Fact(DisplayName = "ThrowIfInvalid should throw ArgumentException if EntryUncompressedSizeLimit is greater than TotalUncompressedSizeLimit")] + public void ThrowIfInvalid_EntryCompressedSizeLimitIsGreaterThanTotalUncompressedSizeLimit_ShouldThrowArgumentException() + { + // Arrange + var config = new FileValidatorConfiguration + { + SupportedFileTypes = new() { ".jpg" }, + FileSizeLimit = 25, + ZipValidationConfiguration = new() + { + Enabled = true, + MaxEntries = -1, + TotalUncompressedSizeLimit = 25, + EntryUncompressedSizeLimit = 30, + CompressionRateLimit = -1 + } + }; + + // Act + Action act = () => new FileValidator(config); + + // Assert + Assert.Throws(act); + } + + [Theory(DisplayName = "ThrowIfInvalid should throw ArgumentException if CompressionRateLimit is invalid")] + [InlineData(0)] + [InlineData(-25)] + [InlineData(double.PositiveInfinity)] + public void ThrowIfInvalid_CompressionRateLimitIsInvalid_ShouldThrowArgumentException(double value) + { + // Arrange + var config = new FileValidatorConfiguration + { + SupportedFileTypes = new() { ".jpg" }, + FileSizeLimit = 25, + ZipValidationConfiguration = new() + { + Enabled = true, + MaxEntries = -1, + TotalUncompressedSizeLimit = -1, + EntryUncompressedSizeLimit = -1, + CompressionRateLimit = value + } + }; + + // Act + Action act = () => new FileValidator(config); + + // Assert + Assert.Throws(act); } } diff --git a/tests/ByteGuard.FileValidator.Tests.Unit/FileValidatorConfigurationBuilderTests.cs b/tests/ByteGuard.FileValidator.Tests.Unit/FileValidatorConfigurationBuilderTests.cs index 112802a..6bd5ad1 100644 --- a/tests/ByteGuard.FileValidator.Tests.Unit/FileValidatorConfigurationBuilderTests.cs +++ b/tests/ByteGuard.FileValidator.Tests.Unit/FileValidatorConfigurationBuilderTests.cs @@ -36,6 +36,61 @@ public void ThrowOnInvalidFiles_SetsThrowExceptionOnInvalidFileProperty() Assert.False(config.ThrowExceptionOnInvalidFile); } + [Fact(DisplayName = "DisableZipValidation should set Enabled to false")] + public void DisableZipValidation_ShouldSetEnabledToFalse() + { + // Arrange + var builder = new FileValidatorConfigurationBuilder(); + + // Act + builder.AllowFileTypes(".pdf") + .DisableZipValidation(); + + var config = builder.Build(); + + // Assert + Assert.False(config.ZipValidationConfiguration.Enabled); + } + + [Fact(DisplayName = "ConfigureZipValidation should populate all properties values as provided")] + public void ConfigureZipValidation_ShouldPopulateAllPropertiesValuesAsProvided() + { + // Arrange + var builder = new FileValidatorConfigurationBuilder(); + + var expected = new ZipValidationConfiguration() + { + Enabled = false, + MaxEntries = 10, + TotalUncompressedSizeLimit = 10, + EntryUncompressedSizeLimit = 9, + CompressionRateLimit = 20, + RejectSuspiciousPaths = false + }; + + // Act + builder.AllowFileTypes(".pdf") + .ConfigureZipValidation(options => + { + options.Enabled = expected.Enabled; + options.MaxEntries = expected.MaxEntries; + options.TotalUncompressedSizeLimit = expected.TotalUncompressedSizeLimit; + options.EntryUncompressedSizeLimit = expected.EntryUncompressedSizeLimit; + options.CompressionRateLimit = expected.CompressionRateLimit; + options.RejectSuspiciousPaths = expected.RejectSuspiciousPaths; + }); + + var config = builder.Build(); + + // Assert + Assert.Equal(config.ZipValidationConfiguration.Enabled, expected.Enabled); + Assert.Equal(config.ZipValidationConfiguration.MaxEntries, expected.MaxEntries); + Assert.Equal(config.ZipValidationConfiguration.TotalUncompressedSizeLimit, expected.TotalUncompressedSizeLimit); + Assert.Equal(config.ZipValidationConfiguration.EntryUncompressedSizeLimit, expected.EntryUncompressedSizeLimit); + Assert.Equal(config.ZipValidationConfiguration.CompressionRateLimit, expected.CompressionRateLimit); + Assert.Equal(config.ZipValidationConfiguration.RejectSuspiciousPaths, expected.RejectSuspiciousPaths); + } + [Fact(DisplayName = "Build throws exception when configuration is invalid")] public void Build_ThrowsException_WhenConfigurationIsInvalid() { diff --git a/tests/ByteGuard.FileValidator.Tests.Unit/FileValidatorTests.cs b/tests/ByteGuard.FileValidator.Tests.Unit/FileValidatorTests.cs index f1cf31d..3fd0095 100644 --- a/tests/ByteGuard.FileValidator.Tests.Unit/FileValidatorTests.cs +++ b/tests/ByteGuard.FileValidator.Tests.Unit/FileValidatorTests.cs @@ -2,6 +2,7 @@ using ByteGuard.FileValidator.Configuration; using ByteGuard.FileValidator.Exceptions; using ByteGuard.FileValidator.Scanners; +using ByteGuard.FileValidator.Tests.Unit.TestHelpers; using NSubstitute; namespace ByteGuard.FileValidator.Tests.Unit; @@ -1006,6 +1007,151 @@ public void IsValidFile_InvalidOpenXmlFiles_ShouldThrowInvalidOpenXmlFormatExcep Assert.Throws(act); } + [Theory(DisplayName = "IsValidFile(string, Stream) should throw InvalidZipArchiveException for entry names with suspicious paths")] + [InlineData("../evil.txt")] + [InlineData("..\\evil.txt")] + [InlineData("/evil.txt")] + [InlineData("\\evil.txt")] + [InlineData("C:/evil")] + [InlineData("C:\\evil")] + public void IsValidFile_SuspiciousFilePaths_ShouldThrowInvalidZipArchiveException(string entryName) + { + // Arrange + var config = new FileValidatorConfiguration + { + SupportedFileTypes = [FileExtensions.Docx], + FileSizeLimit = ByteSize.MegaBytes(25), + ThrowExceptionOnInvalidFile = true + }; + var fileValidator = new FileValidator(config); + + using var zipStream = ZipTestFactory.CreateZipWithEntry(entryName); + + // Act + Action act = () => fileValidator.IsValidFile("evil.docx", zipStream); + + // Assert + Assert.Throws(act); + } + + [Fact(DisplayName = "IsValidFile(string, Stream) should throw InvalidZipArchiveException when MaxEntries is exceeded")] + public void IsValidFile_MaxEntriesExceeded_ShouldThrowInvalidZipArchiveException() + { + // Arrange + var config = new FileValidatorConfiguration + { + SupportedFileTypes = [FileExtensions.Docx], + FileSizeLimit = ByteSize.MegaBytes(25), + ThrowExceptionOnInvalidFile = true, + ZipValidationConfiguration = new() + { + MaxEntries = 3 + } + }; + var fileValidator = new FileValidator(config); + + var entries = new Dictionary() + { + { "file1.txt", "Content 1" }, + { "file2.txt", "Content 2" }, + { "file3.txt", "Content 3" }, + { "file4.txt", "Content 4" } // Exceeds MaxEntries + }; + using var zipStream = ZipTestFactory.CreateZipWithEntries(entries); + + // Act + Action act = () => fileValidator.IsValidFile("exceed.docx", zipStream); + + // Assert + Assert.Throws(act); + } + + [Fact(DisplayName = "IsValidFile(string, Stream) should throw InvalidZipArchiveException when EntryUncompressedSizeLimit is exceeded")] + public void IsValidFile_EntryUncompressedSizeLimitExceeded_ShouldThrowInvalidZipArchiveException() + { + // Arrange + var config = new FileValidatorConfiguration + { + SupportedFileTypes = [FileExtensions.Docx], + FileSizeLimit = ByteSize.MegaBytes(25), + ThrowExceptionOnInvalidFile = true, + ZipValidationConfiguration = new() + { + EntryUncompressedSizeLimit = 10 // 10 bytes + } + }; + var fileValidator = new FileValidator(config); + + using var zipStream = ZipTestFactory.CreateZipWithEntry("largefile.txt", new string('A', 20)); // 20 bytes, exceeds limit + + // Act + Action act = () => fileValidator.IsValidFile("exceed.docx", zipStream); + + // Assert + Assert.Throws(act); + } + + [Fact(DisplayName = "IsValidFile(string, Stream) should throw InvalidZipArchiveException when CompressionRateLimit is exceeded")] + public void IsValidFile_CompressionRateLimitExceeded_ShouldThrowInvalidZipArchiveException() + { + // Arrange + var zipBytes = ZipTestFactory.CreateZipWithHiglyCompressibleEntry(uncompressedBytes: 5 * 1024 * 1024); // 5 MB + var actualRatio = ZipTestFactory.GetCompressionRate(zipBytes); + + var config = new FileValidatorConfiguration + { + SupportedFileTypes = [FileExtensions.Docx], + FileSizeLimit = ByteSize.MegaBytes(25), + ThrowExceptionOnInvalidFile = true, + ZipValidationConfiguration = new() + { + CompressionRateLimit = actualRatio - 0.1 // Set limit just below actual ratio to trigger exception + } + }; + var fileValidator = new FileValidator(config); + + using var zipStream = new MemoryStream(zipBytes); + + // Act + Action act = () => fileValidator.IsValidFile("exceed.docx", zipStream); + + // Assert + Assert.Throws(act); + } + + [Fact(DisplayName = "IsValidFile(string, Stream) should throw InvalidZipArchiveException when TotalUncompressedSizeLimit is exceeded")] + public void IsValidFile_TotalUncompressedSizeLimitExceeded_ShouldThrowInvalidZipArchiveException() + { + // Arrange + var zipBytes = ZipTestFactory.CreateZipWithFixedSizeEntries(entryCount: 3, bytesPerEntry: 10); // Total 30 bytes + + var config = new FileValidatorConfiguration + { + SupportedFileTypes = [FileExtensions.Docx], + FileSizeLimit = ByteSize.MegaBytes(25), + ThrowExceptionOnInvalidFile = true, + ZipValidationConfiguration = new() + { + RejectSuspiciousPaths = false, + + MaxEntries = -1, + EntryUncompressedSizeLimit = -1, + CompressionRateLimit = -1, + + TotalUncompressedSizeLimit = 29 // Set limit below total size to trigger exception + } + }; + var fileValidator = new FileValidator(config); + + using var zipStream = new MemoryStream(zipBytes); + + // Act + Action act = () => fileValidator.IsValidFile("exceed.docx", zipStream); + + // Assert + Assert.Throws(act); + } + [Theory(DisplayName = "IsValidFile(string, byte[]) should throw InvalidOpenDocumentFormatException for invalid ODF files")] [InlineData("ZIP_test_fake_ODT.odt")] // Invalid ODT public void IsValidFile_InvalidOpenDocumentFormatFiles_ShouldThrowInvalidOpenDocumentFormatException(string fileName) diff --git a/tests/ByteGuard.FileValidator.Tests.Unit/TestHelpers/ZipTestFactory.cs b/tests/ByteGuard.FileValidator.Tests.Unit/TestHelpers/ZipTestFactory.cs new file mode 100644 index 0000000..cf8a948 --- /dev/null +++ b/tests/ByteGuard.FileValidator.Tests.Unit/TestHelpers/ZipTestFactory.cs @@ -0,0 +1,88 @@ +using System.IO.Compression; +using System.Text; + +namespace ByteGuard.FileValidator.Tests.Unit.TestHelpers; + +public static class ZipTestFactory +{ + public static MemoryStream CreateZipWithEntry(string entryName, string contents = "x") + { + var stream = new MemoryStream(); + + using var archive = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: true); + var entry = archive.CreateEntry(entryName, CompressionLevel.Optimal); + using var entryStream = entry.Open(); + using var writer = new StreamWriter(entryStream, Encoding.UTF8, leaveOpen: false); + writer.Write(contents); + + stream.Position = 0; + + return stream; + } + + public static MemoryStream CreateZipWithEntries(Dictionary entries) + { + var stream = new MemoryStream(); + + using (var archive = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: true)) + { + foreach (var kvp in entries) + { + var entry = archive.CreateEntry(kvp.Key, CompressionLevel.Optimal); + using var entryStream = entry.Open(); + using var writer = new StreamWriter(entryStream, Encoding.UTF8, leaveOpen: false); + writer.Write(kvp.Value); + } + } + + stream.Position = 0; + + return stream; + } + + public static byte[] CreateZipWithFixedSizeEntries(int entryCount, int bytesPerEntry, CompressionLevel compression = CompressionLevel.NoCompression) + { + using var memoryStream = new MemoryStream(); + using (var archive = new ZipArchive(memoryStream, ZipArchiveMode.Create, leaveOpen: true)) + { + var payload = new byte[bytesPerEntry]; + for (int i = 0; i < entryCount; i++) + { + var entry = archive.CreateEntry($"file{i}.bin", compression); + using var s = entry.Open(); + s.Write(payload, 0, payload.Length); + } + } + + return memoryStream.ToArray(); + } + + public static byte[] CreateZipWithHiglyCompressibleEntry( + string entryName = "payload.bin", + int uncompressedBytes = 5 * 1024 * 1024) // 5 MB + { + var data = new byte[uncompressedBytes]; + using var memoryStream = new MemoryStream(); + + using (var archive = new ZipArchive(memoryStream, ZipArchiveMode.Create, leaveOpen: true)) + { + var entry = archive.CreateEntry(entryName, CompressionLevel.Optimal); + using var entryStream = entry.Open(); + entryStream.Write(data, 0, data.Length); + } + + return memoryStream.ToArray(); + } + + public static double GetCompressionRate(byte[] zipBytes) + { + using var zipStream = new MemoryStream(zipBytes); + using var archive = new ZipArchive(zipStream, ZipArchiveMode.Read, leaveOpen: true); + var entry = archive.Entries.First(); + + if (entry.Length == 0) return 0; + if (entry.CompressedLength == 0) return double.PositiveInfinity; + + return (double)entry.Length / entry.CompressedLength; + } +} From 5a096548f781fa0acd59ad372a676ffb09a3be11 Mon Sep 17 00:00:00 2001 From: detilium Date: Sun, 21 Dec 2025 16:47:15 +0100 Subject: [PATCH 05/30] Add security policy [skip ci] --- SECURITY.md | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 SECURITY.md diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..5abd01b --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,11 @@ +# Security Policy + +## Supported Versions + +Patches are considered for the two versions under active development in the `main` and `dev` branches. Normally, `main` will contain the most recent release version under maintenance, and `dev` will be a prerelease version undergoing active development. + +## Reporting a Vulnerability + +Please report any possible vulnerabilities using the Security tab in the repository header. + +See [this additional information from GitHub on private vulnerability reporting](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing-information-about-vulnerabilities/privately-reporting-a-security-vulnerability). From f4e33c7071bfaea9e7a45b17ed4ef3b554489edb Mon Sep 17 00:00:00 2001 From: Christian Haase Date: Sun, 21 Dec 2025 16:48:10 +0100 Subject: [PATCH 06/30] Expanded support for ODF formats (we now support .odp and .ods) --- README.md | 16 ++--- src/ByteGuard.FileValidator/FileExtensions.cs | 10 +++ src/ByteGuard.FileValidator/FileValidator.cs | 30 +++++++++ .../Validators/OpenDocumentFormatValidator.cs | 58 ++++++++++++++++++ .../ByteGuard.FileValidator.Tests.Unit.csproj | 7 ++- .../FileValidatorTests.cs | 6 +- .../TestFiles/Positive/ODP_test.odp | Bin 0 -> 200092 bytes .../TestFiles/Positive/ODS_test.ods | Bin 0 -> 31276 bytes 8 files changed, 115 insertions(+), 12 deletions(-) create mode 100644 tests/ByteGuard.FileValidator.Tests.Unit/TestFiles/Positive/ODP_test.odp create mode 100644 tests/ByteGuard.FileValidator.Tests.Unit/TestFiles/Positive/ODS_test.ods diff --git a/README.md b/README.md index ae3e4c4..90c16c4 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ It helps you enforce consistent file upload rules by checking: - Allowed file extensions - File size limits - File signatures (magic numbers) to detect spoofed types -- ZIP cotnaienr safety and specification conformance for Office Open XML / Open Document Formats (`.docx`, `.xlsx`, `.pptx`, `.odt`) +- ZIP container safety and specification conformance for Office Open XML / Open Document Formats (`.docx`, `.xlsx`, `.pptx`, `.odt`, `.odp`, `.ods`) - Malware scan result using a varity of scanners (_requires the addition of a specific ByteGuard.FileValidator scanner package_) > ⚠️ **Important:** This package is one layer in a defense-in-depth strategy. @@ -151,12 +151,12 @@ public async Task Upload(IFormFile file) The following file types are supported by the `FileValidator`: -| Category | Supported extensions | -| ------------- | ------------------------------------------------------------------ | -| **Documents** | `.doc`, `.docx`, `.xls`, `.xlsx`, `.pptx`, `.odt` , `.pdf`, `.rtf` | -| **Images** | `.jpg`, `.jpeg`, `.png,`, `.bmp` | -| **Video** | `.mov`, `.avi`, `.mp4` | -| **Audio** | `.m4a`, `.mp3`, `.wav` | +| Category | Supported extensions | +| ------------- | --------------------------------------------------------------------------------- | +| **Documents** | `.doc`, `.docx`, `.xls`, `.xlsx`, `.pptx`, `.odp`, `.ods`, `.odt`, `.pdf`, `.rtf` | +| **Images** | `.jpg`, `.jpeg`, `.png,`, `.bmp` | +| **Video** | `.mov`, `.avi`, `.mp4` | +| **Audio** | `.m4a`, `.mp3`, `.wav` | ### Validation coverage per type @@ -169,7 +169,7 @@ The following file types are supported by the `FileValidator`: For some formats, additional checks are performed: -- **Microsoft Office / Open Document Format** (`.docx`, `.xlsx`, `.pptx`, `.odt`): +- **Microsoft Office / Open Document Format** (`.docx`, `.xlsx`, `.pptx`, `.ods`, `.odp`, `.odt`): - Extension - File size diff --git a/src/ByteGuard.FileValidator/FileExtensions.cs b/src/ByteGuard.FileValidator/FileExtensions.cs index c3f619b..fbbdee1 100644 --- a/src/ByteGuard.FileValidator/FileExtensions.cs +++ b/src/ByteGuard.FileValidator/FileExtensions.cs @@ -50,6 +50,16 @@ public static class FileExtensions ///
public const string Odt = ".odt"; + /// + /// OpenDocument Spreadsheet. + /// + public const string Ods = ".ods"; + + /// + /// OpenDocument Presentation. + /// + public const string Odp = ".odp"; + /// /// Rich Text Format. /// diff --git a/src/ByteGuard.FileValidator/FileValidator.cs b/src/ByteGuard.FileValidator/FileValidator.cs index 1096a1a..937c2e1 100644 --- a/src/ByteGuard.FileValidator/FileValidator.cs +++ b/src/ByteGuard.FileValidator/FileValidator.cs @@ -84,6 +84,24 @@ public class FileValidator } }, new FileDefinition + { + FileType = FileExtensions.Odp, + // WARNING: This shares the same signature as .zip and could potentially allow for .zip disguised as .odp. + ValidSignatures = new List + { + new byte[] { 0x50, 0x4B, 0x03, 0x04 } // PK␃␄ + } + }, + new FileDefinition + { + FileType = FileExtensions.Ods, + // WARNING: This shares the same signature as .zip and could potentially allow for .zip disguised as .ods. + ValidSignatures = new List + { + new byte[] { 0x50, 0x4B, 0x03, 0x04 } // PK␃␄ + } + }, + new FileDefinition { FileType = FileExtensions.Odt, // WARNING: This shares the same signature as .zip and could potentially allow for .zip disguised as .odt. @@ -232,6 +250,8 @@ public class FileValidator ///
private static readonly List OpenDocumentFormats = new List { + FileExtensions.Odp, + FileExtensions.Ods, FileExtensions.Odt }; @@ -988,6 +1008,16 @@ public bool IsValidOpenDocumentFormat(string fileName, Stream stream) bool isValid; switch (extension.ToLowerInvariant()) { + case FileExtensions.Odp: + { + isValid = OpenDocumentFormatValidator.IsValidOpenDocumentPresentationDocument(stream); + break; + } + case FileExtensions.Ods: + { + isValid = OpenDocumentFormatValidator.IsValidOpenDocumentSpreadsheetDocument(stream); + break; + } case FileExtensions.Odt: { isValid = OpenDocumentFormatValidator.IsValidOpenDocumentTextDocument(stream); diff --git a/src/ByteGuard.FileValidator/Validators/OpenDocumentFormatValidator.cs b/src/ByteGuard.FileValidator/Validators/OpenDocumentFormatValidator.cs index 9bee276..e636000 100644 --- a/src/ByteGuard.FileValidator/Validators/OpenDocumentFormatValidator.cs +++ b/src/ByteGuard.FileValidator/Validators/OpenDocumentFormatValidator.cs @@ -7,9 +7,67 @@ namespace ByteGuard.FileValidator.Validators /// /// /// Common validation for Open Document Format (ODF) files such as .odt, .ods, .odp. + /// + /// For now the validation only checks for the presence of key files within the ODF ZIP archive structure. + /// This should definitely be improved in the future to validate the actual content of these files to ensure they conform to ODF specifications. + /// /// internal static class OpenDocumentFormatValidator { + /// + /// Whether the given content stream is a valid ODF presentation (.odp) file. + /// + /// Content stream. + /// true if valid, false otherwise. + /// Thrown if the provided is null or empty. + internal static bool IsValidOpenDocumentPresentationDocument(Stream stream) + { + if (stream == null || stream.Length == 0) + { + throw new ArgumentNullException(nameof(stream)); + } + + using (var archive = new ZipArchive(stream, ZipArchiveMode.Read)) + { + var mimetypeEntry = archive.GetEntry("mimetype"); + var contentXmlEntry = archive.GetEntry("content.xml"); + + if (mimetypeEntry == null || contentXmlEntry == null) + { + return false; + } + } + + return true; + } + + /// + /// Whether the given content stream is a valid ODF spreadsheet (.ods) file. + /// + /// Content stream. + /// true if valid, false otherwise. + /// Thrown if the provided is null or empty. + internal static bool IsValidOpenDocumentSpreadsheetDocument(Stream stream) + { + if (stream == null || stream.Length == 0) + { + throw new ArgumentNullException(nameof(stream)); + } + + using (var archive = new ZipArchive(stream, ZipArchiveMode.Read)) + { + var mimetypeEntry = archive.GetEntry("mimetype"); + var contentXmlEntry = archive.GetEntry("content.xml"); + + if (mimetypeEntry == null || contentXmlEntry == null) + { + return false; + } + } + + return true; + } + /// /// Whether the given content stream is a valid ODF text (.odt) file. /// diff --git a/tests/ByteGuard.FileValidator.Tests.Unit/ByteGuard.FileValidator.Tests.Unit.csproj b/tests/ByteGuard.FileValidator.Tests.Unit/ByteGuard.FileValidator.Tests.Unit.csproj index f983057..dda3742 100644 --- a/tests/ByteGuard.FileValidator.Tests.Unit/ByteGuard.FileValidator.Tests.Unit.csproj +++ b/tests/ByteGuard.FileValidator.Tests.Unit/ByteGuard.FileValidator.Tests.Unit.csproj @@ -24,6 +24,8 @@ + + @@ -44,8 +46,7 @@ - - + + - diff --git a/tests/ByteGuard.FileValidator.Tests.Unit/FileValidatorTests.cs b/tests/ByteGuard.FileValidator.Tests.Unit/FileValidatorTests.cs index 3fd0095..f1ff043 100644 --- a/tests/ByteGuard.FileValidator.Tests.Unit/FileValidatorTests.cs +++ b/tests/ByteGuard.FileValidator.Tests.Unit/FileValidatorTests.cs @@ -441,7 +441,9 @@ public void IsOpenXmlFormat_ValidOpenXmlFiles_ShouldReturnTrue(string fileName) } [Theory(DisplayName = "IsOpenDocumentFormat should return true for valid ODF files")] - [InlineData("test.odt")] // ODT + [InlineData("test.odp")] + [InlineData("test.ods")] + [InlineData("test.odt")] public void IsOpenDocumentFormat_ValidOpenDocumentFiles_ShouldReturnTrue(string fileName) { // Arrange @@ -610,6 +612,8 @@ public void IsValidOpenXmlDocument_InvalidOpenXmlFiles_ShouldThrowInvalidOpenXml } [Theory(DisplayName = "IsValidOpenDocumentFormat(byte[]) should return true for valid ODF files")] + [InlineData("ODP_test.odp")] + [InlineData("ODS_test.ods")] [InlineData("ODT_test.odt")] public void IsValidOpenDocumentFormat_ValidOpenOpenDocumentFormatFiles_ShouldReturnTrue(string fileName) { diff --git a/tests/ByteGuard.FileValidator.Tests.Unit/TestFiles/Positive/ODP_test.odp b/tests/ByteGuard.FileValidator.Tests.Unit/TestFiles/Positive/ODP_test.odp new file mode 100644 index 0000000000000000000000000000000000000000..0f301b1b94565b4e2917ebbc6c38ed83c4ae3562 GIT binary patch literal 200092 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

*8a}!GJ?b7aEfr4Y^ijLmdQy> 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≦!R_Q53>q2uOubyvZno;RbSuNah11!+b`V#@4Hnp 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 AIsgCw literal 0 HcmV?d00001 diff --git a/tests/ByteGuard.FileValidator.Tests.Unit/TestFiles/Positive/ODS_test.ods b/tests/ByteGuard.FileValidator.Tests.Unit/TestFiles/Positive/ODS_test.ods new file mode 100644 index 0000000000000000000000000000000000000000..a70063e26fb255af6b3dc230bfd9121fa9b95c56 GIT binary patch literal 31276 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 DZ*@~N literal 0 HcmV?d00001 From 3dfae5565a281c2c52e7804c41478a53b0bf6c0e Mon Sep 17 00:00:00 2001 From: detilium Date: Mon, 22 Dec 2025 07:22:03 +0100 Subject: [PATCH 07/30] Add security policy to solution folder [skip ci] --- ByteGuard.FileValidator.slnx | 1 + 1 file changed, 1 insertion(+) diff --git a/ByteGuard.FileValidator.slnx b/ByteGuard.FileValidator.slnx index 4655c20..fe72f6f 100644 --- a/ByteGuard.FileValidator.slnx +++ b/ByteGuard.FileValidator.slnx @@ -5,6 +5,7 @@ + From e5a91a21d1f80066cb439d3b5e00576cb75140e1 Mon Sep 17 00:00:00 2001 From: detilium Date: Fri, 16 Jan 2026 07:35:47 +0100 Subject: [PATCH 08/30] Add code of conduct, contributing info, and issue templates [skip ci] --- .github/ISSUE_TEMPLATE/bug_report.md | 0 .github/ISSUE_TEMPLATE/config.yml | 22 ++++ .github/ISSUE_TEMPLATE/documentation.md | 0 .github/ISSUE_TEMPLATE/feature_request.md | 0 .github/ISSUE_TEMPLATE/general_report.md | 0 CODE_OF_CONDUCT.md | 128 ++++++++++++++++++++++ CONTRIBUTING.md | 72 ++++++++++++ 7 files changed, 222 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/documentation.md create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/ISSUE_TEMPLATE/general_report.md create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..e69de29 diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..601cfaa --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,22 @@ +blank_issues_enabled: false +issue_templates: + - name: "Bug Report" + description: "Report a bug in the system." + title: "[Bug]: " + labels: ["bug"] + body: "./ISSUE_TEMPLATE/bug_report.md" + - name: "Feature Request" + description: "Propose a new feature or improvement." + title: "[Feature]: " + labels: ["enhancement"] + body: "./ISSUE_TEMPLATE/feature_request.md" + - name: "Documentation" + description: "Suggest updates or additions to the documentation." + title: "[Docs]: " + labels: ["documentation"] + body: "./ISSUE_TEMPLATE/documentation.md" + - name: "General Report" + description: "Provide general feedback or inquiries." + title: "[General]: " + labels: ["general"] + body: "./ISSUE_TEMPLATE/general_report.md" diff --git a/.github/ISSUE_TEMPLATE/documentation.md b/.github/ISSUE_TEMPLATE/documentation.md new file mode 100644 index 0000000..e69de29 diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..e69de29 diff --git a/.github/ISSUE_TEMPLATE/general_report.md b/.github/ISSUE_TEMPLATE/general_report.md new file mode 100644 index 0000000..e69de29 diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..495c035 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,128 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity +and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +- Demonstrating empathy and kindness toward other people +- Being respectful of differing opinions, viewpoints, and experiences +- Giving and gracefully accepting constructive feedback +- Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +- Focusing on what is best not just for us as individuals, but for the + overall community + +Examples of unacceptable behavior include: + +- The use of sexualized language or imagery, and sexual attention or + advances of any kind +- Trolling, insulting or derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information, such as a physical or email + address, without their explicit permission +- Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +byteguard-hq@proton.me. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series +of actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or +permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within +the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.0, available at +https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct +enforcement ladder](https://github.com/mozilla/diversity). + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see the FAQ at +https://www.contributor-covenant.org/faq. Translations are available at +https://www.contributor-covenant.org/translations. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..8f4589a --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,72 @@ +# Contributing to ByteGuard + +Thanks for considering contributing to ByteGuard. + +ByteGuard is an open-source initiative focused on practical application-security tooling. Contributions like bug reports, PRs, docs, tests, and feedback help keep the ecosystem useful and trustworthy. + +These are lightweight guidelines for contributing to ByteGuard repositories hosted under the ByteGuard organization. This is a living document — improvements via PR are welcome. + +## Where to start? + +Each repository is the best place to contribute to its own scope: + +- **Core libraries/packages**: changes and fixes belong in the relevant package repo. +- **Docs and examples**: improvements are always welcome (especially clarity and real-world usage). + +If you’re new, look for issues labeled **good first issue**, **up-for-grabs**, or **help wanted** (if the repo uses them). + +Before starting work on a PR, consider commenting on an existing issue or creating one so we can align on approach. + +## Reporting an issue + +Bugs and enhancements are tracked via GitHub Issues. + +When creating an issue, please include: + +- What you expected vs. what happened +- Steps to reproduce (a minimal sample helps a lot) +- Version(s) affected +- Environment details (OS/runtime/framework, where relevant) +- Logs/error output (please **redact secrets**) + +## Requesting a feature/enhancement + +Feature requests are also tracked via GitHub Issues. + +Please include: + +- The problem you’re trying to solve (use-case) +- Your proposed solution (API/behavior, if relevant) +- Alternatives you considered +- Any security implications or compatibility concerns + +## Security vulnerabilities + +**Please do not open public issues for security vulnerabilities.** + +If the repo has **Security Advisories** enabled, report it there. Otherwise, use the security contact information listed in the repository (README/profile) if available. + +Include: + +- Description + impact +- Affected versions +- Repro steps / PoC (if safe to share) +- Suggested mitigation (if you have one) + +## Making a PR + +- If an issue does not already exist, please create one first (or explain the motivation clearly in the PR). +- Fork the repository and create a branch with a descriptive name. +- Keep commits as logical units and reference the related issue when possible. +- Run build/tests locally before opening the PR. +- Prefer small, focused PRs over large multi-purpose changes. +- For behavior changes or fixes, tests are strongly encouraged. + +## Questions? + +ByteGuard has an active and helpful community who are happy to help point you in the right direction or work through any issues you might encounter. You can get in touch via: + +- Our [issue tracker](https://github.com/ByteGuard-HQ/byteguard-file-validator-net/issues) +- Our [Discord server](https://discord.com/invite/XwjdR2jmVZ) + +Finally, when contributing please keep in mind our [Code of Conduct](CODE_OF_CONDUCT.md). From 10a1e6b3c1ea39f52b7ab02bb391fe836d16e3ad Mon Sep 17 00:00:00 2001 From: detilium Date: Fri, 16 Jan 2026 07:51:04 +0100 Subject: [PATCH 09/30] Add issue templates content [skip ci] --- .github/ISSUE_TEMPLATE/bug_report.md | 35 +++++++++++++++++++++++ .github/ISSUE_TEMPLATE/documentation.md | 23 +++++++++++++++ .github/ISSUE_TEMPLATE/feature_request.md | 23 +++++++++++++++ .github/ISSUE_TEMPLATE/general_report.md | 15 ++++++++++ 4 files changed, 96 insertions(+) diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index e69de29..7ade304 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,35 @@ +--- +name: Bug Report +about: Report a bug in the system +title: "[Bug]: " +labels: bug +assignees: "" +--- + +### Description + +A clear and concise description of the bug. + +### Steps to Reproduce + +1. Go to '...' +2. Click on '...' +3. See the error. + +### Expected Behavior + +Explain what you expected to happen. + +### Screenshots + +Add screenshots if applicable. + +### Environment + +- OS: [e.g., Windows, macOS, Linux] +- Browser: [e.g., Chrome, Firefox] +- Version: [e.g., 1.0.0] + +### Additional Context + +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/documentation.md b/.github/ISSUE_TEMPLATE/documentation.md index e69de29..ed88fd7 100644 --- a/.github/ISSUE_TEMPLATE/documentation.md +++ b/.github/ISSUE_TEMPLATE/documentation.md @@ -0,0 +1,23 @@ +--- +name: Documentation +about: Suggest updates or additions to documentation +title: "[Docs]: " +labels: documentation +assignees: "" +--- + +### Documentation Update + +What part of the documentation needs to be updated or added? + +### Why Is This Needed? + +Explain the importance of this update. + +### Suggested Changes + +Provide a detailed description of the changes. + +### Additional Context + +Include any related resources. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index e69de29..8850f58 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,23 @@ +--- +name: Feature Request +about: Suggest a new feature or improvement +title: "[Feature]: " +labels: enhancement +assignees: "" +--- + +### Feature Description + +What feature would you like to see? + +### Why Is This Needed? + +Explain the problem or need for this feature. + +### Suggested Solutions + +Describe how this feature could be implemented. + +### Additional Context + +Add any relevant screenshots, links, or resources. diff --git a/.github/ISSUE_TEMPLATE/general_report.md b/.github/ISSUE_TEMPLATE/general_report.md index e69de29..f2769cf 100644 --- a/.github/ISSUE_TEMPLATE/general_report.md +++ b/.github/ISSUE_TEMPLATE/general_report.md @@ -0,0 +1,15 @@ +--- +name: General Report +about: Provide general feedback or inquiries +title: "[General]: " +labels: general +assignees: "" +--- + +### Feedback or Inquiry + +Provide your feedback or inquiry. + +### Additional Information + +Add any other relevant details here. From 024a20df0b0908d17531f849fa677ac2ca71cd5a Mon Sep 17 00:00:00 2001 From: detilium Date: Fri, 16 Jan 2026 08:03:04 +0100 Subject: [PATCH 10/30] Convert issue templates to yml [skip ci] --- .github/ISSUE_TEMPLATE/bug_report.md | 35 ----------------- .github/ISSUE_TEMPLATE/bug_report.yml | 44 ++++++++++++++++++++++ .github/ISSUE_TEMPLATE/config.yml | 8 ++-- .github/ISSUE_TEMPLATE/documentation.md | 23 ----------- .github/ISSUE_TEMPLATE/documentation.yml | 29 ++++++++++++++ .github/ISSUE_TEMPLATE/feature_request.md | 23 ----------- .github/ISSUE_TEMPLATE/feature_request.yml | 30 +++++++++++++++ .github/ISSUE_TEMPLATE/general_report.md | 15 -------- .github/ISSUE_TEMPLATE/general_report.yml | 17 +++++++++ 9 files changed, 124 insertions(+), 100 deletions(-) delete mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml delete mode 100644 .github/ISSUE_TEMPLATE/documentation.md create mode 100644 .github/ISSUE_TEMPLATE/documentation.yml delete mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml delete mode 100644 .github/ISSUE_TEMPLATE/general_report.md create mode 100644 .github/ISSUE_TEMPLATE/general_report.yml diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md deleted file mode 100644 index 7ade304..0000000 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -name: Bug Report -about: Report a bug in the system -title: "[Bug]: " -labels: bug -assignees: "" ---- - -### Description - -A clear and concise description of the bug. - -### Steps to Reproduce - -1. Go to '...' -2. Click on '...' -3. See the error. - -### Expected Behavior - -Explain what you expected to happen. - -### Screenshots - -Add screenshots if applicable. - -### Environment - -- OS: [e.g., Windows, macOS, Linux] -- Browser: [e.g., Chrome, Firefox] -- Version: [e.g., 1.0.0] - -### Additional Context - -Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..5dc184e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,44 @@ +name: Bug +description: Report a bug in the system +title: "[Bug]: " +labels: bug +body: + - type: textarea + attributes: + label: Description + description: A clear and concise description of the bug. + validations: + required: true + - type: textarea + attributes: + label: Expected behavior + description: Explain what you expected to happen. + validations: + required: true + - type: textarea + attributes: + label: Steps to reproduce + description: Steps the reproduce the behavior. + placeholder: | + 1. Setup ... + 2. Call function ... + 3. See exception ... + validations: + required: false + - type: textarea + attributes: + label: Environment + description: | + examples: + - **NET version**: .NET 9 + - **OS**: Windows 11 + - **Version**: v1.0.1 + render: markdown + validations: + required: false + - type: textarea + attributes: + label: Additional context + description: Add any other context about the problem here. + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 601cfaa..d069249 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -4,19 +4,19 @@ issue_templates: description: "Report a bug in the system." title: "[Bug]: " labels: ["bug"] - body: "./ISSUE_TEMPLATE/bug_report.md" + body: "./ISSUE_TEMPLATE/bug_report.yml" - name: "Feature Request" description: "Propose a new feature or improvement." title: "[Feature]: " labels: ["enhancement"] - body: "./ISSUE_TEMPLATE/feature_request.md" + body: "./ISSUE_TEMPLATE/feature_request.yml" - name: "Documentation" description: "Suggest updates or additions to the documentation." title: "[Docs]: " labels: ["documentation"] - body: "./ISSUE_TEMPLATE/documentation.md" + body: "./ISSUE_TEMPLATE/documentation.tml" - name: "General Report" description: "Provide general feedback or inquiries." title: "[General]: " labels: ["general"] - body: "./ISSUE_TEMPLATE/general_report.md" + body: "./ISSUE_TEMPLATE/general_report.yml" diff --git a/.github/ISSUE_TEMPLATE/documentation.md b/.github/ISSUE_TEMPLATE/documentation.md deleted file mode 100644 index ed88fd7..0000000 --- a/.github/ISSUE_TEMPLATE/documentation.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -name: Documentation -about: Suggest updates or additions to documentation -title: "[Docs]: " -labels: documentation -assignees: "" ---- - -### Documentation Update - -What part of the documentation needs to be updated or added? - -### Why Is This Needed? - -Explain the importance of this update. - -### Suggested Changes - -Provide a detailed description of the changes. - -### Additional Context - -Include any related resources. diff --git a/.github/ISSUE_TEMPLATE/documentation.yml b/.github/ISSUE_TEMPLATE/documentation.yml new file mode 100644 index 0000000..255e5b8 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/documentation.yml @@ -0,0 +1,29 @@ +name: Documentation +description: Suggest updates or additions to documentation +title: "[Docs]: <title>" +labels: docs +body: + - type: textarea + attributes: + label: Documentation update + description: What part of the documentation needs to be updated or added? + validations: + required: true + - type: textarea + attributes: + label: Why is this needed? + description: Explain the importance of this update. + validations: + required: true + - type: textarea + attributes: + label: Suggested changes + description: Provide a detailed description of the changes. + validations: + required: true + - type: textarea + attributes: + label: Additional context + description: Add any other context about the problem here. + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md deleted file mode 100644 index 8850f58..0000000 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -name: Feature Request -about: Suggest a new feature or improvement -title: "[Feature]: " -labels: enhancement -assignees: "" ---- - -### Feature Description - -What feature would you like to see? - -### Why Is This Needed? - -Explain the problem or need for this feature. - -### Suggested Solutions - -Describe how this feature could be implemented. - -### Additional Context - -Add any relevant screenshots, links, or resources. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..8b422aa --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,30 @@ +name: Feature request +description: Suggest a new feature or improvement +title: "[Feature]: <title>" +labels: enhancement +body: + - type: textarea + attributes: + label: Feature description + description: What feature would you like to see? + validations: + required: true + - type: textarea + attributes: + label: Why is this needed? + description: Explain the problem or need for this feature. + validations: + required: true + - type: textarea + attributes: + label: Suggested solutions + description: Describe how this feature could be implemented. + validations: + required: false + render: markdown + - type: textarea + attributes: + label: Additional context + description: Add any other context about the problem here. + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/general_report.md b/.github/ISSUE_TEMPLATE/general_report.md deleted file mode 100644 index f2769cf..0000000 --- a/.github/ISSUE_TEMPLATE/general_report.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -name: General Report -about: Provide general feedback or inquiries -title: "[General]: " -labels: general -assignees: "" ---- - -### Feedback or Inquiry - -Provide your feedback or inquiry. - -### Additional Information - -Add any other relevant details here. diff --git a/.github/ISSUE_TEMPLATE/general_report.yml b/.github/ISSUE_TEMPLATE/general_report.yml new file mode 100644 index 0000000..a9ec86a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/general_report.yml @@ -0,0 +1,17 @@ +name: General report +description: Provide general feedback or inquiries +title: "[General]: <title>" +labels: general +body: + - type: textarea + attributes: + label: Feedback or inquiry + description: Provide your feedback or inquiry. + validations: + required: true + - type: textarea + attributes: + label: Additional context + description: Add any other context about the problem here. + validations: + required: false From be00896a458e4e537cffb7437b3bd5f3f0cb0dbf Mon Sep 17 00:00:00 2001 From: detilium <chrhaase@icloud.com> Date: Fri, 16 Jan 2026 08:03:50 +0100 Subject: [PATCH 11/30] Fix wrong template path [skip ci] --- .github/ISSUE_TEMPLATE/config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index d069249..39f400e 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -14,7 +14,7 @@ issue_templates: description: "Suggest updates or additions to the documentation." title: "[Docs]: " labels: ["documentation"] - body: "./ISSUE_TEMPLATE/documentation.tml" + body: "./ISSUE_TEMPLATE/documentation.yml" - name: "General Report" description: "Provide general feedback or inquiries." title: "[General]: " From 5adc036bcab13abe015269b4e25329f56f152471 Mon Sep 17 00:00:00 2001 From: detilium <chrhaase@icloud.com> Date: Fri, 16 Jan 2026 08:05:10 +0100 Subject: [PATCH 12/30] Remove setup in config [skip ci] --- .github/ISSUE_TEMPLATE/config.yml | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 39f400e..3ba13e0 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,22 +1 @@ blank_issues_enabled: false -issue_templates: - - name: "Bug Report" - description: "Report a bug in the system." - title: "[Bug]: " - labels: ["bug"] - body: "./ISSUE_TEMPLATE/bug_report.yml" - - name: "Feature Request" - description: "Propose a new feature or improvement." - title: "[Feature]: " - labels: ["enhancement"] - body: "./ISSUE_TEMPLATE/feature_request.yml" - - name: "Documentation" - description: "Suggest updates or additions to the documentation." - title: "[Docs]: " - labels: ["documentation"] - body: "./ISSUE_TEMPLATE/documentation.yml" - - name: "General Report" - description: "Provide general feedback or inquiries." - title: "[General]: " - labels: ["general"] - body: "./ISSUE_TEMPLATE/general_report.yml" From 15e6b672774ef49a6c352c08074216bdaf45dbbb Mon Sep 17 00:00:00 2001 From: detilium <chrhaase@icloud.com> Date: Fri, 16 Jan 2026 08:07:06 +0100 Subject: [PATCH 13/30] Fix bokrn issue template yml [skip ci] --- .github/ISSUE_TEMPLATE/bug_report.yml | 8 ++++---- .github/ISSUE_TEMPLATE/documentation.yml | 16 ++++++++-------- .github/ISSUE_TEMPLATE/feature_request.yml | 8 ++++---- .github/ISSUE_TEMPLATE/general_report.yml | 8 ++++---- 4 files changed, 20 insertions(+), 20 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 5dc184e..84485d0 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -7,8 +7,8 @@ body: attributes: label: Description description: A clear and concise description of the bug. - validations: - required: true + validations: + required: true - type: textarea attributes: label: Expected behavior @@ -40,5 +40,5 @@ body: attributes: label: Additional context description: Add any other context about the problem here. - validations: - required: false + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/documentation.yml b/.github/ISSUE_TEMPLATE/documentation.yml index 255e5b8..863cc3a 100644 --- a/.github/ISSUE_TEMPLATE/documentation.yml +++ b/.github/ISSUE_TEMPLATE/documentation.yml @@ -7,23 +7,23 @@ body: attributes: label: Documentation update description: What part of the documentation needs to be updated or added? - validations: - required: true + validations: + required: true - type: textarea attributes: label: Why is this needed? description: Explain the importance of this update. - validations: - required: true + validations: + required: true - type: textarea attributes: label: Suggested changes description: Provide a detailed description of the changes. - validations: - required: true + validations: + required: true - type: textarea attributes: label: Additional context description: Add any other context about the problem here. - validations: - required: false + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 8b422aa..dd9bc65 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -7,8 +7,8 @@ body: attributes: label: Feature description description: What feature would you like to see? - validations: - required: true + validations: + required: true - type: textarea attributes: label: Why is this needed? @@ -26,5 +26,5 @@ body: attributes: label: Additional context description: Add any other context about the problem here. - validations: - required: false + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/general_report.yml b/.github/ISSUE_TEMPLATE/general_report.yml index a9ec86a..ef14528 100644 --- a/.github/ISSUE_TEMPLATE/general_report.yml +++ b/.github/ISSUE_TEMPLATE/general_report.yml @@ -7,11 +7,11 @@ body: attributes: label: Feedback or inquiry description: Provide your feedback or inquiry. - validations: - required: true + validations: + required: true - type: textarea attributes: label: Additional context description: Add any other context about the problem here. - validations: - required: false + validations: + required: false From 27bc3c58f3d215aa87e06560deb96dfa608d9b85 Mon Sep 17 00:00:00 2001 From: detilium <chrhaase@icloud.com> Date: Fri, 16 Jan 2026 08:08:00 +0100 Subject: [PATCH 14/30] Fix feature request issue template [skip ci] --- .github/ISSUE_TEMPLATE/feature_request.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index dd9bc65..d56a1f0 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -21,7 +21,6 @@ body: description: Describe how this feature could be implemented. validations: required: false - render: markdown - type: textarea attributes: label: Additional context From 7b70f0b08328bfc36a2e7e892649d98ab769b2ed Mon Sep 17 00:00:00 2001 From: Christian Haase <chrhaase@icloud.com> Date: Fri, 23 Jan 2026 07:25:33 +0100 Subject: [PATCH 15/30] Fix issue with system.IO.Packaging for net48 target framework #18 (#19) --- src/ByteGuard.FileValidator/ByteGuard.FileValidator.csproj | 5 +++-- src/ByteGuard.FileValidator/Validators/ZipValidator.cs | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/ByteGuard.FileValidator/ByteGuard.FileValidator.csproj b/src/ByteGuard.FileValidator/ByteGuard.FileValidator.csproj index 6055f3a..71c4173 100644 --- a/src/ByteGuard.FileValidator/ByteGuard.FileValidator.csproj +++ b/src/ByteGuard.FileValidator/ByteGuard.FileValidator.csproj @@ -1,7 +1,7 @@ <Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> - <TargetFrameworks>netstandard2.0;net8.0;net9.0;net10.0</TargetFrameworks> + <TargetFrameworks>netstandard2.0;net48;net8.0;net10.0</TargetFrameworks> <Authors>ByteGuard Contributors, detilium</Authors> <Description>ByteGuard File Validator is a security-focused .NET library for validating user-supplied files, providing a configurable API to help you enforce safe and consistent file handling across your applications.</Description> <PackageProjectUrl>https://github.com/ByteGuard-HQ/byteguard-file-validator-net</PackageProjectUrl> @@ -14,7 +14,8 @@ <PackageLicenseExpression>MIT</PackageLicenseExpression> </PropertyGroup> - <ItemGroup Condition="'$(TargetFramework)' == 'net48'"> + <ItemGroup Condition="'$(TargetFrameworkIdentifier)' == '.NETFramework'"> + <Reference Include="WindowsBase" /> <Reference Include="System.IO.Compression" /> </ItemGroup> diff --git a/src/ByteGuard.FileValidator/Validators/ZipValidator.cs b/src/ByteGuard.FileValidator/Validators/ZipValidator.cs index d064f92..45a1f67 100644 --- a/src/ByteGuard.FileValidator/Validators/ZipValidator.cs +++ b/src/ByteGuard.FileValidator/Validators/ZipValidator.cs @@ -112,8 +112,8 @@ private static bool IsSuspiciousZipPath(string fullName) // Path traversal. var normalized = fullName.Replace('\\', '/'); - if (normalized.Contains("../", StringComparison.Ordinal) || - normalized.Contains("/..", StringComparison.Ordinal) || + if (normalized.IndexOf("../", StringComparison.Ordinal) >= 0 || + normalized.IndexOf("/..", StringComparison.Ordinal) >= 0 || normalized.StartsWith("..", StringComparison.Ordinal)) return true; From adc0c4f96f340314fba69996fdabae15bba182c6 Mon Sep 17 00:00:00 2001 From: Christian Haase <chrhaase@icloud.com> Date: Mon, 23 Feb 2026 17:25:45 +0100 Subject: [PATCH 16/30] Basic ODF conformance validation (#22) --- README.md | 54 +---- .../Configuration/ConfigurationValidator.cs | 57 +---- .../FileValidatorConfiguration.cs | 17 +- .../FileValidatorConfigurationBuilder.cs | 32 +-- .../Rules/OdfValidationConfiguration.cs | 12 + .../ZipValidationConfiguration.cs | 132 ----------- src/ByteGuard.FileValidator/FileValidator.cs | 49 +--- .../Validators/OpenDocumentFormatValidator.cs | 209 ++++++++++++++---- .../Validators/ZipValidator.cs | 122 ---------- .../ConfigurationValidatorTests.cs | 177 --------------- .../FileValidatorConfigurationBuilderTests.cs | 55 ----- .../FileValidatorTests.cs | 146 ------------ 12 files changed, 226 insertions(+), 836 deletions(-) create mode 100644 src/ByteGuard.FileValidator/Configuration/Rules/OdfValidationConfiguration.cs delete mode 100644 src/ByteGuard.FileValidator/Configuration/ZipValidationConfiguration.cs delete mode 100644 src/ByteGuard.FileValidator/Validators/ZipValidator.cs diff --git a/README.md b/README.md index 90c16c4..28b2df4 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ It helps you enforce consistent file upload rules by checking: - Allowed file extensions - File size limits - File signatures (magic numbers) to detect spoofed types -- ZIP container safety and specification conformance for Office Open XML / Open Document Formats (`.docx`, `.xlsx`, `.pptx`, `.odt`, `.odp`, `.ods`) +- Security validation for Office Open XML / Open Document Formats (`.docx`, `.xlsx`, `.pptx`, `.odt`, `.odp`, `.ods`) - Malware scan result using a varity of scanners (_requires the addition of a specific ByteGuard.FileValidator scanner package_) > ⚠️ **Important:** This package is one layer in a defense-in-depth strategy. @@ -17,8 +17,7 @@ It helps you enforce consistent file upload rules by checking: - ✅ Validate files by **extension** - ✅ Validate files by **size** - ✅ Validate files by **signature (_magic-numbers_)** -- ✅ Validate files by **specification conformance** for archive-based formats (_Open XML and Open Document Formats_) -- ✅ Validate **ZIP container safety** for ZIP-based formats (_Open XML and Open Document Formats_) to protect against decompression bombs and suspicious paths +- ✅ Validate **security aspects** for archive-based formats (_Open XML and Open Document Formats_) - ✅ **Ensure no malware** through a variety of antimalware scanners - ✅ Validate using file path, `Stream`, or `byte[]` - ✅ Configure which file types to support @@ -50,16 +49,7 @@ var configuration = new FileValidatorConfiguration { SupportedFileTypes = [FileExtensions.Pdf, FileExtensions.Jpg, FileExtensions.Png], FileSizeLimit = ByteSize.MegaBytes(25), - ThrowExceptionOnInvalidFile = false, - ZipValidationConfiguration = new ZipValidationConfiguration - { - Enabled = true, - MaxEntries = 10_000, - TotalUncompressedSizeLimit = ByteSize.MegaBytes(512), - EntryUncompressedSizeLimit = ByteSize.MegaBytes(128), - CompresseionRateLimit = 200.0, - RejectSuspiciousPaths = true - } + ThrowExceptionOnInvalidFile = false }; // Without antimalware scanner @@ -79,15 +69,6 @@ var configuration = new FileValidatorConfigurationBuilder() .AllowFileTypes(FileExtensions.Pdf, FileExtensions.Jpg, FileExtensions.Png) .SetFileSizeLimit(ByteSize.MegaBytes(25)) .SetThrowExceptionOnInvalidFile(false) - .ConfigureZipValidation(zipOptions => - { - zipOptions.Enabled = true; - zipOptions.MaxEntries = 10_000; - zipOptions.TotalUncompressedSizeLimit = ByteSize.MegaBytes(512); - zipOptions.EntryUncompressedSizeLimit = ByteSize.MegaBytes(128); - zipOptions.CompressionRateLimit = 200.0; - zipOptions.RejectSuspiciousPaths = true; - }) .Build(); var fileValidator = new FileValidator(configuration); @@ -105,7 +86,7 @@ The `FileValidator` class provides methods to validate specific aspects of a fil > 1. Extension validation > 2. File size validation > 3. Signature (magic-number) validation -> 4. Optional Open XML / Open Document Format specification conformance validation (for supported types), including ZIP container safety +> 4. Optional Open XML / Open Document Format security validation (for supported types) > 5. Optional antimalware scanning with a compatible scanning package ```csharp @@ -170,12 +151,10 @@ The following file types are supported by the `FileValidator`: For some formats, additional checks are performed: - **Microsoft Office / Open Document Format** (`.docx`, `.xlsx`, `.pptx`, `.ods`, `.odp`, `.odt`): - - Extension - File size - Signature - - ZIP container safety - - Specification conformance + - Archive-based security validation - Malware scan result - **Other binary formats**: @@ -188,23 +167,11 @@ For some formats, additional checks are performed: The `FileValidatorConfiguration` supports: -| Setting | Required | Default | Description | -| ----------------------------- | -------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------- | -| `SupportedFileTypes` | Yes | N/A | A list of allowed file extensions (e.g., `.pdf`, `.jpg`).<br>Use the predefined constants in `FileExtensions` for supported types. | -| `FileSizeLimit` | Yes | N/A | Maximum permitted size of files.<br>Use the static `ByteSize` class provided with this package, to simplify your limit. | -| `ThrowExceptionOnInvalidFile` | No | `true` | Whether to throw an exception on invalid files or return `false`. | -| `ZipValidationConfiguration` | Yes | _See below_ | Specific configuration class to configure how ZIP validation is performed on ZIP-based file formats (_Open XML and Open Document Formats_). | - -The nested `ZipValidationConfiguration` supports: - -| Setting | Required | Default | Description | -| ---------------------------- | -------- | ------------- | -------------------------------------------------------------------------------------------------------------------------------- | -| `Enabled` | Yes | `true` | Whether ZIP validation is enabled. | -| `MaxEntries` | Yes | `10000` | The maximum allowed number of entries within the ZIP container. | -| `TotalUncompressedSizeLimit` | Yes | 512 MB | The total uncompressed size limit of the entire ZIP container. | -| `EntryUncompressedSizeLimit` | Yes | 128 MB | The maximum uncompressed size limit of individuel entries within the ZIP container. | -| `CompressionRateLimit` | Yes | `200` (200:1) | The maximum allowed compression rate (compressed size / uncompressed size). | -| `RejectSuspiciousPaths` | Yes | `true` | Whether files should be rejected if their full name contains suspicious paths (e.g. root paths, drive letters, path traversal.). | +| Setting | Required | Default | Description | +| ----------------------------- | -------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------- | +| `SupportedFileTypes` | Yes | N/A | A list of allowed file extensions (e.g., `.pdf`, `.jpg`).<br>Use the predefined constants in `FileExtensions` for supported types. | +| `FileSizeLimit` | Yes | N/A | Maximum permitted size of files.<br>Use the static `ByteSize` class provided with this package, to simplify your limit. | +| `ThrowExceptionOnInvalidFile` | No | `true` | Whether to throw an exception on invalid files or return `false`. | ### Exceptions @@ -218,7 +185,6 @@ When `ThrowExceptionOnInvalidFile` is set to `true`, validation functions will t | `InvalidSignatureException` | Thrown when the file's signature does not match the expected signature for its type. | | `InvalidOpenXmlFormatException` | Thrown when the internal structure of an Open XML file is invalid (`.docx`, `.xlsx`, `.pptx`, etc.). | | `InvalidOpenDocumentFormatException` | Thrown when the specification conformance of an Open Document Format file is invalid (`.odt`, etc.). | -| `InvalidZipArchiveException` | Thrown when the ZIP-baesd file format does not respect the ZIP validation rules. | | `MalwareDetectedException` | Thrown when the configured antimalware scanner detected malware in the file from a scan result. | ## When to use this package diff --git a/src/ByteGuard.FileValidator/Configuration/ConfigurationValidator.cs b/src/ByteGuard.FileValidator/Configuration/ConfigurationValidator.cs index 1dc9a26..631c94c 100644 --- a/src/ByteGuard.FileValidator/Configuration/ConfigurationValidator.cs +++ b/src/ByteGuard.FileValidator/Configuration/ConfigurationValidator.cs @@ -46,63 +46,20 @@ public static void ThrowIfInvalid(FileValidatorConfiguration configuration) throw new ArgumentException("File size limit must be greater than zero.", nameof(configuration.FileSizeLimit)); } - ValidateZipValidationConfiguration(configuration); + ValidateOdfValidationConfiguration(configuration); } /// <summary> - /// Validate the ZIP validation options on the configuration object. + /// Validate the ODF validation options on the configuration object. /// </summary> /// <param name="configuration">File validator configuration object.</param> - private static void ValidateZipValidationConfiguration(FileValidatorConfiguration configuration) + private static void ValidateOdfValidationConfiguration(FileValidatorConfiguration configuration) { - var zipConfig = configuration.ZipValidationConfiguration - ?? throw new ArgumentNullException( - nameof(configuration.ZipValidationConfiguration), - $"{nameof(configuration.ZipValidationConfiguration)} cannot be null. Disable ZIP validation using 'Enabled' if unwanted."); - - if (zipConfig.Enabled) + if (configuration.FileTypeRules.OdfRules == null) { - if (zipConfig.MaxEntries == 0 || zipConfig.MaxEntries < -1) - { - throw new ArgumentException("MaxEntries on ZIP validation configuration is invalid. Either set a valid positive value or use '-1' for no limit.", nameof(zipConfig.MaxEntries)); - } - - if (zipConfig.TotalUncompressedSizeLimit == 0 || zipConfig.TotalUncompressedSizeLimit < -1) - { - throw new ArgumentException( - "TotalUncompressedSizeLimit on ZIP validation configuration is invalid. Either set a valid positive value or use '-1' for no limit.", - nameof(zipConfig.TotalUncompressedSizeLimit)); - } - - if (zipConfig.EntryUncompressedSizeLimit == 0 || zipConfig.EntryUncompressedSizeLimit < -1) - { - throw new ArgumentException( - "EntryUncompressedSizeLimit on ZIP validation configuration is invalid. Either set a valid positive value or use '-1' for no limit.", - nameof(zipConfig.EntryUncompressedSizeLimit)); - } - - // Ensure EntryUncompressedSizeLimit isn't greater than the TotalUncompressedSizeLimit if defined. - if (zipConfig.EntryUncompressedSizeLimit != -1 && zipConfig.TotalUncompressedSizeLimit != -1 - && zipConfig.EntryUncompressedSizeLimit > zipConfig.TotalUncompressedSizeLimit) - { - throw new ArgumentException( - "EntryUncompressedSizeLimit cannot exceed TotalUncompressedSizeLimit.", - nameof(zipConfig.EntryUncompressedSizeLimit)); - } - - if (double.IsNaN(zipConfig.CompressionRateLimit) || double.IsInfinity(zipConfig.CompressionRateLimit)) - { - throw new ArgumentException( - "CompressionRateLimit must be a finite number. Either set a valid positive value or use '-1' for no limit.", - nameof(zipConfig.CompressionRateLimit)); - } - - if (zipConfig.CompressionRateLimit == 0 || zipConfig.CompressionRateLimit < -1) - { - throw new ArgumentException( - "CompressionRateLimit on ZIP validation configuration is invalid. Either set a valid positive value or use '-1' for no limit.", - nameof(zipConfig.CompressionRateLimit)); - } + throw new ArgumentNullException( + nameof(configuration.FileTypeRules.OdfRules), + $"{nameof(configuration.FileTypeRules.OdfRules)} cannot be null."); } } } diff --git a/src/ByteGuard.FileValidator/Configuration/FileValidatorConfiguration.cs b/src/ByteGuard.FileValidator/Configuration/FileValidatorConfiguration.cs index 3357aaa..c8bde27 100644 --- a/src/ByteGuard.FileValidator/Configuration/FileValidatorConfiguration.cs +++ b/src/ByteGuard.FileValidator/Configuration/FileValidatorConfiguration.cs @@ -1,4 +1,4 @@ -using ByteGuard.FileValidator.Scanners; +using ByteGuard.FileValidator.Configuration.Rules; namespace ByteGuard.FileValidator.Configuration { @@ -31,8 +31,19 @@ public class FileValidatorConfiguration public bool ThrowExceptionOnInvalidFile { get; set; } = true; /// <summary> - /// ZIP validation configuration. + /// Specific file type validation rules. /// </summary> - public ZipValidationConfiguration ZipValidationConfiguration { get; set; } = new(); + public FileTypeRules FileTypeRules { get; set; } = new(); + } + + /// <summary> + /// Specific file type validation rules. + /// </summary> + public class FileTypeRules + { + /// <summary> + /// OpenDocument Format validation rules. + /// </summary> + public OdfValidationRules OdfRules { get; set; } = new(); } } diff --git a/src/ByteGuard.FileValidator/Configuration/FileValidatorConfigurationBuilder.cs b/src/ByteGuard.FileValidator/Configuration/FileValidatorConfigurationBuilder.cs index a1aa611..9ad4a5e 100644 --- a/src/ByteGuard.FileValidator/Configuration/FileValidatorConfigurationBuilder.cs +++ b/src/ByteGuard.FileValidator/Configuration/FileValidatorConfigurationBuilder.cs @@ -1,4 +1,6 @@ -namespace ByteGuard.FileValidator.Configuration +using ByteGuard.FileValidator.Configuration.Rules; + +namespace ByteGuard.FileValidator.Configuration { /// <summary> /// File validator configurations fluent API builder. @@ -8,7 +10,7 @@ public class FileValidatorConfigurationBuilder private readonly List<string> supportedFileTypes = new List<string>(); private bool throwOnInvalidFiles = true; private long fileSizeLimit = ByteSize.MegaBytes(25); - private ZipValidationConfiguration zipConfig = new(); + private OdfValidationRules odfValidationRules = new(); /// <summary> /// Allow specific file types (extensions) to be validated. @@ -43,21 +45,15 @@ public FileValidatorConfigurationBuilder SetFileSizeLimit(long inFileSizeLimit) } /// <summary> - /// Configure the ZIP validation options. + /// Configure the OpenDocument Format validation rules. /// </summary> /// <param name="configure">Configuration action.</param> - public FileValidatorConfigurationBuilder ConfigureZipValidation(Action<ZipValidationConfiguration> configure) + public FileValidatorConfigurationBuilder ConfigureOdfValidationRules(Action<OdfValidationRules> configure) { - configure?.Invoke(zipConfig); + configure?.Invoke(odfValidationRules); return this; } - /// <summary> - /// Disable ZIP validation. - /// </summary> - public FileValidatorConfigurationBuilder DisableZipValidation() - => ConfigureZipValidation(options => options.Enabled = false); - /// <summary> /// Build configuration. /// </summary> @@ -68,19 +64,11 @@ public FileValidatorConfiguration Build() { SupportedFileTypes = supportedFileTypes, ThrowExceptionOnInvalidFile = throwOnInvalidFiles, - FileSizeLimit = fileSizeLimit, - ZipValidationConfiguration = new() - { - Enabled = zipConfig.Enabled, - Scope = zipConfig.Scope, - MaxEntries = zipConfig.MaxEntries, - TotalUncompressedSizeLimit = zipConfig.TotalUncompressedSizeLimit, - EntryUncompressedSizeLimit = zipConfig.EntryUncompressedSizeLimit, - CompressionRateLimit = zipConfig.CompressionRateLimit, - RejectSuspiciousPaths = zipConfig.RejectSuspiciousPaths - } + FileSizeLimit = fileSizeLimit }; + configuration.FileTypeRules.OdfRules = odfValidationRules; + ConfigurationValidator.ThrowIfInvalid(configuration); return configuration; diff --git a/src/ByteGuard.FileValidator/Configuration/Rules/OdfValidationConfiguration.cs b/src/ByteGuard.FileValidator/Configuration/Rules/OdfValidationConfiguration.cs new file mode 100644 index 0000000..0b6325b --- /dev/null +++ b/src/ByteGuard.FileValidator/Configuration/Rules/OdfValidationConfiguration.cs @@ -0,0 +1,12 @@ +namespace ByteGuard.FileValidator.Configuration.Rules; + +/// <summary> +/// Validation rules for OpenDocument Format files. +/// </summary> +public class OdfValidationRules +{ + /// <summary> + /// Whether a valid mimetype file is required in the ODF package. Defaults to <c>true</c>. + /// </summary> + public bool RequireMimetype { get; set; } = true; +} diff --git a/src/ByteGuard.FileValidator/Configuration/ZipValidationConfiguration.cs b/src/ByteGuard.FileValidator/Configuration/ZipValidationConfiguration.cs deleted file mode 100644 index 638285e..0000000 --- a/src/ByteGuard.FileValidator/Configuration/ZipValidationConfiguration.cs +++ /dev/null @@ -1,132 +0,0 @@ -namespace ByteGuard.FileValidator.Configuration -{ - /// <summary> - /// Defines the scope of ZIP validation within the file validator. - /// </summary> - /// <remarks> - /// <em>Internal for now as this is only used in preflight validation of ZIP-based file formats. - /// This enum allows for future expansion with a complete ZIP file validation procedure.</em> - /// </remarks> - [Flags] - internal enum ZipValidationScope - { - /// <summary> - /// No ZIP validation. - /// </summary> - None = 0, - - /// <summary> - /// Validate ZIP-based formats (.docx, .xlsx, .odt, etc). - /// </summary> - ZipBasedFormats = 1, - - /// <summary> - /// Validate ZIP files. - /// </summary> - ZipFiles = 2, - - /// <summary> - /// Validate both ZIP based formats (.docx, .xlsx, .odt, etc.) and ZIP files. - /// </summary> - All = ZipBasedFormats | ZipFiles - } - - /// <summary> - /// Configuration class for the internal ZIP validator. - /// </summary> - public class ZipValidationConfiguration - { - /// <summary> - /// Whether ZIP validation is enabled. - /// </summary> - public bool Enabled { get; set; } = true; - - /// <summary> - /// Scope of ZIP validation. - /// </summary> - /// <remarks> - /// Defines the scope of ZIP validation. Defaults to <see cref="ZipValidationScope.All"/>. - /// <para><em>Internal for now as this is only used in preflight validation of ZIP-based file formats. - /// This enum allows for future expansion with a complete ZIP file validation procedure.</em></para> - /// </remarks> - internal ZipValidationScope Scope { get; set; } = ZipValidationScope.All; - - /// <summary> - /// Max entries within a given ZIP-archive. - /// </summary> - /// <remarks> - /// Defaults to 10.000. Use <c>-1</c> for no limit. - /// </remarks> - public int MaxEntries { get; set; } = 10_000; - - /// <summary> - /// Whether <see cref="MaxEntries"/> is enabled based on its value. - /// </summary> - /// <remarks> - /// Will return <c>false</c> if <see cref="MaxEntries"/> is set to <c>-1</c>. - /// </remarks> - internal bool MaxEntriesEnabled => MaxEntries > 0; - - /// <summary> - /// Max allowed total uncompressed size. - /// </summary> - /// <remarks> - /// Defaults to 512MB. Use <c>-1</c> for no limit. - /// </remarks> - public long TotalUncompressedSizeLimit { get; set; } = ByteSize.MegaBytes(512); - - /// <summary> - /// Whether <see cref="TotalUncompressedSizeLimit"/> is enabled based on its value. - /// </summary> - /// <remarks> - /// Will return <c>false</c> if <see cref="TotalUncompressedSizeLimit"/> is set to <c>-1</c>. - /// </remarks> - internal bool TotalUncompressedSizeLimitEnabled => TotalUncompressedSizeLimit > 0; - - /// <summary> - /// Max allowed uncompressed size for each entry within the ZIP-archive. - /// </summary> - /// <remarks> - /// Defaults to 128MB. Use <c>-1</c> for no limit. - /// </remarks> - public long EntryUncompressedSizeLimit { get; set; } = ByteSize.MegaBytes(128); - - /// <summary> - /// Whether <see cref="EntryUncompressedSizeLimit"/> is enabled based on its value. - /// </summary> - /// <remarks> - /// Will return <c>false</c> if <see cref="EntryUncompressedSizeLimit"/> is set to <c>-1</c>. - /// </remarks> - internal bool EntryUncompressedSizeLimitEnabled => EntryUncompressedSizeLimit > 0; - - /// <summary> - /// Max allowed compression rate. - /// </summary> - /// <remarks> - /// Defaults to 200:1. Use <c>-1</c> for no limit. - /// </remarks> - public double CompressionRateLimit { get; set; } = 200.0; // 200:1 - - /// <summary> - /// Whether <see cref="CompressionRateLimit"/> is enabled based on its value. - /// </summary> - /// <remarks> - /// Will return <c>false</c> if <see cref="CompressionRateLimit"/> is set to <c>-1</c>. - /// </remarks> - internal bool CompressionRateLimitEnabled => CompressionRateLimit > 0; - - /// <summary> - /// Whether to reject suspicious paths within the ZIP-archive. - /// </summary> - /// <remarks> - /// Will handle the following paths as being suspicious: - /// <ul> - /// <li><c>/</c></li> - /// <li><c>\\</c></li> - /// <li>Drive-letters (e.g. <c>C:</c> and <c>D:</c>)</li> - /// <li>Path traversal (e.g. <c>../</c>, <c>\\..</c>, <c>..</c>)</li> - /// </ul> - /// </remarks> - public bool RejectSuspiciousPaths { get; set; } = true; - } -} diff --git a/src/ByteGuard.FileValidator/FileValidator.cs b/src/ByteGuard.FileValidator/FileValidator.cs index 937c2e1..06bbff5 100644 --- a/src/ByteGuard.FileValidator/FileValidator.cs +++ b/src/ByteGuard.FileValidator/FileValidator.cs @@ -312,7 +312,6 @@ public List<string> GetSupportedFileTypes() /// <exception cref="InvalidOpenXmlFormatException">Thrown if the internal ZIP-archive structure does not adhere to the expected Open XML structure of the given file type and <see cref="FileValidatorConfiguration.ThrowExceptionOnInvalidFile"/> is enabled.</exception> /// <exception cref="InvalidOpenDocumentFormatException">Thrown if the internal ZIP-archive structure does not adhere to the expected ODF structure of the given file type and <see cref="FileValidatorConfiguration.ThrowExceptionOnInvalidFile"/> is enabled.</exception> /// <exception cref="MalwareDetectedException">Thrown if antimalware scanner is enabled, malware has been detected in the file and <see cref="FileValidatorConfiguration.ThrowExceptionOnInvalidFile"/> is enabled.</exception> - /// <exception cref="InvalidZipArchiveException">Thrown if the ZIP archive validation fails according to the <see cref="FileValidatorConfiguration.ZipValidationConfiguration"/> options and <see cref="FileValidatorConfiguration.ThrowExceptionOnInvalidFile"/> is enabled.</exception> public bool IsValidFile(string fileName, Stream stream) { // Validate file type. @@ -399,7 +398,6 @@ public bool IsValidFile(string fileName, Stream stream) /// <exception cref="InvalidOpenXmlFormatException">Thrown if the internal ZIP-archive structure does not adhere to the expected Open XML structure of the given file type and <see cref="FileValidatorConfiguration.ThrowExceptionOnInvalidFile"/> is enabled.</exception> /// <exception cref="InvalidOpenDocumentFormatException">Thrown if the internal ZIP-archive structure does not adhere to the expected ODF structure of the given file type and <see cref="FileValidatorConfiguration.ThrowExceptionOnInvalidFile"/> is enabled.</exception> /// <exception cref="MalwareDetectedException">Thrown if antimalware scanner is enabled, malware has been detected in the file and <see cref="FileValidatorConfiguration.ThrowExceptionOnInvalidFile"/> is enabled.</exception> - /// <exception cref="InvalidZipArchiveException">Thrown if the ZIP archive validation fails according to the <see cref="FileValidatorConfiguration.ZipValidationConfiguration"/> options and <see cref="FileValidatorConfiguration.ThrowExceptionOnInvalidFile"/> is enabled.</exception> public bool IsValidFile(string fileName, byte[] content) { using (var stream = new MemoryStream(content)) @@ -419,7 +417,6 @@ public bool IsValidFile(string fileName, byte[] content) /// <exception cref="InvalidOpenXmlFormatException">Thrown if the internal ZIP-archive structure does not adhere to the expected Open XML structure of the given file type and <see cref="FileValidatorConfiguration.ThrowExceptionOnInvalidFile"/> is enabled.</exception> /// <exception cref="InvalidOpenDocumentFormatException">Thrown if the internal ZIP-archive structure does not adhere to the expected ODF structure of the given file type and <see cref="FileValidatorConfiguration.ThrowExceptionOnInvalidFile"/> is enabled.</exception> /// <exception cref="MalwareDetectedException">Thrown if antimalware scanner is enabled, malware has been detected in the file and <see cref="FileValidatorConfiguration.ThrowExceptionOnInvalidFile"/> is enabled.</exception> - /// <exception cref="InvalidZipArchiveException">Thrown if the ZIP archive validation fails according to the <see cref="FileValidatorConfiguration.ZipValidationConfiguration"/> options and <see cref="FileValidatorConfiguration.ThrowExceptionOnInvalidFile"/> is enabled.</exception> public bool IsValidFile(string filePath) { if (string.IsNullOrWhiteSpace(filePath)) @@ -761,7 +758,6 @@ public bool HasValidSize(string filePath) /// <returns><c>true</c> if the file is a valid Open XML file, <c>false</c> otherwise.</returns> /// <exception cref="InvalidOpenXmlFormatException">Thrown if Open XML file is invalid based on the given file type and <see cref="FileValidatorConfiguration.ThrowExceptionOnInvalidFile"/> is enabled.</exception> /// <exception cref="InvalidOperationException">Thrown if the stream is not readable.</exception> - /// <exception cref="InvalidZipArchiveException">Thrown if the ZIP archive validation fails according to the <see cref="FileValidatorConfiguration.ZipValidationConfiguration"/> options and <see cref="FileValidatorConfiguration.ThrowExceptionOnInvalidFile"/> is enabled.</exception> public bool IsValidOpenXmlDocument(string fileName, Stream stream) { if (string.IsNullOrWhiteSpace(fileName)) @@ -798,15 +794,6 @@ public bool IsValidOpenXmlDocument(string fileName, Stream stream) return false; } - // Perform ZIP validation. - bool shouldRunZipValidation = _configuration.ZipValidationConfiguration.Enabled - && _configuration.ZipValidationConfiguration.Scope.HasFlag(ZipValidationScope.ZipBasedFormats); - - if (shouldRunZipValidation) - { - ZipValidator.Validate(stream, _configuration.ZipValidationConfiguration); - } - stream.Seek(0, SeekOrigin.Begin); bool isValid; @@ -902,7 +889,6 @@ public bool IsValidOpenXmlDocument(string fileName, Stream stream) /// <exception cref="ArgumentNullException">Thrown if the file name is null, empty, or whitespace, or if the byte content is null.</exception> /// <exception cref="ArgumentException">Thrown if unable to deduct file type (extension) from the given file name.</exception> /// <exception cref="InvalidOpenXmlFormatException">Thrown if Open XML file is invalid based on the given file type and <see cref="FileValidatorConfiguration.ThrowExceptionOnInvalidFile"/> is enabled.</exception> - /// <exception cref="InvalidZipArchiveException">Thrown if the ZIP archive validation fails according to the <see cref="FileValidatorConfiguration.ZipValidationConfiguration"/> options and <see cref="FileValidatorConfiguration.ThrowExceptionOnInvalidFile"/> is enabled.</exception> public bool IsValidOpenXmlDocument(string fileName, byte[] content) { if (content is null || content.Length == 0) @@ -928,7 +914,6 @@ public bool IsValidOpenXmlDocument(string fileName, byte[] content) /// <returns><c>true</c> if the file is a valid Open XML file, <c>false</c> otherwise.</returns> /// <exception cref="ArgumentNullException">Thrown if the <paramref name="filePath"/> is null or whitespace.</exception> /// <exception cref="InvalidOpenXmlFormatException">Thrown if Open XML file is invalid based on the given file type and <see cref="FileValidatorConfiguration.ThrowExceptionOnInvalidFile"/> is enabled.</exception> - /// <exception cref="InvalidZipArchiveException">Thrown if the ZIP archive validation fails according to the <see cref="FileValidatorConfiguration.ZipValidationConfiguration"/> options and <see cref="FileValidatorConfiguration.ThrowExceptionOnInvalidFile"/> is enabled.</exception> public bool IsValidOpenXmlDocument(string filePath) { if (string.IsNullOrWhiteSpace(filePath)) @@ -957,7 +942,6 @@ public bool IsValidOpenXmlDocument(string filePath) /// <returns><c>true</c> if the file is a valid Open Document Format (ODF) file, <c>false</c> otherwise.</returns> /// <exception cref="InvalidOpenDocumentFormatException">Thrown if Open Document Format (ODF) file is invalid based on the given file type and <see cref="FileValidatorConfiguration.ThrowExceptionOnInvalidFile"/> is enabled.</exception> /// <exception cref="InvalidOperationException">Thrown if the stream is not readable or seekable.</exception> - /// <exception cref="InvalidZipArchiveException">Thrown if the ZIP archive validation fails according to the <see cref="FileValidatorConfiguration.ZipValidationConfiguration"/> options and <see cref="FileValidatorConfiguration.ThrowExceptionOnInvalidFile"/> is enabled.</exception> public bool IsValidOpenDocumentFormat(string fileName, Stream stream) { if (string.IsNullOrWhiteSpace(fileName)) @@ -994,38 +978,9 @@ public bool IsValidOpenDocumentFormat(string fileName, Stream stream) return false; } - // Perform ZIP validation. - bool shouldRunZipValidation = _configuration.ZipValidationConfiguration.Enabled - && _configuration.ZipValidationConfiguration.Scope.HasFlag(ZipValidationScope.ZipBasedFormats); - - if (shouldRunZipValidation) - { - ZipValidator.Validate(stream, _configuration.ZipValidationConfiguration); - } - stream.Seek(0, SeekOrigin.Begin); - bool isValid; - switch (extension.ToLowerInvariant()) - { - case FileExtensions.Odp: - { - isValid = OpenDocumentFormatValidator.IsValidOpenDocumentPresentationDocument(stream); - break; - } - case FileExtensions.Ods: - { - isValid = OpenDocumentFormatValidator.IsValidOpenDocumentSpreadsheetDocument(stream); - break; - } - case FileExtensions.Odt: - { - isValid = OpenDocumentFormatValidator.IsValidOpenDocumentTextDocument(stream); - break; - } - default: - throw new InvalidOpenDocumentFormatException("The provided file extension is not recognized as an Open Document Format file."); - } + var isValid = OpenDocumentFormatValidator.IsValidOpenDocumentFormatFile(fileName, stream, _configuration.FileTypeRules.OdfRules); if (_configuration.ThrowExceptionOnInvalidFile && !isValid) { @@ -1068,7 +1023,6 @@ public bool IsValidOpenDocumentFormat(string fileName, Stream stream) /// <exception cref="ArgumentNullException">Thrown if the file name is null, empty, or whitespace, or if the byte content is null.</exception> /// <exception cref="ArgumentException">Thrown if unable to deduct file type (extension) from the given file name.</exception> /// <exception cref="InvalidOpenDocumentFormatException">Thrown if Open Document Format (ODF) file is invalid based on the given file type and <see cref="FileValidatorConfiguration.ThrowExceptionOnInvalidFile"/> is enabled.</exception> - /// <exception cref="InvalidZipArchiveException">Thrown if the ZIP archive validation fails according to the <see cref="FileValidatorConfiguration.ZipValidationConfiguration"/> options and <see cref="FileValidatorConfiguration.ThrowExceptionOnInvalidFile"/> is enabled.</exception> public bool IsValidOpenDocumentFormat(string fileName, byte[] content) { if (content is null || content.Length == 0) @@ -1094,7 +1048,6 @@ public bool IsValidOpenDocumentFormat(string fileName, byte[] content) /// <returns><c>true</c> if the file is a valid Open Document Format (ODF) file, <c>false</c> otherwise.</returns> /// <exception cref="ArgumentNullException">Thrown if the <paramref name="filePath"/> is null or whitespace.</exception> /// <exception cref="InvalidOpenDocumentFormatException">Thrown if Open Document Format (ODF) file is invalid based on the given file type and <see cref="FileValidatorConfiguration.ThrowExceptionOnInvalidFile"/> is enabled.</exception> - /// <exception cref="InvalidZipArchiveException">Thrown if the ZIP archive validation fails according to the <see cref="FileValidatorConfiguration.ZipValidationConfiguration"/> options and <see cref="FileValidatorConfiguration.ThrowExceptionOnInvalidFile"/> is enabled.</exception> public bool IsValidOpenDocumentFormat(string filePath) { if (string.IsNullOrWhiteSpace(filePath)) diff --git a/src/ByteGuard.FileValidator/Validators/OpenDocumentFormatValidator.cs b/src/ByteGuard.FileValidator/Validators/OpenDocumentFormatValidator.cs index e636000..107a676 100644 --- a/src/ByteGuard.FileValidator/Validators/OpenDocumentFormatValidator.cs +++ b/src/ByteGuard.FileValidator/Validators/OpenDocumentFormatValidator.cs @@ -1,4 +1,7 @@ -using System.IO.Compression; +using System.IO.Compression; +using System.Text; +using System.Xml; +using ByteGuard.FileValidator.Configuration.Rules; namespace ByteGuard.FileValidator.Validators { @@ -7,20 +10,29 @@ namespace ByteGuard.FileValidator.Validators /// </summary> /// <remarks> /// Common validation for Open Document Format (ODF) files such as .odt, .ods, .odp. - /// <para> - /// <em>For now the validation only checks for the presence of key files within the ODF ZIP archive structure. - /// This should definitely be improved in the future to validate the actual content of these files to ensure they conform to ODF specifications.</em> - /// </para> /// </remarks> internal static class OpenDocumentFormatValidator { + private const string ManifestEntryName = "META-INF/manifest.xml"; + private const string ContentEntryName = "content.xml"; + private const string MimetypeEntryName = "mimetype"; + + private static Dictionary<string, string> OdfMimetypeMappings = new(StringComparer.InvariantCultureIgnoreCase) + { + { ".odt", "application/vnd.oasis.opendocument.text" }, + { ".ods", "application/vnd.oasis.opendocument.spreadsheet" }, + { ".odp", "application/vnd.oasis.opendocument.presentation" }, + }; + /// <summary> - /// Whether the given content stream is a valid ODF presentation (.odp) file. + /// Whether the given content stream is a valid ODF file. /// </summary> + /// <param name="fileName">File name including extension (e.g. <c>my-file.odt</c>).</param> /// <param name="stream">Content stream.</param> + /// <param name="rules">ODF validation rules.</param> /// <returns><c>true</c> if valid, <c>false</c> otherwise.</returns> /// <throws cref="ArgumentNullException">Thrown if the provided <paramref name="stream"/> is <c>null</c> or empty.</throws> - internal static bool IsValidOpenDocumentPresentationDocument(Stream stream) + internal static bool IsValidOpenDocumentFormatFile(string fileName, Stream stream, OdfValidationRules rules) { if (stream == null || stream.Length == 0) { @@ -29,70 +41,193 @@ internal static bool IsValidOpenDocumentPresentationDocument(Stream stream) using (var archive = new ZipArchive(stream, ZipArchiveMode.Read)) { - var mimetypeEntry = archive.GetEntry("mimetype"); - var contentXmlEntry = archive.GetEntry("content.xml"); + return PerformOdfValidation(fileName, archive, rules); + } + } - if (mimetypeEntry == null || contentXmlEntry == null) - { - return false; - } + /// <summary> + /// Perform ODF validation. + /// </summary> + /// <param name="fileName">File name including extension (e.g. <c>my-file.odt</c>).</param> + /// <param name="archive">ODF ZIP archive.</param> + /// <param name="rules">ODF validation rules.</param> + /// <returns><c>true</c> if valid, <c>false</c> otherwise.</returns> + private static bool PerformOdfValidation(string fileName, ZipArchive archive, OdfValidationRules rules) + { + var fileExtension = Path.GetExtension(fileName); + if (!OdfMimetypeMappings.ContainsKey(fileExtension)) + { + // Unsupported ODF file extension + return false; + } + + var manifest = archive.GetEntry(ManifestEntryName); + if (manifest is null) + { + // Must contain a manifest.xml entry + return false; + } + + if (!archive.Entries.Any(e => e.FullName.Equals(ContentEntryName, StringComparison.InvariantCultureIgnoreCase))) + { + // Must contain a content.xml entry + return false; + } + + // Validate mimetype entry + if (!IsValidMimetype(fileName, archive, rules)) + { + return false; + } + + // Validate manifest if present + if (!IsBasicValidManifest(manifest)) + { + return false; } return true; } /// <summary> - /// Whether the given content stream is a valid ODF spreadsheet (.ods) file. + /// Whether the mimetype entry is valid. /// </summary> - /// <param name="stream">Content stream.</param> + /// <param name="fileName">File name including extension (e.g. <c>my-file.odt</c>).</param> + /// <param name="archive">ODF ZIP archive.</param> + /// <param name="rules">ODF validation rules.</param> /// <returns><c>true</c> if valid, <c>false</c> otherwise.</returns> - /// <throws cref="ArgumentNullException">Thrown if the provided <paramref name="stream"/> is <c>null</c> or empty.</throws> - internal static bool IsValidOpenDocumentSpreadsheetDocument(Stream stream) + private static bool IsValidMimetype(string fileName, ZipArchive archive, OdfValidationRules rules) { - if (stream == null || stream.Length == 0) + var mimetypeEntry = archive.GetEntry(MimetypeEntryName); + if (mimetypeEntry is null) { - throw new ArgumentNullException(nameof(stream)); + if (rules.RequireMimetype) + { + // Mimetype entry is required but missing + return false; + } } - - using (var archive = new ZipArchive(stream, ZipArchiveMode.Read)) + else { - var mimetypeEntry = archive.GetEntry("mimetype"); - var contentXmlEntry = archive.GetEntry("content.xml"); + if (archive.Entries.Count > 0 && !string.Equals(archive.Entries[0].FullName, MimetypeEntryName, StringComparison.InvariantCultureIgnoreCase)) + { + // The mimetype entry must be the first entry in the ZIP archive + return false; + } - if (mimetypeEntry == null || contentXmlEntry == null) + if (mimetypeEntry.Length != mimetypeEntry.CompressedLength) { + // The mimetype entry must not be compressed return false; } + + // Read and validate mimetype value + var mimetypeContent = Read(mimetypeEntry, 128); + if (mimetypeContent is not null) + { + var expectedMimetype = OdfMimetypeMappings[Path.GetExtension(fileName)]; + var actual = mimetypeContent.Trim(); + if (!string.Equals(actual, expectedMimetype, StringComparison.Ordinal)) + { + return false; + } + } } return true; } /// <summary> - /// Whether the given content stream is a valid ODF text (.odt) file. + /// Whether the manifest entry is valid baased on basic checks. /// </summary> - /// <param name="stream">Content stream.</param> + /// <param name="manifestEntry">Manifest entry to validate.</param> /// <returns><c>true</c> if valid, <c>false</c> otherwise.</returns> - /// <throws cref="ArgumentNullException">Thrown if the provided <paramref name="stream"/> is <c>null</c> or empty.</throws> - internal static bool IsValidOpenDocumentTextDocument(Stream stream) + private static bool IsBasicValidManifest(ZipArchiveEntry manifestEntry) { - if (stream == null || stream.Length == 0) + try { - throw new ArgumentNullException(nameof(stream)); + using var manifestStream = manifestEntry.Open(); + using var xmlReader = XmlReader.Create(manifestStream, new XmlReaderSettings + { + DtdProcessing = DtdProcessing.Prohibit, + IgnoreComments = true, + IgnoreProcessingInstructions = true, + IgnoreWhitespace = true, + }); + + while (xmlReader.Read()) + { + if (xmlReader.NodeType != XmlNodeType.Element) + { + continue; + } + + var fullPath = xmlReader.GetAttribute("full-path") ?? xmlReader.GetAttribute("manifest:full-path"); + if (string.IsNullOrEmpty(fullPath)) + { + continue; + } + + fullPath = fullPath.Replace('\\', '/'); + + if (fullPath.StartsWith("/", StringComparison.Ordinal)) + { + // OK: package root entry + continue; + } + + // Prevent aboslute paths, path traversal, and scheme/drive letters + if (fullPath.StartsWith("../", StringComparison.Ordinal) + || fullPath.IndexOf("..\\", StringComparison.Ordinal) >= 0 + || fullPath.IndexOf(":", StringComparison.Ordinal) >= 0) + { + return false; + } + } + + return true; } + catch + { + // Failed to parse manifest.xml + return false; + } + } - using (var archive = new ZipArchive(stream, ZipArchiveMode.Read)) + /// <summary> + /// Read the content of the given ZIP archive entry as UTF-8 string up to the specified maximum number of bytes. + /// </summary> + /// <param name="entry">ZIP archive entry to read.</param> + /// <param name="maxBytes">Maximum number of bytes to read.</param> + /// <returns>Content of the ZIP archive entry as UTF-8 string, or null if reading failed or exceeded maxBytes.</returns> + private static string? Read(ZipArchiveEntry entry, int maxBytes) + { + try { - var mimetypeEntry = archive.GetEntry("mimetype"); - var contentXmlEntry = archive.GetEntry("content.xml"); + using var entryStream = entry.Open(); + using var memoryStream = new MemoryStream(); + var buffer = new byte[4096]; - if (mimetypeEntry == null || contentXmlEntry == null) + int read; + int total = 0; + + while ((read = entryStream.Read(buffer, 0, buffer.Length)) > 0) { - return false; + total += read; + if (total > maxBytes) + { + return null; + } + + memoryStream.Write(buffer, 0, read); } - } - return true; + return Encoding.ASCII.GetString(memoryStream.ToArray()); + } + catch + { + return null; + } } } } diff --git a/src/ByteGuard.FileValidator/Validators/ZipValidator.cs b/src/ByteGuard.FileValidator/Validators/ZipValidator.cs deleted file mode 100644 index 45a1f67..0000000 --- a/src/ByteGuard.FileValidator/Validators/ZipValidator.cs +++ /dev/null @@ -1,122 +0,0 @@ -using System.IO.Compression; -using ByteGuard.FileValidator.Configuration; -using ByteGuard.FileValidator.Exceptions; - -namespace ByteGuard.FileValidator.Validators; - -internal static class ZipValidator -{ - /// <summary> - /// Validate ZIP archive. - /// </summary> - /// <param name="zipStream">ZIP content stream.</param> - /// <param name="options">ZIP validation configuration.</param> - public static void Validate(Stream zipStream, ZipValidationConfiguration options) - { - // Only perform validation if enabled. - if (!options.Enabled) return; - - if (zipStream is null || zipStream.Length == 0) - { - throw new ArgumentNullException(nameof(zipStream), "Stream cannot be null or empty when validating file signature."); - } - - if (!zipStream.CanRead) - { - throw new InvalidOperationException("Stream is not readable."); - } - - if (!zipStream.CanSeek) - { - throw new InvalidOperationException("Stream is not seekable."); - } - - zipStream.Seek(0, SeekOrigin.Begin); - - using (var archive = new ZipArchive(zipStream, ZipArchiveMode.Read, leaveOpen: true)) - { - // Validat maximum number of entries. - if (options.MaxEntriesEnabled && archive.Entries.Count > options.MaxEntries) - { - throw new InvalidZipArchiveException($"The total count of entries exceeds the defined maximum of {options.MaxEntries}"); - } - - long totalUncompressed = 0; - foreach (var entry in archive.Entries) - { - // Validate suspicious paths. - if (options.RejectSuspiciousPaths && IsSuspiciousZipPath(entry.FullName)) - { - throw new InvalidZipArchiveException($"Suspicious ZIP entry path: '{entry.FullName}'"); - } - - long uncompressed = entry.Length; - long compressed = entry.CompressedLength; - - if (uncompressed < 0 || compressed < 0) - { - throw new InvalidZipArchiveException($"'{entry.FullName}' has invalid size metadata."); - } - - // Validate uncompressed size. - if (options.EntryUncompressedSizeLimitEnabled && uncompressed > options.EntryUncompressedSizeLimit) - { - throw new InvalidZipArchiveException($"'{entry.FullName}' too large uncompressed ({compressed} > {options.EntryUncompressedSizeLimit})."); - } - - // Validate total uncompressed size. - totalUncompressed += uncompressed; - if (options.TotalUncompressedSizeLimitEnabled && totalUncompressed > options.TotalUncompressedSizeLimit) - { - throw new InvalidZipArchiveException($"ZIP total uncompressed too large ({totalUncompressed} > {options.TotalUncompressedSizeLimit})"); - } - - // Validate compression rate. - if (uncompressed > 0) - { - if (compressed == 0) - { - throw new InvalidZipArchiveException($"Entry {entry.FullName} has 0 compressed bytes but {uncompressed} bytes."); - } - - var ratio = (double)uncompressed / compressed; - if (options.CompressionRateLimitEnabled && ratio > options.CompressionRateLimit) - { - throw new InvalidZipArchiveException($"Entry {entry.FullName} compression rate to high ({ratio:0.0}:1 > {options.CompressionRateLimit:0.0}:1)."); - } - } - } - } - } - - /// <summary> - /// Check whether the given name of a ZIP-archive entry looks suspicious. - /// </summary> - /// <remarks> - /// Checks root (e.g. <c>/</c>, <c>\\</c>), drive letters (e.g. <c>C:</c>, <c>D:</c>) and path traversal (e.g., <c>../</c>, <c>\\..</c>, <c>..</c>) - /// </remarks> - /// <param name="fullName">Entry fulle name.</param> - /// <returns><c>true</c> if the name looks suspicious, <c>false</c> otherwise.</returns> - private static bool IsSuspiciousZipPath(string fullName) - { - if (string.IsNullOrWhiteSpace(fullName)) return true; - - // Absolute paths. - if (fullName.StartsWith("/", StringComparison.Ordinal) || - fullName.StartsWith("\\", StringComparison.Ordinal)) - return true; - - // Drive letters (Windows). - if (fullName.Length >= 2 && char.IsLetter(fullName[0]) && fullName[1] == ':') - return true; - - // Path traversal. - var normalized = fullName.Replace('\\', '/'); - if (normalized.IndexOf("../", StringComparison.Ordinal) >= 0 || - normalized.IndexOf("/..", StringComparison.Ordinal) >= 0 || - normalized.StartsWith("..", StringComparison.Ordinal)) - return true; - - return false; - } -} diff --git a/tests/ByteGuard.FileValidator.Tests.Unit/ConfigurationValidatorTests.cs b/tests/ByteGuard.FileValidator.Tests.Unit/ConfigurationValidatorTests.cs index 66eff48..c3818b8 100644 --- a/tests/ByteGuard.FileValidator.Tests.Unit/ConfigurationValidatorTests.cs +++ b/tests/ByteGuard.FileValidator.Tests.Unit/ConfigurationValidatorTests.cs @@ -94,181 +94,4 @@ public void ThrowIfInvalid_FileSizeLimitIsLessThanOrEqualToZero_ShouldThrowArgum // Assert Assert.Throws<ArgumentException>(act); } - - [Fact(DisplayName = "ThrowIfInvalid should throw ArgumentNullException if the ZIP validatiomn options is null")] - public void ThrowIfInvalid_NullZipValidationConfiguration_ShouldThrowArgumentNullException() - { - // Arrange - var config = new FileValidatorConfiguration - { - SupportedFileTypes = new() { ".jpg" }, - FileSizeLimit = 25, - ZipValidationConfiguration = null! - }; - - // Act - Action act = () => new FileValidator(config); - - // Assert - Assert.Throws<ArgumentNullException>(act); - } - - [Fact(DisplayName = "ThrowIfInvalid should not throw any exception if the ZIP validation options is disabled though values are invalid")] - public void ThrowIfInvalid_ZipValidationNotEnabledWithIncorrectValues_ShouldPassValidation() - { - // Arrange - var config = new FileValidatorConfiguration - { - SupportedFileTypes = new() { ".jpg" }, - FileSizeLimit = 25, - ZipValidationConfiguration = new() - { - Enabled = false, - MaxEntries = -25, - TotalUncompressedSizeLimit = -25, - EntryUncompressedSizeLimit = -10, - CompressionRateLimit = double.PositiveInfinity - } - }; - - // Act - var exception = Record.Exception(() => new FileValidator(config)); - - // Assert - Assert.Null(exception); - } - - [Theory(DisplayName = "ThrowIfInvalid should throw ArgumentException if MaxEntries is invalid")] - [InlineData(0)] - [InlineData(-25)] - public void ThrowIfInvalid_MaxEntriesIsInvalid_ShouldThrowArgumentException(int value) - { - // Arrange - var config = new FileValidatorConfiguration - { - SupportedFileTypes = new() { ".jpg" }, - FileSizeLimit = 25, - ZipValidationConfiguration = new() - { - Enabled = true, - MaxEntries = value, - TotalUncompressedSizeLimit = -1, - EntryUncompressedSizeLimit = -1, - CompressionRateLimit = -1 - } - }; - - // Act - Action act = () => new FileValidator(config); - - // Assert - Assert.Throws<ArgumentException>(act); - } - - [Theory(DisplayName = "ThrowIfInvalid should throw ArgumentException if TotalUncompressedSizeLimit is invalid")] - [InlineData(0)] - [InlineData(-25)] - public void ThrowIfInvalid_TotalUncompressedSizeLimitIsInvalid_ShouldThrowArgumentException(long value) - { - // Arrange - var config = new FileValidatorConfiguration - { - SupportedFileTypes = new() { ".jpg" }, - FileSizeLimit = 25, - ZipValidationConfiguration = new() - { - Enabled = true, - MaxEntries = -1, - TotalUncompressedSizeLimit = value, - EntryUncompressedSizeLimit = -1, - CompressionRateLimit = -1 - } - }; - - // Act - Action act = () => new FileValidator(config); - - // Assert - Assert.Throws<ArgumentException>(act); - } - - [Theory(DisplayName = "ThrowIfInvalid should throw ArgumentException if EntryUncompressedSizeLimit is invalid")] - [InlineData(0)] - [InlineData(-25)] - public void ThrowIfInvalid_EntryUncompressedSizeLimitIsInvalid_ShouldThrowArgumentException(long value) - { - // Arrange - var config = new FileValidatorConfiguration - { - SupportedFileTypes = new() { ".jpg" }, - FileSizeLimit = 25, - ZipValidationConfiguration = new() - { - Enabled = true, - MaxEntries = -1, - TotalUncompressedSizeLimit = -1, - EntryUncompressedSizeLimit = value, - CompressionRateLimit = -1 - } - }; - - // Act - Action act = () => new FileValidator(config); - - // Assert - Assert.Throws<ArgumentException>(act); - } - - [Fact(DisplayName = "ThrowIfInvalid should throw ArgumentException if EntryUncompressedSizeLimit is greater than TotalUncompressedSizeLimit")] - public void ThrowIfInvalid_EntryCompressedSizeLimitIsGreaterThanTotalUncompressedSizeLimit_ShouldThrowArgumentException() - { - // Arrange - var config = new FileValidatorConfiguration - { - SupportedFileTypes = new() { ".jpg" }, - FileSizeLimit = 25, - ZipValidationConfiguration = new() - { - Enabled = true, - MaxEntries = -1, - TotalUncompressedSizeLimit = 25, - EntryUncompressedSizeLimit = 30, - CompressionRateLimit = -1 - } - }; - - // Act - Action act = () => new FileValidator(config); - - // Assert - Assert.Throws<ArgumentException>(act); - } - - [Theory(DisplayName = "ThrowIfInvalid should throw ArgumentException if CompressionRateLimit is invalid")] - [InlineData(0)] - [InlineData(-25)] - [InlineData(double.PositiveInfinity)] - public void ThrowIfInvalid_CompressionRateLimitIsInvalid_ShouldThrowArgumentException(double value) - { - // Arrange - var config = new FileValidatorConfiguration - { - SupportedFileTypes = new() { ".jpg" }, - FileSizeLimit = 25, - ZipValidationConfiguration = new() - { - Enabled = true, - MaxEntries = -1, - TotalUncompressedSizeLimit = -1, - EntryUncompressedSizeLimit = -1, - CompressionRateLimit = value - } - }; - - // Act - Action act = () => new FileValidator(config); - - // Assert - Assert.Throws<ArgumentException>(act); - } } diff --git a/tests/ByteGuard.FileValidator.Tests.Unit/FileValidatorConfigurationBuilderTests.cs b/tests/ByteGuard.FileValidator.Tests.Unit/FileValidatorConfigurationBuilderTests.cs index 6bd5ad1..112802a 100644 --- a/tests/ByteGuard.FileValidator.Tests.Unit/FileValidatorConfigurationBuilderTests.cs +++ b/tests/ByteGuard.FileValidator.Tests.Unit/FileValidatorConfigurationBuilderTests.cs @@ -36,61 +36,6 @@ public void ThrowOnInvalidFiles_SetsThrowExceptionOnInvalidFileProperty() Assert.False(config.ThrowExceptionOnInvalidFile); } - [Fact(DisplayName = "DisableZipValidation should set Enabled to false")] - public void DisableZipValidation_ShouldSetEnabledToFalse() - { - // Arrange - var builder = new FileValidatorConfigurationBuilder(); - - // Act - builder.AllowFileTypes(".pdf") - .DisableZipValidation(); - - var config = builder.Build(); - - // Assert - Assert.False(config.ZipValidationConfiguration.Enabled); - } - - [Fact(DisplayName = "ConfigureZipValidation should populate all properties values as provided")] - public void ConfigureZipValidation_ShouldPopulateAllPropertiesValuesAsProvided() - { - // Arrange - var builder = new FileValidatorConfigurationBuilder(); - - var expected = new ZipValidationConfiguration() - { - Enabled = false, - MaxEntries = 10, - TotalUncompressedSizeLimit = 10, - EntryUncompressedSizeLimit = 9, - CompressionRateLimit = 20, - RejectSuspiciousPaths = false - }; - - // Act - builder.AllowFileTypes(".pdf") - .ConfigureZipValidation(options => - { - options.Enabled = expected.Enabled; - options.MaxEntries = expected.MaxEntries; - options.TotalUncompressedSizeLimit = expected.TotalUncompressedSizeLimit; - options.EntryUncompressedSizeLimit = expected.EntryUncompressedSizeLimit; - options.CompressionRateLimit = expected.CompressionRateLimit; - options.RejectSuspiciousPaths = expected.RejectSuspiciousPaths; - }); - - var config = builder.Build(); - - // Assert - Assert.Equal(config.ZipValidationConfiguration.Enabled, expected.Enabled); - Assert.Equal(config.ZipValidationConfiguration.MaxEntries, expected.MaxEntries); - Assert.Equal(config.ZipValidationConfiguration.TotalUncompressedSizeLimit, expected.TotalUncompressedSizeLimit); - Assert.Equal(config.ZipValidationConfiguration.EntryUncompressedSizeLimit, expected.EntryUncompressedSizeLimit); - Assert.Equal(config.ZipValidationConfiguration.CompressionRateLimit, expected.CompressionRateLimit); - Assert.Equal(config.ZipValidationConfiguration.RejectSuspiciousPaths, expected.RejectSuspiciousPaths); - } - [Fact(DisplayName = "Build throws exception when configuration is invalid")] public void Build_ThrowsException_WhenConfigurationIsInvalid() { diff --git a/tests/ByteGuard.FileValidator.Tests.Unit/FileValidatorTests.cs b/tests/ByteGuard.FileValidator.Tests.Unit/FileValidatorTests.cs index f1ff043..35bd941 100644 --- a/tests/ByteGuard.FileValidator.Tests.Unit/FileValidatorTests.cs +++ b/tests/ByteGuard.FileValidator.Tests.Unit/FileValidatorTests.cs @@ -922,7 +922,6 @@ public void IsValidFile_ValidateOpenXmlFiles(string fileName, bool expectedResul [Theory(DisplayName = "IsValidFile(string, byte[]) should validate Open XML files")] [InlineData("ODT_test.odt", true)] // Valid ODT - [InlineData("ZIP_test_fake_ODT.odt", false)] // Invalid ODT public void IsValidFile_ValidateOpenDocumentFormatFiles(string fileName, bool expectedResult) { // Arrange @@ -1011,151 +1010,6 @@ public void IsValidFile_InvalidOpenXmlFiles_ShouldThrowInvalidOpenXmlFormatExcep Assert.Throws<InvalidOpenXmlFormatException>(act); } - [Theory(DisplayName = "IsValidFile(string, Stream) should throw InvalidZipArchiveException for entry names with suspicious paths")] - [InlineData("../evil.txt")] - [InlineData("..\\evil.txt")] - [InlineData("/evil.txt")] - [InlineData("\\evil.txt")] - [InlineData("C:/evil")] - [InlineData("C:\\evil")] - public void IsValidFile_SuspiciousFilePaths_ShouldThrowInvalidZipArchiveException(string entryName) - { - // Arrange - var config = new FileValidatorConfiguration - { - SupportedFileTypes = [FileExtensions.Docx], - FileSizeLimit = ByteSize.MegaBytes(25), - ThrowExceptionOnInvalidFile = true - }; - var fileValidator = new FileValidator(config); - - using var zipStream = ZipTestFactory.CreateZipWithEntry(entryName); - - // Act - Action act = () => fileValidator.IsValidFile("evil.docx", zipStream); - - // Assert - Assert.Throws<InvalidZipArchiveException>(act); - } - - [Fact(DisplayName = "IsValidFile(string, Stream) should throw InvalidZipArchiveException when MaxEntries is exceeded")] - public void IsValidFile_MaxEntriesExceeded_ShouldThrowInvalidZipArchiveException() - { - // Arrange - var config = new FileValidatorConfiguration - { - SupportedFileTypes = [FileExtensions.Docx], - FileSizeLimit = ByteSize.MegaBytes(25), - ThrowExceptionOnInvalidFile = true, - ZipValidationConfiguration = new() - { - MaxEntries = 3 - } - }; - var fileValidator = new FileValidator(config); - - var entries = new Dictionary<string, string>() - { - { "file1.txt", "Content 1" }, - { "file2.txt", "Content 2" }, - { "file3.txt", "Content 3" }, - { "file4.txt", "Content 4" } // Exceeds MaxEntries - }; - using var zipStream = ZipTestFactory.CreateZipWithEntries(entries); - - // Act - Action act = () => fileValidator.IsValidFile("exceed.docx", zipStream); - - // Assert - Assert.Throws<InvalidZipArchiveException>(act); - } - - [Fact(DisplayName = "IsValidFile(string, Stream) should throw InvalidZipArchiveException when EntryUncompressedSizeLimit is exceeded")] - public void IsValidFile_EntryUncompressedSizeLimitExceeded_ShouldThrowInvalidZipArchiveException() - { - // Arrange - var config = new FileValidatorConfiguration - { - SupportedFileTypes = [FileExtensions.Docx], - FileSizeLimit = ByteSize.MegaBytes(25), - ThrowExceptionOnInvalidFile = true, - ZipValidationConfiguration = new() - { - EntryUncompressedSizeLimit = 10 // 10 bytes - } - }; - var fileValidator = new FileValidator(config); - - using var zipStream = ZipTestFactory.CreateZipWithEntry("largefile.txt", new string('A', 20)); // 20 bytes, exceeds limit - - // Act - Action act = () => fileValidator.IsValidFile("exceed.docx", zipStream); - - // Assert - Assert.Throws<InvalidZipArchiveException>(act); - } - - [Fact(DisplayName = "IsValidFile(string, Stream) should throw InvalidZipArchiveException when CompressionRateLimit is exceeded")] - public void IsValidFile_CompressionRateLimitExceeded_ShouldThrowInvalidZipArchiveException() - { - // Arrange - var zipBytes = ZipTestFactory.CreateZipWithHiglyCompressibleEntry(uncompressedBytes: 5 * 1024 * 1024); // 5 MB - var actualRatio = ZipTestFactory.GetCompressionRate(zipBytes); - - var config = new FileValidatorConfiguration - { - SupportedFileTypes = [FileExtensions.Docx], - FileSizeLimit = ByteSize.MegaBytes(25), - ThrowExceptionOnInvalidFile = true, - ZipValidationConfiguration = new() - { - CompressionRateLimit = actualRatio - 0.1 // Set limit just below actual ratio to trigger exception - } - }; - var fileValidator = new FileValidator(config); - - using var zipStream = new MemoryStream(zipBytes); - - // Act - Action act = () => fileValidator.IsValidFile("exceed.docx", zipStream); - - // Assert - Assert.Throws<InvalidZipArchiveException>(act); - } - - [Fact(DisplayName = "IsValidFile(string, Stream) should throw InvalidZipArchiveException when TotalUncompressedSizeLimit is exceeded")] - public void IsValidFile_TotalUncompressedSizeLimitExceeded_ShouldThrowInvalidZipArchiveException() - { - // Arrange - var zipBytes = ZipTestFactory.CreateZipWithFixedSizeEntries(entryCount: 3, bytesPerEntry: 10); // Total 30 bytes - - var config = new FileValidatorConfiguration - { - SupportedFileTypes = [FileExtensions.Docx], - FileSizeLimit = ByteSize.MegaBytes(25), - ThrowExceptionOnInvalidFile = true, - ZipValidationConfiguration = new() - { - RejectSuspiciousPaths = false, - - MaxEntries = -1, - EntryUncompressedSizeLimit = -1, - CompressionRateLimit = -1, - - TotalUncompressedSizeLimit = 29 // Set limit below total size to trigger exception - } - }; - var fileValidator = new FileValidator(config); - - using var zipStream = new MemoryStream(zipBytes); - - // Act - Action act = () => fileValidator.IsValidFile("exceed.docx", zipStream); - - // Assert - Assert.Throws<InvalidZipArchiveException>(act); - } - [Theory(DisplayName = "IsValidFile(string, byte[]) should throw InvalidOpenDocumentFormatException for invalid ODF files")] [InlineData("ZIP_test_fake_ODT.odt")] // Invalid ODT public void IsValidFile_InvalidOpenDocumentFormatFiles_ShouldThrowInvalidOpenDocumentFormatException(string fileName) From 984d1bfeae259b8ec40cf0f958d5c7d5004fcd74 Mon Sep 17 00:00:00 2001 From: chaadfh <133214342+chaadfh@users.noreply.github.com> Date: Tue, 24 Feb 2026 10:58:02 +0100 Subject: [PATCH 17/30] Convert OpenXML conformance validation to configurable validation setting (#24) --- .../Configuration/ConfigurationValidator.cs | 29 ++++++- .../FileValidatorConfiguration.cs | 5 ++ .../FileValidatorConfigurationBuilder.cs | 15 +++- .../Configuration/Rules/OpenXmlRules.cs | 22 +++++ .../Exceptions/InvalidZipArchiveException.cs | 25 ------ src/ByteGuard.FileValidator/FileValidator.cs | 24 +----- .../Validators/OpenXmlFormatValidator.cs | 83 +++++++++++++++---- .../ConfigurationValidatorTests.cs | 55 ++++++++++++ .../FileValidatorConfigurationBuilderTests.cs | 42 ++++++++++ 9 files changed, 234 insertions(+), 66 deletions(-) create mode 100644 src/ByteGuard.FileValidator/Configuration/Rules/OpenXmlRules.cs delete mode 100644 src/ByteGuard.FileValidator/Exceptions/InvalidZipArchiveException.cs diff --git a/src/ByteGuard.FileValidator/Configuration/ConfigurationValidator.cs b/src/ByteGuard.FileValidator/Configuration/ConfigurationValidator.cs index 631c94c..88cfcba 100644 --- a/src/ByteGuard.FileValidator/Configuration/ConfigurationValidator.cs +++ b/src/ByteGuard.FileValidator/Configuration/ConfigurationValidator.cs @@ -1,4 +1,5 @@ using ByteGuard.FileValidator.Exceptions; +using DocumentFormat.OpenXml; namespace ByteGuard.FileValidator.Configuration { @@ -46,14 +47,15 @@ public static void ThrowIfInvalid(FileValidatorConfiguration configuration) throw new ArgumentException("File size limit must be greater than zero.", nameof(configuration.FileSizeLimit)); } - ValidateOdfValidationConfiguration(configuration); + ValidateOdfRules(configuration); + ValidateOpenXmlRules(configuration); } /// <summary> - /// Validate the ODF validation options on the configuration object. + /// Validate the ODF rules on the configuration object. /// </summary> /// <param name="configuration">File validator configuration object.</param> - private static void ValidateOdfValidationConfiguration(FileValidatorConfiguration configuration) + private static void ValidateOdfRules(FileValidatorConfiguration configuration) { if (configuration.FileTypeRules.OdfRules == null) { @@ -62,5 +64,26 @@ private static void ValidateOdfValidationConfiguration(FileValidatorConfiguratio $"{nameof(configuration.FileTypeRules.OdfRules)} cannot be null."); } } + + /// <summary> + /// Validate the Open XML rules on the configuration object. + /// </summary> + /// <param name="configuration">File validator configuration object.</param> + private static void ValidateOpenXmlRules(FileValidatorConfiguration configuration) + { + if (configuration.FileTypeRules.OpenXmlRules == null) + { + throw new ArgumentNullException( + nameof(configuration.FileTypeRules.OpenXmlRules), + $"{nameof(configuration.FileTypeRules.OpenXmlRules)} cannot be null."); + } + + if (configuration.FileTypeRules.OpenXmlRules.ConformanceVersion == FileFormatVersions.None) + { + throw new ArgumentException( + $"{nameof(configuration.FileTypeRules.OpenXmlRules.ConformanceVersion)} cannot be 'None'.", + nameof(configuration.FileTypeRules.OpenXmlRules.ConformanceVersion)); + } + } } } diff --git a/src/ByteGuard.FileValidator/Configuration/FileValidatorConfiguration.cs b/src/ByteGuard.FileValidator/Configuration/FileValidatorConfiguration.cs index c8bde27..4cdc305 100644 --- a/src/ByteGuard.FileValidator/Configuration/FileValidatorConfiguration.cs +++ b/src/ByteGuard.FileValidator/Configuration/FileValidatorConfiguration.cs @@ -45,5 +45,10 @@ public class FileTypeRules /// OpenDocument Format validation rules. /// </summary> public OdfValidationRules OdfRules { get; set; } = new(); + + /// <summary> + /// Open XML validation rules. + /// </summary> + public OpenXmlRules OpenXmlRules { get; set; } = new(); } } diff --git a/src/ByteGuard.FileValidator/Configuration/FileValidatorConfigurationBuilder.cs b/src/ByteGuard.FileValidator/Configuration/FileValidatorConfigurationBuilder.cs index 9ad4a5e..8a4d30a 100644 --- a/src/ByteGuard.FileValidator/Configuration/FileValidatorConfigurationBuilder.cs +++ b/src/ByteGuard.FileValidator/Configuration/FileValidatorConfigurationBuilder.cs @@ -10,7 +10,9 @@ public class FileValidatorConfigurationBuilder private readonly List<string> supportedFileTypes = new List<string>(); private bool throwOnInvalidFiles = true; private long fileSizeLimit = ByteSize.MegaBytes(25); + private OdfValidationRules odfValidationRules = new(); + private OpenXmlRules openXmlRules = new(); /// <summary> /// Allow specific file types (extensions) to be validated. @@ -50,7 +52,17 @@ public FileValidatorConfigurationBuilder SetFileSizeLimit(long inFileSizeLimit) /// <param name="configure">Configuration action.</param> public FileValidatorConfigurationBuilder ConfigureOdfValidationRules(Action<OdfValidationRules> configure) { - configure?.Invoke(odfValidationRules); + configure.Invoke(odfValidationRules); + return this; + } + + /// <summary> + /// Configure the Open XML validation rules. + /// </summary> + /// <param name="configure">Configuration action.</param> + public FileValidatorConfigurationBuilder ConfigureOpenXmlValidationRules(Action<OpenXmlRules> configure) + { + configure.Invoke(openXmlRules); return this; } @@ -68,6 +80,7 @@ public FileValidatorConfiguration Build() }; configuration.FileTypeRules.OdfRules = odfValidationRules; + configuration.FileTypeRules.OpenXmlRules = openXmlRules; ConfigurationValidator.ThrowIfInvalid(configuration); diff --git a/src/ByteGuard.FileValidator/Configuration/Rules/OpenXmlRules.cs b/src/ByteGuard.FileValidator/Configuration/Rules/OpenXmlRules.cs new file mode 100644 index 0000000..a6c5712 --- /dev/null +++ b/src/ByteGuard.FileValidator/Configuration/Rules/OpenXmlRules.cs @@ -0,0 +1,22 @@ +using DocumentFormat.OpenXml; + +namespace ByteGuard.FileValidator.Configuration.Rules; + +/// <summary> +/// Validation rules for Open XML files. +/// </summary> +public class OpenXmlRules +{ + /// <summary> + /// Whether to perform conformance validation. Defaults to <c>true</c>. + /// </summary> + public bool PerformConformanceValidation { get; set; } = true; + + /// <summary> + /// Version to use for conformance validation if enabled. Defaults to <c>Office2007</c>. + /// </summary> + /// <remarks> + /// See <see cref="FileFormatVersions"/> form the <c>DocumentFormat.OpenXml</c> NuGet package. + /// </remarks> + public FileFormatVersions ConformanceVersion { get; set; } = FileFormatVersions.Office2007; +} diff --git a/src/ByteGuard.FileValidator/Exceptions/InvalidZipArchiveException.cs b/src/ByteGuard.FileValidator/Exceptions/InvalidZipArchiveException.cs deleted file mode 100644 index 7e48fb0..0000000 --- a/src/ByteGuard.FileValidator/Exceptions/InvalidZipArchiveException.cs +++ /dev/null @@ -1,25 +0,0 @@ -namespace ByteGuard.FileValidator.Exceptions; - -/// <summary> -/// Exception type used specifically when a given file is invalid based on the ZIP validation. -/// </summary> -public class InvalidZipArchiveException : Exception -{ - /// <summary> - /// Construct a new <see cref="InvalidZipArchiveException"/> to indicate that the internal - /// ZIP-archive structure in the provided file is not valid based on the defined validation configurations. - /// </summary> - public InvalidZipArchiveException() - : base("ZIP-archive is invalid based on the defined validation configurations.") - { - } - - /// <summary> - /// Construct a new <see cref="InvalidZipArchiveException"/> to indicate that the internal - /// ZIP-archive structure in the provided file is not valid based on the defined validation configurations. - /// </summary> - /// <param name="message">Custom exception message.</param> - public InvalidZipArchiveException(string message) : base(message) - { - } -} diff --git a/src/ByteGuard.FileValidator/FileValidator.cs b/src/ByteGuard.FileValidator/FileValidator.cs index 06bbff5..98fa726 100644 --- a/src/ByteGuard.FileValidator/FileValidator.cs +++ b/src/ByteGuard.FileValidator/FileValidator.cs @@ -801,17 +801,17 @@ public bool IsValidOpenXmlDocument(string fileName, Stream stream) { case FileExtensions.Docx: { - isValid = OpenXmlFormatValidator.IsValidWordDocument(stream); + isValid = OpenXmlFormatValidator.IsValidWordDocument(stream, _configuration.FileTypeRules.OpenXmlRules); break; } case FileExtensions.Xlsx: { - isValid = OpenXmlFormatValidator.IsValidSpreadsheetDocument(stream); + isValid = OpenXmlFormatValidator.IsValidSpreadsheetDocument(stream, _configuration.FileTypeRules.OpenXmlRules); break; } case FileExtensions.Pptx: { - isValid = OpenXmlFormatValidator.IsValidPresentationDocument(stream); + isValid = OpenXmlFormatValidator.IsValidPresentationDocument(stream, _configuration.FileTypeRules.OpenXmlRules); break; } default: @@ -862,15 +862,6 @@ public bool IsValidOpenXmlDocument(string fileName, Stream stream) throw; } - return false; - } - catch (InvalidZipArchiveException) - { - if (_configuration.ThrowExceptionOnInvalidFile) - { - throw; - } - return false; } } @@ -996,15 +987,6 @@ public bool IsValidOpenDocumentFormat(string fileName, Stream stream) throw new InvalidOpenDocumentFormatException("The provided file is not a valid Open Document Format file. See inner exception for details.", e); } - return false; - } - catch (InvalidZipArchiveException) - { - if (_configuration.ThrowExceptionOnInvalidFile) - { - throw; - } - return false; } } diff --git a/src/ByteGuard.FileValidator/Validators/OpenXmlFormatValidator.cs b/src/ByteGuard.FileValidator/Validators/OpenXmlFormatValidator.cs index 594e46e..c36e4ae 100644 --- a/src/ByteGuard.FileValidator/Validators/OpenXmlFormatValidator.cs +++ b/src/ByteGuard.FileValidator/Validators/OpenXmlFormatValidator.cs @@ -1,4 +1,5 @@ -using DocumentFormat.OpenXml; +using ByteGuard.FileValidator.Configuration.Rules; +using DocumentFormat.OpenXml; using DocumentFormat.OpenXml.Packaging; using DocumentFormat.OpenXml.Validation; using ByteGuard.FileValidator.Exceptions; @@ -17,10 +18,11 @@ internal static class OpenXmlFormatValidator /// Whether the given content stream is a valid Word document. /// </summary> /// <param name="stream">Stream in question.</param> + /// <param name="rules">Open XML specific validation rules.</param> /// <returns><c>true</c> if valid, <c>false</c> otherwise.</returns> /// <exception cref="ArgumentNullException">Thrown if the provided stream is null.</exception> /// <exception cref="InvalidOpenXmlFormatException">Thrown if document type is not supported (macros, templates).</exception> - internal static bool IsValidWordDocument(Stream stream) + internal static bool IsValidWordDocument(Stream stream, OpenXmlRules rules) { if (stream == null || stream.Length == 0) { @@ -42,11 +44,25 @@ internal static bool IsValidWordDocument(Stream stream) throw new InvalidOpenXmlFormatException("Document is a template."); } - // Validate structure. - var validator = new OpenXmlValidator(); - if (validator.Validate(wordDocument).Any()) + // Base structure validation + if (wordDocument.MainDocumentPart == null) { - return false; + throw new InvalidOpenXmlFormatException("Unable to retrieve main document part in document."); + } + + if (wordDocument.MainDocumentPart.Document == null) + { + throw new InvalidOpenXmlFormatException("Document does not adhere to required format."); + } + + // Validate specification conformance. + if (rules.PerformConformanceValidation) + { + var validator = new OpenXmlValidator(rules.ConformanceVersion); + if (validator.Validate(wordDocument).Any()) + { + return false; + } } } @@ -57,10 +73,11 @@ internal static bool IsValidWordDocument(Stream stream) /// Whether the given content stream is a valid spreadsheet (Excel). /// </summary> /// <param name="stream">Stream in question.</param> + /// <param name="rules">Open XML specific validation rules.</param> /// <returns><c>true</c> if valid, <c>false</c> otherwise.</returns> /// <exception cref="ArgumentNullException">Thrown if the provided stream is null.</exception> /// <exception cref="InvalidOpenXmlFormatException">Thrown if document type is not supported (macros, add-ins, templates).</exception> - internal static bool IsValidSpreadsheetDocument(Stream stream) + internal static bool IsValidSpreadsheetDocument(Stream stream, OpenXmlRules rules) { if (stream == null || stream.Length == 0) { @@ -88,11 +105,30 @@ internal static bool IsValidSpreadsheetDocument(Stream stream) throw new InvalidOpenXmlFormatException("Spreadsheet is a template."); } - // Validate structure. - var validator = new OpenXmlValidator(); - if (validator.Validate(spreadsheetDocument).Any()) + // Base structure validation + if (spreadsheetDocument.WorkbookPart == null) + { + throw new InvalidOpenXmlFormatException("Unable to retrieve workbook part in spreadsheet."); + } + + if (spreadsheetDocument.WorkbookPart.Workbook == null) + { + throw new InvalidOpenXmlFormatException("Spreadsheet does not adhere to required format."); + } + + if (!spreadsheetDocument.WorkbookPart.WorksheetParts.Any()) { - return false; + throw new InvalidOpenXmlFormatException("Spreadsheet does not contain any worksheets."); + } + + // Validate specification conformance. + if (rules.PerformConformanceValidation) + { + var validator = new OpenXmlValidator(rules.ConformanceVersion); + if (validator.Validate(spreadsheetDocument).Any()) + { + return false; + } } } @@ -103,10 +139,11 @@ internal static bool IsValidSpreadsheetDocument(Stream stream) /// Whether the given content stream is a valid presentation (PowerPoint). /// </summary> /// <param name="stream">Stream in question.</param> + /// <param name="rules">Open XML specific validation rules.</param> /// <returns><c>true</c> if valid, <c>false</c> otherwise.</returns> /// <exception cref="ArgumentNullException">Thrown if the provided stream is null.</exception> /// <exception cref="InvalidOpenXmlFormatException">Thrown if document type is not supported (macros, add-ins, templates).</exception> - internal static bool IsValidPresentationDocument(Stream stream) + internal static bool IsValidPresentationDocument(Stream stream, OpenXmlRules rules) { if (stream == null || stream.Length == 0) { @@ -135,11 +172,25 @@ internal static bool IsValidPresentationDocument(Stream stream) throw new InvalidOpenXmlFormatException("Presentation is a template."); } - // Validate structure. - var validator = new OpenXmlValidator(); - if (validator.Validate(presentationDocument).Any()) + // Base structure validation + if (presentationDocument.PresentationPart == null) + { + throw new InvalidOpenXmlFormatException("Unable to retrieve presentation part in presentation."); + } + + if (!presentationDocument.PresentationPart.SlideParts.Any()) + { + throw new InvalidOpenXmlFormatException("Presentation does not contain any slides."); + } + + // Validate specification conformance. + if (rules.PerformConformanceValidation) { - return false; + var validator = new OpenXmlValidator(rules.ConformanceVersion); + if (validator.Validate(presentationDocument).Any()) + { + return false; + } } } diff --git a/tests/ByteGuard.FileValidator.Tests.Unit/ConfigurationValidatorTests.cs b/tests/ByteGuard.FileValidator.Tests.Unit/ConfigurationValidatorTests.cs index c3818b8..72cf3cc 100644 --- a/tests/ByteGuard.FileValidator.Tests.Unit/ConfigurationValidatorTests.cs +++ b/tests/ByteGuard.FileValidator.Tests.Unit/ConfigurationValidatorTests.cs @@ -1,5 +1,6 @@ using ByteGuard.FileValidator.Configuration; using ByteGuard.FileValidator.Exceptions; +using DocumentFormat.OpenXml; namespace ByteGuard.FileValidator.Tests.Unit; @@ -94,4 +95,58 @@ public void ThrowIfInvalid_FileSizeLimitIsLessThanOrEqualToZero_ShouldThrowArgum // Assert Assert.Throws<ArgumentException>(act); } + + [Fact(DisplayName = "ThrowIfInvalid should throw ArgumentNullException if OdfRules is null")] + public void ThrowIfInvalid_OdfRulesIsNull_ShouldThrowArgumentNullException() + { + // Arrange + var config = new FileValidatorConfiguration + { + SupportedFileTypes = new() { ".odt" }, + FileSizeLimit = ByteSize.MegaBytes(25) + }; + config.FileTypeRules.OdfRules = null!; + + // Act + Action act = () => new FileValidator(config); + + // Assert + Assert.Throws<ArgumentNullException>(act); + } + + [Fact(DisplayName = "ThrowIfInvalid should throw ArgumentNullException if OpenXmlRules is null")] + public void ThrowIfInvalid_OpenXmlRulesIsNull_ShouldThrowArgumentNullException() + { + // Arrange + var config = new FileValidatorConfiguration + { + SupportedFileTypes = new() { ".docx" }, + FileSizeLimit = ByteSize.MegaBytes(25) + }; + config.FileTypeRules.OpenXmlRules = null!; + + // Act + Action act = () => new FileValidator(config); + + // Assert + Assert.Throws<ArgumentNullException>(act); + } + + [Fact(DisplayName = "ThrowIfInvalid should throw ArgumentException if OpenXmlRules.ConformanceVersion is 'None'")] + public void ThrowIfInvalid_OpenXmlConformanceVersionIsNone_ShouldThrowArgumentException() + { + // Arrange + var config = new FileValidatorConfiguration + { + SupportedFileTypes = new() { ".docx" }, + FileSizeLimit = ByteSize.MegaBytes(25) + }; + config.FileTypeRules.OpenXmlRules.ConformanceVersion = FileFormatVersions.None; + + // Act + Action act = () => new FileValidator(config); + + // Assert + Assert.Throws<ArgumentException>(act); + } } diff --git a/tests/ByteGuard.FileValidator.Tests.Unit/FileValidatorConfigurationBuilderTests.cs b/tests/ByteGuard.FileValidator.Tests.Unit/FileValidatorConfigurationBuilderTests.cs index 112802a..11c7474 100644 --- a/tests/ByteGuard.FileValidator.Tests.Unit/FileValidatorConfigurationBuilderTests.cs +++ b/tests/ByteGuard.FileValidator.Tests.Unit/FileValidatorConfigurationBuilderTests.cs @@ -1,4 +1,5 @@ using ByteGuard.FileValidator.Configuration; +using DocumentFormat.OpenXml; namespace ByteGuard.FileValidator.Tests.Unit; @@ -49,4 +50,45 @@ public void Build_ThrowsException_WhenConfigurationIsInvalid() // Act & Assert Assert.ThrowsAny<Exception>(act); } + + [Fact(DisplayName = "ConfigureOdfValidationRules sets the correct values on the configuration object")] + public void ConfigureOdfValidationRules_SetsCorrectValues() + { + // Arrange + var builder = new FileValidatorConfigurationBuilder() + .AllowFileTypes(".odt"); + + // Act + builder.ConfigureOdfValidationRules(config => + { + config.RequireMimetype = false; + }); + + var config = builder.Build(); + + // Assert + Assert.False(config.FileTypeRules.OdfRules.RequireMimetype); + } + + [Fact(DisplayName = "ConfigureOpenXmlValidationRules sets the correct values on the configuration object")] + public void ConfigureOpenXmlValidationRules_SetsCorrectValues() + { + // Arrange + var expectedConformanceVersion = FileFormatVersions.Office2016; + var builder = new FileValidatorConfigurationBuilder() + .AllowFileTypes(".docx"); + + // Act + builder.ConfigureOpenXmlValidationRules(config => + { + config.PerformConformanceValidation = true; + config.ConformanceVersion = expectedConformanceVersion; + }); + + var config = builder.Build(); + + // Assert + Assert.True(config.FileTypeRules.OdfRules.RequireMimetype); + Assert.Equal(expectedConformanceVersion, config.FileTypeRules.OpenXmlRules.ConformanceVersion); + } } From aac98afcf369cc8dfa28439bb913cd586b82b553 Mon Sep 17 00:00:00 2001 From: Christian Haase <chrhaase@icloud.com> Date: Sat, 28 Feb 2026 22:25:06 +0100 Subject: [PATCH 18/30] Rename the ODF validation rules --- .../Configuration/FileValidatorConfiguration.cs | 2 +- .../Configuration/FileValidatorConfigurationBuilder.cs | 6 +++--- .../Rules/{OdfValidationConfiguration.cs => OdfRules.cs} | 2 +- .../Validators/OpenDocumentFormatValidator.cs | 6 +++--- 4 files changed, 8 insertions(+), 8 deletions(-) rename src/ByteGuard.FileValidator/Configuration/Rules/{OdfValidationConfiguration.cs => OdfRules.cs} (88%) diff --git a/src/ByteGuard.FileValidator/Configuration/FileValidatorConfiguration.cs b/src/ByteGuard.FileValidator/Configuration/FileValidatorConfiguration.cs index 4cdc305..84b6e5c 100644 --- a/src/ByteGuard.FileValidator/Configuration/FileValidatorConfiguration.cs +++ b/src/ByteGuard.FileValidator/Configuration/FileValidatorConfiguration.cs @@ -44,7 +44,7 @@ public class FileTypeRules /// <summary> /// OpenDocument Format validation rules. /// </summary> - public OdfValidationRules OdfRules { get; set; } = new(); + public OdfRules OdfRules { get; set; } = new(); /// <summary> /// Open XML validation rules. diff --git a/src/ByteGuard.FileValidator/Configuration/FileValidatorConfigurationBuilder.cs b/src/ByteGuard.FileValidator/Configuration/FileValidatorConfigurationBuilder.cs index 8a4d30a..1f904e7 100644 --- a/src/ByteGuard.FileValidator/Configuration/FileValidatorConfigurationBuilder.cs +++ b/src/ByteGuard.FileValidator/Configuration/FileValidatorConfigurationBuilder.cs @@ -10,8 +10,8 @@ public class FileValidatorConfigurationBuilder private readonly List<string> supportedFileTypes = new List<string>(); private bool throwOnInvalidFiles = true; private long fileSizeLimit = ByteSize.MegaBytes(25); - - private OdfValidationRules odfValidationRules = new(); + + private OdfRules odfValidationRules = new(); private OpenXmlRules openXmlRules = new(); /// <summary> @@ -50,7 +50,7 @@ public FileValidatorConfigurationBuilder SetFileSizeLimit(long inFileSizeLimit) /// Configure the OpenDocument Format validation rules. /// </summary> /// <param name="configure">Configuration action.</param> - public FileValidatorConfigurationBuilder ConfigureOdfValidationRules(Action<OdfValidationRules> configure) + public FileValidatorConfigurationBuilder ConfigureOdfValidationRules(Action<OdfRules> configure) { configure.Invoke(odfValidationRules); return this; diff --git a/src/ByteGuard.FileValidator/Configuration/Rules/OdfValidationConfiguration.cs b/src/ByteGuard.FileValidator/Configuration/Rules/OdfRules.cs similarity index 88% rename from src/ByteGuard.FileValidator/Configuration/Rules/OdfValidationConfiguration.cs rename to src/ByteGuard.FileValidator/Configuration/Rules/OdfRules.cs index 0b6325b..bb6da6a 100644 --- a/src/ByteGuard.FileValidator/Configuration/Rules/OdfValidationConfiguration.cs +++ b/src/ByteGuard.FileValidator/Configuration/Rules/OdfRules.cs @@ -3,7 +3,7 @@ /// <summary> /// Validation rules for OpenDocument Format files. /// </summary> -public class OdfValidationRules +public class OdfRules { /// <summary> /// Whether a valid mimetype file is required in the ODF package. Defaults to <c>true</c>. diff --git a/src/ByteGuard.FileValidator/Validators/OpenDocumentFormatValidator.cs b/src/ByteGuard.FileValidator/Validators/OpenDocumentFormatValidator.cs index 107a676..6fbac8a 100644 --- a/src/ByteGuard.FileValidator/Validators/OpenDocumentFormatValidator.cs +++ b/src/ByteGuard.FileValidator/Validators/OpenDocumentFormatValidator.cs @@ -32,7 +32,7 @@ internal static class OpenDocumentFormatValidator /// <param name="rules">ODF validation rules.</param> /// <returns><c>true</c> if valid, <c>false</c> otherwise.</returns> /// <throws cref="ArgumentNullException">Thrown if the provided <paramref name="stream"/> is <c>null</c> or empty.</throws> - internal static bool IsValidOpenDocumentFormatFile(string fileName, Stream stream, OdfValidationRules rules) + internal static bool IsValidOpenDocumentFormatFile(string fileName, Stream stream, OdfRules rules) { if (stream == null || stream.Length == 0) { @@ -52,7 +52,7 @@ internal static bool IsValidOpenDocumentFormatFile(string fileName, Stream strea /// <param name="archive">ODF ZIP archive.</param> /// <param name="rules">ODF validation rules.</param> /// <returns><c>true</c> if valid, <c>false</c> otherwise.</returns> - private static bool PerformOdfValidation(string fileName, ZipArchive archive, OdfValidationRules rules) + private static bool PerformOdfValidation(string fileName, ZipArchive archive, OdfRules rules) { var fileExtension = Path.GetExtension(fileName); if (!OdfMimetypeMappings.ContainsKey(fileExtension)) @@ -96,7 +96,7 @@ private static bool PerformOdfValidation(string fileName, ZipArchive archive, Od /// <param name="archive">ODF ZIP archive.</param> /// <param name="rules">ODF validation rules.</param> /// <returns><c>true</c> if valid, <c>false</c> otherwise.</returns> - private static bool IsValidMimetype(string fileName, ZipArchive archive, OdfValidationRules rules) + private static bool IsValidMimetype(string fileName, ZipArchive archive, OdfRules rules) { var mimetypeEntry = archive.GetEntry(MimetypeEntryName); if (mimetypeEntry is null) From 940efbe5af0cb1fcb51046f1572eea3f7a2b4a68 Mon Sep 17 00:00:00 2001 From: detilium <chrhaase@icloud.com> Date: Sun, 1 Mar 2026 09:25:32 +0100 Subject: [PATCH 19/30] Update readme [skip ci] --- .github/workflows/ci.yml | 41 +++++++++---------- README.md | 25 +++++++++-- .../Configuration/Rules/OpenXmlRules.cs | 4 +- 3 files changed, 43 insertions(+), 27 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8f86899..f87b405 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,10 +1,10 @@ -name: Continout Integration +name: Continous Integration on: push: - branches: [ "dev", "master" ] + branches: ["dev", "master"] pull_request: - branches: [ "dev", "master" ] + branches: ["dev", "master"] env: CI_BUILD_NUMBER_BASE: ${{ github.run_number }} @@ -13,7 +13,6 @@ env: jobs: build: - # We need to run on Windows as we're supporting .NET Framework runs-on: windows-latest @@ -21,21 +20,21 @@ jobs: contents: write steps: - - uses: actions/checkout@v5 - - name: Setup .NET - uses: actions/setup-dotnet@v5 - with: - dotnet-version: 10.0.x - - name: Compute and set build number - shell: bash - run: | + - uses: actions/checkout@v5 + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: 10.0.x + - name: Compute and set build number + shell: bash + run: | echo "CI_BUILD_NUMBER=$(($CI_BUILD_NUMBER_BASE+1000))" >> $GITHUB_ENV - - name: Build & Push - env: - PR_TRIGGER: ${{ env.PR_TRIGGER }} - DOTNET_CLI_TELEMTRY_OPTOUT: true - NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - shell: pwsh - run: | - ./Build.ps1 \ No newline at end of file + - name: Build & Push + env: + PR_TRIGGER: ${{ env.PR_TRIGGER }} + DOTNET_CLI_TELEMTRY_OPTOUT: true + NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: pwsh + run: | + ./Build.ps1 diff --git a/README.md b/README.md index 28b2df4..f3cfc66 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ It helps you enforce consistent file upload rules by checking: - ✅ Validate files by **extension** - ✅ Validate files by **size** - ✅ Validate files by **signature (_magic-numbers_)** -- ✅ Validate **security aspects** for archive-based formats (_Open XML and Open Document Formats_) +- ✅ Validate **specification conformance** for archive-based formats (_Open XML and Open Document Formats_) - ✅ **Ensure no malware** through a variety of antimalware scanners - ✅ Validate using file path, `Stream`, or `byte[]` - ✅ Configure which file types to support @@ -154,7 +154,7 @@ For some formats, additional checks are performed: - Extension - File size - Signature - - Archive-based security validation + - Basic specification conformance validation - Malware scan result - **Other binary formats**: @@ -173,6 +173,23 @@ The `FileValidatorConfiguration` supports: | `FileSizeLimit` | Yes | N/A | Maximum permitted size of files.<br>Use the static `ByteSize` class provided with this package, to simplify your limit. | | `ThrowExceptionOnInvalidFile` | No | `true` | Whether to throw an exception on invalid files or return `false`. | +### File type specific validation rules + +The `FileValidatorConfiguration` contains file type specific validation rules through `FileTypeRules`. These settings allow for fine control over validation rules for the individual file types, where supported. + +#### ODF rules + +| Setting | Default | Description | +| ------------------ | ------- | --------------------------------------------------------- | +|  `RequireMimetype` | `true` |  Whether a `mimetype` file is required to pass validation | + +#### Open XML rules + +| Setting | Default | Description | +| ------------------------------ | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `PerformConformanceValidation` | `true` |  Whether a conformance/specification validation should be performed as part of the seucirt validation | +| `ConformanceVersion` |  `Office2010` | Defines the version speification version to validate against (_valid options are defined by `FileFormatVersion` in [`DocumentFormat.OpenXml`](https://github.com/dotnet/Open-XML-SDK)_) | + ### Exceptions When `ThrowExceptionOnInvalidFile` is set to `true`, validation functions will throw one of the appropriate exceptions defined below. However, when `ThrowExceptionOnInvalidFile` is set to `false`, all validation functions will either return `true` or `false`. @@ -183,8 +200,8 @@ When `ThrowExceptionOnInvalidFile` is set to `true`, validation functions will t | `UnsupportedFileException` | Thrown when the file extension is not in the list of supported types. | | `InvalidFileSizeException` | Thrown when the file size exceeds the configured file size limit. | | `InvalidSignatureException` | Thrown when the file's signature does not match the expected signature for its type. | -| `InvalidOpenXmlFormatException` | Thrown when the internal structure of an Open XML file is invalid (`.docx`, `.xlsx`, `.pptx`, etc.). | -| `InvalidOpenDocumentFormatException` | Thrown when the specification conformance of an Open Document Format file is invalid (`.odt`, etc.). | +| `InvalidOpenXmlFormatException` | Thrown when the validation of an Open XML file is invalid (`.docx`, `.xlsx`, `.pptx`, etc.). | +| `InvalidOpenDocumentFormatException` | Thrown when the validation of an Open Document Format file is invalid (`.odt`, `.ods`, `.odp` etc.). | | `MalwareDetectedException` | Thrown when the configured antimalware scanner detected malware in the file from a scan result. | ## When to use this package diff --git a/src/ByteGuard.FileValidator/Configuration/Rules/OpenXmlRules.cs b/src/ByteGuard.FileValidator/Configuration/Rules/OpenXmlRules.cs index a6c5712..3f8e21c 100644 --- a/src/ByteGuard.FileValidator/Configuration/Rules/OpenXmlRules.cs +++ b/src/ByteGuard.FileValidator/Configuration/Rules/OpenXmlRules.cs @@ -11,12 +11,12 @@ public class OpenXmlRules /// Whether to perform conformance validation. Defaults to <c>true</c>. /// </summary> public bool PerformConformanceValidation { get; set; } = true; - + /// <summary> /// Version to use for conformance validation if enabled. Defaults to <c>Office2007</c>. /// </summary> /// <remarks> /// See <see cref="FileFormatVersions"/> form the <c>DocumentFormat.OpenXml</c> NuGet package. /// </remarks> - public FileFormatVersions ConformanceVersion { get; set; } = FileFormatVersions.Office2007; + public FileFormatVersions ConformanceVersion { get; set; } = FileFormatVersions.Office2010; } From 59540349807c9dd89eaed7cf7f55d2ceaacf5545 Mon Sep 17 00:00:00 2001 From: detilium <chrhaase@icloud.com> Date: Sun, 1 Mar 2026 17:40:25 +0100 Subject: [PATCH 20/30] Bump version for dev [skip ci] --- Directory.Version.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Version.props b/Directory.Version.props index 8d2acde..9db3b3b 100644 --- a/Directory.Version.props +++ b/Directory.Version.props @@ -1,5 +1,5 @@ <Project> <PropertyGroup> - <VersionPrefix>1.2.0</VersionPrefix> + <VersionPrefix>1.2.1</VersionPrefix> </PropertyGroup> </Project> \ No newline at end of file From 45fd603a8b1343fb77142d01e851f1d3621a1bdb Mon Sep 17 00:00:00 2001 From: Christian Haase <chrhaase@icloud.com> Date: Tue, 14 Apr 2026 17:57:00 +0200 Subject: [PATCH 21/30] Add support for .txt files (#27) --- src/ByteGuard.FileValidator/FileExtensions.cs | 5 +++++ src/ByteGuard.FileValidator/FileValidator.cs | 11 ++++++++++ .../Models/FileDefinition.cs | 10 ++++++++++ .../FileValidatorTests.cs | 20 +++++++++++++++++++ 4 files changed, 46 insertions(+) diff --git a/src/ByteGuard.FileValidator/FileExtensions.cs b/src/ByteGuard.FileValidator/FileExtensions.cs index fbbdee1..3d80447 100644 --- a/src/ByteGuard.FileValidator/FileExtensions.cs +++ b/src/ByteGuard.FileValidator/FileExtensions.cs @@ -109,5 +109,10 @@ public static class FileExtensions /// Waveform Audio File Format. /// </summary> public const string Wav = ".wav"; + + /// <summary> + /// Text File. + /// </summary> + public const string Txt = ".txt"; } } diff --git a/src/ByteGuard.FileValidator/FileValidator.cs b/src/ByteGuard.FileValidator/FileValidator.cs index 1451ed3..da00241 100644 --- a/src/ByteGuard.FileValidator/FileValidator.cs +++ b/src/ByteGuard.FileValidator/FileValidator.cs @@ -230,6 +230,11 @@ public class FileValidator { new byte[] { 0x57, 0x41, 0x56, 0x45 } // WAVE } + }, + new FileDefinition + { + FileType = FileExtensions.Txt, + AllowMissingSignature = true } }; @@ -522,6 +527,12 @@ public bool HasValidSignature(string fileName, Stream stream) return false; } + // If the file definition allows for missing signatures, we can return early as the signature validation is effectively bypassed. + if (fileDefinition.AllowMissingSignature) + { + return true; + } + // As PDF documents are somewhat special in terms of both signature validation, // we need to investigate these files further. The PdfValidator is made specifically // for this purpose. diff --git a/src/ByteGuard.FileValidator/Models/FileDefinition.cs b/src/ByteGuard.FileValidator/Models/FileDefinition.cs index 2700c2f..df623d7 100644 --- a/src/ByteGuard.FileValidator/Models/FileDefinition.cs +++ b/src/ByteGuard.FileValidator/Models/FileDefinition.cs @@ -10,6 +10,16 @@ internal class FileDefinition /// </summary> public string FileType { get; set; } = default!; + /// <summary> + /// Whether this file type is allowed to omit a binary signature. Defaults to <c>false</c>. + /// </summary> + /// <remarks> + /// Some file types, such as .txt files, do not have a binary signature. + /// Setting this property to <c>true</c> allows files of this type to be considered valid even if they do not have a binary signature. + /// This is useful for file types that do not have a binary signature, but still need to be validated by other mechanisms. + /// </remarks> + public bool AllowMissingSignature { get; set; } = false; + /// <summary> /// Valid header signatures. /// </summary> diff --git a/tests/ByteGuard.FileValidator.Tests.Unit/FileValidatorTests.cs b/tests/ByteGuard.FileValidator.Tests.Unit/FileValidatorTests.cs index 35bd941..30227e9 100644 --- a/tests/ByteGuard.FileValidator.Tests.Unit/FileValidatorTests.cs +++ b/tests/ByteGuard.FileValidator.Tests.Unit/FileValidatorTests.cs @@ -418,6 +418,26 @@ public void HasValidSignature_FileNameDoesNotHaveExtension_ShouldThrowArgumentEx Assert.Throws<ArgumentException>(act); } + [Theory(DisplayName = "HasValidSignature(byte[]) should skip signature validation for file types that allow missing signatures and return true")] + [InlineData(new byte[] { 0x74, 0x65, 0x73, 0x74 }, "test.txt")] // TXT + public void HasValidSignature_FileTypeAllowsMissingSignature_ShouldReturnTrue(byte[] fileBytes, string fileName) + { + // Arrange + var config = new FileValidatorConfiguration + { + SupportedFileTypes = [Path.GetExtension(fileName)], + FileSizeLimit = ByteSize.MegaBytes(25), + ThrowExceptionOnInvalidFile = true + }; + var fileValidator = new FileValidator(config); + + // Act + var result = fileValidator.HasValidSignature(fileName, fileBytes); + + // Assert + Assert.True(result); + } + [Theory(DisplayName = "IsOpenXmlFormat should return true for valid Open XML files")] [InlineData("test.docx")] // DOCX [InlineData("test.xlsx")] // XLSX From cb8aacca59d77082c2a425f0e07946b7b2b7d8d0 Mon Sep 17 00:00:00 2001 From: detilium <chrhaase@icloud.com> Date: Tue, 14 Apr 2026 17:58:34 +0200 Subject: [PATCH 22/30] New file type supported - bump minor version --- Directory.Version.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Version.props b/Directory.Version.props index 9db3b3b..702302f 100644 --- a/Directory.Version.props +++ b/Directory.Version.props @@ -1,5 +1,5 @@ <Project> <PropertyGroup> - <VersionPrefix>1.2.1</VersionPrefix> + <VersionPrefix>1.3.0</VersionPrefix> </PropertyGroup> </Project> \ No newline at end of file From 1a7784a3fc73a1baa86b66104f95183a557d13a8 Mon Sep 17 00:00:00 2001 From: Christian Haase <chrhaase@icloud.com> Date: Tue, 14 Apr 2026 18:05:57 +0200 Subject: [PATCH 23/30] Add support for CSV (#28) --- src/ByteGuard.FileValidator/FileExtensions.cs | 5 +++++ src/ByteGuard.FileValidator/FileValidator.cs | 5 +++++ .../ByteGuard.FileValidator.Tests.Unit/FileValidatorTests.cs | 1 + 3 files changed, 11 insertions(+) diff --git a/src/ByteGuard.FileValidator/FileExtensions.cs b/src/ByteGuard.FileValidator/FileExtensions.cs index 3d80447..4dbecf9 100644 --- a/src/ByteGuard.FileValidator/FileExtensions.cs +++ b/src/ByteGuard.FileValidator/FileExtensions.cs @@ -114,5 +114,10 @@ public static class FileExtensions /// Text File. /// </summary> public const string Txt = ".txt"; + + /// <summary> + /// Comma-Separated Values. + /// </summary> + public const string Csv = ".csv"; } } diff --git a/src/ByteGuard.FileValidator/FileValidator.cs b/src/ByteGuard.FileValidator/FileValidator.cs index da00241..d43f8ca 100644 --- a/src/ByteGuard.FileValidator/FileValidator.cs +++ b/src/ByteGuard.FileValidator/FileValidator.cs @@ -235,6 +235,11 @@ public class FileValidator { FileType = FileExtensions.Txt, AllowMissingSignature = true + }, + new FileDefinition + { + FileType = FileExtensions.Csv, + AllowMissingSignature = true } }; diff --git a/tests/ByteGuard.FileValidator.Tests.Unit/FileValidatorTests.cs b/tests/ByteGuard.FileValidator.Tests.Unit/FileValidatorTests.cs index 30227e9..facb7fc 100644 --- a/tests/ByteGuard.FileValidator.Tests.Unit/FileValidatorTests.cs +++ b/tests/ByteGuard.FileValidator.Tests.Unit/FileValidatorTests.cs @@ -420,6 +420,7 @@ public void HasValidSignature_FileNameDoesNotHaveExtension_ShouldThrowArgumentEx [Theory(DisplayName = "HasValidSignature(byte[]) should skip signature validation for file types that allow missing signatures and return true")] [InlineData(new byte[] { 0x74, 0x65, 0x73, 0x74 }, "test.txt")] // TXT + [InlineData(new byte[] { 0x74, 0x65, 0x73, 0x74, 0x3B, 0x63, 0x6F, 0x6C, 0x75, 0x6D, 0x6E, 0x31 }, "test.csv")] // CSV public void HasValidSignature_FileTypeAllowsMissingSignature_ShouldReturnTrue(byte[] fileBytes, string fileName) { // Arrange From a83c7f653b7a26c7b23cfa8516b27dd4e5d83928 Mon Sep 17 00:00:00 2001 From: Christian Haase <chrhaase@icloud.com> Date: Wed, 15 Apr 2026 15:58:48 +0200 Subject: [PATCH 24/30] Add support for .ico files (#29) --- src/ByteGuard.FileValidator/FileExtensions.cs | 5 +++++ src/ByteGuard.FileValidator/FileValidator.cs | 8 ++++++++ 2 files changed, 13 insertions(+) diff --git a/src/ByteGuard.FileValidator/FileExtensions.cs b/src/ByteGuard.FileValidator/FileExtensions.cs index 4dbecf9..86d31f5 100644 --- a/src/ByteGuard.FileValidator/FileExtensions.cs +++ b/src/ByteGuard.FileValidator/FileExtensions.cs @@ -119,5 +119,10 @@ public static class FileExtensions /// Comma-Separated Values. /// </summary> public const string Csv = ".csv"; + + /// <summary> + /// Icon File. + /// </summary> + public const string Ico = ".ico"; } } diff --git a/src/ByteGuard.FileValidator/FileValidator.cs b/src/ByteGuard.FileValidator/FileValidator.cs index d43f8ca..8308bff 100644 --- a/src/ByteGuard.FileValidator/FileValidator.cs +++ b/src/ByteGuard.FileValidator/FileValidator.cs @@ -240,6 +240,14 @@ public class FileValidator { FileType = FileExtensions.Csv, AllowMissingSignature = true + }, + new FileDefinition + { + FileType = FileExtensions.Ico, + ValidSignatures = new List<byte[]> + { + new byte[] { 0x00, 0x00, 0x01, 0x00 }, // ICO + } } }; From 7a838e2829f0634232e459d274a258e8878553d7 Mon Sep 17 00:00:00 2001 From: detilium <chrhaase@icloud.com> Date: Wed, 15 Apr 2026 16:18:09 +0200 Subject: [PATCH 25/30] Update readme [skip ci] --- README.md | 45 ++++++++------------------------------------- 1 file changed, 8 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index f3cfc66..88244f9 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,14 @@ # ByteGuard File Validator ![NuGet Version](https://img.shields.io/nuget/v/ByteGuard.FileValidator) -**ByteGuard.FileValidator** is a lightweight security-focused library for validating user-supplied files in .NET applications. +**ByteGuard.FileValidator** is a lightweight security-focused library for validating user-supplied files in .NET applications. + It helps you enforce consistent file upload rules by checking: - Allowed file extensions - File size limits - File signatures (magic numbers) to detect spoofed types - Security validation for Office Open XML / Open Document Formats (`.docx`, `.xlsx`, `.pptx`, `.odt`, `.odp`, `.ods`) -- Malware scan result using a varity of scanners (_requires the addition of a specific ByteGuard.FileValidator scanner package_) +- Malware scan result using a varity of scanners (_requires the addition of a specific [ByteGuard.FileValidator](https://github.com/ByteGuard-HQ/byteguard-file-validator-net/wiki/Available-AV-scanners) scanner package_) > ⚠️ **Important:** This package is one layer in a defense-in-depth strategy. > It does **not** replace endpoint protection, sandboxing, input validation, or other security controls. @@ -18,9 +19,10 @@ It helps you enforce consistent file upload rules by checking: - ✅ Validate files by **size** - ✅ Validate files by **signature (_magic-numbers_)** - ✅ Validate **specification conformance** for archive-based formats (_Open XML and Open Document Formats_) -- ✅ **Ensure no malware** through a variety of antimalware scanners +- ✅ **Ensure no malware** through a variety of [antimalware scanners](https://github.com/ByteGuard-HQ/byteguard-file-validator-net/wiki/Available-AV-scanners) - ✅ Validate using file path, `Stream`, or `byte[]` -- ✅ Configure which file types to support +- ✅ [Rich integration with NET Core](https://github.com/ByteGuard-HQ/byteguard-file-validator-extensions-dependency-injection) +- ✅ Configure which [file types](https://github.com/ByteGuard-HQ/byteguard-file-validator-net/wiki/Validation-features) to support - ✅ Configure whether to **throw exceptions** or simply return a boolean - ✅ **Fluent configuration API** for easy setup @@ -38,7 +40,7 @@ dotnet add package ByteGuard.FileValidator ### Antimalware scanners -In order to use the antimalware scanning capabilities, ensure you have a ByteGuard.FileValidator antimalware package referenced as well. You can find the relevant scanner package on [NuGet](https://www.nuget.org/packages?q=ByteGuard.FileValidator.Scanner.&includeComputedFrameworks=true&prerel=true&sortby=relevance) under the namespace `ByteGuard.FileValidator.Scanner`. +In order to use the antimalware scanning capabilities, ensure you have a [ByteGuard.FileValidator antimalware package](https://github.com/ByteGuard-HQ/byteguard-file-validator-net/wiki/Available-AV-scanners) referenced as well. You can find the relevant scanner package on [NuGet](https://www.nuget.org/packages?q=ByteGuard.FileValidator.Scanner.&includeComputedFrameworks=true&prerel=true&sortby=relevance) under the namespace `ByteGuard.FileValidator.Scanner`. ## Usage @@ -130,38 +132,7 @@ public async Task<IActionResult> Upload(IFormFile file) ## Supported File Extensions -The following file types are supported by the `FileValidator`: - -| Category | Supported extensions | -| ------------- | --------------------------------------------------------------------------------- | -| **Documents** | `.doc`, `.docx`, `.xls`, `.xlsx`, `.pptx`, `.odp`, `.ods`, `.odt`, `.pdf`, `.rtf` | -| **Images** | `.jpg`, `.jpeg`, `.png,`, `.bmp` | -| **Video** | `.mov`, `.avi`, `.mp4` | -| **Audio** | `.m4a`, `.mp3`, `.wav` | - -### Validation coverage per type - -`IsValidFile` always validates: - -- File extension (_against `SupportedFileTypes`_) -- File size (_against `FileSizeLimit`_) -- File signature (_magic number_) -- Malware scan result (_if an antimalware scanner has been configured_) - -For some formats, additional checks are performed: - -- **Microsoft Office / Open Document Format** (`.docx`, `.xlsx`, `.pptx`, `.ods`, `.odp`, `.odt`): - - Extension - - File size - - Signature - - Basic specification conformance validation - - Malware scan result - -- **Other binary formats**: - - Extension - - File size - - Signature - - Malware scan result +The file validator supports a variety of different file extensions. The complete list including the individual validation mechanisms per type, is available on the [WIKI](https://github.com/ByteGuard-HQ/byteguard-file-validator-net/wiki/Validation-features). ## Configuration Options From a2c40524def7457dcb6f3a42b7b44da87206f0cb Mon Sep 17 00:00:00 2001 From: Christian Haase <chrhaase@icloud.com> Date: Wed, 15 Apr 2026 16:18:21 +0200 Subject: [PATCH 26/30] Add support for .heic files (#30) --- src/ByteGuard.FileValidator/FileExtensions.cs | 5 +++++ src/ByteGuard.FileValidator/FileValidator.cs | 11 ++++++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/ByteGuard.FileValidator/FileExtensions.cs b/src/ByteGuard.FileValidator/FileExtensions.cs index 86d31f5..1e5ccce 100644 --- a/src/ByteGuard.FileValidator/FileExtensions.cs +++ b/src/ByteGuard.FileValidator/FileExtensions.cs @@ -124,5 +124,10 @@ public static class FileExtensions /// Icon File. /// </summary> public const string Ico = ".ico"; + + /// <summary> + /// High Efficiency Image Container. + /// </summary> + public const string Heic = ".heic"; } } diff --git a/src/ByteGuard.FileValidator/FileValidator.cs b/src/ByteGuard.FileValidator/FileValidator.cs index 8308bff..ee893de 100644 --- a/src/ByteGuard.FileValidator/FileValidator.cs +++ b/src/ByteGuard.FileValidator/FileValidator.cs @@ -246,8 +246,17 @@ public class FileValidator FileType = FileExtensions.Ico, ValidSignatures = new List<byte[]> { - new byte[] { 0x00, 0x00, 0x01, 0x00 }, // ICO + new byte[] { 0x00, 0x00, 0x01, 0x00 }, // ␀␀␁␀ } + }, + new FileDefinition + { + FileType = FileExtensions.Heic, + ValidSignatures = new List<byte[]> + { + new byte[] { 0x66, 0x74, 0x79, 0x70, 0x68, 0x65, 0x69, 0x63 } // ftypheic + }, + SignatureOffset = 4 } }; From cae34bdbbc260cb855892b564d18b276de9951b3 Mon Sep 17 00:00:00 2001 From: Christian Haase <chrhaase@icloud.com> Date: Wed, 15 Apr 2026 16:28:13 +0200 Subject: [PATCH 27/30] Add support for .gif files (#31) --- src/ByteGuard.FileValidator/FileExtensions.cs | 5 +++++ src/ByteGuard.FileValidator/FileValidator.cs | 8 ++++++++ 2 files changed, 13 insertions(+) diff --git a/src/ByteGuard.FileValidator/FileExtensions.cs b/src/ByteGuard.FileValidator/FileExtensions.cs index 1e5ccce..9d67bb2 100644 --- a/src/ByteGuard.FileValidator/FileExtensions.cs +++ b/src/ByteGuard.FileValidator/FileExtensions.cs @@ -129,5 +129,10 @@ public static class FileExtensions /// High Efficiency Image Container. /// </summary> public const string Heic = ".heic"; + + /// <summary> + /// Graphics Interchange Format. + /// </summary> + public const string Gif = ".gif"; } } diff --git a/src/ByteGuard.FileValidator/FileValidator.cs b/src/ByteGuard.FileValidator/FileValidator.cs index ee893de..37b5ac4 100644 --- a/src/ByteGuard.FileValidator/FileValidator.cs +++ b/src/ByteGuard.FileValidator/FileValidator.cs @@ -257,6 +257,14 @@ public class FileValidator new byte[] { 0x66, 0x74, 0x79, 0x70, 0x68, 0x65, 0x69, 0x63 } // ftypheic }, SignatureOffset = 4 + }, + new FileDefinition + { + FileType = FileExtensions.Gif, + ValidSignatures = new List<byte[]> + { + new byte[] { 0x47, 0x49, 0x46, 0x38 }, // GIF8 + } } }; From d6a0b9ae0b9f2210fe83965d56ad79b0e54158c8 Mon Sep 17 00:00:00 2001 From: Christian Haase <chrhaase@icloud.com> Date: Wed, 15 Apr 2026 16:34:34 +0200 Subject: [PATCH 28/30] Add support for .tif/.tiff files (#32) --- src/ByteGuard.FileValidator/FileExtensions.cs | 10 +++++++++ src/ByteGuard.FileValidator/FileValidator.cs | 22 +++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/src/ByteGuard.FileValidator/FileExtensions.cs b/src/ByteGuard.FileValidator/FileExtensions.cs index 9d67bb2..3ec3011 100644 --- a/src/ByteGuard.FileValidator/FileExtensions.cs +++ b/src/ByteGuard.FileValidator/FileExtensions.cs @@ -134,5 +134,15 @@ public static class FileExtensions /// Graphics Interchange Format. /// </summary> public const string Gif = ".gif"; + + /// <summary> + /// Tag Image File Format. + /// </summary> + public const string Tif = ".tif"; + + /// <summary> + /// Tag Image File Format. + /// </summary> + public const string Tiff = ".tiff"; } } diff --git a/src/ByteGuard.FileValidator/FileValidator.cs b/src/ByteGuard.FileValidator/FileValidator.cs index 37b5ac4..fd181e8 100644 --- a/src/ByteGuard.FileValidator/FileValidator.cs +++ b/src/ByteGuard.FileValidator/FileValidator.cs @@ -265,6 +265,28 @@ public class FileValidator { new byte[] { 0x47, 0x49, 0x46, 0x38 }, // GIF8 } + }, + new FileDefinition + { + FileType = FileExtensions.Tif, + ValidSignatures = new List<byte[]> + { + new byte[] { 0x49, 0x49, 0x2A, 0x00 }, // II*␀ (Intel byte order) + new byte[] { 0x4D, 0x4D, 0x00, 0x2A }, // MM␀* (Motorola byte order) + new byte[] { 0x49, 0x49, 0x2B, 0x00 }, // II+␀ (BigTIFF, Intel byte order) + new byte[] { 0x4D, 0x4D, 0x00, 0x2B } // MM␀+ (BigTIFF, Motorola byte order) + } + }, + new FileDefinition + { + FileType = FileExtensions.Tiff, + ValidSignatures = new List<byte[]> + { + new byte[] { 0x49, 0x49, 0x2A, 0x00 }, // II*␀ (Intel byte order) + new byte[] { 0x4D, 0x4D, 0x00, 0x2A }, // MM␀* (Motorola byte order) + new byte[] { 0x49, 0x49, 0x2B, 0x00 }, // II+␀ (BigTIFF, Intel byte order) + new byte[] { 0x4D, 0x4D, 0x00, 0x2B } // MM␀+ (BigTIFF, Motorola byte order) + } } }; From 895262d0b334e1ea5ac59e8c4102f40f23d68dc9 Mon Sep 17 00:00:00 2001 From: Christian Haase <chrhaase@icloud.com> Date: Wed, 15 Apr 2026 16:50:54 +0200 Subject: [PATCH 29/30] Add support for OGG files (#33) --- src/ByteGuard.FileValidator/FileExtensions.cs | 20 ++++++++++++ src/ByteGuard.FileValidator/FileValidator.cs | 32 +++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/src/ByteGuard.FileValidator/FileExtensions.cs b/src/ByteGuard.FileValidator/FileExtensions.cs index 3ec3011..e48fa49 100644 --- a/src/ByteGuard.FileValidator/FileExtensions.cs +++ b/src/ByteGuard.FileValidator/FileExtensions.cs @@ -144,5 +144,25 @@ public static class FileExtensions /// Tag Image File Format. /// </summary> public const string Tiff = ".tiff"; + + /// <summary> + /// Ogg Vorbis Audio. + /// </summary> + public const string Ogg = ".ogg"; + + /// <summary> + /// Ogg Vorbis Audio. + /// </summary> + public const string Oga = ".oga"; + + /// <summary> + /// Ogg Video. + /// </summary> + public const string Ogv = ".ogv"; + + /// <summary> + /// Ogg Multiplex. + /// </summary> + public const string Ogx = ".ogx"; } } diff --git a/src/ByteGuard.FileValidator/FileValidator.cs b/src/ByteGuard.FileValidator/FileValidator.cs index fd181e8..11040ab 100644 --- a/src/ByteGuard.FileValidator/FileValidator.cs +++ b/src/ByteGuard.FileValidator/FileValidator.cs @@ -287,6 +287,38 @@ public class FileValidator new byte[] { 0x49, 0x49, 0x2B, 0x00 }, // II+␀ (BigTIFF, Intel byte order) new byte[] { 0x4D, 0x4D, 0x00, 0x2B } // MM␀+ (BigTIFF, Motorola byte order) } + }, + new FileDefinition + { + FileType = FileExtensions.Ogg, + ValidSignatures = new List<byte[]> + { + new byte[] { 0x4F, 0x67, 0x67, 0x53 } // OggS + } + }, + new FileDefinition + { + FileType = FileExtensions.Oga, + ValidSignatures = new List<byte[]> + { + new byte[] { 0x4F, 0x67, 0x67, 0x53 } // OggS + } + }, + new FileDefinition + { + FileType = FileExtensions.Ogv, + ValidSignatures = new List<byte[]> + { + new byte[] { 0x4F, 0x67, 0x67, 0x53 } // OggS + } + }, + new FileDefinition + { + FileType = FileExtensions.Ogx, + ValidSignatures = new List<byte[]> + { + new byte[] { 0x4F, 0x67, 0x67, 0x53 } // OggS + } } }; From 831066172c05fe25106e53e879d9c8076498a2d3 Mon Sep 17 00:00:00 2001 From: Christian Haase <chrhaase@icloud.com> Date: Wed, 15 Apr 2026 17:00:24 +0200 Subject: [PATCH 30/30] Move supported definitions to separate file (#34) --- .../Configuration/ConfigurationValidator.cs | 2 +- .../FileDefinitions.cs | 316 ++++++++++++++++++ src/ByteGuard.FileValidator/FileValidator.cs | 315 +---------------- 3 files changed, 319 insertions(+), 314 deletions(-) create mode 100644 src/ByteGuard.FileValidator/FileDefinitions.cs diff --git a/src/ByteGuard.FileValidator/Configuration/ConfigurationValidator.cs b/src/ByteGuard.FileValidator/Configuration/ConfigurationValidator.cs index 88cfcba..bfca612 100644 --- a/src/ByteGuard.FileValidator/Configuration/ConfigurationValidator.cs +++ b/src/ByteGuard.FileValidator/Configuration/ConfigurationValidator.cs @@ -36,7 +36,7 @@ public static void ThrowIfInvalid(FileValidatorConfiguration configuration) } // Validate file type is supported by the current version of FileValidator. - if (!FileValidator.SupportedFileDefinitions.Any(f => f.FileType.Equals(fileType))) + if (!FileDefinitions.SupportedFileDefinitions.Any(f => f.FileType.Equals(fileType))) { throw new UnsupportedFileException($"File type '{fileType}' is not supported in the current version of FileValidator."); } diff --git a/src/ByteGuard.FileValidator/FileDefinitions.cs b/src/ByteGuard.FileValidator/FileDefinitions.cs new file mode 100644 index 0000000..34c6f16 --- /dev/null +++ b/src/ByteGuard.FileValidator/FileDefinitions.cs @@ -0,0 +1,316 @@ +using ByteGuard.FileValidator.Models; + +namespace ByteGuard.FileValidator; + +internal static class FileDefinitions +{ + /// <summary> + /// List of all supported valid file definitions, incl. their header signatures (magic numbers) + /// and potentially their corresponding valid subtype signatures. + /// </summary> + internal static readonly List<FileDefinition> SupportedFileDefinitions = new List<FileDefinition> + { + new FileDefinition + { + FileType = FileExtensions.Jpeg, + ValidSignatures = new List<byte[]> + { + new byte[] { 0xFF, 0xD8, 0xFF } // ÿØÿ + } + }, + new FileDefinition + { + FileType = FileExtensions.Jpg, + ValidSignatures = new List<byte[]> + { + new byte[] { 0xFF, 0xD8, 0xFF } // ÿØÿ + } + }, + new FileDefinition + { + FileType = FileExtensions.Jpe, + ValidSignatures = new List<byte[]> + { + new byte[] { 0xFF, 0xD8, 0xFF } // ÿØÿ + } + }, + new FileDefinition + { + FileType = FileExtensions.Pdf, + ValidSignatures = new List<byte[]> + { + new byte[] { 0x25, 0x50, 0x44, 0x46, 0x2D } // %PDF- + } + }, + new FileDefinition + { + FileType = FileExtensions.Png, + ValidSignatures = new List<byte[]> + { + new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A } // ‰PNG␍␊␚␊ + } + }, + new FileDefinition + { + FileType = FileExtensions.Bmp, + ValidSignatures = new List<byte[]> + { + new byte[] { 0x42, 0x4D } // BM + } + }, + new FileDefinition + { + FileType = FileExtensions.Doc, + ValidSignatures = new List<byte[]> + { + new byte[] { 0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1 } // ÐÏ␑ࡱ␚á + } + }, + new FileDefinition + { + FileType = FileExtensions.Docx, + // WARNING: This shares the same signature as .zip and could potentially allow for .zip disguised as .docx. + ValidSignatures = new List<byte[]> + { + new byte[] { 0x50, 0x4B, 0x03, 0x04 } // PK␃␄ + } + }, + new FileDefinition + { + FileType = FileExtensions.Odp, + // WARNING: This shares the same signature as .zip and could potentially allow for .zip disguised as .odp. + ValidSignatures = new List<byte[]> + { + new byte[] { 0x50, 0x4B, 0x03, 0x04 } // PK␃␄ + } + }, + new FileDefinition + { + FileType = FileExtensions.Ods, + // WARNING: This shares the same signature as .zip and could potentially allow for .zip disguised as .ods. + ValidSignatures = new List<byte[]> + { + new byte[] { 0x50, 0x4B, 0x03, 0x04 } // PK␃␄ + } + }, + new FileDefinition + { + FileType = FileExtensions.Odt, + // WARNING: This shares the same signature as .zip and could potentially allow for .zip disguised as .odt. + ValidSignatures = new List<byte[]> + { + new byte[] { 0x50, 0x4B, 0x03, 0x04 } // PK␃␄ + } + }, + new FileDefinition + { + FileType = FileExtensions.Rtf, + ValidSignatures = new List<byte[]> + { + new byte[] { 0x7B, 0x5C, 0x72, 0x74, 0x66, 0x31 } // {\rtf1 + } + }, + new FileDefinition + { + FileType = FileExtensions.Xlsx, + // WARNING: This shares the same signature as .zip and could potentially allow for .zip disguised as .xlsx. + ValidSignatures = new List<byte[]> + { + new byte[] { 0x50, 0x4B, 0x03, 0x04 } // PK␃␄ + } + }, + new FileDefinition + { + FileType = FileExtensions.Xls, + ValidSignatures = new List<byte[]> + { + new byte[] { 0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1 } // ÐÏ␑ࡱ␚á + } + }, + new FileDefinition + { + FileType = FileExtensions.Pptx, + // WARNING: This shares the same signature as .zip and could potentially allow for .zip disguised as .pptx. + ValidSignatures = new List<byte[]> + { + new byte[] { 0x50, 0x4B, 0x03, 0x04 } // PK␃␄ + } + }, + new FileDefinition + { + FileType = FileExtensions.M4a, + ValidSignatures = new List<byte[]> + { + new byte[] { 0x66, 0x74, 0x79, 0x70 } // ftyp + }, + SignatureOffset = 4, + HasSubtype = true, + SubtypeOffset = 8, + ValidSubtypeSignatures = new List<byte[]> + { + new byte[] { 0x4D, 0x34, 0x41, 0x20 } // M4A_ + } + }, + new FileDefinition + { + FileType = FileExtensions.Mov, + ValidSignatures = new List<byte[]> + { + new byte[] { 0x66, 0x74, 0x79, 0x70 } // ftyp + }, + SignatureOffset = 4, + HasSubtype = true, + SubtypeOffset = 8, + ValidSubtypeSignatures = new List<byte[]> + { + new byte[] { 0x71, 0x74, 0x20, 0x20 } // qt__ (Quicktime) + } + }, + new FileDefinition + { + FileType = FileExtensions.Avi, + ValidSignatures = new List<byte[]> + { + new byte[] { 0x52, 0x49, 0x46, 0x46 } // RIFF + }, + HasSubtype = true, + SubtypeOffset = 8, + ValidSubtypeSignatures = new List<byte[]> + { + new byte[] { 0x41, 0x56, 0x49, 0x20 } // AVI_ + } + }, + new FileDefinition + { + FileType = FileExtensions.Mp3, + ValidSignatures = new List<byte[]> + { + new byte[] { 0xFF, 0xFB }, // Without ID3 tag + new byte[] { 0xFF, 0xF2 }, // Without ID3 tag + new byte[] { 0xFF, 0xF3 }, // Without ID3 tag + new byte[] { 0x49, 0x44, 0x33 } // ID3 + } + }, + new FileDefinition + { + FileType = FileExtensions.Mp4, + ValidSignatures = new List<byte[]> + { + new byte[] { 0x66, 0x74, 0x79, 0x70 } // ftyp + }, + SignatureOffset = 4, + HasSubtype = true, + SubtypeOffset = 8, + ValidSubtypeSignatures = new List<byte[]> + { + new byte[] { 0x6D, 0x6D, 0x70, 0x34 }, // mmp4 (MP4) + new byte[] { 0x6D, 0x70, 0x34, 0x32 }, // mp42 (MP4 v2) + new byte[] { 0x69, 0x73, 0x6F, 0x6D }, // isom (ISO Base Media File) + new byte[] { 0x4D, 0x53, 0x4E, 0x56 } // MSNV (MPEG-4) + } + }, + new FileDefinition + { + FileType = FileExtensions.Wav, + ValidSignatures = new List<byte[]> + { + new byte[] { 0x52, 0x49, 0x46, 0x46 } // RIFF + }, + HasSubtype = true, + SubtypeOffset = 8, + ValidSubtypeSignatures = new List<byte[]> + { + new byte[] { 0x57, 0x41, 0x56, 0x45 } // WAVE + } + }, + new FileDefinition + { + FileType = FileExtensions.Txt, + AllowMissingSignature = true + }, + new FileDefinition + { + FileType = FileExtensions.Csv, + AllowMissingSignature = true + }, + new FileDefinition + { + FileType = FileExtensions.Ico, + ValidSignatures = new List<byte[]> + { + new byte[] { 0x00, 0x00, 0x01, 0x00 }, // ␀␀␁␀ + } + }, + new FileDefinition + { + FileType = FileExtensions.Heic, + ValidSignatures = new List<byte[]> + { + new byte[] { 0x66, 0x74, 0x79, 0x70, 0x68, 0x65, 0x69, 0x63 } // ftypheic + }, + SignatureOffset = 4 + }, + new FileDefinition + { + FileType = FileExtensions.Gif, + ValidSignatures = new List<byte[]> + { + new byte[] { 0x47, 0x49, 0x46, 0x38 }, // GIF8 + } + }, + new FileDefinition + { + FileType = FileExtensions.Tif, + ValidSignatures = new List<byte[]> + { + new byte[] { 0x49, 0x49, 0x2A, 0x00 }, // II*␀ (Intel byte order) + new byte[] { 0x4D, 0x4D, 0x00, 0x2A }, // MM␀* (Motorola byte order) + new byte[] { 0x49, 0x49, 0x2B, 0x00 }, // II+␀ (BigTIFF, Intel byte order) + new byte[] { 0x4D, 0x4D, 0x00, 0x2B } // MM␀+ (BigTIFF, Motorola byte order) + } + }, + new FileDefinition + { + FileType = FileExtensions.Tiff, + ValidSignatures = new List<byte[]> + { + new byte[] { 0x49, 0x49, 0x2A, 0x00 }, // II*␀ (Intel byte order) + new byte[] { 0x4D, 0x4D, 0x00, 0x2A }, // MM␀* (Motorola byte order) + new byte[] { 0x49, 0x49, 0x2B, 0x00 }, // II+␀ (BigTIFF, Intel byte order) + new byte[] { 0x4D, 0x4D, 0x00, 0x2B } // MM␀+ (BigTIFF, Motorola byte order) + } + }, + new FileDefinition + { + FileType = FileExtensions.Ogg, + ValidSignatures = new List<byte[]> + { + new byte[] { 0x4F, 0x67, 0x67, 0x53 } // OggS + } + }, + new FileDefinition + { + FileType = FileExtensions.Oga, + ValidSignatures = new List<byte[]> + { + new byte[] { 0x4F, 0x67, 0x67, 0x53 } // OggS + } + }, + new FileDefinition + { + FileType = FileExtensions.Ogv, + ValidSignatures = new List<byte[]> + { + new byte[] { 0x4F, 0x67, 0x67, 0x53 } // OggS + } + }, + new FileDefinition + { + FileType = FileExtensions.Ogx, + ValidSignatures = new List<byte[]> + { + new byte[] { 0x4F, 0x67, 0x67, 0x53 } // OggS + } + } + }; +} diff --git a/src/ByteGuard.FileValidator/FileValidator.cs b/src/ByteGuard.FileValidator/FileValidator.cs index 11040ab..77333c0 100644 --- a/src/ByteGuard.FileValidator/FileValidator.cs +++ b/src/ByteGuard.FileValidator/FileValidator.cs @@ -1,7 +1,6 @@ using DocumentFormat.OpenXml.Packaging; using ByteGuard.FileValidator.Configuration; using ByteGuard.FileValidator.Exceptions; -using ByteGuard.FileValidator.Models; using ByteGuard.FileValidator.Validators; using ByteGuard.FileValidator.Scanners; @@ -12,316 +11,6 @@ namespace ByteGuard.FileValidator /// </summary> public class FileValidator { - /// <summary> - /// List of all supported valid file definitions, incl. their header signatures (magic numbers) - /// and potentially their corresponding valid subtype signatures. - /// </summary> - internal static readonly List<FileDefinition> SupportedFileDefinitions = new List<FileDefinition> - { - new FileDefinition - { - FileType = FileExtensions.Jpeg, - ValidSignatures = new List<byte[]> - { - new byte[] { 0xFF, 0xD8, 0xFF } // ÿØÿ - } - }, - new FileDefinition - { - FileType = FileExtensions.Jpg, - ValidSignatures = new List<byte[]> - { - new byte[] { 0xFF, 0xD8, 0xFF } // ÿØÿ - } - }, - new FileDefinition - { - FileType = FileExtensions.Jpe, - ValidSignatures = new List<byte[]> - { - new byte[] { 0xFF, 0xD8, 0xFF } // ÿØÿ - } - }, - new FileDefinition - { - FileType = FileExtensions.Pdf, - ValidSignatures = new List<byte[]> - { - new byte[] { 0x25, 0x50, 0x44, 0x46, 0x2D } // %PDF- - } - }, - new FileDefinition - { - FileType = FileExtensions.Png, - ValidSignatures = new List<byte[]> - { - new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A } // ‰PNG␍␊␚␊ - } - }, - new FileDefinition - { - FileType = FileExtensions.Bmp, - ValidSignatures = new List<byte[]> - { - new byte[] { 0x42, 0x4D } // BM - } - }, - new FileDefinition - { - FileType = FileExtensions.Doc, - ValidSignatures = new List<byte[]> - { - new byte[] { 0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1 } // ÐÏ␑ࡱ␚á - } - }, - new FileDefinition - { - FileType = FileExtensions.Docx, - // WARNING: This shares the same signature as .zip and could potentially allow for .zip disguised as .docx. - ValidSignatures = new List<byte[]> - { - new byte[] { 0x50, 0x4B, 0x03, 0x04 } // PK␃␄ - } - }, - new FileDefinition - { - FileType = FileExtensions.Odp, - // WARNING: This shares the same signature as .zip and could potentially allow for .zip disguised as .odp. - ValidSignatures = new List<byte[]> - { - new byte[] { 0x50, 0x4B, 0x03, 0x04 } // PK␃␄ - } - }, - new FileDefinition - { - FileType = FileExtensions.Ods, - // WARNING: This shares the same signature as .zip and could potentially allow for .zip disguised as .ods. - ValidSignatures = new List<byte[]> - { - new byte[] { 0x50, 0x4B, 0x03, 0x04 } // PK␃␄ - } - }, - new FileDefinition - { - FileType = FileExtensions.Odt, - // WARNING: This shares the same signature as .zip and could potentially allow for .zip disguised as .odt. - ValidSignatures = new List<byte[]> - { - new byte[] { 0x50, 0x4B, 0x03, 0x04 } // PK␃␄ - } - }, - new FileDefinition - { - FileType = FileExtensions.Rtf, - ValidSignatures = new List<byte[]> - { - new byte[] { 0x7B, 0x5C, 0x72, 0x74, 0x66, 0x31 } // {\rtf1 - } - }, - new FileDefinition - { - FileType = FileExtensions.Xlsx, - // WARNING: This shares the same signature as .zip and could potentially allow for .zip disguised as .xlsx. - ValidSignatures = new List<byte[]> - { - new byte[] { 0x50, 0x4B, 0x03, 0x04 } // PK␃␄ - } - }, - new FileDefinition - { - FileType = FileExtensions.Xls, - ValidSignatures = new List<byte[]> - { - new byte[] { 0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1 } // ÐÏ␑ࡱ␚á - } - }, - new FileDefinition - { - FileType = FileExtensions.Pptx, - // WARNING: This shares the same signature as .zip and could potentially allow for .zip disguised as .pptx. - ValidSignatures = new List<byte[]> - { - new byte[] { 0x50, 0x4B, 0x03, 0x04 } // PK␃␄ - } - }, - new FileDefinition - { - FileType = FileExtensions.M4a, - ValidSignatures = new List<byte[]> - { - new byte[] { 0x66, 0x74, 0x79, 0x70 } // ftyp - }, - SignatureOffset = 4, - HasSubtype = true, - SubtypeOffset = 8, - ValidSubtypeSignatures = new List<byte[]> - { - new byte[] { 0x4D, 0x34, 0x41, 0x20 } // M4A_ - } - }, - new FileDefinition - { - FileType = FileExtensions.Mov, - ValidSignatures = new List<byte[]> - { - new byte[] { 0x66, 0x74, 0x79, 0x70 } // ftyp - }, - SignatureOffset = 4, - HasSubtype = true, - SubtypeOffset = 8, - ValidSubtypeSignatures = new List<byte[]> - { - new byte[] { 0x71, 0x74, 0x20, 0x20 } // qt__ (Quicktime) - } - }, - new FileDefinition - { - FileType = FileExtensions.Avi, - ValidSignatures = new List<byte[]> - { - new byte[] { 0x52, 0x49, 0x46, 0x46 } // RIFF - }, - HasSubtype = true, - SubtypeOffset = 8, - ValidSubtypeSignatures = new List<byte[]> - { - new byte[] { 0x41, 0x56, 0x49, 0x20 } // AVI_ - } - }, - new FileDefinition - { - FileType = FileExtensions.Mp3, - ValidSignatures = new List<byte[]> - { - new byte[] { 0xFF, 0xFB }, // Without ID3 tag - new byte[] { 0xFF, 0xF2 }, // Without ID3 tag - new byte[] { 0xFF, 0xF3 }, // Without ID3 tag - new byte[] { 0x49, 0x44, 0x33 } // ID3 - } - }, - new FileDefinition - { - FileType = FileExtensions.Mp4, - ValidSignatures = new List<byte[]> - { - new byte[] { 0x66, 0x74, 0x79, 0x70 } // ftyp - }, - SignatureOffset = 4, - HasSubtype = true, - SubtypeOffset = 8, - ValidSubtypeSignatures = new List<byte[]> - { - new byte[] { 0x6D, 0x6D, 0x70, 0x34 }, // mmp4 (MP4) - new byte[] { 0x6D, 0x70, 0x34, 0x32 }, // mp42 (MP4 v2) - new byte[] { 0x69, 0x73, 0x6F, 0x6D }, // isom (ISO Base Media File) - new byte[] { 0x4D, 0x53, 0x4E, 0x56 } // MSNV (MPEG-4) - } - }, - new FileDefinition - { - FileType = FileExtensions.Wav, - ValidSignatures = new List<byte[]> - { - new byte[] { 0x52, 0x49, 0x46, 0x46 } // RIFF - }, - HasSubtype = true, - SubtypeOffset = 8, - ValidSubtypeSignatures = new List<byte[]> - { - new byte[] { 0x57, 0x41, 0x56, 0x45 } // WAVE - } - }, - new FileDefinition - { - FileType = FileExtensions.Txt, - AllowMissingSignature = true - }, - new FileDefinition - { - FileType = FileExtensions.Csv, - AllowMissingSignature = true - }, - new FileDefinition - { - FileType = FileExtensions.Ico, - ValidSignatures = new List<byte[]> - { - new byte[] { 0x00, 0x00, 0x01, 0x00 }, // ␀␀␁␀ - } - }, - new FileDefinition - { - FileType = FileExtensions.Heic, - ValidSignatures = new List<byte[]> - { - new byte[] { 0x66, 0x74, 0x79, 0x70, 0x68, 0x65, 0x69, 0x63 } // ftypheic - }, - SignatureOffset = 4 - }, - new FileDefinition - { - FileType = FileExtensions.Gif, - ValidSignatures = new List<byte[]> - { - new byte[] { 0x47, 0x49, 0x46, 0x38 }, // GIF8 - } - }, - new FileDefinition - { - FileType = FileExtensions.Tif, - ValidSignatures = new List<byte[]> - { - new byte[] { 0x49, 0x49, 0x2A, 0x00 }, // II*␀ (Intel byte order) - new byte[] { 0x4D, 0x4D, 0x00, 0x2A }, // MM␀* (Motorola byte order) - new byte[] { 0x49, 0x49, 0x2B, 0x00 }, // II+␀ (BigTIFF, Intel byte order) - new byte[] { 0x4D, 0x4D, 0x00, 0x2B } // MM␀+ (BigTIFF, Motorola byte order) - } - }, - new FileDefinition - { - FileType = FileExtensions.Tiff, - ValidSignatures = new List<byte[]> - { - new byte[] { 0x49, 0x49, 0x2A, 0x00 }, // II*␀ (Intel byte order) - new byte[] { 0x4D, 0x4D, 0x00, 0x2A }, // MM␀* (Motorola byte order) - new byte[] { 0x49, 0x49, 0x2B, 0x00 }, // II+␀ (BigTIFF, Intel byte order) - new byte[] { 0x4D, 0x4D, 0x00, 0x2B } // MM␀+ (BigTIFF, Motorola byte order) - } - }, - new FileDefinition - { - FileType = FileExtensions.Ogg, - ValidSignatures = new List<byte[]> - { - new byte[] { 0x4F, 0x67, 0x67, 0x53 } // OggS - } - }, - new FileDefinition - { - FileType = FileExtensions.Oga, - ValidSignatures = new List<byte[]> - { - new byte[] { 0x4F, 0x67, 0x67, 0x53 } // OggS - } - }, - new FileDefinition - { - FileType = FileExtensions.Ogv, - ValidSignatures = new List<byte[]> - { - new byte[] { 0x4F, 0x67, 0x67, 0x53 } // OggS - } - }, - new FileDefinition - { - FileType = FileExtensions.Ogx, - ValidSignatures = new List<byte[]> - { - new byte[] { 0x4F, 0x67, 0x67, 0x53 } // OggS - } - } - }; - /// <summary> /// Specific file extensions for files that are Open XML documents. /// These require extra care when validating, as Open XML files are ZIP-archives and can contain potentially harmful content. @@ -543,7 +232,7 @@ public bool IsValidFileType(string fileName) { var extension = Path.GetExtension(fileName).ToLowerInvariant(); var isSupported = _configuration.SupportedFileTypes.Contains(extension, StringComparer.InvariantCultureIgnoreCase) && - SupportedFileDefinitions.Any(fd => fd.FileType.Equals(extension, StringComparison.InvariantCultureIgnoreCase)); + FileDefinitions.SupportedFileDefinitions.Any(fd => fd.FileType.Equals(extension, StringComparison.InvariantCultureIgnoreCase)); if (!isSupported && _configuration.ThrowExceptionOnInvalidFile) { @@ -598,7 +287,7 @@ public bool HasValidSignature(string fileName, Stream stream) throw new InvalidOperationException("Stream is not seekable."); } - var fileDefinition = SupportedFileDefinitions.FirstOrDefault(fd => + var fileDefinition = FileDefinitions.SupportedFileDefinitions.FirstOrDefault(fd => fd.FileType.Equals(extension, StringComparison.InvariantCultureIgnoreCase)); if (fileDefinition == null)