Gin中c.FormFile()不清洗文件名控制符,需先用strings.Map过滤非法字符再取扩展名并白名单校验,配合魔术字节检测,且清洗与扫描顺序不可颠倒。

上传文件名里藏控制符,c.FormFile() 不会自动清理
Gin 的 c.FormFile() 只返回 *multipart.FileHeader,其中的 Filename 字段是客户端原始传入的字符串,含 \x00、\r\n、../ 等完全不校验。直接拼进 os.OpenFile() 或 filepath.Join() 会导致路径遍历、文件覆盖或 panic。
实操建议:
- 永远不用
filepath.Clean(header.Filename)做“过滤”——它只压缩语义(如a/../b→b),不删非法字符 - 先用
strings.Map清洗:只保留rune >= 0x20 && rune (可见 ASCII)、<code>'.'、'-'、'_',其余映射为-1(即删除) - 再用
filepath.Base()提取纯文件名,它能天然剥离路径前缀(如../../etc/passwd→passwd) - 最后用
filepath.Join("uploads", cleanName)拼接,避免手动字符串拼接
文件内容含控制符,c.SaveUploadedFile() 不检查
c.SaveUploadedFile() 是透传写入,不会扫描文件体里的 \x00、\x1b、\r\n 等。若该文件后续被当作文本读取(如日志提取、配置解析),可能触发 json.Unmarshal panic、SQL 注入或终端污染。
实操建议:
- 对高风险用途(如用户头像转 base64 后存 DB、上传的 CSV 被
encoding/csv解析),必须在保存前读取并扫描前 64KB:用io.ReadFull(f, buf)读固定长度,再用strings.IndexFunc(bufStr, func(r rune) bool { return r - 发现控制符立即
c.AbortWithStatus(400),别等保存完再检查 - 不扫全量:文件 >1MB 直接拒绝(
c.AbortWithStatus(413)),控制符攻击不靠体积 - 跳过二进制文件(如 JPEG/PNG)的内容扫描——它们本就含合法非 ASCII 字节,应靠魔术字节校验而非字符清洗
后缀名伪造 + 控制符组合,单靠 filepath.Ext() 会失效
攻击者可传 shell.php%00.jpg 或 avatar.jpg\x00.exe,filepath.Ext() 返回 .jpg,看似安全,实际后缀后的控制符会干扰后续 MIME 判断或系统调用。
实操建议:
- 清洗文件名时,必须在去控制符之后再取扩展名,顺序不能反
- 白名单扩展名校验必须基于清洗后的字符串,例如:
ext := strings.ToLower(filepath.Ext(cleanName)),再比对map[string]bool{".jpg": true, ".png": true} - 禁用所有含
%、\x编码痕迹的文件名(如匹配%[0-9A-Fa-f]{2})——这不是 URL 解码场景,客户端不该发编码名 - 对允许的扩展名,仍需配合魔术字节校验(如 PNG 必须以
0x89 0x50 0x4E 0x47开头),二者缺一不可
中间件里统一过滤,但别碰 c.Request.Body 两次
如果已在中间件里读过一次 c.Request.Body(比如为了扫描控制符),再调用 c.FormFile() 会失败——因为 multipart 解析需要原始 body 流,已被消费。
实操建议:
- 不要在全局中间件里无差别读
c.Request.Body;只在明确要处理文件上传的路由 handler 开头做:用io.ReadAll(c.Request.Body)+c.Request.Body = io.NopCloser(bytes.NewReader(body))重放 - 若必须全局拦截,改用
c.Request.MultipartReader()获取 reader 后,手动解析 boundary 并提取 header 中的filename=部分进行清洗(绕过完整 body 读取) - 对
c.FormFile()返回的file,其底层Open()返回的io.ReadCloser不支持重复读,扫描内容后需自己缓存 bytes 或重开文件句柄 - 性能敏感场景,把清洗逻辑下沉到
securejoin.SecureJoin()或自定义sanitizeFilename()函数,避免每次分配新闭包

















