Skip to content

安全边界与性能测量:别让输入决定程序规则 ​

程序接收的查询参数、表单、存储、网络响应都只是数据。风险通常出现在某一步把数据升级成了代码、HTML、属性路径或权限判断。先找到这个边界,才知道应该在哪校验。

文本显示不需要 HTML 解释权 ​

js
function showComment(container, comment) {
  container.textContent = String(comment);
}

这个函数适合纯文本评论。把它换成 innerHTML 后,浏览器会解析标记,事件属性等内容可能带来 XSS。危险不仅来自 script 标签;所以“用正则删掉 script”不是完整防御。

如果产品确实需要富文本,应采用经过维护的 HTML 清理方案,并针对允许标签和属性配置;URL、CSS、HTML 属性、JavaScript 字符串是不同上下文,不能把一种转义到处复用。CSP 等机制可增加防御层,但不能代替正确处理输入。

避免把用户字符串交给 eval 或 Function。需要算式功能时定义允许的语法并解析,不能为了少写几行代码,把整个 JavaScript 解释器交给输入者。

动态键为何可能越过对象边界 ​

普通对象会沿原型链查找属性。接收任意键并进行多层写入的通用 merge 函数,可能被引向 __proto__ 或 constructor.prototype,最终改变其他对象看到的默认属性。

对固定结构,最简单的方式是只提取允许字段:

js
function readPreferences(input) {
  if (!input || typeof input !== 'object') {
    throw new TypeError('设置必须是对象');
  }
  return {
    theme: Object.hasOwn(input, 'theme') && input.theme === 'dark'
      ? 'dark' : 'light',
    pageSize: Object.hasOwn(input, 'pageSize') && input.pageSize === 20
      ? 20 : 10
  };
}
console.log(readPreferences(JSON.parse('{"__proto__":{"admin":true}}')));
// { theme: 'light', pageSize: 10 }

JSON.parse 本身不会因为出现这个键就立刻污染所有对象;危险在后续如何合并、写入和使用它。不能把“存在特殊字段”与“已经发生污染”混为一谈。

对于真正的任意键字典,可选 Map 或无原型对象,但嵌套处理也必须遵守相同边界。只过滤一个键无法自动阻止所有原型路径,权限判断应依据可信且明确的自有数据。

跟踪一次局部原型被改变的过程 ​

只说“防原型污染”容易变成记住一个危险键。下面程序不修改 Object.prototype,只操作它新建的局部对象,观察从数据到赋值的边界:

js
const payload = JSON.parse('{"__proto__":{"theme":"dark"}}');
console.log(Object.hasOwn(payload, '__proto__')); // true
const assigned = Object.assign({}, payload);
console.log(assigned.theme); // dark
console.log(Object.hasOwn(assigned, 'theme')); // false
console.log({}.theme); // undefined:全局 Object.prototype 没被修改
const spread = { ...payload };
console.log(spread.theme); // undefined
console.log(Object.hasOwn(spread, '__proto__')); // true

JSON 解析得到一个普通自有键,第一步尚未改变全局或目标原型。Object.assign 读取这个键的值,再向普通目标赋值;目标继承的 __proto__ setter 被触发,改变了该目标的原型。因此读取 assigned.theme 沿新原型链找到 dark,但自身没有 theme。

对象展开创建自有数据属性,不走同一套目标 setter,所以此例没有改变 spread 的原型。这个差异不等于“展开任意用户对象总是安全”:它仍可能触发源 getter、浅复制危险内容,并把特殊键送到后续不安全合并里。

针对固定配置,更好的问题是“我们允许哪些字段进入”,而不只是“有哪些键要删掉”。前面的 readPreferences 只提取 theme/pageSize,并检查自有属性,其他输入没有获得控制配置结构的权力。对任意深层路径则还需防止 constructor.prototype 等途径,不能只过滤一层 __proto__。

textContent 安全不代表所有输出位置都一样 ​

把用户文本放进 textContent 可以避免 HTML 解析,却不能替 URL 字段决定允许什么协议。比如一个“外部资料链接”应允许哪些地址,需要独立的 URL 策略。

js
function externalAddress(raw) {
  const url = new URL(raw); // 本接口要求绝对地址
  if (url.protocol !== 'https:' && url.protocol !== 'http:') {
    throw new TypeError('只允许 HTTP 或 HTTPS 链接');
  }
  return url.href;
}
console.log(externalAddress('https://example.test/notes?q=作用域'));

此函数只限定协议,不宣称站点可信或内容安全。若业务只允许站内链接,还需要核对 origin;若允许下载、图片或富文本,又有各自约束。输出位置决定解释器和权限边界,一种处理方法不能替所有位置兜底。

正则也可能把很小输入变成很大工作量 ​

带有歧义的嵌套量词可能在某些失败输入上大量回溯,例如 (a+)+ 一类结构。危险常发生在最后一个字符使整体匹配失败时,不能只用全匹配成功的短字符串测试。

不要在共享页面上故意运行超长灾难性输入来“观察卡死”。可通过分析表达式、限定输入长度、减少歧义或改成清晰的逐字符检查降低风险。具体复杂度和优化取决于引擎,不能声称所有正则都固定线性或所有引擎都同样慢。

先测用户等待在哪里,再改代码 ​

一个列表慢可能由网络、解析、计算、DOM 更新或布局引起。先用浏览器性能面板确认主要耗时,不能只看某个数组方法的微基准就决定重写全部循环。

js
const values = Array.from({ length: 10000 }, (_, i) => i);
const start = performance.now();
let sum = 0;
for (const value of values) sum += value;
const elapsed = performance.now() - start;
console.log(sum); // 49995000
console.log('本次耗时毫秒:', elapsed); // 不承诺固定数值

这只能描述本次环境中的一次测量。计时精度、引擎预热、垃圾回收、后台任务和输入规模都会影响结果。重复测量要保留结果分布,比较等价工作,避免把 console 输出时间算进被测循环。

布局相关代码尤其注意读写交错:每次改变样式又立即读取几何信息,可能触发同步布局。先收集需要读取的信息,再集中写入,通常更容易减少这种强制往返;仍应以实际页面追踪验证。

规范规定可观察语义,并不承诺某种代码永远被内联、使用某个隐藏类或固定分配在栈上。实现细节适合解释测量现象,不适合替代正确性依据。

读写交错为什么可能触发布局往返 ​

假设要让若干卡片变成同样宽度。若每改一个元素的样式,就立即读取该元素的几何信息,浏览器为返回准确值可能需要把之前未完成的样式与布局工作算出来。

js
// 函数定义;boxes 是页面中的元素数组。
function resizeAndMeasure(boxes, width) {
  for (const box of boxes) box.style.width = `${width}px`;
  return boxes.map(box => box.getBoundingClientRect().width);
}

这里先集中写入,再集中读取,减少逐个元素“写→读→写→读”的机会。但读第一次几何信息仍可能触发布局,实际是否更快还受 DOM 结构、样式与浏览器优化影响。它是可解释的优化方向,不是脱离页面即可保证的固定倍数。

性能面板里应关注耗时长段究竟落在 JavaScript、样式计算还是布局,修改后用相同输入和交互重测。若主要时间在大图片解码,改一个数组循环不是针对瓶颈;若主要问题是一次任务过长,平均总耗时下降也不一定代表交互延迟达标。

性能与安全有共同的输入边界:不受限制的消息队列、文件大小、正则输入都可能把正常操作放大成无法承受的工作量。对这些地方设置与业务相关的上限,比凭空优化所有小函数更有针对性。

练习 ​

  1. 为什么清除 script 标签不能保证 innerHTML 安全?纯文本场景应该怎样处理?

  2. 为什么只运行一次计时不能证明 for 循环永远比其他遍历方式快?

  3. 局部合并实验中assigned.theme存在、hasOwn却为false,为什么?这是否说明Object.prototype已污染?

  4. 实现 sameOriginAddress(raw, base):允许相对地址,解析后只接受与 base 同源的 HTTP/HTTPS 地址。验证 /notes、同源绝对地址、其他站点、javascript: 地址。必须比较 origin,不能只检查字符串前缀。

查看本节答案

用心记录每一步成长