概念定义
浏览器把你的页面以零不透明度渲染在攻击者站点的 iframe 里,诱饵内容压在上面。用户瞄准诱饵,点击坐标却落在你的按钮上——因为是用户浏览器发起请求,会话 Cookie 一路随行。
工作机制
头部防御声明谁可以框嵌:DENY、SAMEORIGIN,或带源列表的 CSP frame-ancestors。frame-ancestors 在受支持处取代 X-FrameOptions,且能正确表达允许列表。服务端横幅与敏感操作的再认证,抬高单次被窃点击的成交成本。
使用场景
所有登录后改状态的页面。合法需要被嵌入的公开页(组件、文档)应通过 frame-ancestors 把策略限定给可信嵌入者,而不是全开。
常见误区
HTTPS 与能否被框嵌完全正交;“登录页设了头就够”是错觉——仪表盘同样可做受害者。JavaScript 防框嵌可被剥离脚本的沙箱 iframe 绕过,头部策略才是可靠控制。过渡期同时发送两种指令;让单次点击不足以完成操作的交互设计(输入确认词、长按确认)是纵深一层。
代码示例
export function createFlagSet(names) {
if (!Array.isArray(names) || names.length === 0) {
throw new TypeError("names must be a non-empty array");
}
if (names.length > 30) throw new RangeError("at most 30 flags are supported");
const bits = new Map();
const all = [];
for (const name of names) {
if (typeof name !== "string" || name.trim() === "") {
throw new TypeError("flag names must be non-empty strings");
}
const normalized = name.trim();
if (bits.has(normalized)) throw new Error(`duplicate flag "${normalized}"`);
bits.set(normalized, 1 << bits.size);
all.push(normalized);
}
const resolve = (name) => {
const bit = bits.get(name);
if (bit === undefined) throw new Error(`unknown flag "${name}"`);
return bit;
};
return {
names: () => [...all],
mask(...selected) {
return selected.reduce((mask, name) => mask | resolve(name), 0);
},
has(mask, name) {
return (mask & resolve(name)) !== 0;
},
add(mask, name) {
return mask | resolve(name);
},
remove(mask, name) {
return mask & ~resolve(name);
},
toggle(mask, name) {
return mask ^ resolve(name);
},
toNames(mask) {
return all.filter((name) => (mask & resolve(name)) !== 0);
},
};
}
const permissions = createFlagSet(["read", "write", "admin"]);
let mask = permissions.mask("read", "write");
console.log(mask, permissions.toNames(mask));
mask = permissions.remove(mask, "write");
mask = permissions.toggle(mask, "admin");
console.log(permissions.has(mask, "write"), permissions.toNames(mask));
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);