← 中文资源

简体中文版本

Content-Disposition 文件名解析

从响应头提取安全的基础文件名,支持 RFC 5987 编码并拒绝目录穿越与空名。

阅读英文原文

功能说明

下载接口常通过 Content-Disposition 告诉浏览器保存文件名,但服务端返回的值不可信:可能包含路径穿越片段、控制字符、超长名称或多种编码形式。直接把整个值拼进保存路径,可能覆盖程序文件或写到目录之外。

这个解析器优先读取 RFC 5987 的 filename* 形式,按其中的字符集解码百分号转义,再统一调用 safe_basename 只保留最后一段文件名。没有扩展形式时回退到普通 filename 参数,支持带引号和不带引号两种写法。

关键代码

两个正则分别匹配 filename* 与 filename。filename* 的值形如 UTF-8’’%E5%B9%B4%E6%8A%A5.pdf,先按单引号切出字符集和编码部分,再用指定编码严格解码;字符集不存在或字节非法时退化为默认 utf-8 的宽松解码。safe_basename 把反斜杠统一成正斜杠,取最后一段并去除首尾空白,空串、单点、双点和含空字节的名称返回 None。

使用注意

解析出的名称仍需过滤 Windows 保留名(如 con、nul)和结尾的点或空格,并按目标文件系统长度截断。允许中文文件名时不要再用 ASCII 编码保存。filename* 的字符集字段可能为空,此时按 utf-8 处理。返回 None 表示应改用默认名称,不要退化为空字符串。服务端生成响应头时同样需要对引号和换行做转义,避免响应头注入。

验证方式

四条示例分别输出 report final.pdf、passwd、年报.pdf 和 None。第二条虽然带有多个上级目录,最终只保留 basename;第三条通过 filename* 正确解码中文;第四条的双点被拒绝。可以再加入 charset 非法的用例,确认解析器回退到 utf-8 而不是抛异常。

代码示例

import re
from pathlib import PurePosixPath
from urllib.parse import unquote

NAME_STAR = re.compile(r"filename\*\s*=\s*([^;]+)", re.IGNORECASE)
NAME = re.compile(r'filename\s*=\s*(?:"([^"]*)"|([^;]+))', re.IGNORECASE)


def safe_basename(value: str) -> str | None:
    candidate = PurePosixPath(value.replace("\\", "/")).name.strip()
    if candidate in {"", ".", ".."} or "\x00" in candidate:
        return None
    return candidate


def attachment_filename(header: str) -> str | None:
    match = NAME_STAR.search(header)
    if match:
        value = match.group(1).strip().strip('"')
        charset, _, remainder = value.partition("'")
        _, _, encoded = remainder.partition("'")
        try:
            decoded = unquote(
                encoded or remainder,
                encoding=charset or "utf-8",
                errors="strict",
            )
        except (LookupError, UnicodeDecodeError):
            decoded = unquote(encoded or remainder)
        return safe_basename(decoded)
    match = NAME.search(header)
    if not match:
        return None
    value = match.group(1) if match.group(1) is not None else match.group(2)
    return safe_basename(unquote(value.strip()))


def main() -> None:
    headers = [
        'attachment; filename="report final.pdf"',
        "attachment; filename=../../etc/passwd",
        "attachment; filename*=UTF-8''%E5%B9%B4%E6%8A%A5.pdf",
        'attachment; filename=".."',
    ]
    for header in headers:
        print(attachment_filename(header))


if __name__ == "__main__":
    main()