☰
深入解析 Cloudflare OS Connect Handoff:用 Ticket 与 Nonce 将 Gatekeeper 连接流程安全绑定回发起浏览器
2026/10/4 15:07:51 网站建设 项目流程
  • 人工智能
  • AI 应用
  • AI Agent
  • Agent 沙箱
  • AI 安全治理

【免费下载链接】cloudflare-os

Agent workspace built on Cloudflare Workers for creating documents, building apps, and running agents with your company’s context and systems.

项目地址:https://gitcode.com/GitHub_Trending/cl/cloudflare-os
点击查看免费下载

Cloudflare OS 的 Workshop 通过独立部署的 Gatekeeper(如 Google、GitHub、Slack 等连接器)与外部服务交互,而 connect / reconnect / ensure-resources / sign-in 流程都以浏览器弹窗形式完成 OAuth 授权。本文基于仓库文档 docs/connect-handoff.md 展开,讲解这套流程如何通过"Ticket + Nonce"双凭证机制,把"谁完成的流程"严格绑定到"发起流程的那个浏览器弹窗",从而抵御将连接 URL 当作 bearer 能力钓鱼的威胁。读完本文,你将掌握该机制的威胁模型、服务端与浏览器端的完整实现、部署约束与全部用户可见失败模式,可直接据此理解或复现同类弹窗回交(popup handoff)安全设计。

一、威胁模型:为什么"完成流程"不能等于"激活连接"

一个 connect / reconnect / sign-in URL 本质上是bearer 能力(bearer capability):谁打开它,谁就能走完整个流程,而 HTTP 请求本身无法把"完成流程的浏览器"与"发起流程的用户"关联起来。

攻击场景十分直接:攻击者在自己账户里发起一个 connect,然后把 URL 钓鱼发给受害者;受害者打开 URL 完成 OAuth 授权后,受害者的第三方凭据会落入攻击者的 Workshop 账户(对 sign-in 而言,攻击者则获得一个以受害者身份登录的会话)。GatekeeperVendor.connectAccount()的接口注释明确警告了这一点,见 packages/workshop-shared/src/gatekeeper.ts。

防御的核心原则是:"完成流程"激活不了任何东西。流程完成时,GatekeeperConnectCallback.complete()/reconnectComplete()只返回一个ConnectHandoff,真正的激活(激活 grant)必须由发起者自己的会话在 Workshop 侧赎回(redeem)之后才发生。整个机制由五个部分组成:

组成部分位置职责
Ticket(ConnectHandoff)packages/workshop-shared/src/gatekeeper.ts一次性、仅存哈希的赎回凭证
Nonce(ConnectFlowStart)packages/workshop-shared/src/api.ts把赎回绑定到 Workshop 打开的那个弹窗
Gatekeeper 完成页(connectHandoffPageHtml)packages/gatekeeper-kit/src/connect-pages.ts把弹窗导航到 Workshop 的 handoff 页面
Workshop 的/connect/handoff页面packages/workshop-frontend/src/ConnectHandoffPage.tsx读取 ticket 与 nonce 并赎回
服务端packages/workshop-backend/src/connect-handoff.ts、user.ts、auth/login-flow.ts存哈希、校验、激活 grant

二、Ticket:一次性、仅存 SHA-256 哈希的赎回凭证

流程完成时,complete()返回ConnectHandoff = { targetOrigin, ticket },其中:

  • ticket是全新的 256 位随机秘密,由newSecretToken()生成(crypto.getRandomValues填充 32 字节),渲染为 64 个小写十六进制字符。服务端只存它的 SHA-256 哈希(hashSecret),因此存储泄露不会暴露任何可赎回的东西。newSecretToken同时被会话令牌、handoff ticket 与流程 nonce 复用,见 packages/workshop-backend/src/connect-handoff.ts。
  • 哈希存放在发起者自己的 Durable Object 中:connect / reconnect 的哈希写入发起用户 DO 的pendingHandoffs(由#stagePendingHandoff写入);sign-in 的哈希则存入PendingLoginDO(deliver()时传入ticketHash,见 packages/workshop-backend/src/auth/login-flow.ts)。
  • ticket 是单次使用(single-use),且在流程完成后的PENDING_HANDOFF_LIFETIME_MS(两分钟)内有效,见 packages/workshop-backend/src/connect-handoff.ts。
  • targetOrigin只来自部署配置:handoffTargetOrigin(env)只读取PUBLIC_BASE_URL并取其origin,未配置时直接抛错(fail closed)。任何请求头(如Origin)都不被采纳,客户端无法用任何声明把 ticket 路由到别的源,见 packages/workshop-backend/src/connect-handoff.ts。

关键的安全性质:在 ticket 被赎回之前,Gatekeeper 持有的凭据对任何 Workshop 账户都不可达。connect 被暂存(stagePendingConnect);reconnect 的新凭据以stageId暂存在 Gatekeeper 内(stagePendingRestore);sign-in 的令牌停在PendingLogin中。connect 的赎回经由发起者自己已认证的会话完成(AuthenticatedApi.completeConnectHandoff只在调用者自己的 DO 里查 ticket),因此受害者即使替攻击者完成了流程,得到的也是自己的会话无法赎回的 ticket——攻击链就此断开。

三、Nonce:把赎回绑定到"Workshop 打开的那个弹窗"

Ticket 只把赎回绑定到用户,Nonce 则把它绑定到Workshop 为该流程打开的弹窗。每个流程开始(connectAccount、reconnectAccount、ensureAccountResources、startGatekeeperLogin)时,服务端都会铸造第二个newSecretToken(),把 hex 与url一起返回,即ConnectFlowStart = { url, nonce }(类型见 packages/workshop-shared/src/api.ts)。

3.1 弹窗如何携带 nonce:openDisownedPopup

Workshop 标签页把 nonce 写进弹窗自己的sessionStorage,而不是自己的 storage。openDisownedPopup(packages/workshop-frontend/src/connectHandoff.ts)的完整步骤是:

  1. window.open('', name, 'popup,width=520,height=680')先打开空弹窗——同源的about:blank,因此popup.sessionStorage可写;
  2. 立即popup.opener = null(手工弃养,而非使用noopenerfeature——noopener会让window.open()即使成功也返回null,与弹窗被拦截无法区分);
  3. 写入{ kind, nonce }到HANDOFF_KEY('gadgets.handoff')键下;
  4. 最后才用popup.location.replace(url)导航到 Gatekeeper 流程 URL。

openConnectWindow在此基础上还会在打开新弹窗前尽力关闭本标签页上一个 connect 弹窗,避免陈旧弹窗滞留在新弹窗之后(packages/workshop-frontend/src/connectHandoff.ts)。

3.2 为什么必须放在"弹窗的" storage

  • nonce 因此只存在于两个地方:服务器和那个弹窗。任何其他方式打开的 handoff 链接——新标签页、粘贴的 URL、Workshop 里的target=_blank链接、攻击者发给受害者的链接——都不持有 nonce,赎回不了任何东西。若没有 nonce,公开的confirmLogin(ticket)会让任何持有 ticket 的人无需任何点击就把一次登录推入受害者的标签页;公开的赎回端点则会成为"ticket 是否存活"的一次点击即知的 oracle。
  • sessionStorage按top-level browsing context 与 origin 隔离:它能在弹窗穿越 Gatekeeper 与第三方 provider 的整个旅程中存活(那些异源文档看到的是另一份存储),等弹窗回到 Workshop origin 时又可读。
  • 每次弹窗都获得全新窗口名(uniquePopupName('gadgets-connect')/uniquePopupName('gatekeeper-login')):window.open('', existingName)会返回已有窗口但不导航它,而仍停在 provider 页面上的弹窗是跨源的,向其写 storage 会抛异常。窗口名携带随机后缀crypto.randomUUID()而非按文档计数的计数器,因为刷新会重置计数器,而旧弹窗仍保留其名字(packages/workshop-frontend/src/connectHandoff.ts)。

3.3 服务端:nonce 的登记与校验顺序

对于 connect 类流程,openConnectFlow(accountId)(在user.ts)记录{ nonceHash, accountId, expiresAt }到pendingConnectFlows,存活CONNECT_FLOW_LIFETIME_MS(30 分钟,其量级涵盖 Gatekeeper 发起 nonce 的生命周期 + OAuth nonce 生命周期 + handoff 窗口,见 packages/workshop-backend/src/connect-handoff.ts)。

completeConnectHandoff(ticket, nonce)对两者分别做hashPresentedSecret(不是 64 位小写 hex 的值会被哈希成"查不到",见 packages/workshop-backend/src/connect-handoff.ts),然后按此顺序执行:

  1. 读取并删除 ticket 的pendingHandoffs记录;
  2. 读取并删除 nonce 的pendingConnectFlows记录;
  3. 校验两者都存在、都未过期、且flow.accountId === record.accountId。

删除发生在校验之前、且处于 DO 的 input gate 之下,因此:ticket 无论后续如何都会被消耗(spent);错误的 nonce 同样会消耗掉 ticket;nonce 不能被拿来回放攻击另一个 ticket。被校验拒绝的暂存 connect 会像未赎回的一样被丢弃(#dropPendingConnect,同时吊销 grant)。

对于 sign-in,nonce 还承担寻址职责:startGatekeeperLogin用idFromName(hash of nonce)为PendingLoginDO 命名,于是confirmLogin(ticket, nonce)可以在登录标签页只持有attempt能力、完全不知道 DO id 的情况下找到这次尝试。

四、账户连接(Account Connect)完整流程

  1. 标签页调用AuthenticatedApi.connectAccount(vendorId)(或reconnectAccount/ensureAccountResources)。用户 DO 向 vendor 索取流程url,用openConnectFlow铸造 nonce,返回{ url, nonce }。
  2. openConnectWindow(flow)打开携带 nonce 的弃养弹窗并导航到url。
  3. 弹窗穿越 Gatekeeper 与 provider。成功后 Gatekeeper 调用callback.complete(user);GatekeeperConnectCallbackImpl(user.ts内)转交stagePendingConnect,存入 ticket 哈希并返回{ targetOrigin, ticket }。
  4. Gatekeeper 渲染connectHandoffPageHtml(handoff)(packages/gatekeeper-kit/src/connect-pages.ts),其内联脚本执行window.location.replace(targetOrigin + "/connect/handoff#" + encodeURIComponent(ticket))。kit 只校验targetOrigin恰好是一个 origin(new URL(...).origin与原文严格相等,否则抛错),对 handoff 一无所知——这是整条流程中唯一没有 RPC client 的文档。
  5. /connect/handoff是 Workshop 的 SPA:路由src/routes/connect.handoff.tsx,组件ConnectHandoffPage,由src/routes/__root.tsx以isHandoff标志独立、无头部渲染。页面从 URL fragment 读 ticket(ticketFromHandoffFragment,必须解码为 64 位小写 hex 否则视为无效),从自己的sessionStorage读 nonce(readPopupHandoff,读取即删除该记录),用history.replaceState剥掉 fragment,然后像任何 Workshop 标签页一样认证自己的 WebSocket RPC 会话(useAuth:共享的localStorage里的authToken,或在 Access 部署中走 Cloudflare Access cookie),最后调用completeConnectHandoff(ticket, nonce)。成功则window.close(),并对拒绝关闭的浏览器显示 "Connected"(packages/workshop-frontend/src/ConnectHandoffPage.tsx)。
  6. 用户 DO 激活 grant(connect 走putConnectedAccount;restore 走commitReconnect(stageId)加markCredentialsRestored)并通知订阅者。发起流程的标签页通过subscribeConnectedAccounts()得知新账户——每个界面本来就在用这个订阅,标签页无需等待赎回。

五、登录(Sign-in)流程:谁赎回什么发生了对调

登录弹窗没有会话,因此"谁赎回什么"与 connect 不同。

  1. 登录标签页调用PublicApi.startGatekeeperLogin(vendorId):服务端铸造 nonce、按 nonce 哈希命名PendingLoginDO 并调用其begin(),把LoginConnectCallbackImpl交给 Gatekeeper,返回{ url, nonce, attempt }。attempt是LoginAttempt能力(packages/workshop-shared/src/api.ts),持有它就是收取会话令牌的能力。
  2. OAuthButtons用同一个openDisownedPopup(url, uniquePopupName('gatekeeper-login'), { kind: 'login', nonce })打开弃养弹窗,并每隔RECEIVE_POLL_MS(每秒)轮询attempt.receive()。
  3. Gatekeeper 调用complete(user)。LoginConnectCallbackImpl读取经 provider 验证的邮箱、铸造会话、把"<email>:<secret>"令牌以新鲜 ticket 的哈希为键停放在PendingLoginDO(deliver(token, ticketHash),见 packages/workshop-backend/src/auth/login-flow.ts);complete()返回{ targetOrigin, ticket },Gatekeeper 完成页按前述方式把弹窗导航到/connect/handoff#<ticket>。
  4. ConnectHandoffPage看到kind: 'login',改调PublicApi.confirmLogin(ticket, nonce)。后端按idFromName(hash(nonce))找到 DO 并调用confirm(ticket):若 ticket 哈希匹配则把已投递结果标记为 confirmed;错误的 ticket 直接抛错且不触碰结果,因此不会消耗掉正确 ticket 即将确认的东西(packages/workshop-backend/src/auth/login-flow.ts)。
  5. 登录标签页下一次receive()拿到令牌(同时清除结果,重复调用拿不到第二份),写入localStorage.authToken并重新认证。

弹窗永远看不到令牌:令牌只释放给持有attempt能力的一方,而该能力从不离开登录标签页。反过来,只持有attempt也什么都得不到——receive()在持有 nonce 的弹窗确认 ticket 之前一直返回null。

六、为什么 URL fragment 是安全的

ticket 只经 URLfragment传输,这带来多重保障:

  • 浏览器从不把 fragment 发给服务器,也不放进Referer头,因此沿途任何访问日志都看不到它;
  • location.replace()不留下可供回退的历史条目;
  • ConnectHandoffPage在读取后立刻用history.replaceState剥掉 fragment,其存储记录也在读取时被消费,因此刷新或重渲染都无法再次呈现 ticket;
  • ticket 单次使用,并在流程完成后两分钟过期。

由此形成整个设计的不变式:ticket 只可能到达后端提供的targetOrigin上的文档。kit 拒绝任何不是精确 origin 的targetOrigin,而 origin 本身仅来自PUBLIC_BASE_URL。完成页connectHandoffPageHtml还通过scriptLiteral将<、>、&及\u2028/\u2029全部转义为\uXXXX,杜绝任何值(包括含</script>的值)提前终结脚本(packages/gatekeeper-kit/src/connect-pages.ts);其响应由htmlResponse统一附加Cache-Control: no-store、CSP: frame-ancestors 'none'、Referrer-Policy: no-referrer等加固头(packages/gatekeeper-kit/src/connect-pages.ts)。

七、为什么不用 postMessage、opener 或 BroadcastChannel

保留window.opener的弹窗会把流程中的每个页面都暴露给反向 tabnabbing(reverse tabnabbing):弹窗途经的任何文档——provider 的页面,或用户粘贴过 URL 的某个 MCP 服务器——都能把已认证的 Workshop 标签页导航到钓鱼页面。因此 Workshop 在导航前就弃养(disown)弹窗;而没有 opener 就没有可postMessage的对象。此外,用 COOP 隔离自己页面的 provider 本来就会切断 opener,依赖它的设计在这些 provider 面前必然失效。

同源BroadcastChannel从完成页发消息只在 Gatekeeper 与 Workshop 同源部署时才成立,且此时弹窗已经是带自身会话的 Workshop SPA,频道只能在这种单一部署形态下省去一次页面加载,代价却是要维护和测试第二条传输通道。重定向是唯一传输通道,并且对任意主机上的 Gatekeeper 都成立。

八、部署注意事项

  • /connect/handoff必须作为 SPA 直接提供。fragment 能穿过 HTTP 重定向,但读取它的页面必须是我们的:packages/router以not_found_handling: single-page-application提供前端资源,覆盖了这一点。该路径字面量被gatekeeper-kit与workshop-frontend两侧的测试各自钉死(kit 刻意复制字面量,因为它发布给 Gatekeeper、不能依赖workshop-frontend,见 packages/gatekeeper-kit/src/connect-pages.ts;前端侧常量HANDOFF_PATH = '/connect/handoff'见 packages/workshop-frontend/src/connectHandoff.ts)。
  • kit 与 Workshop 必须一起部署:kit 的完成页导航到 Workshop 路径,Workshop 的流程开始返回该页面需要的 nonce。这一切换不做协商,因此在改变 handoff 的部署之前已加载的 Workshop 标签页,下次 connect 前需要刷新:它之后再发起的 connect 不会写入 nonce,弹窗会落在 "This link isn't valid"(其文案提示刷新)。旧 kit 的完成页则到达不了任何人。本仓库中每次 RPC 形态变更都有同样的陈旧标签页窗口,且没有对应的重载机制。
  • 每次 connect 在弹窗中消耗一次 SPA 加载(handoff 页面),并带有自己的 WebSocket 会话。
  • 即使 Workshop 标签页被关闭,connect 也能完成:弹窗自行赎回 ticket,账户会在任意标签页下次订阅时出现在用户列表中。

九、共享 Gatekeeper(Shared Gatekeeper)

多个 Workshop 可以绑定到同一个 Gatekeeper。每个 Workshop 的回调(GatekeeperConnectCallbackImpl或LoginConnectCallbackImpl,均为 Workshop 后端入口)用自己的handoffTargetOrigin(env)铸造 handoff,因此无论 Gatekeeper 运行在哪个主机上,完成页都会把弹窗送回发起流程的那个 Workshop。文档同时记录了一个遗留开放问题(Kenton):Gatekeeper 如何授权哪些 Workshop 可以绑定到它。

十、用户可见的失败模式与自动过期清理

下表完整列出用户可能看到的所有失败呈现,以及文案所在位置:

用户看到什么文案位置
"Pop-up blocked. Please allow pop-ups and try again."openDisownedPopup(packages/workshop-frontend/src/connectHandoff.ts)。登录在OAuthButtons的错误横幅中展示;connect 调用点记录日志并 toast 各自的通用标题:"Failed to start connection flow" / "Failed to start reconnect flow"(GatekeeperModal.tsx、BlueprintLandingPage.tsx)、"Failed to start connection flow" / "Failed to start re-authentication flow"(ResourcePicker.tsx、ObserverConfigModal.tsx)、"Failed to start connection"(OnboardingWizard.tsx、routes/gatekeepers.tsx)、"Failed to start Cloudflare connection"(OutOfCreditsModal.tsx、UsageSettings.tsx)。
"This browser blocks storage in pop-ups, so the flow cannot complete. Allow site data for this site and try again."openDisownedPopup(packages/workshop-frontend/src/connectHandoff.ts):nonce 写不进弹窗的sessionStorage时,弹窗被重新关闭、什么都不启动(流程反正无法完成),呈现方式同上。
"This link isn't valid"INVALID(packages/workshop-frontend/src/ConnectHandoffPage.tsx):fragment 无 ticket,或弹窗 storage 无 nonce 记录(页面以其他方式打开、storage 不可读、或 Workshop 标签页早于引入 nonce 的部署而未写入)。不发起服务器调用即显示,文案提示刷新 Workshop。
"You're signed out"SIGNED_OUT(packages/workshop-frontend/src/ConnectHandoffPage.tsx):connect 弹窗的useAuth在localStorage中找不到authToken。在 Cloudflare Access 部署中useAuth始终持有 pipelined stub,过期的 Access 身份会在服务端被拒,呈现为 "Could not complete the connection" 加认证错误。
"Could not complete the connection" + 服务器消息packages/workshop-frontend/src/ConnectHandoffPage.tsx;消息来自completeConnectHandoff:"This connection attempt has expired. Please try again."(user.ts,针对未知、已消耗或已过期的 ticket/nonce,或两者不匹配)。因弹窗 RPC 连接断开而失败的赎回,会在会话重连后再次呈现(main.tsx每次中断发布一个替换 stub,页面useAuth在其上重新认证);连接中断期间页面显示 "Finishing up…" 而非传输错误。重试是安全的,因为 ticket 与 nonce 都单次使用:对已落地的调用的重复请求会被当作过期而拒绝。
"Could not sign in" + 服务器消息packages/workshop-frontend/src/ConnectHandoffPage.tsx;消息是login-flow.ts的EXPIRED_MESSAGE("This sign-in attempt has expired. Please try again.",见 packages/workshop-backend/src/auth/login-flow.ts)或LoginConnectCallbackImpl通过PendingLogin.fail()记录的原因(无验证邮箱、注册已禁用、"Sign-in failed. Please try again.")。PendingLogin.#result()在报告过期或失败结果时清除它,因此原因只会送达弹窗的confirmLogin()与登录标签页的receive()中先读到的一方,另一方(标签页OAuthButtons的错误横幅,或弹窗)显示EXPIRED_MESSAGE。

过期清理全部在用户无感知的情况下由 alarm 完成:

  • 用户 DO 的alarm()在PENDING_HANDOFF_LIFETIME_MS内 ticket 未归来时丢弃暂存 connect(经#dropPendingConnect吊销 grant),并在CONNECT_FLOW_LIFETIME_MS内 nonce 从未呈现时丢弃该流程(无可吊销之物);
  • PendingLogin的 alarm 在PENDING_HANDOFF_LIFETIME_MS后清除未被接收的登录结果,或在LOGIN_PENDING_LIFETIME_MS后清除 Gatekeeper 从未投递过的 attempt。LOGIN_PENDING_LIFETIME_MS等于CONNECT_FLOW_LIFETIME_MS(见 packages/workshop-backend/src/auth/login-flow.ts)——所有终结于 handoff 页面的流程共用同一个时间预算。

小结与源码索引

Connect Handoff 的设计可以浓缩为一句话:流程可以"完成",但只有"发起者自己的会话 + 发起者自己的弹窗"两者同时到场,grant 才会被激活——ticket 绑定用户(只存哈希、单次使用、两分钟过期),nonce 绑定弹窗(只存在于服务器与那个弹窗、30 分钟预算),两者在服务端 DO 的 input gate 下按"先删除、后校验"的顺序成对消费,任何一侧缺失或错配都会让整个流程归于无效。想深入验证实现细节,可以顺次阅读:

  • 威胁模型与接口语义:packages/workshop-shared/src/gatekeeper.ts(ConnectHandoff)、packages/workshop-shared/src/api.ts(ConnectFlowStart/LoginAttempt);
  • Gatekeeper 侧完成页:packages/gatekeeper-kit/src/connect-pages.ts;
  • 浏览器侧弹窗与页面:packages/workshop-frontend/src/connectHandoff.ts、packages/workshop-frontend/src/ConnectHandoffPage.tsx、路由 packages/workshop-frontend/src/routes/connect.handoff.tsx;
  • 服务端常量与哈希:packages/workshop-backend/src/connect-handoff.ts,登录投递与确认:packages/workshop-backend/src/auth/login-flow.ts。
  • 人工智能
  • AI 应用
  • AI Agent
  • Agent 沙箱
  • AI 安全治理

【免费下载链接】cloudflare-os

Agent workspace built on Cloudflare Workers for creating documents, building apps, and running agents with your company’s context and systems.

项目地址:https://gitcode.com/GitHub_Trending/cl/cloudflare-os
点击查看免费下载

相关推荐

上一篇:Corsair Gmail 插件完全指南:从端点、OAuth 鉴权到 Pub/Sub 邮件事件同步
下一篇:Puerts 普洱精品支持计划全解析:高级/特级服务内容、费用模式与六大增值技术详解

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询