概念定义
缓存用键判定两个请求相同:通常是方法、路径与部分头。凡“响应依赖它、键却忽略它”的输入都是攻击杠杆。攻击者设置投毒输入、让缓存把恶意响应存到干净键下,然后扬长而去——之后所有请求干净 URL 的用户都被投毒。
工作机制
寻找“非键输入且影响响应”的组合:由 Host 派生的重定向、错误页里反射的头、页面里内嵌的开放重定向 URL。带着恶意输入请求一次,缓存按干净键存储结果;受害者的页面里从此出现攻击者控制的脚本、重定向或内容。
使用场景
加固一切“响应内容派生自请求头或未识别参数”的端点:校验并钉死派生值、在边缘剥除非键输入、配置好 Vary 与缓存键让每个响应维度都被键控。
常见误区
键的规范化因产品而异:一个 CDN 的非键输入在另一个是键控的——必须测试实际链路。HTTPS 拦不住它,攻击发生在 TLS 边界内的代理层;短 TTL 也只是心理安慰,边缘规模下几秒就能覆盖大量用户。相关变体还有 fat GET 与请求走私衍生的缓存欺骗;应监控边缘存储响应中的异常主机名与脚本。
代码示例
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);
function assertSecret(secret: string): void {
if (typeof secret !== "string" || secret.length === 0) {
throw new TypeError("secret must be a non-empty string");
}
}
export async function deriveAesKey(
secret: string,
salt: Uint8Array,
info: string,
): Promise<CryptoKey> {
assertSecret(secret);
if (!(salt instanceof Uint8Array)) throw new TypeError("salt must be a Uint8Array");
if (typeof info !== "string") throw new TypeError("info must be a string");
const material = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(secret),
"HKDF",
false,
["deriveKey"],
);
return crypto.subtle.deriveKey(
{ name: "HKDF", hash: "SHA-256", salt, info: new TextEncoder().encode(info) },
material,
{ name: "AES-GCM", length: 256 },
false,
["encrypt", "decrypt"],
);
}
export async function deriveBytes(
secret: string,
salt: Uint8Array,
info: string,
length = 32,
): Promise<Uint8Array> {
assertSecret(secret);
if (!(salt instanceof Uint8Array)) throw new TypeError("salt must be a Uint8Array");
if (typeof info !== "string") throw new TypeError("info must be a string");
if (!Number.isInteger(length) || length < 1 || length > 255) {
throw new RangeError("length must be an integer between 1 and 255");
}
const material = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(secret),
"HKDF",
false,
["deriveBits"],
);
const bits = await crypto.subtle.deriveBits(
{ name: "HKDF", hash: "SHA-256", salt, info: new TextEncoder().encode(info) },
material,
length * 8,
);
return new Uint8Array(bits);
}