← 中文资源

简体中文版本

MIME 类型嗅探与 nosniff

浏览器可能忽略声明的 Content-Type,转而根据字节内容推断资源类型;X-Content-Type-Options: nosniff 强制浏览器信任声明的类型,避免上传内容被当作脚本执行。

阅读英文原文

概念概述

MIME 类型嗅探与 nosniff(MIME Type Sniffing and nosniff)指的是:浏览器可能忽略声明的 Content-Type,转而根据字节内容推断资源类型;X-Content-Type-Options: nosniff 强制浏览器信任声明的类型,避免上传内容被当作脚本执行。

从更严格的定义看,MIME sniffing is the browser behaviour of inferring a response’s effective type by inspecting its leading bytes rather than trusting the Content-Type header that the server declared. 该概念在工程实践中的价值,在于它能帮助团队用统一的语言描述一个具体的质量属性或设计约束,从而在评审与协作中减少歧义。

为什么需要它

It affects every response an application serves that an attacker could influence, from user uploads to error pages that echo input. 它与 Server Name Indication (SNI)、Application-Layer Protocol Negotiation (ALPN)、PROXY Protocol for Client Address Preservation 等概念经常一起出现,用于解决相近的工程问题。理解它的适用场景,有助于在多种备选方案之间做出有依据的取舍,而不是凭直觉选择技术方案。

常见误解

  • sniffing only ever makes rendering slightly more convenient
  • a correct Content-Type header removes all cross-site scripting risk

需要避免的反模式

  • serving user uploads inline with a guessed media type
  • omitting X-Content-Type-Options on JSON and HTML responses

实践要点

  1. The browser looks at byte signatures such as a leading script or html prefix and can promote a text/plain body into executable markup, which turns a harmless upload endpoint into a cross-site scripting vector.
  2. Sending X-Content-Type-Options: nosniff tells browsers to honour the declared type exactly, and modern engines additionally refuse to run scripts or apply styles whose type does not match.
  3. Sniffing is a defence-in-depth concern: the durable fix is to serve untrusted content from a separate origin or with Content-Disposition: attachment so it is never parsed as a document.

参考来源

https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Content-Type-Options

代码示例

export interface ContentType {
  type: string;
  subtype: string;
  essence: string;
  parameters: Record<string, string>;
}

function splitParameters(header: string): string[] {
  const parts: string[] = [];
  let current = "";
  let quoted = false;
  for (let index = 0; index < header.length; index += 1) {
    const character = header[index]!;
    if (character === "\\" && quoted && index + 1 < header.length) {
      current += character + header[index + 1];
      index += 1;
      continue;
    }
    if (character === '"') quoted = !quoted;
    if (character === ";" && !quoted) {
      parts.push(current);
      current = "";
      continue;
    }
    current += character;
  }
  parts.push(current);
  return parts;
}

export function parseContentType(header: string): ContentType {
  if (typeof header !== "string" || header.trim() === "") {
    throw new TypeError("header must be a non-empty string");
  }
  const [essencePart, ...parameterParts] = splitParameters(header);
  const [type, subtype] = (essencePart ?? "").split("/");
  const token = /^[a-z0-9!#$&^_.+*-]+$/i;
  if (!type || !subtype || !token.test(type) || !token.test(subtype)) {
    throw new SyntaxError("invalid media type: " + essencePart);
  }
  const parameters: Record<string, string> = {};
  for (const part of parameterParts) {
    const separator = part.indexOf("=");
    if (separator <= 0) continue;
    const name = part.slice(0, separator).trim().toLowerCase();
    let value = part.slice(separator + 1).trim();
    if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) {
      value = value.slice(1, -1).replace(/\\(.)/g, "$1");
    }
    if (!(name in parameters)) parameters[name] = value;
  }
  return {
    type: type.toLowerCase(),
    subtype: subtype.toLowerCase(),
    essence: type.toLowerCase() + "/" + subtype.toLowerCase(),
    parameters,
  };
}
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"'));