用户报了一个很窄的 bug:某个快捷键打不上标签。
业务日志看起来一切正常——它说「要打的标签 id 没查到」,像是接口返回了空。接口我手动调过,数据好好的。
真正的线索只在 Console 里,而且和业务日志长得毫无关系:
Access to fetch at 'https://static-cdn.example.com/api/tags'from origin 'https://app.example.com' has been blocked by CORS policy:'Access-Control-Allow-Origin' must not be the wildcard '*'when credentials mode is 'include'static-cdn.example.com。我从来没写过这个域名。
根因:三件事凑在一起
① 内容脚本注入每一个 frame。
all_frames: true 是必须的——目标表单渲在嵌套 iframe 里。代价是你的代码会在页面上每一个同源 frame 里各跑一份,包括那些你根本没意识到存在的。
② 富文本编辑器的那个 iframe 带着自己的 <base>。
很多基于 iframe 的富文本编辑器(TinyMCE 一类)会创建一个 srcless 的 iframe,然后往里写文档。而它写进去的文档头部有这么一行:
<base href="https://static-cdn.example.com/cmps/tinymce/">编辑器需要它来加载自己的皮肤、图标、插件——那些资源确实在 CDN 上。对编辑器自己完全合理。
③ 相对路径按 document.baseURI 解析,不是按 origin。
这是 Web 规范,一直如此。只是平时 baseURI === location.href,你感觉不到区别。
三条合起来:
// 在那个编辑器 iframe 里跑的内容脚本fetch('/api/tags')// 实际请求:https://static-cdn.example.com/api/tags ← 不是同源了而 CDN 返回的是 Access-Control-Allow-Origin: *。这个通配符与 credentials: 'include' 天然冲突——规范明确禁止这个组合。所以必然被拒,一次都不会成功。
在 DevTools 里一眼验证
// 在那个编辑器 iframe 的上下文里new URL('/api/tags', document.baseURI)// → https://static-cdn.example.com/api/tags ❌
new URL('/api/tags', location.origin)// → https://app.example.com/api/tags ✅关键在于:<base> 只影响相对路径的解析,它不改变 frame 的 origin。 所以 location.origin 在那个 iframe 里仍然是主站——拼绝对地址就能绕开。
修法:一个函数,全项目强制走
/** * 同源 API 的绝对地址拼接。 * * 内容脚本注入每一个 frame,而富文本编辑器那个 iframe 带着指向 CDN 的 <base href>。 * 相对路径按 document.baseURI 解析 → 请求被改写到 CDN 域 → ACAO:* 与 * credentials:'include' 冲突 → 必然被 CORS 拒绝。 * * location.origin 不受 <base> 影响,所以拼出来的绝对地址恒定正确。 */export function sameOriginApi(path: string): string { try { return new URL(path, location.origin).href; } catch { // origin 取不到(about:blank 之类的极端情况)→ 原样返回。 // 行为与改造前一致,不制造新的失败模式 return path; }}用法就是把裸路径包一层:
fetch('/api/tags?x=1', { credentials: 'include' })fetch(sameOriginApi('/api/tags?x=1'), { credentials: 'include' })改完之后我去全项目搜了一遍裸相对路径,一共八处:标签接口、消息发送、三个业务接口、上下文读取……
其中一处是工单提交本身。
这些模块全都没有 frame 保护——也就是说它们随时可能在那个编辑器 iframe 里被调用到。之所以只有标签那一处暴露出来,纯粹是因为其它几处恰好都只在顶层帧被触发过。
凡是可能在非顶层帧执行的代码,相对路径都是定时炸弹。
为什么这个 bug 这么难定位
三个原因叠在一起:
① 业务日志说的是「接口返回空」,而真相是「请求被浏览器拒了」。 这两件事在代码里长得一模一样——fetch reject 被 catch 成了「查不到」。
② CORS 错误只在 Console 里,而且属于另一个 frame。 扩展的日志打在隔离世界,浏览器的 CORS 报错打在页面上下文,两者在 DevTools 里默认不在一起显示。
③ 手测能成功。 我在顶层帧的 Console 里调同一个接口,一切正常——因为顶层帧的 baseURI 是主站。
第三条最误导人。我因此误诊了一整轮,诊断成「写进去了但界面没渲染」,还据此加了一层回读校验。证据看着很像:桥返回 ok: true,而字段是空的。
真跑一遍看日志才发现:id 压根没查到,根本没走到写值那一步。
「手测能成功 ↔ 实际跑失败」这个差异本身就是信号 —— 它通常意味着两者的执行环境不同。而对扩展来说,最常见的「不同」就是 frame。
排查口诀
这类问题以后按这个顺序看:
- 出现莫名其妙的 CORS,或者「接口返回空但没报错」→ 先看请求的域名对不对。 不是你写的那个域名,就是路径解析出了问题。
- 确认这段代码可能在哪些 frame 里跑。
all_frames: true意味着「所有」。 - 在可疑的那个 frame 里敲一行
document.baseURI。
事后看,这个 bug 的真正成本不在修——修只花了几分钟,加一个函数、改八处调用。
成本在于它让我对一个完全正确的模块产生了怀疑,并且顺着那个怀疑加了一层本不需要的代码。
(那层回读校验我最后留下了。它是个好东西,只是治的是另一个病。)