Skip to content

专题验证 · 隐私与鉴权

常驻清单,不归档。判断层专用——机械层先跑完再看这里。 对象:admin/proxy.ts + admin/lib/auth.ts + 生产站点 admin.lampio.app + Supabase 的 RLS / 授权 / Storage 姿态 体裁与纪律见 README.md

bash
pnpm --dir admin test && ./scripts/privacy-verify.sh

0. 这个域为什么危险

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.tsservice_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/N510 张同步表的 RLS 与 auth.uid() = user_id、无 NULL user_id
别处ops-verify.sh M2anon 读不到 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
A4Preview / 分支部署那套环境变量是什么姿态?生产验绿不代表 preview 也绿。preview 若配了更宽的 ADMIN_EMAILS("临时方便测试"),它同样公网可达且同样持 service_role。本仓无法验证,要去 Vercel 控制台逐环境看
A5AuthGuard 有没有被当成安全边界在用?不是边界,只是「session 过期就跳走」的交互层。2026-08-14 的缺口正是「以为它在挡」。复发信号:有人给它加回 adminEmails prop,或新页面靠它而不是靠门
A6鉴权失败的响应里,能不能区分「token 无效」和「不在白名单」?区分开就是账号探测器。当前统一抛「无权限」

2.2 数据面(红线 #2)

#问句假绿风险
B1新加的查询读了用户表的哪些列,是显式批准过的吗?✅ 已下沉成断言(允许清单)。这里要问的是清单本身:批准某列时想清楚它如何保持聚合了吗?⚠️ 判据刻意做成允许清单而非禁止清单 —— 禁止清单会静默过期,新加的列天生不在里面,于是自动获得通行证,而加列的人不会想起 admin 这边还有一份账
B2top-N 热门词里,会不会出现只有一个人学过的词?聚合的形式对,k-匿名性未必。计数为 1 的词进了榜 = 那一行其实只描述一个人。当前靠「按 count 倒序取 top-N」自然规避,但那是巧合不是约束:换成「最近新增的词」就立刻破。机械层管不了这个,只能人看
B3有没有任何逐行用户数据被当 prop 传给 Client Component?传下去就进 RSC flight payload。user_id 与内容列不共选(已断言)挡住了最典型的一种,挡不住「服务端算完再把中间结果整个传下去」
B4admin 读的表,是不是仍然只有那两张?断言会在读第三张表时红。要人判的是:那次扩张该不该批

2.3 密钥与边界

#问句假绿风险
C1密钥扫描的阳性对照还在、还能命中吗?🔴 否定断言最危险的失效不是误判,而是根本没扫到东西 —— 会安静全绿。本域已实测两次(见 §1)。任何时候改了扫描口径(换页面、换抓取方式、换构建产物形式),先问阳性对照有没有跟着动
C2github_pat_* / sntry[su]_* 形状的那几条,在当前环境里有可能命中吗本地与 CI 都不注入这两个 token,正则找的是一个不可能出现的字符串 —— 那是空断言,属防御纵深而非有效检查。变量那条是能命中的。别把它记成「验过了」
C3新增持 token 的外部调用时,红线 #7 要求的三处都跟了吗?三处分别是脱敏 pattern / 单测 / 字节扫描。漏了脱敏那处的后果是:token 被拼进异常 message 就直接进 Sentry,且不会报错
C4"use client" 文件对服务端模块的导入,还都是 import type 吗?✅ 已下沉成断言。⚠️ 但当前没有 server-only 包做构建期硬阻断(见 K2)—— 断言与字节扫描都是「事后发现」,不是「根本编不过」
C5anon key 出现在浏览器里,是不是仍然只靠 RLS 兜底?是设计如此。但要记得它是公开值(随桌面端二进制分发)—— 任何「知道 key 才能调」的推理都是错的,见 K1

2.4 Supabase 侧姿态

#问句假绿风险
D1库里有没有没人做过隐私决定的表?✅ 已下沉成断言(P0 全库分桶)。这是本专题覆盖面的总闸:下面每条检查都只看它认识的表,认不出的会被安静跳过。所以真正的问题不是「某张表配错没」,是「有没有人想过它该不该公开」
D2服务专用表的判据是「零 policy」还是「policy 写得对」?必须是。这类表的正确姿态是任何前端角色都碰不到,service_role 靠 BYPASSRLS 走后门。有人为调试加一条 using (true) 就全网可读,界面上什么都不会变
D3usage_events 有没有长出 SELECT policy?它是唯一允许 anon 的表。一条 SELECT policy = 全量遥测流(install_id + props)可被逐条拉走。加了忘删不会有任何症状
D4definer 函数对 anon 的 EXECUTE 授权,是不是仍然只有基线里那些?🔴 definer 以属主权限运行、绕过全部 RLS。对 anon 开一个就是给公网开一个绕过 RLS 的读口。⚠️ 判据是集合相等不是「集合为空」—— 原因见 K1 与下方「恒红」那条
D5快照桶还是 private 吗?四条 policy 的前缀判据还在吗?🔒 这是用户正文(网页/EPUB 快照 HTML)的唯一屏障。public 从 false 翻成 true 时,桶里每个对象凭 URL 直取 —— 没有报错、没有界面变化、桌面端照常工作
D6RLS 之外,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 文档与真相源

#问句
F1admin/CLAUDE.md §3 的红线与代码实现一致吗?(⚠️ 当前该节没有 #8 —— 编号从 7 跳到 9,见 K5)
F2supabase/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.tsapi 路由归属
lib/auth.ts 改成 process.env[K]env 字面量访问
lib/auth.ts 顶层 import { cookies } from "next/headers"edge runtime 约束
客户端组件的 import type 去掉 typeclient 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.sh

2026-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 时 —— 那时它就不再是「预防」而是「复发防止」
K3usage_events 允许 anon 无鉴权 INSERT 且 with check (true)客户端遥测的设计如此(桌面端匿名上报,没有 session 可用)。读侧是关死的(P4 双向断言)这不是隐私缺口,是滥用面:任何人可无限灌行。出现异常增长、或该表开始进成本账时处理。届时的方向是配额/去重,不是关掉写入
K4recommended_* 上那几条 Service write policy 是装饰性的service_rolerolbypassrls = t(实测),RLS 对它整个不生效 —— 那些 policy 一次都没起过作用不必删(无害)。但要记住这个事实:任何「靠 policy 限制 admin 能做什么」的推理都是错的。admin 的边界只有门与锁
K5admin/CLAUDE.md §3 的红线编号缺 #8(7 → 9)可能是删过一条,也可能是当初写漏。红线是唯一真相源,一个洞会让人怀疑自己漏读了一条下次编辑该节时确认:是补一条回去,还是重排编号并在 CHANGELOG 留一句
K7已解决(2026-08-27 当日)db.<ref>.supabase.coIPv6-only(只有 AAAA、无 A 记录),本机无 IPv6 出口时 getaddrinfo 直接失败 —— 同一次会话里先能连、后连不上。已把 Keychain lampio-supabase-db 换成 pooleraws-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 也仍会有连不上的时候。别因为「现在稳了」就把它去掉
K6Preview / 分支部署的 env 姿态未验证本仓看不到 Vercel 的逐环境变量,privacy-verify.sh 只打生产别名下次开 preview 分支时;或接入 Vercel API 后把它并进 P8。在此之前,A4 那一格每轮都要人工确认一次,不能跳

⚠️ 这张表本身也会腐烂。 每轮验收后更新它 —— 一条「已知未修」若其实已被修, 会让下一轮跳过一次本该做的检查,那是最贵的一种过期。


5. 验收台账

每轮一行。不新建报告文档 —— 产物按 README.md 的规矩拆散。

日期基线机械层判断层结论留痕
2026-08-14 → 08-150.1.0-dev.10尚无开发中自曝「鉴权只在客户端」:curl /admin/observability 无凭据取到运营数据。修为门 + 锁两层CHANGELOG · launch-todo §C5
2026-08-27(补二)0.1.0-dev.10PASS=16 FAIL=0 SKIP=0K7 解决: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.10PASS=7 FAIL=0 SKIP=8脚本自身的一处真缺陷,由一次 DNS 抖动当场暴露psq() 失败回显空串,而 P1/P3/P6 把空串读成「这张表在库里不存在」→ 一次网络抖动报出 10 条 FAIL,逐条宣称服务专用表和快照桶不存在。这正是 README「取不到 ≠ 值不对」那条,也是「因与所守之物无关的原因而常红」的教科书形状。已修:连通性活体探针(3 次重试)+ 所有依赖库的检查降级为 SKIP + 「查不到行数」与「0 行」分开。根因是 K7(IPv6-only 主机)本目录;K7 + backlog
2026-08-270.1.0-dev.10PASS=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