概念定义
同源策略按主机名而不是 IP 判定。若主机名随时间映射到不同 IP,一个源就横跨两个网络——而攻击者控制 DNS 区域与 TTL,可以按计划改变映射。
工作机制
页面先从 attacker.example 加载:首次应答来自真正的公网服务器并下发脚本;短 TTL 过后同一域名解析到 192.168.x.x,脚本继续向“同源”发 fetch,浏览器欣然把请求送往内网设备,并因源从未改变而能读取响应。
使用场景
防御核心是给每个内网服务做认证与授权——网络位置不是身份;或在解析器侧阻断“公网域名解析到私网段”,并强制所有客户端走该解析器。家庭与 OT 网络是惯常受害者,路由器出厂即带无认证面板。
常见误区
“内网服务浏览器够不着”不成立:浏览器本身就是代理,请求从边界内发出。防火墙也拦不住——流量只是普通出站 DNS 加浏览器 HTTP。浏览器的 Local Network Access 类防护在逐步铺开但各引擎进度不一;内网服务上 TLS 能阻止脚本读响应,却挡不住盲发。在解析器策略里钉住内网主机名,胜过逐个修补应用。
代码示例
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);
}
export async function sha256Hex(input: string | Uint8Array): Promise<string> {
if (typeof input !== "string" && !(input instanceof Uint8Array)) {
throw new TypeError("input must be a string or Uint8Array");
}
const bytes = typeof input === "string" ? new TextEncoder().encode(input) : input;
const digest = await crypto.subtle.digest("SHA-256", bytes);
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
}
export async function verifySha256(input: string | Uint8Array, expectedHex: string): Promise<boolean> {
if (typeof expectedHex !== "string" || !/^[0-9a-f]{64}$/i.test(expectedHex)) {
throw new RangeError("expectedHex must contain 64 hexadecimal characters");
}
const actual = await sha256Hex(input);
return actual === expectedHex.toLowerCase();
}