用户报了一个很窄的 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 里仍然是主站——拼绝对地址就能绕开。

修法:一个函数,全项目强制走

src/shared/same-origin-api.ts
/**
* 同源 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。

排查口诀

这类问题以后按这个顺序看:

  1. 出现莫名其妙的 CORS,或者「接口返回空但没报错」→ 先看请求的域名对不对。 不是你写的那个域名,就是路径解析出了问题。
  2. 确认这段代码可能在哪些 frame 里跑。 all_frames: true 意味着「所有」。
  3. 在可疑的那个 frame 里敲一行 document.baseURI。

事后看,这个 bug 的真正成本不在修——修只花了几分钟,加一个函数、改八处调用。

成本在于它让我对一个完全正确的模块产生了怀疑,并且顺着那个怀疑加了一层本不需要的代码。

(那层回读校验我最后留下了。它是个好东西,只是治的是另一个病。)