概念定义
多数 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()