diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
new file mode 100644
index 0000000..84485d0
--- /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
new file mode 100644
index 0000000..3ba13e0
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -0,0 +1 @@
+blank_issues_enabled: false
diff --git a/.github/ISSUE_TEMPLATE/documentation.yml b/.github/ISSUE_TEMPLATE/documentation.yml
new file mode 100644
index 0000000..863cc3a
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/documentation.yml
@@ -0,0 +1,29 @@
+name: Documentation
+description: Suggest updates or additions to documentation
+title: "[Docs]: "
+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.yml b/.github/ISSUE_TEMPLATE/feature_request.yml
new file mode 100644
index 0000000..d56a1f0
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature_request.yml
@@ -0,0 +1,29 @@
+name: Feature request
+description: Suggest a new feature or improvement
+title: "[Feature]: "
+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
+ - 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.yml b/.github/ISSUE_TEMPLATE/general_report.yml
new file mode 100644
index 0000000..ef14528
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/general_report.yml
@@ -0,0 +1,17 @@
+name: General report
+description: Provide general feedback or inquiries
+title: "[General]: "
+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
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/ByteGuard.FileValidator.slnx b/ByteGuard.FileValidator.slnx
index 4655c20..fe72f6f 100644
--- a/ByteGuard.FileValidator.slnx
+++ b/ByteGuard.FileValidator.slnx
@@ -5,6 +5,7 @@
+
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).
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..f3cfc66 100644
--- a/README.md
+++ b/README.md
@@ -1,23 +1,23 @@
-
# ByteGuard File Validator 
**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
+- 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.
-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
- ✅ 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 **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
@@ -27,21 +27,24 @@ 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],
@@ -49,6 +52,7 @@ var configuration = new FileValidatorConfiguration
ThrowExceptionOnInvalidFile = false
};
+// Without antimalware scanner
var fileValidator = new FileValidator(configuration);
var isValid = fileValidator.IsValidFile("example.pdf", fileStream);
@@ -76,12 +80,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 security validation (for supported types)
> 5. Optional antimalware scanning with a compatible scanning package
```csharp
@@ -94,6 +99,7 @@ bool isMalwareClean = fileValidator.IsMalwareClean(fileName, fileStream);
```
### Example
+
```csharp
[HttpPost("upload")]
public async Task Upload(IFormFile file)
@@ -117,30 +123,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`, `.odp`, `.ods`, `.odt`, `.pdf`, `.rtf` |
+| **Images** | `.jpg`, `.jpeg`, `.png,`, `.bmp` |
+| **Video** | `.mov`, `.avi`, `.mp4` |
+| **Audio** | `.m4a`, `.mp3`, `.wav` |
### Validation coverage per type
@@ -153,14 +150,14 @@ 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`, `.ods`, `.odp`, `.odt`):
- Extension
- File size
- Signature
- - Specification conformance
+ - Basic specification conformance validation
- 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 +167,42 @@ 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`. |
+
+### 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`.
-| 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. |
+| 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 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
@@ -197,4 +211,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/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).
diff --git a/src/ByteGuard.FileValidator/ByteGuard.FileValidator.csproj b/src/ByteGuard.FileValidator/ByteGuard.FileValidator.csproj
index d4b15f8..71c4173 100644
--- a/src/ByteGuard.FileValidator/ByteGuard.FileValidator.csproj
+++ b/src/ByteGuard.FileValidator/ByteGuard.FileValidator.csproj
@@ -1,7 +1,7 @@
- netstandard2.0;net48;net8.0;net9.0;net10.0
+ netstandard2.0;net48;net8.0;net10.0
ByteGuard Contributors, detilium
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.
https://github.com/ByteGuard-HQ/byteguard-file-validator-net
diff --git a/src/ByteGuard.FileValidator/Configuration/ConfigurationValidator.cs b/src/ByteGuard.FileValidator/Configuration/ConfigurationValidator.cs
index ff59372..88cfcba 100644
--- a/src/ByteGuard.FileValidator/Configuration/ConfigurationValidator.cs
+++ b/src/ByteGuard.FileValidator/Configuration/ConfigurationValidator.cs
@@ -1,9 +1,10 @@
using ByteGuard.FileValidator.Exceptions;
+using DocumentFormat.OpenXml;
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 +12,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 +46,44 @@ public static void ThrowIfInvalid(FileValidatorConfiguration configuration)
{
throw new ArgumentException("File size limit must be greater than zero.", nameof(configuration.FileSizeLimit));
}
+
+ ValidateOdfRules(configuration);
+ ValidateOpenXmlRules(configuration);
+ }
+
+ ///
+ /// Validate the ODF rules on the configuration object.
+ ///
+ /// File validator configuration object.
+ private static void ValidateOdfRules(FileValidatorConfiguration configuration)
+ {
+ if (configuration.FileTypeRules.OdfRules == null)
+ {
+ throw new ArgumentNullException(
+ nameof(configuration.FileTypeRules.OdfRules),
+ $"{nameof(configuration.FileTypeRules.OdfRules)} cannot be null.");
+ }
+ }
+
+ ///
+ /// Validate the Open XML rules on the configuration object.
+ ///
+ /// File validator configuration object.
+ 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 262cf8e..84b6e5c 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
{
@@ -29,5 +29,26 @@ public class FileValidatorConfiguration
/// Whether to throw an exception if an unsupported/invalid file is encountered. Defaults to true.
///
public bool ThrowExceptionOnInvalidFile { get; set; } = true;
+
+ ///
+ /// Specific file type validation rules.
+ ///
+ public FileTypeRules FileTypeRules { get; set; } = new();
+ }
+
+ ///
+ /// Specific file type validation rules.
+ ///
+ public class FileTypeRules
+ {
+ ///
+ /// OpenDocument Format validation rules.
+ ///
+ public OdfRules OdfRules { get; set; } = new();
+
+ ///
+ /// Open XML validation rules.
+ ///
+ public OpenXmlRules OpenXmlRules { get; set; } = new();
}
}
diff --git a/src/ByteGuard.FileValidator/Configuration/FileValidatorConfigurationBuilder.cs b/src/ByteGuard.FileValidator/Configuration/FileValidatorConfigurationBuilder.cs
index 670785d..1f904e7 100644
--- a/src/ByteGuard.FileValidator/Configuration/FileValidatorConfigurationBuilder.cs
+++ b/src/ByteGuard.FileValidator/Configuration/FileValidatorConfigurationBuilder.cs
@@ -1,4 +1,4 @@
-using ByteGuard.FileValidator.Scanners;
+using ByteGuard.FileValidator.Configuration.Rules;
namespace ByteGuard.FileValidator.Configuration
{
@@ -11,6 +11,9 @@ public class FileValidatorConfigurationBuilder
private bool throwOnInvalidFiles = true;
private long fileSizeLimit = ByteSize.MegaBytes(25);
+ private OdfRules odfValidationRules = new();
+ private OpenXmlRules openXmlRules = new();
+
///
/// Allow specific file types (extensions) to be validated.
///
@@ -43,6 +46,26 @@ public FileValidatorConfigurationBuilder SetFileSizeLimit(long inFileSizeLimit)
return this;
}
+ ///
+ /// Configure the OpenDocument Format validation rules.
+ ///
+ /// Configuration action.
+ public FileValidatorConfigurationBuilder ConfigureOdfValidationRules(Action configure)
+ {
+ configure.Invoke(odfValidationRules);
+ return this;
+ }
+
+ ///
+ /// Configure the Open XML validation rules.
+ ///
+ /// Configuration action.
+ public FileValidatorConfigurationBuilder ConfigureOpenXmlValidationRules(Action configure)
+ {
+ configure.Invoke(openXmlRules);
+ return this;
+ }
+
///
/// Build configuration.
///
@@ -56,6 +79,9 @@ public FileValidatorConfiguration Build()
FileSizeLimit = fileSizeLimit
};
+ configuration.FileTypeRules.OdfRules = odfValidationRules;
+ configuration.FileTypeRules.OpenXmlRules = openXmlRules;
+
ConfigurationValidator.ThrowIfInvalid(configuration);
return configuration;
diff --git a/src/ByteGuard.FileValidator/Configuration/Rules/OdfRules.cs b/src/ByteGuard.FileValidator/Configuration/Rules/OdfRules.cs
new file mode 100644
index 0000000..bb6da6a
--- /dev/null
+++ b/src/ByteGuard.FileValidator/Configuration/Rules/OdfRules.cs
@@ -0,0 +1,12 @@
+namespace ByteGuard.FileValidator.Configuration.Rules;
+
+///
+/// Validation rules for OpenDocument Format files.
+///
+public class OdfRules
+{
+ ///
+ /// Whether a valid mimetype file is required in the ODF package. Defaults to true.
+ ///
+ public bool RequireMimetype { get; set; } = true;
+}
diff --git a/src/ByteGuard.FileValidator/Configuration/Rules/OpenXmlRules.cs b/src/ByteGuard.FileValidator/Configuration/Rules/OpenXmlRules.cs
new file mode 100644
index 0000000..3f8e21c
--- /dev/null
+++ b/src/ByteGuard.FileValidator/Configuration/Rules/OpenXmlRules.cs
@@ -0,0 +1,22 @@
+using DocumentFormat.OpenXml;
+
+namespace ByteGuard.FileValidator.Configuration.Rules;
+
+///
+/// Validation rules for Open XML files.
+///
+public class OpenXmlRules
+{
+ ///
+ /// Whether to perform conformance validation. Defaults to true.
+ ///
+ public bool PerformConformanceValidation { get; set; } = true;
+
+ ///
+ /// Version to use for conformance validation if enabled. Defaults to Office2007.
+ ///
+ ///
+ /// See form the DocumentFormat.OpenXml NuGet package.
+ ///
+ public FileFormatVersions ConformanceVersion { get; set; } = FileFormatVersions.Office2010;
+}
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 ea32deb..1451ed3 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
};
@@ -283,6 +303,7 @@ public List GetSupportedFileTypes()
///
/// Whether the given file is valid based on all parameters.
+ /// Whether the given file is valid based on all parameters.
///
/// File name including extension (e.g. my-file.jpg).
/// Stream content of the file.
@@ -290,6 +311,8 @@ 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.
public bool IsValidFile(string fileName, Stream stream)
{
// Validate file type.
@@ -374,6 +397,8 @@ 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.
public bool IsValidFile(string fileName, byte[] content)
{
using (var stream = new MemoryStream(content))
@@ -391,6 +416,8 @@ 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.
public bool IsValidFile(string filePath)
{
if (string.IsNullOrWhiteSpace(filePath))
@@ -768,22 +795,24 @@ public bool IsValidOpenXmlDocument(string fileName, Stream stream)
return false;
}
+ stream.Seek(0, SeekOrigin.Begin);
+
bool isValid;
switch (extension.ToLowerInvariant())
{
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:
@@ -941,18 +970,9 @@ public bool IsValidOpenDocumentFormat(string fileName, Stream stream)
return false;
}
- bool isValid;
+ stream.Seek(0, SeekOrigin.Begin);
- switch (extension.ToLowerInvariant())
- {
- 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)
{
diff --git a/src/ByteGuard.FileValidator/Validators/OpenDocumentFormatValidator.cs b/src/ByteGuard.FileValidator/Validators/OpenDocumentFormatValidator.cs
index 9bee276..6fbac8a 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
{
@@ -10,13 +13,26 @@ namespace ByteGuard.FileValidator.Validators
///
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 OdfMimetypeMappings = new(StringComparer.InvariantCultureIgnoreCase)
+ {
+ { ".odt", "application/vnd.oasis.opendocument.text" },
+ { ".ods", "application/vnd.oasis.opendocument.spreadsheet" },
+ { ".odp", "application/vnd.oasis.opendocument.presentation" },
+ };
+
///
- /// Whether the given content stream is a valid ODF text (.odt) file.
+ /// Whether the given content stream is a valid ODF file.
///
+ /// File name including extension (e.g. my-file.odt).
/// Content stream.
+ /// ODF validation rules.
/// true if valid, false otherwise.
/// Thrown if the provided is null or empty.
- internal static bool IsValidOpenDocumentTextDocument(Stream stream)
+ internal static bool IsValidOpenDocumentFormatFile(string fileName, Stream stream, OdfRules rules)
{
if (stream == null || stream.Length == 0)
{
@@ -25,16 +41,193 @@ internal static bool IsValidOpenDocumentTextDocument(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);
+ }
+ }
+
+ ///
+ /// Perform ODF validation.
+ ///
+ /// File name including extension (e.g. my-file.odt).
+ /// ODF ZIP archive.
+ /// ODF validation rules.
+ /// true if valid, false otherwise.
+ private static bool PerformOdfValidation(string fileName, ZipArchive archive, OdfRules 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;
+ }
+
+ ///
+ /// Whether the mimetype entry is valid.
+ ///
+ /// File name including extension (e.g. my-file.odt).
+ /// ODF ZIP archive.
+ /// ODF validation rules.
+ /// true if valid, false otherwise.
+ private static bool IsValidMimetype(string fileName, ZipArchive archive, OdfRules rules)
+ {
+ var mimetypeEntry = archive.GetEntry(MimetypeEntryName);
+ if (mimetypeEntry is null)
+ {
+ if (rules.RequireMimetype)
+ {
+ // Mimetype entry is required but missing
+ return false;
+ }
+ }
+ else
+ {
+ 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;
}
+
+ ///
+ /// Whether the manifest entry is valid baased on basic checks.
+ ///
+ /// Manifest entry to validate.
+ /// true if valid, false otherwise.
+ private static bool IsBasicValidManifest(ZipArchiveEntry manifestEntry)
+ {
+ try
+ {
+ 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;
+ }
+ }
+
+ ///
+ /// Read the content of the given ZIP archive entry as UTF-8 string up to the specified maximum number of bytes.
+ ///
+ /// ZIP archive entry to read.
+ /// Maximum number of bytes to read.
+ /// Content of the ZIP archive entry as UTF-8 string, or null if reading failed or exceeded maxBytes.
+ private static string? Read(ZipArchiveEntry entry, int maxBytes)
+ {
+ try
+ {
+ using var entryStream = entry.Open();
+ using var memoryStream = new MemoryStream();
+ var buffer = new byte[4096];
+
+ int read;
+ int total = 0;
+
+ while ((read = entryStream.Read(buffer, 0, buffer.Length)) > 0)
+ {
+ total += read;
+ if (total > maxBytes)
+ {
+ return null;
+ }
+
+ memoryStream.Write(buffer, 0, read);
+ }
+
+ return Encoding.ASCII.GetString(memoryStream.ToArray());
+ }
+ catch
+ {
+ return null;
+ }
+ }
}
}
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.
///
/// Stream in question.
+ /// Open XML specific validation rules.
/// true if valid, false otherwise.
/// Thrown if the provided stream is null.
/// Thrown if document type is not supported (macros, templates).
- 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).
///
/// Stream in question.
+ /// Open XML specific validation rules.
/// true if valid, false otherwise.
/// Thrown if the provided stream is null.
/// Thrown if document type is not supported (macros, add-ins, templates).
- 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).
///
/// Stream in question.
+ /// Open XML specific validation rules.
/// true if valid, false otherwise.
/// Thrown if the provided stream is null.
/// Thrown if document type is not supported (macros, add-ins, templates).
- 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/ByteGuard.FileValidator.Tests.Unit.csproj b/tests/ByteGuard.FileValidator.Tests.Unit/ByteGuard.FileValidator.Tests.Unit.csproj
index 340f3d1..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,4 +46,7 @@
+
+
+
diff --git a/tests/ByteGuard.FileValidator.Tests.Unit/ConfigurationValidatorTests.cs b/tests/ByteGuard.FileValidator.Tests.Unit/ConfigurationValidatorTests.cs
index 62ee7e8..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;
@@ -37,7 +38,7 @@ public void ThrowIfInvalid_SupportedFileTypesIsEmpty_ShouldThrowArgumentExceptio
// Arrange
var config = new FileValidatorConfiguration
{
- SupportedFileTypes = new List()
+ SupportedFileTypes = new()
};
// Act
@@ -53,7 +54,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 +70,7 @@ public void ThrowIfInvalid_FileTypeIsUnsupported_ShouldThrowUnsupportedFileExcep
// Arrange
var config = new FileValidatorConfiguration
{
- SupportedFileTypes = new List { ".unsupported", ".jpg" }
+ SupportedFileTypes = new() { ".unsupported", ".jpg" }
};
// Act
@@ -85,14 +86,67 @@ 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 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(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(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(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(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);
+ }
}
diff --git a/tests/ByteGuard.FileValidator.Tests.Unit/FileValidatorTests.cs b/tests/ByteGuard.FileValidator.Tests.Unit/FileValidatorTests.cs
index f1cf31d..35bd941 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;
@@ -440,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
@@ -609,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)
{
@@ -917,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
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 0000000..0f301b1
Binary files /dev/null and b/tests/ByteGuard.FileValidator.Tests.Unit/TestFiles/Positive/ODP_test.odp differ
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 0000000..a70063e
Binary files /dev/null and b/tests/ByteGuard.FileValidator.Tests.Unit/TestFiles/Positive/ODS_test.ods differ
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;
+ }
+}