← 中文资源

简体中文版本

安全解析 Content-Disposition 响应头

解析 attachment/inline 与 RFC 5987 filename*,并剥离不可信文件名中的目录部分。

阅读英文原文

功能说明

该示例解析 HTTP 响应中的 Content-Disposition 头,提取处置类型、文件名与全部参数。它同时支持普通的 filename 和 RFC 5987 的 filename*,后者用字符集与百分号编码传递非 ASCII 文件名。解析结果适合下载器、文件预览与对象存储代理使用。

关键代码

splitParameters 按分号切分,但会跟踪双引号状态,避免把引号内的分号误当分隔符。类型只接受 inline 与 attachment,其他值直接抛错。参数名统一转小写后存入 Map,引号值通过正则去掉反斜杠转义。resolveFilename 优先使用 filename*,按 charset’language’value 的格式提取并解码,解码失败时退回原始值。sanitize 只保留路径最后一段,阻止目录穿越。

使用注意

文件名来自不可信响应时,绝不能直接拼到本地路径。该函数只取 basename,仍建议再过滤控制字符与保留名。filename* 的字符集目前按 UTF-8 处理,其他编码可能解码失败并退回原文。参数重复时后出现的值覆盖先前的值。该解析器不处理多段 Content-Disposition,也不解析 multipart 正文。

验证方式

断言普通 filename 解析为 report.pdf,filename* 的百分号编码还原为中文文件名。测试包含分号的引号文件名不会被截断。传入 ../../etc/passwd 时结果只剩 passwd。再验证缺少 filename 返回 null、未知处置类型抛错、空字符串抛 TypeError。

代码示例

export function parseContentDisposition(header) {
  if (typeof header !== "string" || header.trim() === "") {
    throw new TypeError("header must be a non-empty string");
  }
  const segments = splitParameters(header);
  const type = segments.shift().trim().toLowerCase();
  if (type !== "inline" && type !== "attachment") {
    throw new Error(`unsupported disposition type "${type}"`);
  }
  const parameters = new Map();
  for (const segment of segments) {
    const equals = segment.indexOf("=");
    if (equals === -1) continue;
    const name = segment.slice(0, equals).trim().toLowerCase();
    if (name === "") continue;
    parameters.set(name, unquote(segment.slice(equals + 1).trim()));
  }
  return { type, filename: resolveFilename(parameters), parameters };
}

function splitParameters(header) {
  const segments = [];
  let current = "";
  let quoted = false;
  for (const character of header) {
    if (character === '"') quoted = !quoted;
    if (character === ";" && !quoted) {
      segments.push(current);
      current = "";
      continue;
    }
    current += character;
  }
  segments.push(current);
  return segments;
}

function unquote(value) {
  if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) {
    return value.slice(1, -1).replace(/\\(.)/g, "$1");
  }
  return value;
}

function resolveFilename(parameters) {
  const extended = parameters.get("filename*");
  if (extended !== undefined) {
    const match = /^([\w-]+)'([\w-]*)'(.+)$/.exec(extended);
    if (match !== null) {
      try {
        return sanitize(decodeURIComponent(match[3]));
      } catch {
        return sanitize(extended);
      }
    }
  }
  const plain = parameters.get("filename");
  return plain === undefined ? null : sanitize(plain);
}

function sanitize(filename) {
  return filename.split(/[\\/]/).pop() || null;
}

console.log(parseContentDisposition('attachment; filename="report.pdf"'));
console.log(parseContentDisposition("attachment; filename*=UTF-8''%E4%B8%AD%E6%96%87.pdf"));
console.log(parseContentDisposition('inline; filename="../../etc/passwd"'));