修复 AI Gateway 图片 MIME 类型错误:用魔数检测替代扩展名猜测
问题背景
在使用 Hermes Discord Gateway 发送图片时,遇到了一个隐蔽的 bug:
arduino
HTTP 400: messages.16.content.1.image.source.base64:
The image was specified using the image/webp media type,
but the image appears to be a image/png image
Claude API 校验失败,原因是传入的 MIME type(image/webp)与图片实际内容(PNG 格式)不符。
根本原因分析
排查路径如下:
- Discord CDN 的行为 :Discord 下载入站图片附件时,会根据 HTTP Response 的
Content-Typeheader 决定本地缓存文件的扩展名 - CDN 转码问题 :Discord CDN 对 PNG 图片有时返回
content-type: image/webp(Discord 内部会自动做格式转换),导致文件被存成.webp后缀 - MIME 猜测逻辑缺陷 :Gateway 的
_guess_mime()函数纯粹基于文件扩展名判断类型,文件名叫.webp→ 返回image/webp - Claude API 严格校验:Claude 在接收 base64 图片时会校验实际字节头与声明的 MIME type 是否一致,不匹配直接 400
整个链路:文件内容是 PNG,但扩展名是 .webp → MIME 声明错误 → Claude 拒绝。
修复方案:魔数(Magic Number)检测
核心思路:不再依赖文件扩展名猜 MIME type,改为读取文件头字节,通过魔数来判断真实格式。
常见图片格式的魔数
| 格式 | 字节头(Hex) | 说明 |
|---|---|---|
| PNG | 89 50 4E 47 0D 0A 1A 0A |
固定 8 字节签名 |
| JPEG | FF D8 FF |
JFIF/EXIF 前缀 |
| WebP | 52 49 46 46 xx xx xx xx 57 45 42 50 |
RIFF....WEBP |
| GIF | 47 49 46 38 |
GIF8 |
修复代码
python
def _guess_mime(path: str, data: bytes | None = None) -> str:
"""
优先用文件头魔数判断真实 MIME type,
兜底才使用文件扩展名。
"""
# 读取文件头字节
header: bytes = b""
if data is not None:
header = data[:12]
else:
try:
with open(path, "rb") as f:
header = f.read(12)
except OSError:
pass
# 魔数匹配
if header[:8] == b"\x89PNG\r\n\x1a\n":
return "image/png"
if header[:3] == b"\xff\xd8\xff":
return "image/jpeg"
if header[:6] in (b"GIF87a", b"GIF89a"):
return "image/gif"
if header[:4] == b"RIFF" and header[8:12] == b"WEBP":
return "image/webp"
# 兜底:扩展名猜测
ext = os.path.splitext(path)[1].lower()
return {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".webp": "image/webp",
}.get(ext, "application/octet-stream")
关键细节
魔数优先于扩展名:无论文件被命名成什么扩展名,字节头永远反映真实格式。
传递 bytes 避免重复 IO :_file_to_data_url 已经读了文件内容,直接把 bytes 传给 _guess_mime,不用再 open 一次:
python
def _file_to_data_url(path: str) -> str:
with open(path, "rb") as f:
data = f.read()
mime = _guess_mime(path, data=data) # 传入已读字节
b64 = base64.b64encode(data).decode()
return f"data:{mime};base64,{b64}"
实际效果
修复后,即使 Discord CDN 把 PNG 文件存成了 .webp 扩展名,魔数检测也能正确识别出 image/png,Claude API 接收正常,HTTP 400 错误消失。
经验总结
不要用文件扩展名判断文件类型------这是安全和兼容性领域的经典教训,在 AI Gateway 场景下同样成立:
- 扩展名是用户/系统可以随意命名的元数据,不可信
- 文件头字节是由生成工具写入的实际数据,可靠
- CDN、代理、缓存系统在转码/转发过程中可能改变扩展名但不改变内容
这个 bug 的出现恰好是"三方联动"------Discord CDN 的转码行为 + Gateway 的扩展名猜测逻辑 + Claude API 的严格校验,三者叠加才暴露出来。单独看每一方都"没错",但组合在一起就出问题了。这类分布式系统中的隐蔽 bug,往往需要把整个数据流串起来才能找到根因。