主题
专题验证 · 隐私与鉴权
常驻清单,不归档。判断层专用——机械层先跑完再看这里。 对象:
admin/proxy.ts+admin/lib/auth.ts+ 生产站点admin.lampio.app+ Supabase 的 RLS / 授权 / Storage 姿态 体裁与纪律见README.md
bash
pnpm --dir admin test && ./scripts/privacy-verify.sh0. 这个域为什么危险
admin 持 service_role,而 service_role 在 Postgres 里是 BYPASSRLS。 这一条决定了本域的全部形状:库里那三十来张表的 RLS 策略写得再对,对 admin 都不生效。 所以**「谁能进 admin」就是「谁能读到全部用户的全部数据」**,中间没有第二道闸。
而那个后台是公网可达的(admin.lampio.app)。Vercel 的 SSO 只罩带 hash 的部署 URL, 不罩生产别名 —— proxy.ts 是唯一屏障。
三条只在这个域里成立的性质:
| 性质 | 后果 |
|---|---|
| anon 对 public 下每张表默认持满 GRANT(Supabase 建库即如此) | RLS 不是「加固」,是唯一屏障。一张忘了开 RLS 的新表 = 全网可读且可写 |
| 鉴权失效不改变任何界面 | AuthGuard 照常挡住 UI,而数据早已进了 RSC flight payload。2026-08-14 实测 curl 直接取到运营数据 |
| 密钥泄漏不报错 | 没人需要写错 import —— 一个 Server Component 把含 key 的对象当 prop 传下去就够了 |
所以本清单的第一原则:
🔴 「后台打不开」不是证据。 每一格要问的是:如果这道屏障没了,会有任何东西变红吗? 没有,那这一格就是靠运气在绿 —— 而这个域的运气只需要用光一次。
⚠️ 元前提:本地全绿证明的是源码,不是线上那份
admin/tests/** 跑在 next build && next start 的本地产物上。它证明这份源码是对的, 证不了 Vercel 上跑的那份 + 那套环境变量是对的。两者分叉的真实途径:某次部署没带上 proxy.ts 的改动、env 指向了别的 Supabase 项目、某条路由被静态化。
这正是 scripts/privacy-verify.sh 的 P8–P13 存在的理由,也是为什么 「跑了 pnpm test 全绿」不能当作本专题的结论。
🔒 P8-P13 自 2026-08-30 起在定时巡检里跑(
.github/workflows/monitor-prod.yml, 每天 01:00 UTC,--max-skip=8),P0-P7 仍是手工 / 有凭据时才跑。 按 schedule 不按 commit:P8-P13 验的是「已部署的站点」不是「这次改动」, 而且 admin 走 Vercel 部署、Vercel 不等 Actions ⇒ 真正会出事的时刻 (部署完、而没有任何人提交任何东西)恰恰是 commit 触发永远覆盖不到的。⚠️ 读下表时要分清哪几行是「每天都在验」、哪几行是「只有人记得跑时才验」 —— 后者的假绿风险高得多。 ⚠️ 站点不可达时脚本判 SKIP 而不是 FAIL(取不到 ≠ 没问题),于是 SKIP 超基线 → 红。 这在监控语境下是正确语义,但也意味着网络抖动会造成偶发红:看到红先问「它为什么红」, 别因为偶发红就把基线抬高。
1. 机械层已覆盖(不要在判断层重复)
| 层 | 位置 | 守什么 |
|---|---|---|
| 构建态 | admin/tests/e2e/auth-gate.spec.ts | 门(逐页 307 + action POST + 伪造 cookie + 不回显失败原因)· 锁(requireAdmin() 源码扫描)· 页面清单与文件系统对账 |
| 构建态 | admin/tests/e2e/no-secret-leak.spec.ts | service_role / sb_secret_ / GitHub / Sentry token 不进下发字节 · 全 chunk 扫描 · 两条阳性对照 |
| 构建态 | admin/tests/e2e/{security-headers,csp-runtime}.spec.ts | 安全头 · CSP 指令 · nonce 逐请求 · 真浏览器验 hydration |
| 源码 | admin/tests/unit/privacy-invariants.spec.ts | 红线 #2(用户列允许清单 + user_id 不与他列共选)· app/api/** 归属 · lib/auth.ts 的 edge 约束 · client graph 边界 |
| 源码 | admin/tests/unit/sentry-scrub.spec.ts | 上报脱敏 |
| 部署态 | scripts/privacy-verify.sh P0–P13 | 全库隐私分桶(新表必须被分类)· 服务专用表零 policy · 第 11 张用户表 · 只写不读的遥测 · anon 可执行的 definer 函数基线 · Storage 桶与前缀隔离 · 生产站点的门 / 头 / nonce / 下发字节 / 公开端点字段集 |
| 别处 | sync-verify.sh N4/N5 | 10 张同步表的 RLS 与 auth.uid() = user_id、无 NULL user_id |
| 别处 | ops-verify.sh M2 | anon 读不到 5 张 ops_ 表、调不动 5 个 ops 函数 |
| 别处 | sync-consistency.md S8/S9/S16 | 桌面端登出隔离 · 换用户清理 · 快照上传归属 |
新增的守卫全部做过注入式反向验证(2026-08-27,见 §2):源码层 7 条各注入一处真实缺陷、 部署态 6 条各篡改一处期望值,全部变红,还原后 git diff 零残留。
其中两条守卫在建立当天就抓到了自己是瞎子:
P12的阳性对照连续两次判红 —— 第一次因为只扫文档 HTML 不扫 JS chunk(anon key 在 chunk 里), 第二次因为用贪婪sed抽<script src>,整页压成一行时只捞得到最后一个。 没有阳性对照的话,这条「没找到 service_role」会一直绿着,而它当时几乎什么都没扫。privacy-invariants的 client-graph 扫描首版逐行匹配,把import type {\n …\n} from "@/lib/queries"这种多行写法全部误判成违规(假红)。 假红与假绿一样要修:一道会对正确代码报警的闸门,很快就没人看了。
2. 判断层逐格判据
2.1 门与锁
| # | 问句 | 假绿风险 |
|---|---|---|
| A1 | 门的 matcher 现在还覆盖得到 Server Action 的 POST 吗? | 门按 URL 前缀拦,而 action 的 POST 只是恰好打在 /admin/** 上。换个路由布局这个「恰好」就没了,而门的断言仍然全绿(它测的是页面路径)。锁(requireAdmin())是为这一刻准备的 —— 别因为「门已经挡住了」就觉得锁多余 |
| A2 | 验签用的是 getUser() 还是 getSession()? | getSession() 只解本地 cookie 的内容 —— 那是攻击者可以随便捏造的字节。换过去之后一切照常工作,只是任何人都能伪造身份进后台 |
| A3 | 配置缺失(env 没配 / Supabase 不可达)时,门是 fail-open 还是 fail-closed? | 🔒 当前 fail-closed(无法验证任何人 → 一律当匿名)。放行才是危险方向:那等于配置写错就自动变成公开后台。⚠️ 这条本地测不出来 —— 本地永远有 env |
| A4 | Preview / 分支部署那套环境变量是什么姿态? | 生产验绿不代表 preview 也绿。preview 若配了更宽的 ADMIN_EMAILS("临时方便测试"),它同样公网可达且同样持 service_role。本仓无法验证,要去 Vercel 控制台逐环境看 |
| A5 | AuthGuard 有没有被当成安全边界在用? | 它不是边界,只是「session 过期就跳走」的交互层。2026-08-14 的缺口正是「以为它在挡」。复发信号:有人给它加回 adminEmails prop,或新页面靠它而不是靠门 |
| A6 | 鉴权失败的响应里,能不能区分「token 无效」和「不在白名单」? | 区分开就是账号探测器。当前统一抛「无权限」 |
2.2 数据面(红线 #2)
| # | 问句 | 假绿风险 |
|---|---|---|
| B1 | 新加的查询读了用户表的哪些列,是显式批准过的吗? | ✅ 已下沉成断言(允许清单)。这里要问的是清单本身:批准某列时想清楚它如何保持聚合了吗?⚠️ 判据刻意做成允许清单而非禁止清单 —— 禁止清单会静默过期,新加的列天生不在里面,于是自动获得通行证,而加列的人不会想起 admin 这边还有一份账 |
| B2 | top-N 热门词里,会不会出现只有一个人学过的词? | 聚合的形式对,k-匿名性未必。计数为 1 的词进了榜 = 那一行其实只描述一个人。当前靠「按 count 倒序取 top-N」自然规避,但那是巧合不是约束:换成「最近新增的词」就立刻破。机械层管不了这个,只能人看 |
| B3 | 有没有任何逐行用户数据被当 prop 传给 Client Component? | 传下去就进 RSC flight payload。user_id 与内容列不共选(已断言)挡住了最典型的一种,挡不住「服务端算完再把中间结果整个传下去」 |
| B4 | admin 读的表,是不是仍然只有那两张? | 断言会在读第三张表时红。要人判的是:那次扩张该不该批 |
2.3 密钥与边界
| # | 问句 | 假绿风险 |
|---|---|---|
| C1 | 密钥扫描的阳性对照还在、还能命中吗? | 🔴 否定断言最危险的失效不是误判,而是根本没扫到东西 —— 会安静全绿。本域已实测两次(见 §1)。任何时候改了扫描口径(换页面、换抓取方式、换构建产物形式),先问阳性对照有没有跟着动 |
| C2 | 扫 github_pat_* / sntry[su]_* 形状的那几条,在当前环境里有可能命中吗? | 本地与 CI 都不注入这两个 token,正则找的是一个不可能出现的字符串 —— 那是空断言,属防御纵深而非有效检查。变量名那条是能命中的。别把它记成「验过了」 |
| C3 | 新增持 token 的外部调用时,红线 #7 要求的三处都跟了吗? | 三处分别是脱敏 pattern / 单测 / 字节扫描。漏了脱敏那处的后果是:token 被拼进异常 message 就直接进 Sentry,且不会报错 |
| C4 | "use client" 文件对服务端模块的导入,还都是 import type 吗? | ✅ 已下沉成断言。⚠️ 但当前没有 server-only 包做构建期硬阻断(见 K2)—— 断言与字节扫描都是「事后发现」,不是「根本编不过」 |
| C5 | anon key 出现在浏览器里,是不是仍然只靠 RLS 兜底? | 是设计如此。但要记得它是公开值(随桌面端二进制分发)—— 任何「知道 key 才能调」的推理都是错的,见 K1 |
2.4 Supabase 侧姿态
| # | 问句 | 假绿风险 |
|---|---|---|
| D1 | 库里有没有没人做过隐私决定的表? | ✅ 已下沉成断言(P0 全库分桶)。这是本专题覆盖面的总闸:下面每条检查都只看它认识的表,认不出的会被安静跳过。所以真正的问题不是「某张表配错没」,是「有没有人想过它该不该公开」 |
| D2 | 服务专用表的判据是「零 policy」还是「policy 写得对」? | 必须是零。这类表的正确姿态是任何前端角色都碰不到,service_role 靠 BYPASSRLS 走后门。有人为调试加一条 using (true) 就全网可读,界面上什么都不会变 |
| D3 | usage_events 有没有长出 SELECT policy? | 它是唯一允许 anon 写的表。一条 SELECT policy = 全量遥测流(install_id + props)可被逐条拉走。加了忘删不会有任何症状 |
| D4 | definer 函数对 anon 的 EXECUTE 授权,是不是仍然只有基线里那些? | 🔴 definer 以属主权限运行、绕过全部 RLS。对 anon 开一个就是给公网开一个绕过 RLS 的读口。⚠️ 判据是集合相等不是「集合为空」—— 原因见 K1 与下方「恒红」那条 |
| D5 | 快照桶还是 private 吗?四条 policy 的前缀判据还在吗? | 🔒 这是用户正文(网页/EPUB 快照 HTML)的唯一屏障。public 从 false 翻成 true 时,桶里每个对象凭 URL 直取 —— 没有报错、没有界面变化、桌面端照常工作 |
| D6 | RLS 之外,anon 的表级 GRANT 有被收紧过吗? | 没有,也不打算 —— Supabase 的模型就是「GRANT 全开 + RLS 兜底」。记住这一点的意义是:任何一次 DISABLE ROW LEVEL SECURITY(哪怕只为排查五分钟)都会让那张表在那五分钟里全网可读且可写 |
2.5 公开面
| # | 问句 | 假绿风险 |
|---|---|---|
| E1 | /api/health 回的字段集合还是原来那些吗? | ✅ 已下沉成断言。它是无鉴权端点,每个字段都是情报。当前刻意回 commit SHA(发布链路核对需要)—— 那是权衡过的,不是漏的 |
| E2 | 新增的 app/api/** 路由,谁在管它的鉴权? | ✅ 已下沉成断言。门只覆盖 /admin/**,app/api/ 下新建的路由天生公开且不会有任何东西提醒你 |
| E3 | 生产的 CSP 里 style-src 还带着 'unsafe-inline' 吗? | 带着,是 Tailwind 的既定代价。这条本身不算缺陷,但它意味着 CSP 对样式注入没有防护 —— 别拿「有 CSP」当成全面防护的证据 |
| E4 | 两次请求的 nonce 还不一样吗? | ✅ 已下沉成断言。相同 = 那条路由被静态化了(admin 红线 #3),后果是页面 hydrate 不了而 HTTP 仍 200 —— 白屏或「点什么都没反应」,头部断言抓不到 |
2.6 文档与真相源
| # | 问句 |
|---|---|
| F1 | admin/CLAUDE.md §3 的红线与代码实现一致吗?(⚠️ 当前该节没有 #8 —— 编号从 7 跳到 9,见 K5) |
| F2 | supabase/sql/*.sql 里写的授权意图,与库里 pg_proc / pg_policies 的实际状态一致吗?(K1 就是这条不一致的实例) |
| F3 | 本清单 §1 那张「已覆盖」表里的断言,是不是都还在、都还能红? |
3. 反向验证配方
只验通过路径 = 没验。 下面全部不依赖「记得改回来」(trap 兜底 + git diff --quiet 复核)。
3.1 源码层:注入 → 必须红 → 还原 → 复核零残留
七种值得常备的注入(2026-08-27 全部实测变红):
| 注入 | 该红的守卫 |
|---|---|
新增一个读 user_page_annotations.source_text 的查询 | 允许清单 |
把某处 select("user_id") 改成 select("user_id, word") | 归属共选 |
新建一个不带 requireAdmin() 的 app/api/**/route.ts | api 路由归属 |
lib/auth.ts 改成 process.env[K] | env 字面量访问 |
lib/auth.ts 顶层 import { cookies } from "next/headers" | edge runtime 约束 |
客户端组件的 import type 去掉 type | client graph 边界 |
新建一个 dashboard 页但不加进 DASHBOARD_PATHS | 页面清单对账 |
⚠️ 注入必须能通过编译。 首次做「允许清单」那条时,把 select("source_type") 换成 select("source_ref"),下游 .map(r => r.source_type) 类型立刻不过 → next build 挂 → Playwright 的 webServer 起不来 → 测试根本没跑。那既不是红也不是绿,是没验, 而输出里看起来只是一条报错。改成「追加一个自洽的新函数」才真正跑起来 —— 那也更像真实缺陷的形状。
3.2 部署态:篡改期望值 → 必须红
证明每条断言比的是活数据而不是写死的通过值。已实测五条:从分桶里拿掉一张真实存在的表 (报「未分类」)· 往分桶里塞一张不存在的表(报「账已过期」)· 收紧 definer 基线(报漂移)· 把桶名改成不存在的(报桶不存在)· 篡改 /api/health 期望字段集(报字段变了)。
3.3 🔴 拆掉门本身(这一条最值钱)
前两类验的都是「断言会不会红」。这一条验的是断言看不看得见真实的鉴权失效。
把 proxy.ts 的 needsAuthGate 改成恒 false → pnpm build && pnpm start -p 3222
→ PRIVACY_VERIFY_BASE=http://127.0.0.1:3222 ./scripts/privacy-verify.sh2026-08-27 实测:P8 逐页报 HTTP 200(应 307),P9 三个绕过变体全部报出 响应体里出现 __next_f —— 后者正是「页面真的渲染了、数据已进 payload」的证据, 比只看状态码强一档。还原后 git diff 零残留。
⚠️ PRIVACY_VERIFY_BASE 只为这一步存在。设了它的那一轮会打印警告,因为它验的不是生产。
3.4 判据类不需要造生产故障
与 ops-monitoring.md §2.4 同法:抽成纯函数、成对写单测 (正向 = 故障输入变色;🔴 反向 = 把旧判据的表达式原样写进断言,钉住它当时会说 ok)。 本专题目前没有「灯该什么颜色」类判据,故未用到;ops 页的凭据/告警卡若将来搬进本域再说。
3.5 🔒 不要做的事
- 不要为了验证而临时放宽生产的任何一条 policy / GRANT / bucket 设置。 「改回原值」被忘掉的风险不为零,而这个域忘一次的代价是全部用户数据。 要验放宽会不会被发现,就在本地构建上放宽(§3.3),别动生产。
- 不要把真实密钥值写进任何断言、日志或结论。要证明某个值存在,证明它的 长度、形状或行为(本轮全程如此:只打印
anon key length: NNN、只判 JWT 的role声明)。
4. 已知未修 / 有意接受
不要当成新发现报上来,但每轮仍要独立确认现状(还在?变严重了?条件变了?)。
| # | 事情 | 为什么现在不修 | 什么条件下重新处理 |
|---|---|---|---|
| K1 | 🔴 get_user_count / get_registration_trend 对 anon 开放 EXECUTE。二者是 SECURITY DEFINER、读 auth.users,而 admin-analytics-rpcs.sql 首行写着「仅供 admin service_role 调用」—— 声明与部署态不符。2026-08-27 实测:带公开 anon key 直打 PostgREST,get_user_count 返回 200 + 注册总数,get_registration_trend 返回 200 + 逐日趋势 | 泄漏的是业务指标(注册数与趋势),不是用户 PII —— 没有邮箱、没有 user_id、没有学习内容。级别够不上停下手里的事去改,但够得上记账:它是「DDL 注释里的意图从未被机械核对过」的实例 | 修法是一行 revoke execute … from anon, authenticated;(bump_quota 已是这个写法,可照抄)。修完必须同步收紧 privacy-verify.sh 的 P5 基线,否则那条会因为「集合不再等于基线」而变红。已进 backlog |
| K2 | 没有 server-only 包做构建期硬阻断。客户端误引服务端模块,当前只能被单测(源码意图)和 e2e 字节扫描(终局证据)事后发现 | 两道事后网都在,且都做过反向验证;加包要碰依赖与所有服务端模块的头部 | 下次动 lib/supabase-server.ts 时顺手加。或某次真的漏进 bundle 时 —— 那时它就不再是「预防」而是「复发防止」 |
| K3 | usage_events 允许 anon 无鉴权 INSERT 且 with check (true) | 客户端遥测的设计如此(桌面端匿名上报,没有 session 可用)。读侧是关死的(P4 双向断言) | 这不是隐私缺口,是滥用面:任何人可无限灌行。出现异常增长、或该表开始进成本账时处理。届时的方向是配额/去重,不是关掉写入 |
| K4 | recommended_* 上那几条 Service write policy 是装饰性的 | service_role 的 rolbypassrls = t(实测),RLS 对它整个不生效 —— 那些 policy 一次都没起过作用 | 不必删(无害)。但要记住这个事实:任何「靠 policy 限制 admin 能做什么」的推理都是错的。admin 的边界只有门与锁 |
| K5 | admin/CLAUDE.md §3 的红线编号缺 #8(7 → 9) | 可能是删过一条,也可能是当初写漏。红线是唯一真相源,一个洞会让人怀疑自己漏读了一条 | 下次编辑该节时确认:是补一条回去,还是重排编号并在 CHANGELOG 留一句 |
| K7 | ✅ 已解决(2026-08-27 当日):db.<ref>.supabase.co 是 IPv6-only(只有 AAAA、无 A 记录),本机无 IPv6 出口时 getaddrinfo 直接失败 —— 同一次会话里先能连、后连不上。已把 Keychain lampio-supabase-db 换成 pooler(aws-1-ap-northeast-2.pooler.supabase.com:5432,session mode,IPv4 可达),一处改动,5 个脚本一起受益(原直连串备份在 service lampio-supabase-db-direct,可回滚) | 切换前逐项验过 pooler 支持全部用法:pg_policies / storage.* / pg_proc / cron.job / 以及 pg_dump -Fc --schema=public(备份同形)。切换后四个只读脚本全绿,backup-supabase.sh 真跑一次成功 | 🔴 脚本侧的处置要留着:连不上一律 SKIP 而非 FAIL。那条不是为这次网络问题写的权宜,是「取不到 ≠ 值不对」的一般纪律 —— 换了 pooler 也仍会有连不上的时候。别因为「现在稳了」就把它去掉 |
| K6 | Preview / 分支部署的 env 姿态未验证 | 本仓看不到 Vercel 的逐环境变量,privacy-verify.sh 只打生产别名 | 下次开 preview 分支时;或接入 Vercel API 后把它并进 P8。在此之前,A4 那一格每轮都要人工确认一次,不能跳 |
⚠️ 这张表本身也会腐烂。 每轮验收后更新它 —— 一条「已知未修」若其实已被修, 会让下一轮跳过一次本该做的检查,那是最贵的一种过期。
5. 验收台账
每轮一行。不新建报告文档 —— 产物按 README.md 的规矩拆散。
| 日期 | 基线 | 机械层 | 判断层结论 | 留痕 |
|---|---|---|---|---|
| 2026-08-14 → 08-15 | 0.1.0-dev.10 | 尚无 | 开发中自曝「鉴权只在客户端」:curl /admin/observability 无凭据取到运营数据。修为门 + 锁两层 | CHANGELOG · launch-todo §C5 |
| 2026-08-27(补二) | 0.1.0-dev.10 | PASS=16 FAIL=0 SKIP=0 | K7 解决:Keychain 连接串换 pooler(IPv4 可达),四个只读脚本全绿(ops-verify 的 M4 也从 SKIP 恢复),backup-supabase.sh 端到端真跑一次成功 —— 且首次以非零数据走通 storage 备份路径(此前每次都是「落盘 0 个对象 ✅」,那条路径从没被真实数据验过)。顺带澄清两个误判:备份是周任务(周日 03:30)不是日任务,「48 小时前」正常;清点到 0 个对象 当时也是对的(那 6 个快照建于 08-26,晚于上次备份) | CHANGELOG;K7 |
| 2026-08-27(补) | 0.1.0-dev.10 | PASS=7 FAIL=0 SKIP=8 | 脚本自身的一处真缺陷,由一次 DNS 抖动当场暴露:psq() 失败回显空串,而 P1/P3/P6 把空串读成「这张表在库里不存在」→ 一次网络抖动报出 10 条 FAIL,逐条宣称服务专用表和快照桶不存在。这正是 README「取不到 ≠ 值不对」那条,也是「因与所守之物无关的原因而常红」的教科书形状。已修:连通性活体探针(3 次重试)+ 所有依赖库的检查降级为 SKIP + 「查不到行数」与「0 行」分开。根因是 K7(IPv6-only 主机) | 本目录;K7 + backlog |
| 2026-08-27 | 0.1.0-dev.10 | PASS=16 FAIL=0 SKIP=0(首次) | 建成本专题。新增部署态脚本(P0–P13)+ 源码层单测(红线 #2 允许清单 / api 路由归属 / edge 约束 / client graph)+ 消灭 auth-gate 里手抄的页面清单。13 条新守卫全部注入验证过鉴别力,其中拆门那次实测 P8/P9 逐页变红。新发现 K1(definer 函数对 anon 开放,已实测确认)+ K2 / K5;K3 / K4 / K6 记为有意接受或无法在本仓验证 | 本目录;K1 进 backlog |