systemslibrarian/secure-file-upload-dotnet
GitHub: systemslibrarian/secure-file-upload-dotnet
适用于ASP.NET Core 8的生产级八层文件上传安全管道,提供纵深防御以抵御各类文件上传攻击。
Stars: 0 | Forks: 0
# secure-file-upload-dotnet
**适用于 ASP.NET Core 8+ 的纵深防御文件上传管道**
[](https://www.nuget.org/packages/SecureFileUpload.Core)
[](https://github.com/systemslibrarian/secure-file-upload-dotnet/actions/workflows/nuget-publish.yml)
[](https://github.com/systemslibrarian/secure-file-upload-dotnet/blob/main/LICENSE.md)
一个从生产环境 ASP.NET Core 8 文档录入工作流中提取的 8 层文件上传验证与存储管道。代码已经过去标识化处理、进行了通用化改造并发布以供复用——这是与生产环境中使用的相同的管道结构,而非一个玩具示例。
本仓库的目标是展示在真实的 C# 环境中,一个*严苛、失败即关闭 (fail-closed)* 的上传管道是什么样的:下面的每一项声明都由 [`src/`](https://github.com/systemslibrarian/secure-file-upload-dotnet/tree/main/src) 中的代码提供支持,且每一个已知的局限性都记录在 [`KNOWN-GAPS.md`](https://github.com/systemslibrarian/secure-file-upload-dotnet/blob/main/KNOWN-GAPS.md) 中。
[`SECURITY-ANALYSIS.md`](https://github.com/systemslibrarian/secure-file-upload-dotnet/blob/main/SECURITY-ANALYSIS.md) 记录了对该确切代码进行的一场结构化对抗性 AI 红队评估——包含原始发现、当前解决状态以及仍然存在的残余缺陷。
## 为什么会有这个项目
安全文件上传是 Web 开发中最容易被处理不当的领域之一。大多数教程只教你如何*接收*文件。很少有教程教你如何防御:
- Polyglot 文件(有效的图像 + 嵌入的可执行文件)
- 双重扩展名攻击(`photo.pdf.exe`)
- MIME 伪装
- Magic-byte(魔数)伪造
- 通过文件名操作进行的路径遍历
- PDF JavaScript 注入
- ZIP 炸弹 / 像素洪水攻击
- 通过精心构造的文件名进行的日志注入
- 磁盘耗尽攻击
本代码库解决了上述所有问题,并且红队分析会指出它在哪些方面仍然存在不足。
## 8 层验证管道
每个上传的文件都会按顺序通过所有层。**任何验证层的失败都会立即拒绝该文件。** 该管道在每个*内容*决策上都采取失败即关闭 (fail-closed) 策略——未知类型、格式错误的文件以及验证异常都会导致拒绝。唯一的故意例外是病毒扫描器的*可用性*(第 7 层),根据设计它是失败即开放 (fail-open) 并被明确追踪的;请参阅下面的[关键设计决策](#key-design-decisions-and-why)。
```
┌─────────────────────────────────────────────────────────────────┐
│ INCOMING FILE UPLOAD │
└─────────────────────────┬───────────────────────────────────────┘
│
┌───────────────▼────────────────┐
│ Layer 1: File Size Check │ Rejects oversized files before any buffering
│ (per-file and total batch) │ Also enforces minimum size per format
└───────────────┬────────────────┘
│
┌───────────────▼────────────────┐
│ Layer 2: Extension Allowlist │ Strict allowlist: .jpg .jpeg .png .webp .pdf
│ │ Everything else is rejected
└───────────────┬────────────────┘
│
┌───────────────▼────────────────┐
│ Layer 3: MIME + Extension │ Browser-reported MIME must match extension
│ Cross-Validation │ Catches extension-spoofed uploads
└───────────────┬────────────────┘
│
┌───────────────▼────────────────┐
│ Layer 4: Magic Bytes │ File signature read from actual bytes
│ (File Signature Check) │ Not from filename or Content-Type header
└───────────────┬────────────────┘
│
┌───────────────▼────────────────┐
│ Layer 5: Filename Inspection │ Double-extension, Unicode tricks,
│ │ path traversal, reserved names (NUL, COM1...)
└───────────────┬────────────────┘
│
┌───────────────▼────────────────┐
│ Layer 6: Deep Content │ Format-specific structural walking:
│ Validation (FileContentValidator) │ JPEG segment walker, PNG chunk walker,
│ │ WebP RIFF tree, PDF pattern scan,
│ │ PDF FlateDecode stream inspection.
│ │ Detects embedded executables, scripts,
│ │ JavaScript in PDF, dangerous PDF objects
└───────────────┬────────────────┘
│
┌───────────────▼────────────────┐
│ Layer 7: Virus Scan │ Windows Defender (Windows) OR
│ (IVirusScanService) │ ClamAV via clamd zINSTREAM (Linux/cross-platform).
│ │ Fail-closed on signature hit (infected → reject).
│ │ Fail-open on scanner availability (timeout/down →
│ │ accept as NotScanned; tracked in result, never
│ │ silently "clean"). Only runs when VirusScan:Enabled=true.
└───────────────┬────────────────┘
│
┌───────────────▼────────────────┐
│ Layer 8: Encrypted Storage │ AES-256-GCM envelope encryption (v2):
│ │ per-file random DEK wrapped under master KEK.
│ │ Image recompression strips polyglot tails.
│ │ Randomized filename, outside wwwroot,
│ │ path traversal re-checked before write
└───────────────┴────────────────┘
```
## 源文件
- `src/FileUploadService.cs` — 编排完整的 8 层管道。处理批量限制、磁盘容量检查、图像重压缩(缺陷 1 缓解措施)、信封加密写入 (v2)、检索时的解密以及日志注入安全的文件名处理。
- `src/FileContentValidator.cs` — 第 6 层深度内容验证。针对 JPEG、PNG、WebP、PDF 的特定格式结构遍历。基于模式的威胁检测。**FlateDecode 压缩的 PDF 流检查**(缺陷 2 缓解措施)。对未知类型采取失败即关闭 (fail-closed) 策略。
- `src/WindowsDefenderScanService.cs` — 第 7 层通过 Windows Defender `MpCmdRun.exe` 进行病毒扫描。包括临时文件的安全删除(删除前清零)。适用于 Windows。
- `src/ClamAvScanService.cs` — 第 7 层通过 `zINSTREAM` 协议经 TCP 连接 `clamd` 进行病毒扫描。不写入临时文件——用户字节绝不会接触磁盘。适用于 Linux / 容器 / macOS。
- `src/SecureFileDownloadController.cs` — 参考用的内部人员下载处理器。强制使用 `Content-Disposition: attachment`,锁定响应头(CSP `sandbox`、`nosniff`、`X-Frame-Options: DENY`、COOP/COEP/CORP、no-store),并在读取时重新检查路径遍历。
- `src/ReplacementCardInputModel.cs` — 示例模型,展示了如何通过多部分表单中的 `List` 绑定文件上传,并附带验证过的用户字段。
- `tests/Fuzz/` — 针对 `FileContentValidator.ValidateAsync` 的 SharpFuzz + AFL++ 测试工具。用于捕获由攻击者精心构造的输入导致的未处理异常、挂起和失控的内存分配。请参阅 `tests/Fuzz/README.md`。
## 关键设计决策(及其原因)
### 内容决策采用失败即关闭 (Fail-Closed) 策略
未知的文件类型、格式错误的结构、深度验证异常、缺失或仍为占位符的加密密钥,以及位于 `wwwroot` 内的存储路径,都会导致拒绝或应用拒绝启动。任何*内容*决策的默认策略都是**拒绝**,而不是允许。
唯一的故意例外是**病毒扫描器的可用性**(第 7 层)。返回*已感染*状态的扫描器总是会拒绝文件。如果扫描器不可达、超时或抛出异常,则会被视为**带有明确 `NotScanned` 追踪的失败即开放 (fail-open)**——文件之所以被接受,仅仅是因为第 1-6 层已经对其放行,并且该结果会在 `FileUploadResult.ScanNotScannedCount` 中显示,并作为 `VIRUS_SCAN_OPERATIONAL_FAILURE` 被记录。这在 [`KNOWN-GAPS.md`](https://github.com/systemslibrarian/secure-file-upload-dotnet/blob/main/KNOWN-GAPS.md) 中有文档记录,对于 `clamd` 宕机绝不能阻塞合法注册的用户文档工作流来说,这是正确的权衡;如果部署环境需要将扫描器可用性作为硬性阻断条件,则应切换到队列扫描模型(请参阅 [`docs/hardening-roadmap.md`](https://github.com/systemslibrarian/secure-file-upload-dotnet/blob/main/docs/hardening-roadmap.md) §1.3)。
### 基于签名优先的分类
深度验证器在分派给特定格式的验证器之前,会从 magic bytes(魔数)检测*实际*的文件类型。一个声称是 `.jpg` 但以 `%PDF` 开头的文件,会在任何特定格式的逻辑运行之前被作为类型不匹配捕获。
### 扩展名 ↔ MIME 交叉验证(第 3 层)
浏览器报告的 `Content-Type` 头会与声明的扩展名进行验证。以 `image/jpeg` MIME 类型到达的 `.pdf` 文件会被拒绝。扩展名和 MIME 类型都不会被独立信任。
### wwwroot 之外的存储(第 8 层)
存储根目录会在构造时被验证为位于 `wwwroot` 之外。如果有人将路径错误配置为解析到 Web 根目录内(文件将可被直接提供),应用程序将**拒绝启动**。这是在 `IWebHostEnvironment` 级别强制执行的,而不仅仅是作为文档说明。
### 随机化文件名
文件以 `{sanitizedLastName}{dateStamp}{formType}Doc{n}{randomSuffix}.ext` 的形式存储。磁盘上永远不会使用原始文件名。这可以防止基于文件名的路径遍历,并消除了攻击者对最终存储路径的任何控制权。
### 使用 PBKDF2 的 AES-256-GCM(信封加密)
启用加密后,文件将使用**信封加密 (格式 v2)** 进行存储:
1. 为每个文件生成一个新的随机 256 位数据加密密钥 (DEK)。
2. 文件有效载荷在 DEK 下使用 AES-256-GCM 进行加密。
3. DEK 本身随后由主密钥加密密钥 (KEK) 封装(加密),而 KEK 是通过 PBKDF2-SHA256 以 600,000 次迭代(OWASP 2024 建议)从 `EncryptionSecret` 派生而来的。
4. 磁盘上的布局为:`marker || dek_nonce || dek_tag || wrapped_dek || file_nonce || file_tag || ciphertext`。
这意味着**轮换主密钥只需重新封装每个文件的 DEK**——文件有效载荷本身不需要重新加密。旧版的单密钥 v1 文件仍可读取,以实现向后兼容。如果 `EncryptionEnabled=true` 但密钥缺失或仍设为占位符,应用程序将拒绝启动。
### 图像重压缩(Polyglot 防御)
当 `FileUpload:RecompressImages=true`(默认)时,JPEG / PNG / WebP 上传会在加密前通过 ImageSharp 进行解码和重新编码。这会**剥离附加在图像结构末尾的任何数据**(即 polyglot 向量——一个同时也是有效 PHP/EXE 的 JPEG)。PDF 和其他格式不受影响。
### FlateDecode 压缩的 PDF 流检查
PDF 验证器会遍历每个 `stream … endstream` 块,尝试使用 `DeflateStream` 进行解压,并针对解压后的字节重新运行危险模式扫描。这可以捕获隐藏在压缩对象流中的 `/JavaScript`、`/Launch` 等。受 `MaxCompressedStreamsToInspect` 和 `MaxDecompressedStreamBytes` 限制,以保证对 ZIP 炸弹的安全性。
### 日志注入安全的文件名处理
每个受攻击者控制的文件名在写入日志或在面向用户的错误消息中回显之前,都会经过 `SanitizeForLog` 处理。它会剥离 ANSI 转义序列、控制字符、结构化日志占位符(`{`、`}`、`|`)、CRLF 以及 Unicode bidi/零宽度花样字符。
### 跨平台病毒扫描
`IVirusScanService` 有两个生产环境的实现:
- **`WindowsDefenderScanService`** — 调用 `MpCmdRun.exe`;需要 Windows。
- **`ClamAvScanService`** — 使用已文档化的 `zINSTREAM` 协议通过 TCP 直接与 `clamd` 通信。不写入临时文件。跨平台(Linux、macOS、容器)。
检测是失败即关闭 (fail-closed) 的:任何明确的恶意软件签名都会拒绝上传。操作失败(超时、守护进程关闭、无法解析的响应)则是带有明确 `NotScanned` 追踪的失败即开放 (fail-open)——文件之所以被接受,仅仅是因为它已经通过了第 1-6 层,并且该结果会计入 `FileUploadResult.ScanNotScannedCount` 并作为 `VIRUS_SCAN_OPERATIONAL_FAILURE` 日志事件发出。结果**绝不会悄无声息地被重新标记为 "clean"**。
### 加固的下载暴露面 (`SecureFileDownloadController`)
安全地提供解密后的用户文档是一个与安全接收文档完全不同的问题。参考下载控制器:
- 在读取时重新检查路径遍历(在上传时检查之上的纵深防御)。
- 强制每个响应使用 `Content-Type: application/octet-stream` + `Content-Disposition: attachment`,以便浏览器**无法内联渲染文件**——PDF 永远不会调用 Adobe Reader,图像永远不会被 MIME 嗅探为 HTML。
- 发送一组严格的响应头:`X-Content-Type-Options: nosniff`、`X-Frame-Options: DENY`、`Content-Security-Policy: default-src 'none'; … sandbox`、`Cache-Control: no-store, private`、`Cross-Origin-{Resource,Opener,Embedder}-Policy`、`Referrer-Policy: no-referrer`,以及限制性的 `Permissions-Policy`。
- 通过 `ContentDispositionHeaderValue.SetHttpFileName`(RFC 6266 UTF-8)对文件名进行编码,以击败头注入。
请将其挂载在经过身份验证和 MFA 验证的内部人员路由下。**切勿**匿名公开。
### 安全的临时文件删除 (WindowsDefenderScanService)
病毒扫描器将文件写入临时目录以进行扫描。扫描完成后,临时文件在删除前会被零覆盖。这减少了(虽然不能保证)从释放的磁盘扇区中恢复敏感内容的可能性。
### ArrayPool + 缓冲区清零 (FileContentValidator)
内容验证使用 `ArrayPool` 作为读取缓冲区。该缓冲区在**返回到池之前会被清零**,以防止用户文档内容(身份证、水电费账单)泄漏到后续请求中。
### PathHelper.IsPathUnderBase (而非 StartsWith)
路径遍历检查使用正确的基础路径检查,而不是 `string.StartsWith`。`StartsWith` 方法有一个众所周知的前缀混淆缺陷:在进行简单前缀检查时,`/uploads_evil` 会匹配 `/uploads`。此辅助工具使用规范化的路径和目录分隔符边界检查。
## 配置参考
```
{
"FileUpload": {
"StorageRoot": "../uploads",
"MaxFileSizeBytes": 10485760,
"MaxFileCount": 5,
"MaxTotalUploadBytes": 52428800,
"MinStorageFreeBytes": 536870912,
"MinTempFreeBytes": 536870912,
"LowDiskWarningBytes": 2147483648,
"RecompressImages": true,
"JpegRecompressQuality": 95,
"EncryptionEnabled": false,
"EncryptionSecret": "CHANGE_THIS_TO_A_REAL_SECRET_MINIMUM_32_CHARS"
},
"FileContent": {
"InspectCompressedPdfStreams": true,
"MaxCompressedStreamsToInspect": 64,
"MaxDecompressedStreamBytes": 16777216,
"RejectEncryptedPdfs": true,
"RejectInteractivePdfs": false,
"MaxImageWidth": 10000,
"MaxImageHeight": 10000,
"MaxImagePixels": 40000000
},
"VirusScan": {
"Enabled": false,
"WindowsDefender": {
"MpCmdRunPath": "C:\\Program Files\\Windows Defender\\MpCmdRun.exe",
"TempScanPath": "C:\\Temp\\VirusScan",
"TimeoutSeconds": 30
},
"ClamAv": {
"Host": "localhost",
"Port": 3310,
"TimeoutSeconds": 30,
"MaxStreamBytes": 26214400
}
}
}
```
**StorageRoot** 是相对于 `ContentRootPath`(而不是 `wwwRootPath`)解析的。像 `../uploads` 这样的相对路径是典型的做法,以确保它位于 Web 根目录之外。
**EncryptionSecret** 必须至少包含 32 个字符,并且不得包含字符串 `CHANGE_THIS`。如果 `EncryptionEnabled` 为 true 且密钥未通过此检查,**应用程序将不会启动**。
**RecompressImages** 默认为 `true`。仅当对用户上传的图像有字节级精确保留的硬性要求时才将其设置为 `false`(您将承担 polyglot 攻击的风险)。
**ClamAv:MaxStreamBytes** 必须与您的 `clamd.conf` 中的StreamMaxLength` 设置保持一致。
## 依赖项
NuGet 包声明了这些依赖项——无需手动安装:
- **ASP.NET Core 8+** 共享框架(通过 `FrameworkReference` 引用)
- **[SixLabors.ImageSharp 3.1.x](https://github.com/SixLabors/ImageSharp)** — 图像结构验证和针对 polyglot 尾部的重压缩
在运行时,还需要一个扫描器后端(不是 NuGet 包):
- **Windows Defender** (`MpCmdRun.exe`) — 仅限 Windows,由 `WindowsDefenderScanService` 使用
- **ClamAV** (通过 TCP 监听的 `clamd`) — Linux/macOS/容器,由 `ClamAvScanService` 使用
扫描器由 `AddSecureFileUpload()` 基于 `OperatingSystem.IsWindows()` 自动选择。可以通过在 appsettings 中设置 `VirusScan:Enabled: false` 来完全禁用病毒扫描(其余 7 层仍会运行)。
## 安装
```
dotnet add package SecureFileUpload.Core
```
需要带有 ASP.NET Core 的 .NET 8+。此包针对 `net8.0` 并依赖于 ASP.NET Core 共享框架,该框架随每个 ASP.NET Core 8+ 运行时一起提供——无需安装任何额外的东西。
## 发布流程
NuGet 发布由 GitHub Actions 通过 `.github/workflows/nuget-publish.yml` 处理。
1. 推送更改到 `main` 分支,以运行构建、打包和模糊测试工具检查,而不会发布。
2. 确保仓库或 `nuget-publish` 环境具有一个范围限定为 `SecureFileUpload.Core` 的 `NUGET_API_KEY` 密钥。
3. 推送版本标签以发布到 NuGet.org。工作流直接从标签名称派生包版本:
- `v1.0.1` 发布包版本 `1.0.1`
- `v1.0.1-preview.1` 发布包版本 `1.0.1-preview.1`
4. 工作流使用 `--skip-duplicate` 推送 `.nupkg` 和 `.snupkg` 这两个工件,因此如果版本已经存在,重新运行不会造成破坏。
示例:
```
git tag v1.0.1-preview.1
git push origin v1.0.1-preview.1
```
项目文件的 `` 对于本地打包和非标签的 CI 工件仍然有用,但对于已发布的 NuGet 版本,标签构建才是事实标准。
## 集成模式
### 最小注册 (推荐)
```
// Program.cs
using SecureFileUpload.Services;
// Registers FileContentValidator, the platform-appropriate IVirusScanService,
// and IFileUploadService in one call. Scanner options are read from appsettings
// ("FileContent" section). Pass a lambda to override in code.
builder.Services.AddSecureFileUpload();
// Size limit must match FileUpload:MaxTotalUploadBytes in appsettings.
builder.Services.Configure(options =>
{
options.MultipartBodyLengthLimit = 53_477_376; // 51 MB — adjust to match your config
});
```
### 手动注册 (如果您需要完全控制)
```
// Program.cs / Startup registration
builder.Services.AddSingleton();
// Pick ONE virus scanner based on platform:
if (OperatingSystem.IsWindows())
builder.Services.AddSingleton();
else
builder.Services.AddSingleton();
builder.Services.AddSingleton();
// Configure multipart body size limit to match your FileUpload:MaxTotalUploadBytes
builder.Services.Configure(options =>
{
options.MultipartBodyLengthLimit = 53_477_376; // 51 MB
});
```
### Controller 用法
```
[HttpPost]
[RequestSizeLimit(53_477_376)]
public async Task Submit(MyInputModel model)
{
if (!ModelState.IsValid)
return View(model);
var files = Request.Form.Files;
var result = await _fileUploadService.UploadFilesAsync(files, model.LastName, "remote");
if (!result.Success)
{
// result.Errors contains user-safe messages
// result.WorkflowOutcome: AllSaved | PartialSaved | AllRejected | NoFiles
foreach (var error in result.Errors)
ModelState.AddModelError(string.Empty, error);
return View(model);
}
// ...
}
```
## 文档
- [`docs/threat-model.md`](https://github.com/systemslibrarian/secure-file-upload-dotnet/blob/main/docs/threat-model.md) — 每一层击败了什么攻击
- [`docs/hardening-roadmap.md`](https://github.com/systemslibrarian/secure-file-upload-dotnet/blob/main/docs/hardening-roadmap.md) — 达到最强现实安全态势的建议
- [`SECURITY-ANALYSIS.md`](https://github.com/systemslibrarian/secure-file-upload-dotnet/blob/main/SECURITY-ANALYSIS.md) — AI 红队对抗性发现(附带当前解决状态)
- [`KNOWN-GAPS.md`](https://github.com/systemslibrarian/secure-file-upload-dotnet/blob/main/KNOWN-GAPS.md) — 坦诚的局限性以及本项目未能防范的内容
- [`tests/attack-vectors.md`](https://github.com/systemslibrarian/secure-file-upload-dotnet/blob/main/tests/attack-vectors.md) — 按层划分的攻击测试用例(手动 + 自动化指南)
- [`tests/Fuzz/`](https://github.com/systemslibrarian/secure-file-upload-dotnet/tree/main/tests/Fuzz) — 用于深度内容验证器的 SharpFuzz + AFL++ 测试工具
## 许可证
MIT。自由使用。请注明出处,但这并非强制要求。
## 贡献
欢迎提交 Issue 和 PR,特别是:
- 验证层的单元测试覆盖
- 额外的格式验证器(GIF、BMP 深度内容验证)
- 面向高吞吐量部署的异步/排队病毒扫描工作器
标签:8层安全验证, AI红队分析, ASP.NET Core 8, CISA项目, C#安全库, DNS 反向解析, DNS 解析, Magic-byte校验, MIME伪造, NuGet包, PDF JavaScript注入, Web安全, ZIP炸弹, 像素洪泛攻击, 双重扩展名攻击, 多态文件, 安全编码, 对抗性安全分析, 文件上传安全, 文件验证管道, 文档接入工作流, 漏洞防御, 生产级, 纵深防御, 网络安全, 网络安全审计, 蓝队分析, 路径遍历, 隐私保护