漏洞

一张空白请求,怎么让两条路同时消失——重走 Tailscale TS-2026-002

TS-2026-002 影响显式向 tailnet 开放设备 Web 界面的 Tailscale 客户端:能连到 TCP 5252 并完成登录检查的 peer 可以提交一个没有选择任何路由操作的请求;旧 handler 既没有索要出口节点或子网能力,也没有带回两组现有配置,随后却给两项空值盖上写入印章,清除了目标正在使用的出口节点和对外通告的子网路由。

手绘调查场景:一张空白请求卡依次穿过四道检查门,来到路由控制台后,通往出口灯塔的道路和通往多个子网房屋的道路逐渐淡去;修复后的守卫在控制台前拦住另一张空白卡。
文章导航

研究依据依据 Tailscale 公告与文档、固定的 v1.98.0 源码、易受影响父提交 f7f8b0a0 、直接修复 da0a2775 、权限重构 f15a4f44 及本地重跑的 handler 回归完成重建

来源Tailscale TS-2026-002 安全公告与产品文档 / 固定的 v1.98.0 源码与提交历史 / 易受影响及修复后回归测试 / SOSEC 源码重建

1 这份请求只有两个字节

请求正文只有一个左花括号和一个右花括号:{}。它没有写出口节点,没有列子网,也没有拨动“修改出口”和“修改路由”这两个开关。把它放到一台已有真实配置的节点前面——左边抽屉里是正在使用的出口节点,右边抽屉里是正在通告的私网前缀——任何人的第一反应都会是:什么也不该发生。

旧版 handler 给出的答案恰好相反。请求走完后,两个抽屉都空了。ExitNodeID 被写成空值,AdvertiseRoutes 变成空切片。这不是虚构某个受害者的时间线,也没有为了戏剧性杜撰操作者;它是把修复前的公开源码钉在固定提交上,用非空初始状态和内存 LocalAPI 记录器重放出来的一次状态迁移。

这两个抽屉承担着方向相反的工作。出口节点决定“这台机器自己的流量从哪里出去”;通告路由决定“其他 peer 可以经由这台机器走到哪里”。一台设备完全可能一边使用云端出口上网,一边把实验室网段通告给 tailnet。空请求一次清掉两组偏好,所以它既可能改变目标自己的出网路径,也可能让其他节点失去一段内部网络。

还要区分“本机使用出口”和“本机提供出口”。前者由 ExitNodeID 指向另一节点;后者由 AdvertiseRoutes 中成对默认前缀表达。运维界面常把两者都叫 exit node,事故工单也容易只写“出口掉了”。如果没有在记录里写清角色,恢复人员可能重新选择了上游出口,却遗漏本机原本向他人提供的出口服务。

公开影响最清楚的是可用性和策略漂移。出口选择消失后,新连接可能回到本地网络出站,公网源地址、DNS 路径或预期的检查点随之改变;通告列表消失后,控制面会逐步撤回子网路由和出口默认路由。这里没有证据表明攻击者安装了新路由、读取了加密流量或取得了系统 shell,文章也不会把配置破坏扩写成这些更强结论。

Tailscale 在 2026 年 5 月 13 日发布 TS-2026-002。公告覆盖桌面 Linux、macOS 与 Windows 客户端,从 1.56.0 起到 1.98.0 修复前的构建。版本只是第一道条件:设备 Web 界面还必须被显式开放给 tailnet 内的 peer,来源身份要能连接目标的 TCP 5252,并完成 Web 界面的登录检查。

因此,那张空白卡片并不是凭空落到路由处理函数里。它先要穿过四道门:目标正在运行易受影响的代码;远程设备 Web 已开启;tailnet 策略允许来源到达 5252;来源通过管理登录。TS-2026-002 出现在四道门之后,应用本该继续问第五个问题:这个已知身份究竟能不能改出口节点、子网通告,或同时改两者?

1.1 四道门把卡片送到第五把锁前

设备 Web 是客户端管理面。受支持的桌面端可以提供本地页面,但本地便利地址并不等于其他节点也能访问。peer-facing 管理需要额外启用,常见来源包括文档里的 Web 客户端开关、持久服务、启动参数或产品集成。资产清单除了安装版本,还要记录实际运行版本、监听模式、绑定地址、启动方式和远程开放的时间段。

第二道门由网络策略控制。Tailscale grants 把“包能否到达”放在 ip 规则里,把“应用允许做什么”放在 app 能力里。拥有应用能力的身份可能根本连不到目标;能连到 5252 并登录的身份也可能只拿到一部分 Web 功能。漏洞恰恰利用了后一种组合:管理通道成立,路由功能却应受更细的能力约束。

第三、第四道门让服务可达并确认操作者身份,但登录不等于拥有全部管理权。设备所有权、tags、用户组与 grants 会影响可见功能。路由页面对应的能力值包括 exitNodessubnets,通配能力可以覆盖更广功能;SSH、账户等其他设置另有自己的功能分组。旧代码原本想用请求里的两个选择位把每项操作接到对应能力。

正常的出口修改会把 SetExitNode 置真,于是权限函数检查 exitNodes。正常的子网修改会把 SetRoutes 置真,于是检查 subnets。空对象让两者都保持假,权限函数没有提出任何路由能力问题,随后返回允许。身份验证仍然真实,前四道门也确实限制了可达来源,但第五把锁被一个从未命名的“零操作”绕开了。

这也解释了为什么临时移除 exitNodessubnets 并不能单独兜住旧版本。危险请求没有选择任何能力,这正是缺陷所在。关闭 peer-facing Web 可以拆掉服务路径;收紧 5252 可以减少来源;升级运行中的客户端会修正 handler;精细 grants 仍能限制正常操作,但无法让一段根本不发起能力检查的旧逻辑突然安全。

所以,一个只报“发现 Tailscale 1.90”的扫描结果只能圈出候选,不能完成事故判断。每个节点至少要分别记录运行版本、远程 Web、历史网络路径、登录证据、旧路由与变化结果,并标注证据覆盖窗口。节点完全可以同时处于“必须修复”和“利用情况未知”,升级工单不能自动变成入侵定性。

负面结论尤其需要分母。“没有 5252 流量”只有在采集覆盖目标、身份和完整时期时才接近不可达;日志若只保留 30 天,就只能写“最近 30 天未观察到”。缺运行证据就取进程与产物,缺监听或策略历史就查 unit、偏好、revision 与身份变化,缺旧路由就找备份、控制面审计和 peer 快照。把未知接到下一项补证动作,才不会被红黄绿颜色误读成低风险。

优先级还要看节点角色。没有出口选择和通告路由的开发机仍需升级,但一次空写入未必产生可见故障;生产子网路由器可能让许多 peer 同时失去前缀;既使用受控出口又通告关键网段的设备,则恰好持有复现中的两组显眼非空值。符合条件的资产都要修,承担路由职责的节点先保全状态、先恢复。

手绘序列依次展示旧版桌面客户端、点亮的远程管理灯、受策略控制的桥、完成认证的钥匙,最终通向有两个抽屉的路由控制台;下方单独画着应用能力锁,而空白卡没有触发它。
图 1:易受影响的运行版本、远程 Web、TCP 5252 可达性和登录检查把请求送到 handler;真正漏掉的是下一问——空白卡没有选择任何路由能力,下游却仍然修改两组路由偏好。

1.2 两条消失的路,方向正好相反

第一个抽屉是 ExitNodeID。只要它非空,客户端就选择另一台 Tailscale 节点作为互联网出口。清空这个 ID 会取消选择,新连接可能改走普通本地网络,公网源地址、DNS 解析链和合规检查路径都可能变化。旧连接是否立即断开取决于协议、缓存与切换时机,不能把所有症状强行压到同一个秒点上。

第二个抽屉是 AdvertiseRoutes。普通私网前缀表示本机愿意替 tailnet 转发的子网;IPv4 与 IPv6 的两条默认前缀共同表示本机愿意充当出口节点。空切片会让两类通告都从本地偏好中消失,控制面随后向 peer 分发新状态。即使控制台还保留过去的批准,也无法替客户端通告一条它已经不再上报的路由。

因此,“把出口节点重新打开”并不够精确。恢复可能包括重新选择本机使用的上游出口、恢复本机向他人提供的 IPv4/IPv6 默认路由对,以及重新通告普通子网。升级前的基线应把选中出口、默认路由、非默认前缀和每条通告的控制面批准分别保存,出口侧再记录预期公网地址、DNS 与检查路径,通告侧记录依赖业务和冗余路由器。

偏好写入与用户感知之间还有传播时间:本地先改状态,控制面接收通告变化,peer 再收敛路由。时间线应分别记录本地偏好变化、首个 peer 撤路由和首个业务症状。配置管理说明“应该是什么”,旧偏好快照说明“当时是什么”,控制面审计说明“平台看见什么”,peer 路由表说明“最终用了什么”;材料冲突时先保留原件与采集时间,不要挑最顺眼的一份当真相。

合法管理也能产生同样终态:管理员可能取消出口、退役网段,自动化也可能提交空配置。真正有辨识力的是组合行为——受影响进程存在符合条件的管理路径,随后两组原本非空的偏好意外同时清空,来源身份、会话、请求时间、旧值、新值和 peer 侧后果能够彼此衔接。

至此,开场的谜面已经明确:四道门解释了空卡如何到达,两只抽屉解释了什么会消失。还缺的一块,是一个没有选中任何抽屉的请求,为什么最后拿到了清空两只抽屉的权力。答案藏在接口的成长史里——它从“每次全量替换”变成“前端可以局部修改”,可最后那台写入机器仍沿用最初的两枚印章。

2 接口换了工作,最后的印章却没换

修复 diff 里最醒目的是一条很短的拒绝条件,但漏洞并不是某个开发者突然漏写一行。它是在接口不断加能力时长出来的。2023 年 11 月,路由页面做的是全量替换:交出完整的出口与通告配置,两组一起写。一个月后,它学会只改其中一组;2024 年 1 月,它又学会按功能授权。可写入端仍保留最初“两个字段都存在”的姿势。

提交历史可以摆成五幅连续画面。ecd1ccb9175dce235bb5e77d0e0d610655867a74 加入路由管理面;97f8577ad28c6c9fcc68cdade89a6b51adfb67d4 加入两枚操作选择位和状态带回逻辑;9aa704a05d4c446668898265c5597ed4c51295ec 把能力检查绑到选择位;da0a277565dcc9661cef732434b5a2ae61f86009 拒绝空选择并修正保存;f15a4f441621e537340234d63271d3cf4dffe4b5 把逐项权限检查移进 handler。

只看最后修复,很容易把问题理解成“缺一条 if”。把五幅画连起来,才知道空状态为何能躲这么久。每次功能演进都围绕正常页面请求展开:用户选择出口、编辑子网,或明确同时修改两者。页面不会主动发送两个选择位都为假的请求;公开 JSON 解码器却能生成它,服务端必须为这个状态作出决定。

2.1 最初的工作台每次都重写两个抽屉

第一版请求里没有 SetExitNodeSetRoutes。表单提交想要的路由状态,handler 计算前缀,最后用两个真掩码替换两组偏好。空配置在当时有一个完整含义:把出口和通告都改为空。行为虽然宽,但输入和输出是一致的——调用方描述完整期望状态,后端写入完整期望状态。

选择位是为局部编辑加入的。用户只换出口时,不该重新填写所有子网;用户只改子网时,也不该意外重置已经选中的出口。于是请求多了两个布尔开关,告诉 handler 当前页面究竟在改哪一组。为了继续调用那套宽写入 API,handler 先读取现有偏好,再把没选中的那组旧值补进即将发出的更新。

可以把它想成有两只抽屉、但只有一台压印机的工作台。选择蓝抽屉,就把红抽屉的旧内容复制过来,最后两只一起盖章;选择红抽屉,就复制蓝抽屉,再一起盖章。这两条路都正确。问题是两个布尔值天生有四种组合:只蓝、只红、两者、两者都不选。页面只走前三种里的常用路径,解码器却允许完整的四格表。

旧保存逻辑把页面假设直接写进了分支:如果选出口,就带回现有非出口路由;否则如果选子网,就带回现有出口 ID 与默认路由意图。它回答的是“选中一边时该复制另一边什么”,没有回答“一边都没选怎么办”。来到第四格时,两条分支都不执行,请求对象继续保留 Go 零值。

在最初的全量表单里,空输入和清空两组是同一个动作,所以两枚恒真掩码并不自相矛盾。真正的语义转折发生在选择位加入之后:从那一刻起,值字段只有在相应操作被选择时才代表新期望。若评审只盯后来加入的权限函数,就会错过协议已经换了一套存在性规则、写入端却还沿用旧规则这一根本变化。

更关键的是,最后的写入构造器没有随着选择位一起变化。单项编辑仍需把两组完整状态交给 LocalAPI,因此 ExitNodeIDSetAdvertiseRoutesSet 继续恒真。前端选择位决定保存哪些旧值,后端掩码决定哪些字段具有写入效力,两者之间没有一条约束。接口前半段已经局部化,后半段仍像全量表单。

能力检查随后也围绕选择位生长。选出口就检查 exitNodes,选子网就检查 subnets,这是很自然的映射。可第四格仍没有名字:没有选择位为真,就没有拒绝条件触发;没有保存分支执行,就没有旧状态回填;最后两枚写入印章却照常抬起。三个局部合理的设计,在同一格里拼成了危险结果。

这里没有哪个组件需要表现得离谱。JSON 把缺失布尔值解成假;权限函数检查已选择功能;保存分支照顾页面会发出的请求;路由计算器对空输入返回空结果;MaskedPrefs 忠实应用真掩码。漏洞出现在它们交接的缝隙里:没有组件负责把“零个操作”翻译成一个明确的服务端结论。

五幅手绘工作台依次展示全量替换控制台、加入选择杆、加入权限钥匙、空白卡滑入被忽略的空槽,以及修复后的守卫在压印机前拦截空卡。
图 2:端点从全量替换成长为选择性编辑和选择性授权,协议里随之出现“零操作”组合;直到修复加入最外层守卫,这个组合才第一次得到安全、可见的含义。

2.2 测试盯着页面,却没有看解码器能造出什么

围绕 UI 写测试,自然会覆盖用户能点击的控件:选一个出口,确认它变了;编辑子网,确认出口还在;同时提交两组,确认两边都更新。这些测试有价值,它们证明页面与 handler 在正常请求上达成一致。可公开协议能表达的状态总比那个页面常用的状态多。

部分更新测试必须从显眼的非空初值开始。给每个受保护字段放一个不同值,枚举所有选择位组合,再记录 HTTP 结果、权限检查、输出掩码、输出值、读次数和写次数。如果夹具里出口本来就是空、路由列表本来也是空,那么一次破坏性清除与真正 no-op 看起来完全一样,测试再绿也没有辨识力。

两枚选择位应自带四格合同:零个操作得到明确错误;只改出口要保存子网;只改子网要保存出口选择和默认通告;两者都改要同时应用,并索要两项能力。能力机制加入后,再把四格与无能力、单项能力、通配能力和完整能力交叉。这个矩阵并不大,却能把页面假设变成服务端保证。

错误测试还必须观察副作用。只断言“返回 error”可能放过一种更糟的实现:handler 先写偏好,后面才失败,wantErr=true 仍然通过。修复后的上游 fake 会记录是否收到 PATCH,所以空选择的预期不是一句“报错”,而是“报错,并且偏好写入次数为零”。后半句才真正保住开场里的两只抽屉。

这张合同应放在公开请求类型附近,而不只藏在前端测试里:前端负责按钮会发什么,handler 负责解码器接受的全部形状,LocalAPI 记录器负责掩码翻译,更高层集成负责进程和路由结果。每次协议字段变化,评审都应重新列出可表示组合,并用归一化规则把无意义状态收敛成已命名操作或可见错误。

前端 schema 只能减少误用,不能代替服务端合同。公开 HTTP 端点仍会遇到旧客户端、自动化和直接请求;提交说明也应把 false-false 等新增组合、保存规则和写入规则写清,使后续能力检查有现成矩阵可以扩展,而不是只留下“支持分别编辑出口和子网”一句功能描述。

回移补丁同样要移植完整合同。只加空选择 guard 会关闭公告主路径,却可能保留双操作只检查一项能力的旧 else if;只搬权限重构,又可能留下空请求不保存旧值。下游需要的是非空操作、逐项授权、逐项保存与拒绝时零写入共同成立。

版本还分四个时间:源码何时改、哪个 tag 包含、稳定渠道何时交付、目标进程何时运行。v1.98.0 是源码锚点,却属于测试或候选构建;1.98.1 是首个稳定 1.98,Linux 包因无关的 MagicDNS 回归被撤回,1.98.2 才修正那项回归。源码、发布、平台和资产负责人应分别留下 commit、产物、渠道与进程证据,生产处置安装当前受支持修复版本,而不是机械追逐历史标签。

现在,空白卡已经有了一段身世:选择性编辑让它在协议里出生;页面不会发它,所以测试没看见;权限跟随真选择位,因而没有向它要钥匙;后端仍沿用全量写入,最后给它两枚印章。接下来只需看解码器如何处理三种不同信封,就会发现它们出来时全都变成了相同的两个假值。

3 三只不同信封,拆开后成了同一个零操作

公开请求类型并不复杂。两个布尔值负责选择受保护的路由组,后面的字段承载出口节点、是否通告默认路由以及子网前缀。简洁本身没有错,问题在于普通 Go 布尔值解码后只记得真或假。它不会记住这个假是字段缺失、调用方明确发送,还是正文只带了结构体不认识的成员。

type postRoutesRequest struct {
    SetExitNode bool
    SetRoutes   bool
    // 后面是出口节点与通告路由值
}

对这两个选择位来说,三种正文会以同一状态进入 handler:{}{"setExitNode":false,"setRoutes":false},以及在未知成员被忽略时的 {"misspelledSelector":true}。来源可能是恶意请求、客户端缺陷、版本错配或简单拼写错误;走到权限判断时,来历已经丢失,服务端只看见两个 false。

零长度 HTTP body 是另一回事。JSON 解码器会先返回 EOF,外层包装在进入这个 handler 前就拒绝它。因此本文说的“空请求”指有效 JSON 表达的空操作选择,不是任意没有字节的 body。这个差异也说明,严格拒绝未知成员虽能改善诊断,却无法处理 {} 和两个显式假值。

3.1 解码之后,false 已经忘了自己从哪里来

把布尔值换成指针可以区分“缺失”和“出现”,却不会自动决定“两项都出现但都为假”算不算操作。枚举或显式操作列表会更易读,同样需要规定零元素以及组合元素的结果。真正稳固的做法,是在解码后马上建立服务端操作集合:至少有一个已知操作,组合合法,然后让后面所有判断都使用这份结果。

直接修复选择了最小表达:SetExitNodeSetRoutes 都不为真,就返回客户端错误。它不会因为正文别处出现一个非空前缀便猜测调用方想改子网。若调用方忘记拨动 SetRoutes,就收到可见失败。静默猜测会让普通值字段绕过操作协议,也会把版本错配和拼写错误藏起来。

未知成员和重复键应有明确策略,但严格解码只是诊断层:它能阻止拼写错误悄悄变成零值,却拦不住语法合法的 {} 与两个显式 false。不论哪种线格式最终被接受,解码结果都必须先归一化成少量、完整、可授权的操作,才可以读取或修改偏好。

拒绝结果也应可操作而不过度记录正文。明确的 400、稳定的“没有选择操作”类别和请求关联 ID,可以让不兼容客户端停止重试,并把失败接回管理会话;按客户端版本与来源身份计数,又能区分坏版本发布、旧自动化与有意探测。过去静默清空状态的输入,由此变成一条可统计、可定位的协议错误。

本地易受影响夹具把这个保证变成可测结果。它固定在 da0a2775 的父提交,把三只信封分别解进真实请求类型,现有偏好则放入非空出口 ID 和 192.168.1.0/24。真实 Web handler 接到内存 LocalAPI 记录器,全程不开公网服务、不加入外部 tailnet,也不接触第三方节点。

夹具先用正常出口单项请求证明旧子网会被保存,再发送零选择,排除初值未生效、fake 不保存或路由计算器普遍返回空。三个子用例随后得到相同结果:handler 不报错并发出一次 PATCH,ExitNodeIDSetAdvertiseRoutesSet 都为真,出口 ID 为空,路由切片长度为零。非空初值让这次真实替换无法被默认值掩盖。

2026 年 7 月 16 日重跑时,三种正文都稳定复现。证据把正文形状、解码选择位、权限结果和最终写入掩码接成一条链,又停在受控接口处;理解源码缺陷不需要向生产设备发送会修改配置的请求。

3.2 权限函数一个问题都没问,自然听不到拒绝

旧能力函数采用条件拒绝:如果 SetExitNode 为真但 peer 不能编辑 exitNodes,拒绝;否则,如果 SetRoutes 为真但不能编辑 subnets,拒绝;两个拒绝条件都没触发,就返回允许。只有在调用前已经保证“至少一项为真、组合操作会完整检查”时,这种写法才成立。

空白卡让两个前提都为假。权限函数从未调用两项路由能力检查,直接走到成功。若下游同样把空操作当成零副作用,这种“空集合里的所有操作都已授权”不会造成问题;真正的矛盾是,下游稍后生成了两项写入。被授权的操作集合为空,生效的偏好集合却有两项。

这种模式在权限代码里很常见:循环检查每个已请求资源,全部通过就允许。数学上没有错,工程上却需要一个前提——空集合的副作用必须也是空集合。只要后续代码会自动补默认值、执行批量清理或盖上宽掩码,授权函数就不能独自决定“什么都没请求”的结果。操作有效性应先于能力检查完成。

来源仍需拥有有效管理身份,所以这不是匿名访问设备 Web。它属于应用层权限错配:系统知道操作者是谁,也能取得功能 grants,却因为请求没有选中任何 grant 而跳过检查,随后修改了恰好由那两项 grant 保护的状态。调查日志里,“登录成功”只能证明身份,不能证明功能判断与最后副作用一致。

旧版互斥写法还让双选择变得可疑。两项选择位都为真时,只拥有一项能力的 peer 可能在第一个分支满足后跳过另一项独立检查,具体取决于固定历史版本的谓词形状。后来的 handler 内重构改成两条独立 if,所以双操作必须同时拿出两把钥匙。公告聚焦空选择,最终合同仍要覆盖完整四格。

稳妥的授权函数应接收已经验证过的操作集合,为每个元素解析所需能力,少一项就拒绝,再把同一集合交给保存和写入构造。通配 * 可以满足多项检查,但审计仍应展开为“修改出口选择”“修改通告路由”等具体动作,使权限决定与副作用计划可以直接比较。

权限快照还要保存当时的来源身份、目标、命中能力与策略 revision。peer 之后可能更换 owner、tag 或组,当前策略模拟未必能重现过去;若审计只写“允许”,就无法区分普通路由管理员操作与旧 handler 在零选择时根本没有询问能力。

走到这里,空请求已经通过了认证,也没有声明任何路由功能,但状态还没有真正改变。它的值字段只是普通 Go 零值,一段谨慎的翻译器完全可以把它丢弃。破坏发生在下一段读改写流程:handler 试图保存“页面没有选择的那只抽屉”,可两边都没选时,它一只也没保存,随后又把两只空抽屉交给一套会忠实执行真掩码的后端。

4 七个普通工位,把空白变成了持久状态

源码里没有一行写着“空请求时清除两项路由”。结果分散在几个文件与抽象层中:解码、读取现有偏好、条件保存、路由计算、掩码构造、LocalAPI PATCH、最终应用。只在权限函数处停下,故事会少掉最关键的后半段。必须跟着卡片走完,才看见每个局部正常的工位如何交出一个整体错误的结果。

开场的物件可以继续使用:空白信封来到两根都压低的选择杆前;两名权限守卫没有被问到;两只保存抽屉保持关闭;空路由卷轴没有吐出线缆;压印机却抬起两枚印章,下面两只托盘全空;最后,偏好存储把原来挂在墙上的出口线和子网线都取下。七个动作放进同一画面,因果才清晰。

4.1 handler 读到了旧状态,却没有把它带到下一站

servePostRoutes() 先调用偏好读取。它拿到选中的 ExitNodeID,还拿到一条混合语义的 AdvertiseRoutes 切片:普通前缀代表子网通告,IPv4 与 IPv6 默认路由对代表本机愿意提供出口服务。handler 必须先拆开这两种含义,才能在局部编辑时正确重组。

这种读取对正常单项操作必不可少。只改出口时,宽 PATCH 里仍需包含旧子网;只改子网时,PATCH 里仍需包含旧出口 ID 和默认路由意图。换句话说,Web 层接收一份局部意图,经过读改写翻译,拼出后端偏好 API 所需的完整路由状态。

旧保存逻辑使用正向且互斥的分支。SetExitNode 为真时,把现有非出口前缀复制到工作请求;否则,SetRoutes 为真时,把现有出口选择和出口通告状态复制过去。两个选择位都为假时,两条复制都不会发生。旧偏好明明已经被读取、拆分,却在这一步被丢在原地。

此后的工作请求只剩零值:UseExitNode 为空,AdvertiseExitNode 为假,AdvertiseRoutes 为空。这一刻目标还没被修改。若输出是否存在由已选操作决定,这些值完全可以没有副作用。危险来自下一站——handler 把它们当成完整期望状态,并没有把“没有选择任何操作”带入输出掩码。

路由计算器收到逗号拼接的前缀字符串和出口通告布尔值。它验证地址、规范化掩码、处理特殊前缀,并确保两条默认路由成对出现;若要求通告出口,就补入 IPv4 与 IPv6 默认路由。空字符串配合 false 时,内部路由映射自然为空,函数返回空切片和 nil 错误,这是对输入的正确回答。

让计算器偷偷读取旧偏好并不是好修复。它是一段解析与规范化逻辑,不负责权限和持久化,也无从恢复独立的出口节点 ID。正确位置在上游翻译器:先拒绝没有操作的请求,再为每个未选择组补回现有值,最后让纯计算函数根据明确输入工作。

路由组本身也并不完全对称。上游出口用稳定节点 ID 表示,出口通告却与普通子网共享一条前缀切片。保存时必须先拆,再重组。回归测试若只比较切片长度,很可能错过“一条子网被两条默认路由替换”或只剩一个地址族的错误。应该分别比较出口 ID、默认路由对和规范化后的非出口前缀集合。

4.2 两枚抬起的印章,让空值变成命令

路由计算结束后,handler 构造 MaskedPrefs。把无关字段省略,关键形状非常简单:内嵌偏好里装着空出口 ID 和空路由切片,旁边两项存在性掩码却都是 true。就在第六个工位,普通零值不再只是默认内容,而获得了明确写入效力。

MaskedPrefs{
    Prefs: Prefs{
        ExitNodeID:      "",
        AdvertiseRoutes: nil,
    },
    ExitNodeIDSet:      true,
    AdvertiseRoutesSet: true,
}

本地客户端把这份对象送入偏好 PATCH。LocalAPI 解码后交给后端偏好流程,后面还可能有格式验证、策略限制和状态调整。可这些通用层只看见两项带真掩码的编辑,已经不知道它们来自零操作请求。请求最初的两个 false 没有被写进任何后端合同。

Prefs.ApplyEdits 一贯执行自己的约定:对应的 FooSet 为真,就把 Prefs.Foo 赋给目标,包括零值。空出口 ID 本来就是“取消使用出口”的合法表达;空路由切片本来就是“停止通告任何路由”的合法表达。后端做的正是正常清除操作必须做的事。

底层统一执行掩码是稳定合同:调用方无需猜某些空字符串会被忽略、某些空切片会清除。问题不在 MaskedPrefs 能应用空值,而在 Web 适配器无条件抬起多枚 Set;部分更新必须允许合法清空,适配器就必须证明每枚真掩码来自已经验证和授权的意图。

这份证明还要跨过序列化往返。MaskedPrefs 经 JSON、LocalAPI 解码和字段规范化后,掩码与值仍应符合四格合同;带真空值、假非空值和普通非空编辑的固定向量,可以防止标签、omitempty 或自定义序列化悄悄改变存在性。

集中策略可能限制某台目标的出口修改,但这是设备级附加条件,不能推导为全网客户端都受保护,也无法普遍保护通告子网。恢复与回归还要把 IPv4/IPv6 默认路由作为一对比较,并把它们与规范化后的非出口前缀分开,不能用“切片非空”或单一数量掩盖半套状态。

最终持久化按两项真掩码清空出口选择和通告切片,后端此时已看不见最初的 {}。审计若只在入口记录,会看见 false-false 却无法证明写入;只在持久化后记录,又会丢掉原始选择。更好的记录从归一化操作开始,再补上来源 peer、目标、所需能力、输出掩码、结果与关联 ID,具体节点和前缀可按策略脱敏。

接口与最终存储还要分别验收:LocalAPI 记录器证明 Web 适配器是否发出命令,存储测试证明状态是否真正持久。修复后的空选择在两处都应保持安静;合法请求只出现一次明确修改,随后才发生预期的路由重算和控制面通知。

本地复现正停在这个干净证人处:记录器在平台路由行为带来噪声前,已经捕获两项真掩码与两项空值,足以证明适配器制造了清除命令。修复测试把预期反过来即可——空选择返回错误,记录器没有收到任何偏好对象。

七个手绘机械工位依次展示空白信封、两根压低的选择杆、沉默的权限守卫、关闭的保存抽屉、空路由卷轴、空托盘上方两枚抬起的写入印章,以及墙上被取下的两组既有路由线。
图 3:失败来自组合。解码产生零选择,权限没有提问,保存没有复制旧状态,计算得到空路由,两项真掩码再把空值变成持久清除。

七个工位已经回答“怎么清掉”,还留下一个更通用的问题:为什么同样是空值,有时表示“忽略这个字段”,有时却表示“删除旧值”?答案不在空值本身,而在旁边那枚印章。看懂四张卡片的真值表,就能把这次具体缺陷提炼成可以复用的工程规则。

5 空值是否危险,取决于旁边那枚印章

MaskedPrefs 把“字段的值”和“字段是否参与这次修改”分开。内嵌 Prefs 存普通 Go 值,ExitNodeIDSetAdvertiseRoutesSet 这类掩码负责声明它们是否生效。这是 PATCH 必须解决的问题:调用方既要能不碰某个字段,也要能把它明确设为合法零值。

把每项偏好想成托盘和火漆印章。托盘里可能有一根路由绳,也可能空着。印章没抬起时,存储忽略托盘,墙上的旧绳保持原样;印章抬起后,托盘就成了命令——有绳就替换,空托盘就把旧绳摘下。只看托盘内容,无法区分省略与清除,真正的权力在印章。

5.1 四张卡片,解释了整个偏好合同

每项偏好只有四种概念组合:两种保存旧状态,一种写入新非空值,一种明确清空。最后一格不是异常技巧,它是关闭功能必需的正常能力。TS-2026-002 的问题,是 Web 适配器在没有对应输入操作时,连续两次制造了最后一格。

存在掩码提供的值对现有偏好的影响
false非空保存旧值;本次修改不包含该字段
false保存旧值;没有掩码,零值没有效力
true非空用新值替换旧值
true明确清除旧值

一个看似省事的补丁,是构造掩码时忽略空值。这样可以遮住公告症状,却会破坏合法控制:选择“不使用出口”本来就要清空 ExitNodeID,移除所有通告本来就要写空切片。可以另造专门端点或哨兵值,但现有掩码类型已经能清楚表达这些动作,真正需要修正的是调用方如何获得掩码。

Web 请求自己也有一套存在性模型。SetExitNode=true 表示出口相关值属于这次操作,SetRoutes=true 表示子网值属于这次操作。正确翻译器会让这份选择共同决定输入验证、能力要求、状态保存和输出构造。旧翻译器只在部分步骤参考选择位,最后两项掩码仍然无条件为真。

可以用两个集合描述矛盾。令 O 表示解码后选中的路由操作,令 M 表示输出中获得写入效力的偏好组。合同要求 O 非空、每个元素都得到授权,而且 M 中每项都能解释为已选操作或已证明的保存。易受影响路径却是 O = ∅M = {ExitNodeID, AdvertiseRoutes}

当前架构为了单项编辑仍会写两组字段:选中的一组写新值,未选中的一组写刚读回的旧值。因此合法单项操作里,M 可以比 O 看起来更宽,但多出来的每项必须附带证明——输出值等于同一快照中的现有值。另一种架构可以只设置选中掩码,省掉读改写;两者都能安全,无法解释的宽掩码才危险。

这份证明可以落成机器断言:对每个未选中组,序列化后的规范值必须与读取快照相等;对每个选中组,输出掩码必须为真并满足输入验证;输出中不得出现既未选择、又无法证明保存的组。把断言放进适配器单元测试,比依赖评审者肉眼追几个分支更持久,也能在新增第三个操作时自动暴露缺口。

四张手绘证据卡把启用或停用的存在印章与装有路由绳或空着的托盘组成完整二乘二映射;右下启用印章配空托盘的一格以砖红色标出,表示会清除旧状态。
图 4:空值不会自动被忽略。存在印章决定本次编辑;真掩码配空值是合法清除,所以安全判断必须发生在印章抬起之前。

5.2 一份操作计划,必须完整走过每次交接

最清楚的实现会在解码后只建立一次操作计划:识别字段、拒绝零操作、为每个操作解析能力、为未选择组决定保存或省略、检查组合规则、派生输出掩码,再记录审计并提交持久化。后面的组件不再从原始字段各自猜测调用方意图,因而不会把同一个 false 在不同位置解释成互相冲突的含义。

这不需要大型框架。两个操作可以继续用经过验证的布尔值,也可以用枚举集合或小型内部结构。关键是验证以后,调用链不能忘记究竟批准了什么。把原始请求对象一路往后传,会诱使权限、保存和写入分别从不同字段重新发现意图,正是本案从“没有权限问题”走到“两项真掩码”的原因。

保存逻辑还存在普通并发风险:出口单项请求读到当前子网后,另一授权进程若先改了子网,前者稍后用真路由掩码写回旧快照,仍可能覆盖新状态。更窄掩码、偏好 revision、串行更新或冲突检测都能减轻它;若 API 没有 revision,至少用交错编辑测试并记录 last-writer 行为。这与公告主缺陷不同,却说明宽掩码的保存证明必须指向具体快照。

静态检查可以寻找“输入选择位有条件,构造器却无条件设置多项 FooSet”的翻译器;属性测试给每项偏好放唯一非零标记,生成选择位与能力组合,断言未选中组不变。变异测试则删除空集合 guard、把负向保存改回互斥正向分支,或把独立权限检查改成 else if,要求 false-false、单项保存与双操作缺钥匙用例立即失败。四格表由此变成能抓住历史错误的可执行记忆。

诊断信息也能在不泄露敏感值的情况下暴露矛盾。记录操作名、命中的能力名和输出掩码名,节点 ID 与前缀按需要脱敏。一条“操作:无;掩码:ExitNodeID、AdvertiseRoutes”的跟踪在开发阶段就足以报警;一条“操作:subnets;保存:exit;掩码:两项”则给出宽写入所需的解释。

回到手绘图,后端并没有把空托盘误认成装满的托盘。它看见两枚抬起的印章,按合同执行。修复必须在读取状态之前拦下空白卡,再确保每张有效卡都携带所选杠杆需要的钥匙与保存说明。两个公开修复提交做的,正是这两件事。

6 修复先拦卡片,再碰抽屉

直接安全补丁用很少几行完成两件独立工作。第一,两个选择位都为假时,立即拒绝,发生在读取现有偏好和准备 PATCH 之前。第二,把保存逻辑改成两条彼此独立的规则:没有选择出口,就带回当前出口状态;没有选择子网,就带回当前非出口前缀。一条 guard 关掉空白行,两条负向条件补全有效行。

随后的权限重构把能力检查放进 servePostRoutes(),逐项验证已选择操作。零操作得到客户端错误,缺少对应功能的已选操作得到拒绝,双操作必须同时拥有两项路由能力。最重要的是,所有拒绝都发生在偏好写入接口之前,fake LocalAPI 不会看到 PATCH。

6.1 第一条条件,终于给“零”一个可见含义

da0a2775 开头补上旧协议从未说过的话:调用方必须指定 SetExitNodeSetRoutes。无论两项被省略、显式写 false,还是其他被接受的正文最终留下两个假值,都收敛成同一个无效操作。代码不会等到路由计算阶段,也不会查看值字段是否“看起来像有内容”。

GetPrefs() 之前拒绝不只节省一次读取,还避免无效请求的响应时序受当前路由复杂度影响。它无法参与状态竞争,也不会留下一个似乎开始过偏好修改的审计痕迹。最强回归并不满足于 HTTP 400,它还要求 LocalAPI 调用次数为零。错误响应帮助客户端修正文,零写入才真正保护目标。

通过 guard 后,修复代码按各自抽屉描述保存:SetExitNode 为假,就恢复当前选中出口和默认路由通告意图;SetRoutes 为假,就恢复当前非出口前缀。理解任意一条都不需要先猜另一枚选择位是什么。旧正向分支中隐藏的互斥页面假设被拿掉了。

只改出口时,请求中的出口新值保留,旧子网被复制;只改子网时,新前缀保留,旧出口选择和默认通告被复制;两者都改时,两条保存规则都不执行,调用方明确提供两组新状态;两者都不改则早已在门口返回。四种组合从此都有一个完整结论。

补丁没有改 CalcAdvertiseRoutesMaskedPrefs。计算器仍能把一份有意的空配置变成空路由,掩码类型仍能执行合法清除。变化集中在 Web 适配器:通用工具只会收到一份已验证编辑,或一份补齐了保存值的完整状态。小补丁之所以可信,是因为回归矩阵覆盖了更大的协议行为。

下游回移要移植性质,而不是只搬文本 diff。产品可能包装 handler、改名选择位或增加第三组路由;维护者应按本地请求类型重列组合,确认零操作拒绝、每个未选中组保存或省略、每个已选中组独立授权,以及所有错误零写入。再让同一易受影响夹具在回移前失败、回移后变成零写入;能编译通过或跑到同名测试,不等于语义已与上游一致。

6.2 回归矩阵让每根杠杆、每把钥匙都可观察

上游 TestServePostRoutes 从一个稳定出口 ID 和一条子网开始。空选择预期报错且没有捕获偏好;只改出口选择一个新出口,并要求旧子网还在;只改路由提交 10.0.0.0/8,并要求旧出口还在;双操作要求两组新值同时出现。非平凡初值让保存和清除无法混淆。

f15a4f44 又给这些状态行加上拒绝用例。选择出口却没有 exitNodes 时拒绝;选择子网却没有 subnets 时拒绝;两项检查彼此独立,所以双选择不能只拿一把钥匙通行。API 用例同时验证用户可见状态,区分操作形状错误与功能权限不足。

固定 v1.98.0 源码的 route handler、API 与 peer capability 测试已经聚焦重跑:空请求拒绝、两种单项保存、双项更新、能力转换和缺能力用例全部通过,易受影响父提交夹具则继续产生两项真空掩码。记录绑定精确 revision、Go 工具链、测试名、命令输出与产物 digest;下游分叉还要按本地字段重建矩阵,不能用一句“上游测试通过”代替。

随着架构扩展,测试还应数清更多副作用。无效请求可能在后续报错前生成审计、通知、缓存失效或控制面消息。团队要明确哪些允许发生,并断言次数与顺序。本案最小安全条件仍很简单:false-false 不修改偏好,缺任何所需能力也不修改偏好。

平台测试可以共享 Go 逻辑,但发布验收还要覆盖进程与服务差异。Linux 包、macOS 应用、Windows 安装器、容器镜像和嵌入式产品的升级及重启方式都不同。相同源码测试通过后,仍需确认各平台真正替换了提供 Web 服务的进程,并在重启后保留正确路由偏好。

手绘修复工作台:两根选择杆都压低的空卡被守卫拦下;只抬一根杆的卡只改对应抽屉并把另一只保存在玻璃下;两根都抬起时必须用两把钥匙;缺钥匙的旁路在压印机前被拒绝。
图 5:修复后的合同覆盖全部组合——零操作停在外层守卫;一个操作只改对应抽屉并保存另一只;两个操作需要两把钥匙;任何拒绝都不会触碰压印机。

源码修好不等于资产已经修好。1.98.0 含补丁却属于测试或候选构建;1.98.1 是首个稳定 1.98,Linux 包随后因无关的 MagicDNS 回归被撤回,1.98.2 才修正那项回归。生产环境应安装当前受支持修复版本,而不是强行固定到历史源码锚点。

安装后核对真正提供 Web 服务的进程:客户端自报版本、包或镜像修订、可行时的可执行哈希、启动时间、重启结果,容器的 digest 与任务替换,以及每台 standby。发布清单还应把修复 revision 或等价回移、产物 digest 和运行 build ID 接起来;嵌入式产品则需要设备厂商自己的补丁声明,不能假定沿用上游版本号。

若平台回归迫使回滚,退回旧二进制会重新打开 TS-2026-002,脚本应同步关闭 peer-facing Web、限制 5252,并保留路由基线与替代管理通道;例外记录写明补偿控制、负责人和到期时间。最后在自有 tailnet 或隔离夹具中跑安全矩阵,证明零操作可见失败且偏好不变,单项与双项从已知初值得到预期结果,缺能力在写入前停止。

7 一支舰队,不能靠一个版本号完成恢复

响应清单不该从“所有安装 Tailscale 的设备”一刀切开始,而应寻找四项交集:运行易受影响代码、peer-facing 设备 Web 曾开启、来源到目标 TCP 5252 曾可达、来源能完成登录检查。任意一道门在相关时期都能被证明关闭,公告路径就走不到 handler;某道门的历史证据缺失,就标记未知,不能拿今天的配置替昨天作证。

版本历史可以来自端点清单、包记录、镜像 digest、更新日志和进程遥测;远程 Web 可查服务参数、偏好、unit、管理脚本与监听历史;策略 revision 和身份历史说明哪些 peer 当时能连到服务。调查表同时写明来源与采集方式:自报版本和二进制哈希、当前偏好和历史 unit、模拟策略和当时配置、备份和口述的证据强度并不相同。所有权、tags、组与 posture 都会变化,不能拿今天的 grant 文件替昨天作证,更不能把估计写成事实。

7.1 先重建暴露与路由状态,再改任何东西

升级或关闭服务前,先冻结路由基线:选中的出口节点、是否同时通告两条默认路由、每个非默认前缀以及控制面的批准状态;再从一台已知授权 peer 记录可见路由。目标本地偏好代表它想通告什么,控制面代表收到并批准什么,peer 路由表代表最终收敛到什么。三份视图不一致时,差异本身就是线索。

把管理接触与偏好变化按时间关联。强关联点包括来源 peer 身份、目标节点、5252 流量、登录或管理会话、route handler 审计、daemon 偏好更新、服务重启和路由撤回。普通 WireGuard 流量不是这次 API 调用的 IOC;连到 5252 只证明能接触服务,登录只证明身份,偏好从非空变空才是状态证人。

次级症状适合用来缩小时间窗。目标失去出口后,公网源地址或 DNS 行为可能改变;子网通告撤回后,peer 可能失去内部前缀;健康状态或控制台也可能出现收敛记录。每种症状都有正常原因,所以应拿它们去寻找对应会话与偏好证据,不能单独承担利用定性。

时间归一化要保留原始信息。端点日志、配置审计、流量遥测和用户报告可能来自不同地区和不同步时钟。记录原始时间、时区与已知漂移,把“偏好写入”“首个 peer 撤路由”“首个业务故障”分开。若管理会话先出现,两组偏好随后清空,peer 侧影响再跟上,这条顺序很有说服力;若只剩终态,则按未知处理,同时照常恢复。

可以把结果分成四类。“不受影响”需要运行构建或服务路径不存在的证据;“版本受影响但不可达”需要历史网络证据;“可达但未观察到变化”需要覆盖相关时期的偏好和路由遥测;“观察到变化”仍需会话与来源上下文,才可进一步归因。统一词汇能阻止升级工单变成事故结论,也能防止证据缺口被写成“干净”。

不要为了补证据去试探第三方生产系统。公开行为会真实修改配置,可能直接制造正在调查的中断。机制验证使用自有实验 tailnet、隔离 handler 夹具或有控制台和恢复基线的指定测试机。易受影响源码复现已经证明技术过程,生产现场应把精力放在暴露、状态与恢复。

7.2 按固定顺序,把两条路接回来

下面这套顺序让证据保全与业务恢复不互相踩踏:

  1. 盘点运行代码与远程服务。记录平台、客户端版本、包或镜像修订、进程启动时间、Web 开关、监听地址,并从相关身份验证 TCP 5252 可达性。
  2. 冻结路由基线。在升级或改服务前保存选中出口、默认路由对、全部子网前缀、批准状态和 peer 侧可见路由。
  3. 重建访问路径。保留相关时期的策略版本、节点所有权、tags、组成员、登录证据、配置审计和流量记录。
  4. 不需要远程管理时先收口。使用受支持配置关闭 peer-facing Web,从 peer 侧确认 5252 不再可达;关键路由器同时保留替代管理通道。
  5. 安装并验证当前受支持修复版本。替换或重启活动进程,检查 standby,记录产物来源,并证明旧 handler 已经停止服务。
  6. 逐项恢复真实路由意图。重新选择需要的上游出口,在本机提供出口服务时恢复两条默认通告,重新应用每个已批准子网并确认控制面接收。
  7. 验证收敛与修复合同。检查出口和 DNS、peer 可达与拒绝、重启持久性、false-false 零写入、单项保存、双项授权以及监控告警。

关键子网路由器在升级前要先有带外入口。存在冗余时,先更新非服务成员,观察路由分发,再转移少量流量并继续滚动。一台仍运行旧代码的 standby 不是“暂时没事”,它只是在等待故障切换。回滚计划若可能退到受影响二进制,应自动重新关闭远程 Web、收紧网络路径并建立有期限的例外。

备份也要证明能恢复,不只是存在。对测试节点导出当前路由意图,在隔离环境或维护窗口中还原,再验证选中出口、默认路由对、子网前缀和批准关系。若备份只包含本地偏好却没有控制面批准,恢复后仍可能不可达;若只保存批准而本地不再通告,控制面也无路可发。两侧材料必须成对维护。

恢复时把两个方向分开。要求经中央出口出网的客户端,要重新选择正确出口,并验证公网源地址与 DNS;向别人提供出口的节点,要恢复 IPv4 与 IPv6 默认路由对;子网路由器要逐条恢复规范前缀并确认批准。分别从授权身份和应被拒绝身份测试,控制台里一个绿色字段不能证明端到端转发正常。

路由重叠和高可用会改变表面症状。两台路由器可能同时通告同一前缀,失去其中一台后连接仍在,但冗余已经下降;恢复它后可达性不变,选路偏好却会变化。结案前要记录预期路由器集合、优先行为与故障切换设计。由策略强制出口的节点也要确认到底是本地选择还是集中策略拥有最终决定权。

若暂时无法升级,关闭 peer-facing 设备 Web 是最直接的强控制。必须从 peer 侧验证监听不可达,并监控配置管理是否再次打开。ACL 或 grants 可以进一步缩小管理身份,但要记得为什么只移除路由能力不够。每项临时措施都应有负责人、截止时间、监控规则和排定的升级窗口。

七阶段手绘恢复闭环依次展示设备清单、路由状态记录、策略与会话检查、干净软件包、运行进程验证、出口和子网路由恢复、关闭不必要管理入口;中央的空白请求测试保持两只抽屉不变。
图 6:恢复先保留证据,再关闭旧路径、验证运行修复、分别接回两个方向的路由,最后用空请求零写入证明两只抽屉保持原样。

监控沿用同一条因果词汇:新开启的 peer-facing Web、异常身份到 5252 的可达、少见 peer 登录、路由偏好变化、默认路由对丢失、多条通告清空,以及本地意图与控制面批准不一致。关联规则按时间递进:服务开启只是配置变化,新来源登录提高优先级,两组偏好短时间从非空变空形成高价值组合,peer 侧公网地址变化或前缀撤回再补上结果证据。规则保留原始事件并串成待解释故事,不替分析员自动宣判攻击。

审计可对节点 ID 与前缀哈希或脱敏,但要保留结构:出口是否清空、默认通告是否变化、子网数量、授权功能和来源身份。覆盖率也要有分母,包括已知运行版本、peer-facing Web 负责人、路由偏好导出、管理会话与 peer 身份关联、策略 revision 保留期及关键路由器恢复基线;否则“有监控”仍无法说明下一次能否重建。

告警演练同时放入正常对照:授权管理员依次执行出口单项、子网单项和双项,确认未选中状态保存;再在测试环境提交零选择,确认 400、零写入和可检索错误事件同时出现。检测如果只认识“任何路由变化”,很快会在日常管理噪声中失去可信度。

恢复完成需要四项一致:运行进程包含修复,本地偏好表达预期出口与通告,授权 peer 看见正确路由且未授权 peer 仍被拒绝,零操作得到明确错误并记录零写入。重启或节点接管后再重复这四项,因为版本、服务开关与路由状态都可能随接管改变。

端点团队负责运行产物和服务模式,tailnet 管理员负责 grants、批准与 peer 路由,业务负责人确认出口、DNS 和内部目的地,检测团队负责关联审计,事件负责人记录尚未回答的历史问题。结案同时列出已关闭项与未知项,例如过期登录日志、缺失旧偏好或不完整策略 revision;这比一个“已打补丁”复选框更能让下一次调查接着现有证据开始。

8 空白卡的最终结局,应该是零写入

TS-2026-002 的请求很小,路径却很长。JSON 解码把缺失变成 false;权限把 false 理解成没有路由能力需要检查;保存只在另一项已选时处理 false;路由计算器对空输入给出正确空结果;两项恒真掩码再宣布这些空值属于本次编辑;最终存储忠实执行。危险不是零值自带魔法,而是零值跨过多次交接时不断换了含义。

修复后的 handler 把碎片重新写成一句完整合同:至少选择一个操作;每个已选操作都需要自己的能力;每个未选中状态组必须保存;组合后的值要有效;任何拒绝都不写偏好。零、一项、两项操作的回归矩阵把这句话固定下来,既足够短,能被评审,也足够具体,能抵抗以后重构。

8.1 先定义操作,再设计它的 JSON

管理 API 应从动词开始。本端点只有两个动词:“修改出口节点状态”和“修改通告路由”。对每个动词先规定所需权限、可接受值、跨组约束、受影响偏好、审计字段与回滚行为,再决定它们能否组合,以及零个动词有什么结果。最后才选择布尔、枚举、对象或列表作为线格式。

解码后立即归一化。别名和未知成员按公开规则处理,空集合与冲突组合直接拒绝;请求含义合法之前,不要读取可变偏好。把一个内部操作计划交给权限和持久化,后面的代码就不必从另一组原始字段重新猜意图。计划可以很小,它的价值来自所有步骤都引用同一份。

逐项授权,不使用“遍历几个 flag,没触发拒绝就成功”的隐含默认。组合操作要累积要求,不能选中第一条后跳过其余。通配 grant 可以满足多项要求,但审计输出仍应还原成具体动作。操作者以后缩小权限或响应人员回看历史时,需要知道会话真正改了哪些东西。

部分更新策略也要明确选择。窄掩码只写已选组,可以减少旧快照覆盖;宽掩码沿用现有架构,则必须从一致快照带回每个未选中组,并处理冲突。无论哪种,输出里每枚真掩码都应能解释为已授权操作或已证明保存。构造器里出现无条件掩码时,评审必须能指出那份证明。

设计评审可以固定七问:零操作是否有效;组合是否允许;每项由谁授权;未选中状态如何保存;输出掩码如何派生;错误前有哪些副作用;并发修改如何处理。答案再压成一条属性:最终修改组必须来自已授权操作,或与读取快照完全相等的保存;操作集合为空时,修改集合也为空。每次协议或后端字段变化都重答并转成测试,安全合同便不只存在于作者记忆里。

测试从每项偏好都有唯一非零标记的状态开始,生成操作与能力组合,断言 HTTP 状态、操作集合、所需功能、掩码、值、写次数和最终状态,并覆盖畸形 JSON、零操作、显式 false、未知与重复成员策略、单项、双项和可行的并发快照。fake 还要按顺序记录读取、写入、审计、通知与重算;一个写完偏好才返回的 400 仍是破坏性接口,“错误前零写入”才是两只抽屉没动的证明。

把源码证据与发布证据接在一起:修复提交、首个源码 tag、稳定渠道历史、包或镜像来源、运行进程、聚焦回归结果。上游某提交测试绿色,不能证明下游产物包含它;文件安装成功,不能证明旧进程停止;自定义构建打印一个较高版本号,也不能证明源码等价。发布时顺手保存的几项记录,会省下事故中昂贵的考古。

同一方法可迁移到账号编辑器、防火墙控制台、云策略表单和设备管理 API。请求新增选择位时,列出零、一项与组合操作;后端新增掩码时,证明每枚真掩码来自授权意图或保存状态;handler 新增功能权限时,比较被检查的操作名与最终副作用名。物件会变,那种“检查了空集合、写了非空集合”的矛盾不会变。

8.2 让开场里的物件,全部回到结尾

四道门仍然是有效控制:客户端保持当前版本;设备 Web 只在有负责人和真实需求时远程开放;TCP 5252 只允许预期身份;管理登录被要求并审计。第五把锁同样要保留:每项路由操作拿出对应应用能力,组合操作拿出全部钥匙。它们停卡片的位置不同,谁也不能代替谁。

两只抽屉仍是有效证据:选中出口、默认通告、非默认前缀和批准状态分开保存;受影响版本上两组同时清空,尤其来源缺少路由能力或请求没有已命名操作时,应成为高价值事件。无需永久保存完整流量,可以长期保留节点身份、版本、服务模式、策略 revision、管理会话、变化类型、路由数量和关联 ID 的紧凑索引,再让它指向短周期原始材料或配置备份。这样即使日志轮转,也不必拿今天的截图猜昨天的路径。

两枚印章仍然是有效评审信号。测试和脱敏诊断可以直接展示输出掩码名,并追问每一枚为何获准、值从哪里来。若对应已选操作,就指向能力决定;若对应保存,就指向快照与冲突规则;两种解释都没有,说明写入权力在交接时凭空出现。

修复守卫给出最后验收。提交一份有效 JSON,让两项路由选择位都保持假;handler 应在读取可变偏好前拒绝,LocalAPI 看不到 PATCH,审计不应伪装成成功路由变更,两组显眼旧值原封不动。再分别抬起一根选择杆,确认另一只抽屉保存;两根都抬起时,确认需要两把钥匙。

最初的空白卡之所以危险,是因为每个工位都假定别处已经替它定义含义。解码器相信 handler 会校验组合,权限函数相信选择位覆盖全部副作用,保存分支相信页面只走已知路径,掩码构造器相信值代表授权期望,后端相信真掩码值得执行。修复让一个组件先拥有含义,再让后续组件只执行同一决定。

一份只有 {} 的请求,本来就不该成为值得记住的事件。修复后,卡片停在最外层,得到清楚错误,两枚印章没有抬起,出口节点与通告子网仍在原位。真正值得留下的是复核方法:区分四道可达条件与第五项功能权限,区分两种相反路由方向,沿源码走完七个工位,用四格掩码解释空值,再用零写入矩阵验收。下一次遇到“什么都没选”的管理请求,不必凭直觉判断无害,只需追问它最终抬起了哪些印章。

研究记录

9证据、对象与来源

下面保留本文实际使用的标识、时间和原始材料,便于继续调查。

9.1研究对象

报告涉及的产品、行为者、技术、受影响对象和控制点。

公告TS-2026-002

Tailscale 设备 Web 界面路由能力绕过

受影响版本Tailscale 1.56.0 起至 1.98.0 修复前

显式开启 peer-facing 设备 Web 的 Linux、macOS 与 Windows 客户端

端点TCP 5252 上的 POST /api/routes

认证设备 Web 界面的路由偏好更新

直接修复da0a277565dcc9661cef732434b5a2ae61f86009

拒绝空选择并保存每个未选择路由组

权限重构f15a4f441621e537340234d63271d3cf4dffe4b5

handler 内逐操作能力检查

路由能力tailscale.com/cap/webui: exitNodes, subnets

与两项受保护路由组对应的功能 grants

9.2事件时间

  1. 路由端点加入

    ecd1ccb 以宽偏好掩码加入 Web 路由管理面。

  2. 操作选择位加入

    97f8577 支持单组编辑,但没有拒绝零操作请求。

  3. 功能能力绑定

    9aa704a 只为真选择位检查 grants。

  4. 直接修复提交

    da0a277 拒绝空选择并修正保存逻辑。

  5. 授权移入 handler

    f15a4f4 增加独立功能检查与明确 HTTP 错误。

  6. TS-2026-002 发布

    Tailscale 公开前提、平台、影响与处置。

  7. 请求路径与恢复方案完成对照

    SOSEC 核对请求路径、源码历史、修复回归、发布记录与恢复方案,将它们接成一条完整因果链。

9.3来源与材料

  1. Tailscale 安全公告 TS-2026-002https://tailscale.com/security-bulletins#ts-2026-002
  2. Tailscale 设备 Web 界面:访问、登录、所有权与能力https://tailscale.com/docs/features/client/device-web-interface
  3. Tailscale grants:网络与应用权限https://tailscale.com/docs/features/access-control/grants
  4. Tailscale grants 语法参考https://tailscale.com/docs/reference/syntax/grants
  5. Tailscale 出口节点部署与路由语义https://tailscale.com/docs/features/exit-nodes
  6. Tailscale 子网路由器配置与批准https://tailscale.com/docs/features/subnets/subnet-routers
  7. Tailscale 客户端偏好管理https://tailscale.com/docs/features/client/manage-preferences
  8. Tailscale 配置审计日志https://tailscale.com/docs/features/logging-streaming/configuration-audit-logs
  9. Tailscale 客户端自动更新https://tailscale.com/docs/features/client/automatic-updates
  10. Tailscale changelog 与 1.98 发布渠道历史https://tailscale.com/changelog
  11. 最初 Web 路由端点https://github.com/tailscale/tailscale/commit/ecd1ccb9175dce235bb5e77d0e0d610655867a74
  12. 路由操作选择位与状态保存https://github.com/tailscale/tailscale/commit/97f8577ad28c6c9fcc68cdade89a6b51adfb67d4
  13. 依赖选择位的 Web UI 能力https://github.com/tailscale/tailscale/commit/9aa704a05d4c446668898265c5597ed4c51295ec
  14. 空选择直接修复与 handler 回归矩阵https://github.com/tailscale/tailscale/commit/da0a277565dcc9661cef732434b5a2ae61f86009
  15. handler 内授权与拒绝回归https://github.com/tailscale/tailscale/commit/f15a4f441621e537340234d63271d3cf4dffe4b5