题干与适用场景
一个多租户 B2B 文件服务允许已登录用户上传 PDF、JPEG 和 PNG 附件,单文件上限为 20 MiB。上传完成后,只有同租户内具备权限的成员可以下载。客户端提交的文件字节、原始文件名、扩展名、Content-Type 和文件大小声明都属于不可信输入。
请设计一套安全的上传、扫描、发布与下载 API,并说明如何防御:
- Web shell、伪造类型、双扩展名、路径穿越和覆盖已有对象;
- 恶意 PDF、图片解析器漏洞、病毒和多格式文件;
- 跨租户越权、公开对象地址泄漏和危险的浏览器内联渲染;
- 超大请求、存储配额耗尽、并发扫描和扫描器故障;
- 文件在通过扫描后被替换的竞态,以及重复完成请求造成的状态错乱。
20 MiB、三种允许类型和 B2B 多租户是面试场景约束,不是通用安全阈值。题目要求候选人把身份、上传协议、对象存储、异步任务和下载授权组成一个可证明的安全边界,核心能力属于后端 API 与服务安全,因此归为 backend。
面试官考察点
第一,看候选人是否先建立不变量:未通过安全判定的字节只能留在隔离区,不能被业务用户读取、解析或从应用主域名提供;客户端文件名不能成为存储路径;每次读取都要重新授权。
第二,看是否理解“校验类型”需要纵深防御。扩展名、请求 Content-Type、文件签名字节和解析器结果各自只能提供部分证据。业务白名单、规范化、结构解析、恶意软件扫描,以及适用时的重编码或内容净化应组合使用。
第三,看能否设计清晰的异步状态机。上传成功只代表字节已进入隔离区,不能直接返回“文件可用”。API 需要暴露处理中、可用、拒绝和处理失败等状态,并明确重试、超时和清理规则。
第四,看是否发现扫描竞态。扫描器判定的是某一份确定字节;若同一个对象键可以在扫描后被覆盖,AVAILABLE 状态可能指向另一份从未扫描的内容。扫描结论必须绑定不可变对象版本或内容哈希,发布时做条件更新。
第五,看是否把下载也纳入威胁模型。随机键只减少猜测,不能替代租户与对象级授权。下载应使用受控处理器或短时签名地址,并设置安全的响应头、独立来源和最小缓存策略。
回答前需要澄清的问题
- 文件会被怎样使用? 仅下载、浏览器预览、文本抽取、缩略图和第三方处理的风险不同;每增加一个解析器,就增加一个攻击面。
- 三种类型是否足够? 本题可直接拒绝 ZIP、Office 和其他格式;若允许压缩包,必须再限制层数、文件数、展开后大小和压缩比。
- 用户如何认证? Cookie 会引出 CSRF 防护;Bearer token 仍需正确的 CORS、权限范围和泄漏控制。
- 是否需要直传对象存储? 小文件可以由 API 流式接收;高并发时可发放短时、单对象、仅写入隔离区的上传凭证,完成后由服务端核对真实对象。
- 谁能上传、查看状态、下载和删除? 应分别定义租户角色、对象归属和审计要求,不能只检查“已登录”。
- 扫描服务的 SLA 是什么? 需要确定等待时间、重试次数、故障时是否 fail closed,以及用户看到的状态。
- 是否有合规或数据驻留要求? 加密密钥、保留期、审计日志、删除和备份清理都可能受约束。
- 成功标准是什么? 上传字节落盘、扫描通过和授权用户可下载是三个不同里程碑,API 语义要分别表达。
30 秒回答框架
“我先定义三个不变量:未通过扫描的文件只在隔离区;存储键由服务端生成,原始文件名仅作为受限元数据;任何下载都执行租户和对象级授权。流程使用 PENDING_UPLOAD → QUARANTINED → SCANNING → AVAILABLE/REJECTED/FAILED 状态机。
创建上传时校验用户权限、配额、允许类型和 20 MiB 上限,生成不可猜且不可覆盖的对象键。字节流进入无执行权限、无公共读取权限的隔离存储;服务端根据实际对象大小、规范化扩展名、文件签名和受限解析结果校验,再由隔离的扫描 worker 做恶意软件检测。扫描结论绑定对象版本或 SHA-256,只有条件更新仍匹配时才发布。
下载时重新校验租户和对象权限,再返回短时签名地址或由服务端流式输出,并设置 Content-Disposition: attachment、准确的安全类型和 X-Content-Type-Options: nosniff。扫描失败时保持不可用并限次重试;配额、速率、并发、超时和生命周期清理控制资源。最后用类型欺骗、路径文件名、测试病毒、超限流、重复完成、对象替换和跨租户访问做对抗性验证。”
分步骤深入解答
第一步:先写出信任边界和安全不变量
客户端可以控制 multipart 部件的字节、文件名、Content-Type、声明大小和请求顺序。API 网关、应用服务、对象存储事件和扫描队列也可能重复、延迟或乱序。设计应围绕以下不变量:
- 隔离对象没有公开读取权限,也不会由 Web 服务器执行;
- 只有
AVAILABLE且授权通过的对象可以下载; - 业务记录中的
tenant_id来自认证上下文,不接受客户端自由指定; - 原始文件名不参与路径拼接或对象定位;
- 安全判定绑定一份不可变字节,发布过程不能把结论移植到另一个版本;
- 任何异常、超时或无法判定都保持 fail closed。
“文件名随机化”和“对象不可公开”解决的是不同问题。随机对象键防止覆盖、路径操纵和容易猜测;授权与私有存储负责保密性。两者都要有。
第二步:用状态机分开上传成功与文件可用
建议的最小状态机如下:
PENDING_UPLOAD
├─ bytes verified ─> QUARANTINED ─> SCANNING
│ ├─ safe ─> AVAILABLE
│ ├─ malicious/invalid ─> REJECTED
│ └─ scanner unavailable ─> FAILED
└─ expired/abandoned ─> EXPIREDPOST /uploads 创建会话并返回 upload_id。若采用直传,它同时返回只能写入指定隔离对象、短时有效的凭证。POST /uploads/{id}/complete 只触发服务端核验与扫描,适合返回 202 Accepted。GET /uploads/{id} 返回状态;只有 AVAILABLE 才提供下载入口。
每个迁移都使用数据库条件更新,例如只有当前为 QUARANTINED 的记录才能进入 SCANNING。队列重复投递和客户端重复调用 complete 时,同一个状态迁移最多生效一次。FAILED 表示技术处理失败,REJECTED 表示文件或策略不合格,两者不能混为一个模糊错误。
第三步:安全接收字节并控制资源
创建会话时先检查上传权限、租户剩余配额、用户速率和允许的业务类型。服务端生成随机 upload_id 与对象键,例如 quarantine/{tenant-id}/{uuid};原始文件名经过长度、控制字符和 Unicode 规范化处理后,只作为展示元数据保存。
若 API 代理上传,应流式写入隔离存储并边读边计数,超过 20 MiB 立即中止,不能把整个文件读进进程内存。若客户端直传,签名能力应尽量限制方法、对象键、有效期和大小;直传完成后仍要由可信服务读取对象元数据并核对实际长度、版本与所有权,不能相信客户端的 complete 请求。
隔离 bucket 或目录默认私有、禁止执行、与应用主域名隔离,并使用最小权限账号。上传会话到期、客户端放弃和扫描终态都要有生命周期清理,避免孤儿对象长期占用配额。
第四步:组合类型校验、解析与恶意内容扫描
校验顺序可以先便宜后昂贵,但任何单层都不能直接产生 AVAILABLE:
- 对规范化后的扩展名执行 PDF、JPEG、PNG 白名单;
- 把客户端
Content-Type当作提示,拒绝明显不一致,但不把它当证据; - 检查文件签名和完整结构,防止只在开头放合法 magic bytes;
- 使用保持更新且受限运行的解析器确认文件可完整解析;
- 在隔离 worker 中运行恶意软件扫描,记录引擎与规则版本;
- 对图片可解码后重新编码,对 PDF 可按业务风险使用内容净化。
扫描器和解析器处理攻击者字节,应运行在受限 CPU、内存、临时磁盘、时间和网络权限的沙箱中。题目不允许压缩包,直接拒绝可减少解压炸弹、嵌套深度和路径穿越风险。杀毒结果也不是绝对安全证明,存储隔离、安全下载和最小解析仍需保留。
第五步:把扫描结论绑定到不可变对象再发布
扫描任务读取隔离对象的不可变 versionid,并计算 SHA-256。任务结果至少包含 uploadid、对象版本、哈希、实际大小、检测类型、扫描引擎版本和 verdict。
发布时数据库执行条件迁移:记录仍为 SCANNING,对象版本与哈希仍等于任务输入,才可设为 AVAILABLE。对象存储同时禁止覆盖该版本;若需要把文件复制到 approved 区,复制目标也使用新随机键,并核对复制源版本。任何不匹配都重新隔离或拒绝,不能沿用旧 verdict。
这一绑定关闭了典型的检查与使用时间差:攻击者不能先上传安全文件取得通过结果,再用同一键覆盖成恶意文件。若存储没有版本能力,可使用写一次对象键或以内容哈希寻址,并让业务记录只指向最终不可变对象。
第六步:授权下载并控制浏览器解释方式
GET /files/{id}/download 先用认证上下文查找当前租户,再检查对象归属、角色、删除状态和 AVAILABLE 状态。对象 ID 即使是 UUID,也不能免除这些检查。通过后可由应用流式返回,或生成绑定具体对象、操作和短有效期的签名下载地址。
对不需要浏览器预览的附件,响应使用 Content-Disposition: attachment、经过安全编码的展示文件名、服务端确认的媒体类型,以及 X-Content-Type-Options: nosniff。文件来源与主应用 Cookie 域隔离,可减少主动内容影响主站会话的机会。签名地址的有效期要短,因为删除权限后,已经发出的地址通常不会立刻失效。
下载端还要限制速率与带宽、审计授权结果,并决定代理或缓存是否允许保存私有响应。日志记录对象 ID、租户、操作者和结果,避免记录文件正文或可长期访问的签名 URL。
第七步:设计失败、幂等、配额与可观测性
扫描超时或服务不可用时,文件保持不可下载。worker 可按退避策略限次重试;超过次数进入 FAILED,由人工或受控重试恢复。扫描器返回明确恶意或结构不合法时进入 REJECTED,并按保留策略删除隔离字节。
创建接口可接受幂等键,避免客户端超时重试生成多个会话;complete、扫描消费和删除都以条件状态迁移实现幂等。并发限制至少覆盖用户上传数、租户总字节、单文件大小、扫描队列长度和每租户扫描槽位,防止一个租户挤占全局资源。
关键指标包括各状态停留时间、隔离区字节数、过期会话数、扫描通过/拒绝/失败率、重试次数、队列年龄、解析器超时、跨租户拒绝和下载授权失败。审计日志记录状态迁移、对象版本、哈希、策略与扫描版本,才能回答“哪一份字节由什么规则判定”。
第八步:用对抗性矩阵验证完整链路
验证至少包含:
.jpg.php、大小写混合、空字节和超长 Unicode 文件名;- 伪造
Content-Type、合法开头加恶意尾部、损坏 PDF、多格式文件; - 安全环境中的 EICAR 测试文件,以及会触发解析器超时或资源上限的样本;
- 精确 20 MiB、超出 1 字节、无长度声明、慢速流和大量并发上传;
- 相同幂等键、并发 complete、重复队列消息和乱序扫描结果;
- 扫描期间尝试覆盖对象,确认版本或哈希不匹配时无法发布;
- 另一个租户读取状态、下载或删除对象,确认全部拒绝;
- 扫描器宕机、任务超时、应用重启和孤儿对象清理;
- 下载响应的 attachment、媒体类型、
nosniff、缓存和签名过期行为。
通过条件应同时覆盖安全与业务:被拒绝文件永远没有下载入口,安全文件最终可用,重试不产生重复记录,越权访问无数据泄漏,异常期间保持 fail closed,资源使用受限,并能从审计记录还原完整判定链。
高质量示范回答
“我会把上传设计为隔离、判定、发布三个阶段,并先保证未通过判定的字节永远不可被业务用户读取。创建上传会话时,从认证上下文确定 tenant_id,检查角色、20 MiB 上限、租户配额和 PDF/JPEG/PNG 白名单,再生成随机、不可覆盖的隔离对象键。原始文件名只保留为长度受限且规范化的显示元数据。
字节通过 API 流式写入,或用短时且只允许写指定隔离键的凭证直传。complete 后,服务端核对真实大小、对象版本和归属,把记录从 PENDING_UPLOAD 条件更新到 QUARANTINED,再异步扫描。校验会组合扩展名、服务端检测类型、完整结构解析、恶意软件扫描和适用的重编码;扫描进程使用受限资源与网络权限。
扫描器读取不可变版本并计算 SHA-256。只有记录仍处于 SCANNING,且版本和哈希都与扫描输入相同,才能条件更新为 AVAILABLE;这样安全文件通过后被覆盖也无法复用旧结论。扫描器故障保持 fail closed 并限次重试,明确恶意或不合规则进入 REJECTED。
下载接口每次检查租户、对象权限和 AVAILABLE 状态,再流式输出或签发短时下载地址。附件使用独立来源、Content-Disposition: attachment 和 X-Content-Type-Options: nosniff。最后我会验证类型欺骗、EICAR、超限流、重复 complete、对象覆盖竞态、跨租户读取和扫描器宕机,并监控状态时长、隔离区字节、队列年龄和扫描版本。”
常见错误
- 只检查扩展名 → 双扩展名、大小写和伪装内容可以绕过 → 使用业务白名单、规范化、签名、结构解析与扫描的组合。
- 信任
Content-Type→ 客户端可以任意填写请求头 → 仅把它用于早期筛选,最终类型由服务端检测。 - 使用原始文件名作为路径 → 可能产生路径穿越、覆盖和不同文件系统规范化问题 → 服务端生成随机对象键,原名只作受限元数据。
- 上传落盘后立即可下载 → 扫描尚未完成,恶意字节已经暴露 → 隔离存储并以
AVAILABLE作为唯一发布条件。 - 杀毒通过就宣称绝对安全 → 新样本、解析器漏洞和主动内容仍可能存在 → 保留隔离、最小解析、安全响应头与权限控制。
- 扫描同一个可覆盖键 → 通过结果可能被应用到后来替换的字节 → 绑定不可变版本或内容哈希,并条件发布。
- UUID 等于授权 → 知道或泄漏对象 ID 后可能跨租户读取 → 每次状态查询、下载和删除都做对象级授权。
- 直传后相信客户端 complete → 客户端可伪造大小、类型或对象归属 → 服务端读取对象元数据与字节证据重新核对。
- 扫描器故障时自动放行 → 基础设施故障变成安全绕过 → fail closed、限次重试并暴露清晰处理状态。
- 只限制单文件大小 → 大量合法大小文件仍可耗尽存储与扫描能力 → 增加租户配额、速率、并发和队列背压。
- 在主域名内联展示任意文件 → 浏览器解释和主动内容可能影响主站会话 → 默认附件下载并隔离文件来源。
追问及应对
追问一:有了 magic bytes,为什么还要解析完整结构?
文件签名通常只覆盖开头少量字节。攻击者可以在合法头部后放入另一种内容,或构造同时被多个解析器接受的文件。完整结构解析能确认内部长度、对象关系和结束标记是否符合预期,但解析器本身也处理不可信输入,所以还要限制资源、及时更新并放在隔离环境。
追问二:直传对象存储会不会绕过后端安全?
直传只改变字节传输路径。后端仍创建绑定租户和对象键的短时上传会话,凭证只能写隔离区;完成后由后端核对真实对象、大小、版本和哈希,再触发扫描。对象在 AVAILABLE 前没有读取权限,因此客户端无法通过直传直接发布。
追问三:扫描器暂时不可用时,用户体验怎么处理?
complete 返回处理中状态,查询接口展示仍在安全检查或处理失败可重试。系统限次退避重试,并监控队列年龄;超过阈值进入 FAILED,仍保持不可下载。恢复后可由受控任务重试同一个不可变版本,不能为了可用性跳过判定。
追问四:为什么下载还需要 attachment 和 nosniff?
授权解决谁能拿到字节,响应头控制浏览器如何解释字节。attachment 倾向触发下载,nosniff 阻止浏览器偏离服务端声明类型进行嗅探。配合独立文件来源,可以缩小主动内容影响应用主域 Cookie 和脚本上下文的范围。
追问五:允许 ZIP 后要增加哪些控制?
需要在隔离沙箱中检查每个成员路径,拒绝绝对路径和 ..,限制嵌套层数、成员数量、单成员大小、总展开大小、压缩比、CPU 和时间,并对展开后的每个文件重新执行类型与恶意内容检查。本题业务不需要 ZIP,直接拒绝是更小的攻击面。
追问六:怎样证明扫描结果没有被竞态污染?
让扫描任务记录输入对象的不可变版本和 SHA-256;发布使用数据库条件更新,同时验证当前记录仍为 SCANNING 且版本、哈希完全相同。测试在扫描期间覆盖同一逻辑对象并投递旧结果,预期发布失败。审计日志应能串起输入版本、扫描 verdict 与最终 approved 对象。