Files
blog/content/posts/tab-and-overreach.md
T

104 lines
5.8 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
title: "一个制表符,一个越权"
description: "周五收工复盘:KAP 平台两个同名客户差一个 \t,派单挂错联系人;行级隔离上线当天,复盘揪出 6 个详情类越权端点。安全这件事,永远栽在'看起来一样'和'只做了一半'上。"
date: 2026-08-28T19:35:00+08:00
draft: false
tags:
- 安全
- 权限
- 排障
- 复盘
---
## 背景
周五,两件大事收尾:给自建运维平台上线了**工号绑定 + 行级数据隔离**(12 人企微用户导入),以及修复了一起**三方派单挂错联系人**的生产事故。
一件是新建安全机制,一件是老安全机制失效。巧的是,它们的教训互为镜像。
## 一、一个制表符引发的事故
派单后,第三方平台的工程师端显示的是**历史联系人**,不是工单上的当前联系人。
排查下来,链条很优雅地坏着:
```
第三方平台存在两个"同名"客户:
"\t上海某某信息技术股份有限公司" ← 带一个制表符前缀,主联系人=历史联系人A
"上海某某信息技术股份有限公司" ← 正常,主联系人=正确联系人B
```
- **strip 之后名字完全相同**,列表接口返回的还是脱敏名(`上海****`),精确匹配在生产环境永不命中
- 代码逻辑是「找到就 break」——**盲取第一条**,命中了带制表符那位
- 更糟的是,一次 update 把正确客户的地址写进了历史客户档案(**地址污染**)
### 修复:盲选改裁决
候选 >1 时全部拉详情,按四级裁决:
1. detail.name 精确匹配
2. **主联系人电话归一化对比**(实测唯一可靠的裁决键)
3. 地址对比
4. 全部失败 → 报 `ambiguous` 转人工,**绝不盲选**
顺手把另外两家平台排查了一遍:修吧有同类问题(原来传的是公司名,联系人姓名整个丢失)已补传;大鱼链路本来就传联系人,无需改。
**教训**`matched = c; break` 这种「找到第一条就用」的写法,在数据源不可控(存在脏档案、脱敏名、同名客户)时就是定时炸弹。**候选不唯一时,宁可报错转人工,也不要猜。**
## 二、隔离上线当天,复盘揪出 6 个越权
另一条线是给平台加**行级数据隔离**:每个工程师只能看自己工号的数据,SN 脱敏(`4CE2***Y2`),管理员看全量。
上线过程很顺利——5 个数据面接入隔离,端到端验证:普通用户看 10 条,admin 看 47 条,全对。
**如果故事到这里结束,这篇文章就不存在了。**
晚上复盘时换了个方法:不查「已接入的端点」,而是 grep 全仓所有带 `gh_id` 的模型,反查它们**每一个**查询方法是否都过了隔离。结果揪出 6 个漏网的——全是**详情类端点**:
| 端点 | 越权方式 |
|------|---------|
| getTrend | 传任意 SN 查别人设备的健康趋势 |
| getDetail | 传任意 SN 看客户名、工单号 |
| getRepairs | 传任意 SN 查维修记录 |
| errorStats | 全局统计里返回完整 SN |
| warrantyExpiring | 保修列表未按工号过滤 |
| findById | 传任意 id 查诊断报告详情 |
**为什么会漏?** 列表类端点的查询条件里天然带 `gh_id`(WHERE 一加就隔离了);而详情类端点按主键/SN 查单条,**查询条件本身不含工号**——需要额外反查这条数据的归属人。接入清单是按「页面/功能」列的,详情接口藏在「页面能正常打开」的假象里。
修复后:6 项越权全部拦截,admin 回归正常。
## 镜像:两件事互为对方的教训
| | 制表符事故 | 越权补漏 |
|---|---|---|
| 出事的层 | **数据层**(脏数据 + 盲选代码) | **权限层**(只防了列表没防详情) |
| 根因模式 | 「strip 后一样」→ 误判唯一 | 「列表能看对」→ 误判全防住 |
| 正确姿势 | 候选不唯一 → 四级裁决,绝不盲选 | 隔离要覆盖**所有**查询路径,不只列表 |
共同点就一句话:**验证的时候,你测的路径和攻击者走的路径,往往不是同一条。**
- 验证「客户匹配」用的是干净数据,生产里是脱敏名 + 制表符脏档案
- 验证「隔离生效」点的是列表页面,攻击者直接构造详情请求
## 今日其他(速记)
- **edit_wo 维修站歧义**:用户传「武汉长租」连败 5 次,根因是子串匹配没过滤排除词——候选里只剩「美邦武汉长租」「极算武汉长租」,正确的「总仓(武汉)长租」因含括号反而匹配不上。create_wo 早有排除词机制,edit_wo 漏了(同源机制没同步的又一例)
- **巡检单 46% 建错站**rent_type=未知时默认长租没生效,③级按 API 顺序被短租站稳定截胡。教训:**默认值必须覆盖"未说"的分支,取第一个匹配必须显式优先级**
- **AI 网关 504**:商汤长 prompt 挂起撞 nginx 120sfallback 没来得及跑——把 curl 超时降到 90s 让降级生效,顺手接入 MiniMax 做第三梯队
- **eff_stats 每日双跑**:调度器只触发 1 次,是 agent 模型在同一会话里执行了 2 遍脚本。cron prompt 加「严格只执行一次」,明晨验证
## 总结
今天的关键词是「**补全**」:
1. **数据不可信时,代码要能裁决**——同名客户、脱敏名、脏档案,盲选第一条就是赌博
2. **权限不能只防"正门"**——列表是正门,详情是侧门,越权往往从侧门走
3. **复盘方法比复盘本身重要**——「grep 全部 gh_id 模型反查接入面」这个动作,比发现这 6 个漏洞更有复用价值
4. **默认值也是一种决策**——「用户没说」不等于「随便选」,46% 的错误率就是没想清楚默认值的代价
> 安全的敌人从来不是攻击者,是「看起来已经防住了」。
---
*本文已脱敏,不含真实客户名、联系人、电话或系统密码。*