← 中文资源

简体中文版本

HTTP 响应拆分

响应拆分把 CRLF 注入头部值,使攻击者附加头部或响应体,从而污染缓存并触发跨站脚本。

阅读英文原文

概念概述

HTTP 响应拆分(HTTP Response Splitting)指的是:响应拆分把 CRLF 注入头部值,使攻击者附加头部或响应体,从而污染缓存并触发跨站脚本。

从更严格的定义看,HTTP response splitting is an attack in which carriage return and line feed characters smuggled into a header value cause the server to emit extra headers or a second response that the attacker controls. 该概念在工程实践中的价值,在于它能帮助团队用统一的语言描述一个具体的质量属性或设计约束,从而在评审与协作中减少歧义。

为什么需要它

It arises when user input such as a redirect target or a cookie value is placed into headers without stripping control characters. 它与 Rainbow Tables and Lookup Attacks、Password Salting、Authenticated Encryption (AEAD) 等概念经常一起出现,用于解决相近的工程问题。理解它的适用场景,有助于在多种备选方案之间做出有依据的取舍,而不是凭直觉选择技术方案。

常见误解

  • modern frameworks always reject newline characters in headers
  • response splitting cannot affect shared caches

需要避免的反模式

  • reflecting a query parameter directly into a Location header
  • trusting that an upstream proxy will normalise header values

实践要点

  1. The injected break ends the current header block, so everything after it becomes either a new header or, with a second break, the body of a forged response.
  2. Effects range from cache poisoning that serves the crafted response to other users, to cross-site scripting when the injected body contains markup.
  3. Reject or strip control characters at the boundary before any value reaches a header, and validate redirect targets against an allowlist.

参考来源

https://owasp.org/www-community/attacks/HTTP_Response_Splitting

代码示例

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"'));
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()