safari-mcp 用绑定标签页和来源的凭证约束 Agent 操作,但 Safari 空白标签页缺少 URL,导致正常凭证无法通过来源校验。故障使导航、凭证轮换和关闭操作均被拒绝,作者通过两次单行修改调整守卫逻辑。
我维护着 safari-mcp,这是一个 MCP server,让 AI Agent 能够操作 Mac 上真正的 Safari,包括其中已登录的会话。10 月 7 日,一个 Agent 打开了一个空白标签页,紧接着对这个标签页执行的下一条命令就被拒绝了:
receipt is not valid for this origin
Agent 的操作完全正确。它调用了不带 URL 的 safari_new_tab(),保存了工具返回的 receipt,再把它传给 safari_navigate。结果,这个标签页既无法导航,也无法换发 receipt,甚至无法关闭。它就这么留在一个有名称的 Safari profile 里,Agent 获准执行的所有操作都动不了它。
那天我发布了两个版本,每个版本都只改了一行代码。这两行都位于一个安全检查中,而我当初就是有意把它设计得很严格。
当 safari-mcp 的 Safari 扩展为 Agent 打开标签页时,会为这个标签页签发一个不透明的 receipt。后续每条命令都要携带这个 receipt,扩展在执行任何操作之前,都会检查两件事:
这个 receipt 属于当前标签页;以及
这个标签页仍然位于签发 receipt 时的 origin。
第二项检查才是关键。Agent 操作的浏览器里,同时还有用户的邮箱、银行账户和管理后台。如果某个页面把 Agent 的标签页重定向到了其他地方,我希望 Agent 下一次执行 evaluate 时被拒绝,而不是在当前加载的未知页面上运行。要继续操作,Agent 必须主动请求一个新的 receipt(getReceipt);而扩展只有在能读取到标签页的 http(s) origin 或 about:blank 时,才会签发。
无法确认安全,就拒绝执行,也就是 fail closed。从设计上看,这正是你想要的行为。
修复之前,扩展是这样推导标签页 origin 的:
function _receiptOrigin(rawUrl) {
const raw = String(rawUrl || "");
if (raw === "about:blank") return raw;
try {
const parsed = new URL(raw);
return /^https?:$/.test(parsed.protocol) ? parsed.origin : "";
} catch {
return "";
}
}
空白标签页的 receipt 是基于 about:blank 签发的。我以为 Safari 会把空白标签页的 URL 报告为 about:blank。事实并非如此。它根本不会在这个标签页对象上提供 url 字段。
String(undefined || "") 的结果是 ""。new URL("") 会抛出异常。函数返回 "",而在这段代码中,它表示“完全没有 origin”。"about:blank" !== "",于是检查拒绝了这个标签页自己的 receipt。用于脱困的换发入口调用了同一个函数,得到同样的 "",也拒绝了请求:Cannot issue a receipt for this tab URL。
安全检查完全按照我的设计执行了。只是它无法区分“这个标签页已经跳转到了其他地方”和“Safari 没有告诉我这个标签页在哪里”,于是把两种情况都当成了攻击。
10 月 6 日,另一次运行在一个有名称的 profile 中留下了两个标签页,两者都位于 claude.ai/new。这两次都是页面自身通过重定向或登录跳转改变了标签页的位置。只有当新页面具备扩展能够读取的 http(s) origin 时,才能换发 receipt;如果标签页最终落在一个没有这种 origin 的页面上,或者 Safari 没有向扩展提供该页面的 URL,就无法换发。
和其他所有命令一样,close_tab 也受到 receipt 的 origin 约束。拒绝信息建议换发 receipt,但这恰恰是这两个标签页做不到的事。没有任何操作能关闭它们。
这和空白标签页的问题如出一辙:一条适用于在页面中执行代码的命令的规则,被用在了一条不执行页面代码的命令上。
v2.22.12,修复已经跳转的标签页:
const allowReceiptOriginChange = type === "get_tab_receipt" || type === "close_tab";
关闭标签页不会在页面中运行任何东西,因此现在关闭操作会像 getReceipt 一样,通过 receipt 找到对应的标签页,而不是依赖 origin。其他所有命令仍然绑定到 receipt 的 origin,而扩展从未签发过的 receipt 依然无法关闭任何标签页。
v2.22.13,修复空白标签页:
if (!raw || raw === "about:blank") return "about:blank";
现在,Safari 没有提供 URL 的标签页会被视为空白标签页。在网页 origin 上签发的 receipt,仍然不能在这样的标签页上执行任何页面命令,因此,一个从真实网站跳转到空白页面的标签页,仍然会被拒绝。
origin 绑定本身没有问题。问题出在其中的两个假设。
我把“未知”当成了“不匹配”。一个 fail-closed 检查至少有三种结果:匹配、不匹配,或者平台提供的信息不足以判断。我的代码把第三种并入了第二种,因为读取 URL 的函数,对“没有报告 URL”和“这是一个我不信任的 origin”返回了同一个空字符串。一旦这两种情况共用一个值,安全检查迟早会把合法持有者挡在门外。修复方式是在代码中明确规定:对于扩展创建的标签页,缺失 URL 意味着它是空白页。
我让所有命令遵守同一条规则。origin 检查的目的是阻止代码在错误的页面上运行。close_tab 不会在任何页面中执行代码。把它绑定到 origin 没有增加任何保护,却夺走了唯一的退出途径。安全检查的范围应该紧扣它要防止的危害,而且每项安全检查都需要一个不依赖被检查条件的退出途径。
如果测试在 mock 扩展 API 时,规规矩矩地提供了 url: "about:blank",就永远发现不了第一个问题。这两个问题,都是在真实 Safari profile 第一次做出稍微不寻常的行为时暴露出来的,而这也正是这个项目从一开始就选择操作真实浏览器的原因。
如果你使用 safari-mcp 的 Safari 扩展,这两项修复都在扩展内部,因此需要基于 2.22.13 或更高版本重新构建扩展应用,修复才会生效(步骤见 README 的 “Installing the Extension” 一节)。
你自己的安全检查如何表示“我无法判断”?它有独立的值,还是悄悄和“否”共用了一个值?
如果需要采取进一步行动,你可以考虑屏蔽此人和/或举报滥用行为。