← 中文资源

简体中文版本

CSP Nonce 与哈希

CSP nonce 与哈希取代 unsafe-inline:每响应一个新 nonce,或对内联脚本内容取哈希——保住合法内联脚本、拦住注入的。

阅读英文原文

概念定义

多数 XSS 载荷靠内联脚本执行,而源白名单表达不了“哪段内联是合法的”。nonce 与哈希可以:页面声明每请求的随机值或内容摘要,只有携带精确标记的内联脚本才会执行。

工作机制

服务器每个请求生成密码学随机 nonce,写进 CSP 头并盖到每个合法内联脚本上;浏览器拒绝无标记的内联脚本。配合 strict-dynamic 时,被信任的加载器可继续创建脚本并传播信任。哈希钉住静态片段:脚本一变哈希就变。

使用场景

带少量内联引导片段的服务端渲染页用 nonce;稳定内联脚本的纯静态页用哈希。两者都应配合 report-only 灰度:先报告模式烧完违规再强制。

常见误区

静态 nonce 是致命错误:跨响应复用等于给注入脚本发通行证。CDN 整页缓存回放 nonce 会破坏有效性,缓存必须按 nonce 变化或干脆去内联。旧浏览器兼容保留 unsafe-inline 时,现代浏览器会忽略整个指令,白名单形同虚设。CSP 是输出编码与消毒之后的第二道防线,不是替代。

代码示例

export type DirectiveValue = readonly string[];

export interface CspOptions {
  readonly directives?: Readonly<Record<string, DirectiveValue>>;
  readonly reportOnly?: boolean;
  readonly nonce?: string;
}

const BASE_DIRECTIVES: Record<string, readonly string[]> = {
  "default-src": ["'self'"],
  "script-src": ["'self'"],
  "style-src": ["'self'"],
  "img-src": ["'self'", "data:"],
  "object-src": ["'none'"],
  "base-uri": ["'self'"],
  "frame-ancestors": ["'none'"],
};

export function createNonce(): string {
  const bytes = new Uint8Array(16);
  globalThis.crypto.getRandomValues(bytes);
  let binary = "";
  for (const byte of bytes) binary += String.fromCharCode(byte);
  return btoa(binary);
}

export function buildCsp(options: CspOptions = {}): { name: string; value: string } {
  const directives: Record<string, readonly string[]> = { ...BASE_DIRECTIVES };
  if (options.nonce !== undefined) {
    if (!/^[A-Za-z0-9+/]+={0,2}$/.test(options.nonce)) throw new Error("nonce must be base64");
    directives["script-src"] = [...(directives["script-src"] ?? []), `'nonce-${options.nonce}'`];
  }
  for (const [name, values] of Object.entries(options.directives ?? {})) {
    if (!/^[a-z][a-z0-9-]*$/.test(name)) throw new Error(`invalid directive name "${name}"`);
    for (const value of values) {
      if (typeof value !== "string" || /[;\s]/.test(value)) {
        throw new Error(`invalid value for "${name}"`);
      }
    }
    directives[name] = values;
  }
  const serialized = Object.entries(directives)
    .filter(([, values]) => values.length > 0)
    .map(([name, values]) => `${name} ${values.join(" ")}`)
    .join("; ");
  return {
    name: options.reportOnly === true ? "Content-Security-Policy-Report-Only" : "Content-Security-Policy",
    value: serialized,
  };
}

const header = buildCsp({
  nonce: createNonce(),
  directives: {
    "connect-src": ["'self'", "https://api.example.com"],
    "img-src": ["'self'", "data:", "https://cdn.example.com"],
  },
});
console.log(header.name);
console.log(header.value);
console.log(buildCsp({ reportOnly: true }).name);
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()