ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

深入解析 Cloudflare OS Connect Handoff:用 Ticket 与 Nonce 将 Gatekeeper 连接流程安全绑定回发起浏览器

深入解析 Cloudflare OS Connect Handoff:用 Ticket 与 Nonce 将 Gatekeeper 连接流程安全绑定回发起浏览器 人工智能AI 应用AI AgentAgent 沙箱AI 安全治理【免费下载链接】cloudflare-osAgent 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之后才发生。整个机制由五个部分组成组成部分位置职责TicketConnectHandoffpackages/workshop-shared/src/gatekeeper.ts一次性、仅存哈希的赎回凭证NonceConnectFlowStartpackages/workshop-shared/src/api.ts把赎回绑定到 Workshop 打开的那个弹窗Gatekeeper 完成页connectHandoffPageHtmlpackages/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 的哈希则存入PendingLoginDOdeliver()时传入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 被暂存stagePendingConnectreconnect 的新凭据以stageId暂存在 Gatekeeper 内stagePendingRestoresign-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 弹窗如何携带 nonceopenDisownedPopupWorkshop 标签页把 nonce 写进弹窗自己的sessionStorage而不是自己的 storage。openDisownedPopuppackages/workshop-frontend/src/connectHandoff.ts的完整步骤是window.open(, name, popup,width520,height680)先打开空弹窗——同源的about:blank因此popup.sessionStorage可写立即popup.opener null手工弃养而非使用noopenerfeature——noopener会让window.open()即使成功也返回null与弹窗被拦截无法区分写入{ kind, nonce }到HANDOFF_KEYgadgets.handoff键下最后才用popup.location.replace(url)导航到 Gatekeeper 流程 URL。openConnectWindow在此基础上还会在打开新弹窗前尽力关闭本标签页上一个 connect 弹窗避免陈旧弹窗滞留在新弹窗之后packages/workshop-frontend/src/connectHandoff.ts。3.2 为什么必须放在弹窗的 storagenonce 因此只存在于两个地方服务器和那个弹窗。任何其他方式打开的 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_MS30 分钟其量级涵盖 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然后按此顺序执行读取并删除 ticket 的pendingHandoffs记录读取并删除 nonce 的pendingConnectFlows记录校验两者都存在、都未过期、且flow.accountId record.accountId。删除发生在校验之前、且处于 DO 的 input gate 之下因此ticket 无论后续如何都会被消耗spent错误的 nonce 同样会消耗掉 ticketnonce 不能被拿来回放攻击另一个 ticket。被校验拒绝的暂存 connect 会像未赎回的一样被丢弃#dropPendingConnect同时吊销 grant。对于 sign-innonce 还承担寻址职责startGatekeeperLogin用idFromName(hash of nonce)为PendingLoginDO 命名于是confirmLogin(ticket, nonce)可以在登录标签页只持有attempt能力、完全不知道 DO id 的情况下找到这次尝试。四、账户连接Account Connect完整流程标签页调用AuthenticatedApi.connectAccount(vendorId)或reconnectAccount/ensureAccountResources。用户 DO 向 vendor 索取流程url用openConnectFlow铸造 nonce返回{ url, nonce }。openConnectWindow(flow)打开携带 nonce 的弃养弹窗并导航到url。弹窗穿越 Gatekeeper 与 provider。成功后 Gatekeeper 调用callback.complete(user)GatekeeperConnectCallbackImpluser.ts内转交stagePendingConnect存入 ticket 哈希并返回{ targetOrigin, ticket }。Gatekeeper 渲染connectHandoffPageHtml(handoff)packages/gatekeeper-kit/src/connect-pages.ts其内联脚本执行window.location.replace(targetOrigin /connect/handoff# encodeURIComponent(ticket))。kit 只校验targetOrigin恰好是一个 originnew URL(...).origin与原文严格相等否则抛错对 handoff 一无所知——这是整条流程中唯一没有 RPC client 的文档。/connect/handoff是 Workshop 的 SPA路由src/routes/connect.handoff.tsx组件ConnectHandoffPage由src/routes/__root.tsx以isHandoff标志独立、无头部渲染。页面从 URL fragment 读 ticketticketFromHandoffFragment必须解码为 64 位小写 hex 否则视为无效从自己的sessionStorage读 noncereadPopupHandoff读取即删除该记录用history.replaceState剥掉 fragment然后像任何 Workshop 标签页一样认证自己的 WebSocket RPC 会话useAuth共享的localStorage里的authToken或在 Access 部署中走 Cloudflare Access cookie最后调用completeConnectHandoff(ticket, nonce)。成功则window.close()并对拒绝关闭的浏览器显示 Connectedpackages/workshop-frontend/src/ConnectHandoffPage.tsx。用户 DO 激活 grantconnect 走putConnectedAccountrestore 走commitReconnect(stageId)加markCredentialsRestored并通知订阅者。发起流程的标签页通过subscribeConnectedAccounts()得知新账户——每个界面本来就在用这个订阅标签页无需等待赎回。五、登录Sign-in流程谁赎回什么发生了对调登录弹窗没有会话因此谁赎回什么与 connect 不同。登录标签页调用PublicApi.startGatekeeperLogin(vendorId)服务端铸造 nonce、按 nonce 哈希命名PendingLoginDO 并调用其begin()把LoginConnectCallbackImpl交给 Gatekeeper返回{ url, nonce, attempt }。attempt是LoginAttempt能力packages/workshop-shared/src/api.ts持有它就是收取会话令牌的能力。OAuthButtons用同一个openDisownedPopup(url, uniquePopupName(gatekeeper-login), { kind: login, nonce })打开弃养弹窗并每隔RECEIVE_POLL_MS每秒轮询attempt.receive()。Gatekeeper 调用complete(user)。LoginConnectCallbackImpl读取经 provider 验证的邮箱、铸造会话、把email:secret令牌以新鲜 ticket 的哈希为键停放在PendingLoginDOdeliver(token, ticketHash)见 packages/workshop-backend/src/auth/login-flow.tscomplete()返回{ targetOrigin, ticket }Gatekeeper 完成页按前述方式把弹窗导航到/connect/handoff#ticket。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。登录标签页下一次receive()拿到令牌同时清除结果重复调用拿不到第二份写入localStorage.authToken并重新认证。弹窗永远看不到令牌令牌只释放给持有attempt能力的一方而该能力从不离开登录标签页。反过来只持有attempt也什么都得不到——receive()在持有 nonce 的弹窗确认 ticket 之前一直返回null。六、为什么 URL fragment 是安全的ticket 只经 URLfragment传输这带来多重保障浏览器从不把 fragment 发给服务器也不放进Referer头因此沿途任何访问日志都看不到它location.replace()不留下可供回退的历史条目ConnectHandoffPage在读取后立刻用history.replaceState剥掉 fragment其存储记录也在读取时被消费因此刷新或重渲染都无法再次呈现 ticketticket 单次使用并在流程完成后两分钟过期。由此形成整个设计的不变式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的弹窗会把流程中的每个页面都暴露给反向 tabnabbingreverse 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 isnt valid其文案提示刷新。旧 kit 的完成页则到达不了任何人。本仓库中每次 RPC 形态变更都有同样的陈旧标签页窗口且没有对应的重载机制。每次 connect 在弹窗中消耗一次 SPA 加载handoff 页面并带有自己的 WebSocket 会话。即使 Workshop 标签页被关闭connect 也能完成弹窗自行赎回 ticket账户会在任意标签页下次订阅时出现在用户列表中。九、共享 GatekeeperShared Gatekeeper多个 Workshop 可以绑定到同一个 Gatekeeper。每个 Workshop 的回调GatekeeperConnectCallbackImpl或LoginConnectCallbackImpl均为 Workshop 后端入口用自己的handoffTargetOrigin(env)铸造 handoff因此无论 Gatekeeper 运行在哪个主机上完成页都会把弹窗送回发起流程的那个 Workshop。文档同时记录了一个遗留开放问题KentonGatekeeper 如何授权哪些 Workshop 可以绑定到它。十、用户可见的失败模式与自动过期清理下表完整列出用户可能看到的所有失败呈现以及文案所在位置用户看到什么文案位置Pop-up blocked. Please allow pop-ups and try again.openDisownedPopuppackages/workshop-frontend/src/connectHandoff.ts。登录在OAuthButtons的错误横幅中展示connect 调用点记录日志并 toast 各自的通用标题Failed to start connection flow / Failed to start reconnect flowGatekeeperModal.tsx、BlueprintLandingPage.tsx、Failed to start connection flow / Failed to start re-authentication flowResourcePicker.tsx、ObserverConfigModal.tsx、Failed to start connectionOnboardingWizard.tsx、routes/gatekeepers.tsx、Failed to start Cloudflare connectionOutOfCreditsModal.tsx、UsageSettings.tsx。This browser blocks storage in pop-ups, so the flow cannot complete. Allow site data for this site and try again.openDisownedPopuppackages/workshop-frontend/src/connectHandoff.tsnonce 写不进弹窗的sessionStorage时弹窗被重新关闭、什么都不启动流程反正无法完成呈现方式同上。This link isnt validINVALIDpackages/workshop-frontend/src/ConnectHandoffPage.tsxfragment 无 ticket或弹窗 storage 无 nonce 记录页面以其他方式打开、storage 不可读、或 Workshop 标签页早于引入 nonce 的部署而未写入。不发起服务器调用即显示文案提示刷新 Workshop。Youre signed outSIGNED_OUTpackages/workshop-frontend/src/ConnectHandoffPage.tsxconnect 弹窗的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消息来自completeConnectHandoffThis 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_MESSAGEThis 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.tsConnectHandoff、packages/workshop-shared/src/api.tsConnectFlowStart/LoginAttemptGatekeeper 侧完成页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 AgentAgent 沙箱AI 安全治理【免费下载链接】cloudflare-osAgent 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 基于 Gatekeeper 的 OAuth 免密登录AUTH_GATEKEEPERS 身份接入与 PendingLogin 握手流程详解Cloudflare OS 基于 Gatekeeper 的 OAuth 免密登录AUTH_GATEKEEPERS 身份接入与 PendingLogin 握手流人工智能AI 应用AI AgentAgent 沙箱AI 安全治理gatekeeper-kit 集成实战指南在 Cloudflare Workers 上构建安全的 Gatekeeper 连接流、凭证与观察授权gatekeeper kit 集成实战指南在 Cloudflare Workers 上构建安全的 Gatekeeper 连接流、凭证与观察授权 导读 gad人工智能AI 应用AI AgentAgent 沙箱AI 安全治理cloudflare-os 的 Confluence Gatekeeper 集成指南OAuth 2.0 连接、Markdown 会话 API 与审批机制cloudflare os 的 Confluence Gatekeeper 集成指南OAuth 2.0 连接、Markdown 会话 API 与审批机制 本篇人工智能AI 应用AI AgentAgent 沙箱AI 安全治理上一篇Corsair Gmail 插件完全指南从端点、OAuth 鉴权到 Pub/Sub 邮件事件同步下一篇Puerts 普洱精品支持计划全解析高级/特级服务内容、费用模式与六大增值技术详解创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表