Skip to content

🐛 [Server/COS] 文件名含连续半角空格时预签名 PUT 稳定 403,与文件格式和大小无关 #760

Description

@labilio

Description

聊天附件的文件名只要包含连续两个或以上普通半角空格(U+0020),上传凭证接口可以正常返回 200,但随后向腾讯 COS 执行的预签名 PUT 会稳定返回 403 SignatureDoesNotMatch。Web 最终只显示“上传失败”,用户容易误判为文件过大、格式不支持或网络异常。

该问题已由两个真实文件独立复现:

  • 1,140,120 字节 DOCX:文件名包含连续 3 个和 4 个半角空格;
  • 11,876,252 字节 PDF:纯 ASCII 文件名包含连续 2 个半角空格。

两个样本均满足以下 A/B 结果:文件字节完全不变,只把连续空格折叠成一个空格后,PUT 从 403 变为 200;改为短文件名同样返回 200。因此本问题与文件内容、文件大小上限、文件格式、中文字符以及对象 key 是否使用原始文件名无关。

进一步使用完全脱敏的合成文件验证了 PDF、DOCX、XLSX、TXT 四种格式。每种格式的成功/失败文件在该格式内字节及 SHA-256 完全相同,仅文件名中的空格数量不同:

格式 单个 U+0020 连续两个 U+0020
PDF PUT 200 PUT 403 SignatureDoesNotMatch
DOCX PUT 200 PUT 403 SignatureDoesNotMatch
XLSX PUT 200 PUT 403 SignatureDoesNotMatch
TXT PUT 200 PUT 403 SignatureDoesNotMatch

四个失败文件各执行 4 次新鲜凭证 + PUT,累计 16/16 均返回相同的 403;四个单空格对照累计 16/16 均返回 200。当前环境中的有界实测失败率为 100%。

字符边界矩阵另外覆盖了 25 类文件名。当前只确认连续两个及以上 U+0020 会触发同一 COS 签名错误;单个半角空格、Tab、全角空格 U+3000、不换行空格 U+00A0、窄不换行空格 U+202F、零宽空格 U+200B、中文、Emoji 及常见标点均在当前链路 PUT 200。扩展名后的尾随空格会更早返回凭证 400,U+007F 会更早被 HTTP 客户端拒绝,属于不同问题。

当前代码和运行时证据指向 Server/COS 预签名边界:

  • modules/file/api.goBuildContentDisposition 会把原始 ASCII 空格保留在 filename="..." 中,同时生成 filename*
  • modules/file/service_cos.go 将该 Content-Disposition 加入签名 Header,并调用 MinIO Go PresignHeader
  • MinIO Go v7.0.61 的 SigV4 canonical-header 实现使用 strings.Fields 处理 Header 值;
  • octo-web 将凭证接口返回的 contentDisposition 原样用于 PUT;
  • 浏览器抓包显示凭证返回值与 PUT Header 完全一致;使用相同新鲜预签名 URL、相同 Header、显式 Content-Length 和相同文件字节直接执行 PUT,也会得到相同 403,排除了浏览器独有的 Header 改写;
  • 把连续空格改为一个空格后,同一链路立即返回 200。

因此可以确认故障发生在 Server 生成并签名文件名相关 Header、再由 COS 验签的边界。COS 不返回其内部 canonical string,所以“双方具体在哪一步形成不同 canonical value”属于根据源码和 A/B 结果得到的机制推断;403、触发条件及故障边界本身已经实证。

历史记录:Mininglamp-OSS/octo-web#348 仍处于开放状态,并已在前端排查后标记为跨仓阻塞。该 Issue 早期评论认为后端未加引号或未生成 RFC 5987 filename*,但 2026-08-13 的线上响应已经同时包含这两项,因此旧判断不能解释当前复现。本 Issue 提供当前 Server 代码、真实 COS 返回和可公开夹具,作为后端独立跟踪项。

Steps to Reproduce

  1. 下载本 Issue 最后一条评论中的公开脱敏测试夹具 ZIP 并解压,保留原始文件名。
  2. 在同一个 Octo Web 会话中上传 01-PASS-Octo upload test.pdf
  3. 观察该文件上传成功。
  4. 上传字节完全相同的 02-FAIL-Octo upload test.pdfOctoupload 之间是两个普通半角空格。
  5. 观察文件卡片显示“上传失败”;重试仍失败。
  6. 可分别使用 03/04(DOCX)、05/06(XLSX)、07/08(TXT)重复上述 A/B;每一对均只有一个空格与两个空格的区别。

底层复现边界:对失败文件调用 /api/v1/file/upload/credentials 会得到 200;随后携带响应中的 Content-TypeContent-Disposition、准确 Content-Length 和文件字节执行 COS PUT,会得到 403 SignatureDoesNotMatch

Expected Behavior

合法文件在大小和扩展名均允许的情况下,应能完成上传;文件名中连续的普通空格不应导致预签名 PUT 验证失败。字节相同的文件不应仅因文件名从一个空格变为两个空格而产生 200/403 的差异。

Actual Behavior

  • 上传凭证请求:200;
  • COS CORS preflight:200(浏览器路径已验证);
  • COS PUT:403 SignatureDoesNotMatch
  • Web:只显示通用“上传失败,点击图标重试”;
  • 多次重试不会恢复;
  • 将连续空格折叠为一个空格或改为短文件名后,相同字节上传成功。

该故障会阻断附件消息的后续发送,因此 Bot/OpenClaw 等下游也无法收到附件消息。失败后文件卡片可能继续残留或在后续会话同步时重新出现,这是 octo-web 的另一个失败队列生命周期现象,不是本 Server Issue 的 403 根因。

DMWork Version

线上环境,具体 DMWork 构建号未知;2026-08-13 仍稳定复现。相关用户曾提供 OpenClaw 包版本 2026.7.1-2,但 403 发生在附件上传阶段,与 OpenClaw 处理无关。

Affected Component

Backend (Server)

Severity

P1 - Core Feature Broken

Reproducibility

  • Consistently reproducible

Environment

  • Octo Web:https://im.deepminer.com.cn/
  • 验证日期:2026-08-13
  • 存储服务:Tencent COS
  • Server 源码基线:Mininglamp-OSS/octo-server main2aee19bbf136d32e55411e02f79d7367d475ea37
  • MinIO Go:github.com/minio/minio-go/v7 v7.0.61
  • Web 源码基线:Mininglamp-OSS/octo-web upstream/main51bc8b3ee16209d226d3daad2947b14d137758f1
  • 浏览器与直接 HTTP PUT 均可复现;不是仅浏览器出现的错误

Logs / Screenshots

失败链路的脱敏结果:

credentials: 200
OPTIONS:     200
PUT:         403
COS Code:    SignatureDoesNotMatch

重复性结果:

双空格失败组:PDF/DOCX/XLSX/TXT,累计 16/16 → 403 SignatureDoesNotMatch
单空格对照组:PDF/DOCX/XLSX/TXT,累计 16/16 → 200

公开附件 ZIP 包含:

  • 10 个完全脱敏的 PDF、DOCX、XLSX、TXT 手工复现文件;
  • 每种格式的单空格成功对照和双空格失败文件;
  • 全角空格和不换行空格对照;
  • SHA-256、脱敏上传结果及重复性 JSON;
  • 使用说明。

ZIP SHA-256:DA62AB5CA4567A420B80C1845D82739B03DC233C57BE97E4711BE09E7C7695E1

附件内容为合成数据,不含用户、客户、公司、生产业务数据、本机路径或登录凭据。预签名 URL、签名、Cookie、Token、Request ID 和对象 key 均未写入附件或 Issue。

Workaround

上传前将文件名中的连续普通半角空格折叠为一个空格,或临时改为不含连续空格的文件名。该方式已经在相同文件字节上验证成功,但会改变用户原始文件名。

Metadata

Metadata

Assignees

No one assigned

    Labels

    needs-triageNeeds priority and assignment

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions