← 中文资源

简体中文版本

文件上传校验

文件上传校验必须校验内容而非仅名字与头部:魔数嗅探、扩展名映射、大小上限、存储隔离与处理沙箱共同决定爆炸半径。

阅读英文原文

概念定义

上传文件是攻击者控制的字节,服务器将存储、分发并处理它。每个阶段有自己的解释规则——Web 服务器的 MIME 映射、图片解析器、归档解压器——校验必须落在风险所在的那一阶段。

工作机制

服务端流水线:先限大小再完整读取、按魔数嗅探内容类型、验证声称类型的可解码性、生成随机服务端文件名、存入对象存储或不可执行目录并以下载专用响应头分发、异步放进有资源限制的沙箱 worker 处理。

使用场景

所有上传面,包括 CSV 导入与字体、字幕这类不显眼的通道。未知内容一律 Content-Disposition attachment 加严格 Content-Type 分发,绝不猜测。

常见误区

扩展名检查会被重命名绕过;浏览器给的 MIME 与原始文件名只是装饰;客户端校验是体验不是安全。“存到 Web 根之外”也不够——处理环节的解析器仍是攻击面:归档炸弹、解压尺寸失控、编解码器漏洞。SVG 是带尖角的 HTML:内联分发时其中的脚本会执行,必须消毒或强制下载;解压要封累计输出并校验成员路径;多义文件满足多个解析器,要先决定哪个解析器运行再针对它校验。

代码示例

#[derive(Debug, PartialEq, Eq, Clone, Copy)]
enum FileKind {
    Png,
    Jpeg,
    Gif,
    Pdf,
    Zip,
    Gzip,
    Unknown,
}

fn sniff(bytes: &[u8]) -> FileKind {
    const PNG: &[u8] = &[0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a];
    const JPEG: &[u8] = &[0xff, 0xd8, 0xff];
    const GIF87: &[u8] = b"GIF87a";
    const GIF89: &[u8] = b"GIF89a";
    const PDF: &[u8] = b"%PDF-";
    const ZIP: &[u8] = &[0x50, 0x4b, 0x03, 0x04];
    const GZIP: &[u8] = &[0x1f, 0x8b];

    if bytes.starts_with(PNG) {
        return FileKind::Png;
    }
    if bytes.starts_with(JPEG) {
        return FileKind::Jpeg;
    }
    if bytes.starts_with(GIF87) || bytes.starts_with(GIF89) {
        return FileKind::Gif;
    }
    if bytes.starts_with(PDF) {
        return FileKind::Pdf;
    }
    if bytes.starts_with(ZIP) {
        return FileKind::Zip;
    }
    if bytes.starts_with(GZIP) {
        return FileKind::Gzip;
    }
    FileKind::Unknown
}

fn extension_matches(kind: FileKind, extension: &str) -> bool {
    let normalized = extension.to_ascii_lowercase();
    matches!(
        (kind, normalized.as_str()),
        (FileKind::Png, "png")
            | (FileKind::Jpeg, "jpg" | "jpeg")
            | (FileKind::Gif, "gif")
            | (FileKind::Pdf, "pdf")
            | (FileKind::Zip, "zip")
            | (FileKind::Gzip, "gz" | "tgz")
    )
}

fn main() {
    let samples: [(&[u8], &str); 4] = [
        (&[0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a, 0x00][..], "logo.png"),
        (&b"%PDF-1.7"[..], "invoice.pdf"),
        (&[0xff, 0xd8, 0xff, 0xe0][..], "photo.jpeg"),
        (&[0x50, 0x4b, 0x03, 0x04, 0x14][..], "archive.zip"),
    ];
    for (bytes, name) in samples {
        let kind = sniff(bytes);
        let extension = name.rsplit('.').next().unwrap_or_default();
        println!(
            "{name}: {:?} extension_ok={}",
            kind,
            extension_matches(kind, extension)
        );
    }
    println!("unknown: {:?}", sniff(b"plain text"));
}
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()