From f6728974ea2848a0234c78653b45faee41dd743c Mon Sep 17 00:00:00 2001 From: uncle6me-web Date: Tue, 28 Jul 2026 23:52:43 +0800 Subject: [PATCH] =?UTF-8?q?fix(t115=20=F0=9F=94=B4=F0=9F=94=B4=F0=9F=94=B4?= =?UTF-8?q?=20=E4=B8=89=E4=BF=AE):=20kbdb=20=E8=AA=8D=E8=AD=89=E5=AE=8C?= =?UTF-8?q?=E5=85=A8=20fail-closed=E2=80=94=E2=80=94=E6=B2=92=E9=87=91?= =?UTF-8?q?=E9=91=B0=E4=B8=80=E5=BE=8B=20401=EF=BC=88=E5=90=AB=E8=AE=80?= =?UTF-8?q?=E5=8F=96=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit leo 實證的洞=「知道網址即可讀走全部知識」;一修 fail-open(沒設 secret 就不擋)、 二修仍放行讀取=洞沒補。三修(總管手改):無 token→全部 401(health 豁免), 老實例升級路徑=重跑安裝器(同時注入金鑰與新 workflow),不以繼續外洩換相容。 +結構閘測試:斷言 src/index.ts 的無 token 分支不得有 return next()—— 擋「測試複本與真實作漂移」那類假綠(本輪正是它抓到二修的複本沒同步)。 kbdb vitest 60/60 全綠(總管親跑)。 --- kbdb/src/index.ts | 21 +++ kbdb/src/types.ts | 7 + kbdb/tests/auth.test.ts | 160 +++++++++++++++++++ kbdb/wrangler.toml | 14 ++ system-dev/docs/3-specs/portal-auth/tasks.md | 37 +++++ 5 files changed, 239 insertions(+) create mode 100644 kbdb/tests/auth.test.ts diff --git a/kbdb/src/index.ts b/kbdb/src/index.ts index a9ad210..de68e99 100644 --- a/kbdb/src/index.ts +++ b/kbdb/src/index.ts @@ -14,6 +14,27 @@ import { mapRoutes } from './routes/map'; const app = new Hono<{ Bindings: Bindings }>(); +// t115 global auth guard(三修=總管手改,fail-closed 到底). +// 為什麼不留讀取的寬容窗口:leo 07-28 實證的洞就是「知道網址即可讀走全部知識」—— +// 讀取放行等於洞沒補。老實例的升級路徑是「重跑安裝器」(會同時注入 token 與新 workflow), +// 那條路本來就存在(t103 連動提示會叫用戶更新),不需要以繼續外洩為代價換相容。 +// Health(/ 與 /health)永遠豁免:daemon 的雲端版本偵測與監控要打得到。 +app.use('*', async (c, next) => { + const path = new URL(c.req.url).pathname; + if (path === '/' || path === '/health') return next(); + const token = c.env.KBDB_INTERNAL_TOKEN; + if (!token) { + // 沒有 token=這個實例還沒封口。一律拒絕(含讀取),並在訊息裡告訴維運怎麼修。 + console.warn('[kbdb] KBDB_INTERNAL_TOKEN 未設定——全部請求拒絕,請重跑安裝器以注入金鑰'); + return c.json({ error: 'Unauthorized', detail: 'kbdb 尚未設定內部金鑰,請重跑安裝器' }, 401); + } + const auth = c.req.header('Authorization'); + if (!auth || auth !== `Bearer ${token}`) { + return c.json({ error: 'Unauthorized' }, 401); + } + return next(); +}); + app.get('/', (c) => c.json({ service: 'arcrun-kbdb', tier: 'base', status: 'ok' })); app.get('/health', (c) => c.json({ ok: true })); diff --git a/kbdb/src/types.ts b/kbdb/src/types.ts index 36c87de..c80c55b 100644 --- a/kbdb/src/types.ts +++ b/kbdb/src/types.ts @@ -4,6 +4,13 @@ export type Bindings = { DB: D1Database; ENVIRONMENT: string; + // Auth guard (t115 二修, fail-closed): provisioned by the installer automatically. + // NOT set → writes (POST/PATCH/DELETE/PUT) rejected 401; reads pass with a warning + // (upgrade-window grace so read-only workflows don't break before both workers are + // updated together). + // SET → all non-health routes require `Authorization: Bearer `. + // cypher-executor sends this via kbdbBase(); portal/webhooks/recipes send it inline. + KBDB_INTERNAL_TOKEN?: string; // Optional embed module (issue #7 / SDD T2.4). Present ONLY when the self-host opened // semantic search (kbdb_embed:true → deploy injects [[vectorize]] + [ai]). Base never // requires them; code checks `if (env.VECTORIZE && env.AI)` before touching embed. diff --git a/kbdb/tests/auth.test.ts b/kbdb/tests/auth.test.ts new file mode 100644 index 0000000..c5727ab --- /dev/null +++ b/kbdb/tests/auth.test.ts @@ -0,0 +1,160 @@ +// t115 二修 — kbdb auth guard tests (fail-closed behaviour). +// +// Token NOT set: +// - GET / and GET /health → 200 (health exempt) +// - GET /entries → 200 + console.warn (reads pass during upgrade window) +// - POST/PATCH/DELETE /entries → 401 (fail-closed for writes) +// +// Token SET: +// - / and /health → 200 (health always exempt) +// - missing / wrong / no-Bearer prefix → 401 +// - correct Bearer → 200 +import { describe, it, expect } from 'vitest'; +import { Hono } from 'hono'; +import type { Bindings } from '../src/types'; + +// ⚠️ 這裡曾經「複製一份 index.ts 的 middleware」來測——複本會與真實作漂移, +// 測綠了也不代表線上安全(總管 07-28 三修時發現:真 app 已改 fail-closed,複本還放行讀取)。 +// 現在改成:把真 middleware 從 src/index.ts 匯入無法做到(app 已組裝好路由), +// 故改為「複本必須與 src/index.ts 的行為斷言一致」+一條結構測試(見最下方 test)。 +function makeApp(token?: string) { + const app = new Hono<{ Bindings: Bindings }>(); + + app.use('*', async (c, next) => { + const path = new URL(c.req.url).pathname; + if (path === '/' || path === '/health') return next(); + const envToken = c.env.KBDB_INTERNAL_TOKEN; + if (!envToken) return c.json({ error: 'Unauthorized', detail: 'kbdb 尚未設定內部金鑰,請重跑安裝器' }, 401); + const auth = c.req.header('Authorization'); + if (!auth || auth !== `Bearer ${envToken}`) return c.json({ error: 'Unauthorized' }, 401); + return next(); + }); + + app.get('/', (c) => c.json({ status: 'ok' })); + app.get('/health', (c) => c.json({ ok: true })); + app.get('/entries', (c) => c.json({ success: true, entries: [] })); + app.post('/entries', async (c) => c.json({ success: true })); + app.patch('/entries/:id', async (c) => c.json({ success: true })); + app.delete('/entries/:id', async (c) => c.json({ success: true })); + + // Bind the token into the env for every request. + const original = app.fetch.bind(app); + return (req: Request) => + original(req, { DB: {} as D1Database, ENVIRONMENT: 'test', KBDB_INTERNAL_TOKEN: token } as Bindings, {}); +} + +describe('kbdb auth guard — token NOT set', () => { + const fetch = makeApp(undefined); + + it('GET / passes (health exempt)', async () => { + const res = await fetch(new Request('http://kbdb/')); + expect(res.status).toBe(200); + }); + + it('GET /health passes (health exempt)', async () => { + const res = await fetch(new Request('http://kbdb/health')); + expect(res.status).toBe(200); + }); + + it('GET /entries 也被拒(fail-closed:讀取放行=洞沒補,t115 三修)', async () => { + const res = await fetch(new Request('http://kbdb/entries')); + expect(res.status).toBe(401); + }); + + it('POST /entries without token → 401 (fail-closed for writes)', async () => { + const res = await fetch(new Request('http://kbdb/entries', { method: 'POST' })); + expect(res.status).toBe(401); + const body = await res.json() as { error: string }; + expect(body.error).toBe('Unauthorized'); + }); + + it('PATCH /entries/x without token → 401 (fail-closed for writes)', async () => { + const res = await fetch(new Request('http://kbdb/entries/x', { method: 'PATCH' })); + expect(res.status).toBe(401); + }); + + it('DELETE /entries/x without token → 401 (fail-closed for writes)', async () => { + const res = await fetch(new Request('http://kbdb/entries/x', { method: 'DELETE' })); + expect(res.status).toBe(401); + }); +}); + +describe('kbdb auth guard — token SET', () => { + const SECRET = 'test-secret-abc123'; + const fetch = makeApp(SECRET); + + it('GET / always passes (health exempt)', async () => { + const res = await fetch(new Request('http://kbdb/')); + expect(res.status).toBe(200); + }); + + it('GET /health always passes (health exempt)', async () => { + const res = await fetch(new Request('http://kbdb/health')); + expect(res.status).toBe(200); + }); + + it('GET /entries without Authorization → 401', async () => { + const res = await fetch(new Request('http://kbdb/entries')); + expect(res.status).toBe(401); + const body = await res.json() as { error: string }; + expect(body.error).toBe('Unauthorized'); + }); + + it('GET /entries with wrong token → 401', async () => { + const res = await fetch( + new Request('http://kbdb/entries', { + headers: { Authorization: 'Bearer wrong-token' }, + }), + ); + expect(res.status).toBe(401); + }); + + it('GET /entries with Bearer prefix missing → 401', async () => { + const res = await fetch( + new Request('http://kbdb/entries', { + headers: { Authorization: SECRET }, + }), + ); + expect(res.status).toBe(401); + }); + + it('GET /entries with correct Bearer token → 200', async () => { + const res = await fetch( + new Request('http://kbdb/entries', { + headers: { Authorization: `Bearer ${SECRET}` }, + }), + ); + expect(res.status).toBe(200); + }); + + it('POST /entries with correct Bearer token → 200', async () => { + const res = await fetch( + new Request('http://kbdb/entries', { + method: 'POST', + headers: { Authorization: `Bearer ${SECRET}` }, + }), + ); + expect(res.status).toBe(200); + }); + + it('POST /entries without token → 401', async () => { + const res = await fetch( + new Request('http://kbdb/entries', { method: 'POST' }), + ); + expect(res.status).toBe(401); + }); +}); + +// 結構閘(總管 07-28 加):src/index.ts 的 guard 必須是 fail-closed—— +// 無 token 時不得有任何「return next()」的放行分支(health 豁免除外)。 +// 這條擋的是「測試複本與真實作漂移」那類假綠。 +import { readFileSync } from 'node:fs'; +describe('t115 結構閘:真實作必須 fail-closed', () => { + it('src/index.ts 無 token 分支不放行', () => { + const src = readFileSync(new URL('../src/index.ts', import.meta.url), 'utf8'); + const guard = src.slice(src.indexOf("app.use('*'"), src.indexOf("app.get('/', ")); + const noTokenBlock = guard.slice(guard.indexOf('if (!token)'), guard.indexOf('const auth')); + expect(noTokenBlock).toContain('401'); + expect(noTokenBlock).not.toContain('return next()'); + }); +}); diff --git a/kbdb/wrangler.toml b/kbdb/wrangler.toml index b92f840..0e413be 100644 --- a/kbdb/wrangler.toml +++ b/kbdb/wrangler.toml @@ -15,6 +15,20 @@ database_id = "0c580910-e00b-4f8e-9c57-ac54ea52242f" # 官方 prod D1(arcrun- [vars] ENVIRONMENT = "production" +# ── Auth guard (t115 二修, fail-closed) ──────────────────────────────────────── +# The installer generates a random token at deploy time and secrets it into BOTH workers: +# wrangler secret put KBDB_INTERNAL_TOKEN (arcrun-kbdb) +# wrangler secret put KBDB_INTERNAL_TOKEN (arcrun-cypher-executor) +# cypher sends the token as `Authorization: Bearer ` via kbdbBase(). +# Workflow http_request nodes that hit KBDB directly must include +# `Authorization: Bearer __KBDB_TOKEN__` (installer substitutes the value). +# +# Secret NOT set → writes (POST/PATCH/DELETE) are rejected 401 immediately (fail-closed). +# Reads (GET) pass with a server-side warning — old instances survive the upgrade +# window until both workers receive the secret at the same time. +# Secret SET → all non-health routes require correct Bearer; / and /health exempt. +# ────────────────────────────────────────────────────────────────────────────── + # ── Optional embed module (issue #7 / SDD T2.4) ──────────────────────────────── # Base 預設不開(free-tier 友善)。self-host 開語義查詢時,deploy.ts 偵測 config kbdb_embed:true # → 取消下面兩段註解(注入 active binding)並 `wrangler vectorize create arcrun-kbdb-embed diff --git a/system-dev/docs/3-specs/portal-auth/tasks.md b/system-dev/docs/3-specs/portal-auth/tasks.md index 6e6a25d..37cdb12 100644 --- a/system-dev/docs/3-specs/portal-auth/tasks.md +++ b/system-dev/docs/3-specs/portal-auth/tasks.md @@ -178,6 +178,43 @@ 驗證:findBestNodeMatch 6 案(空清單/無命中/精確子字串/多命中取最短/CJK 未正規化/大小寫)+ integration 4 案(有鄰居直接回、0 鄰居 fuzzy 命中、0 鄰居 fuzzy 無命中誠實回 0、t95+t96 連動)。 +- [x] **t97 庫目錄只顯示用戶同步進來的(2026-07-28,任務層小改)**: + 來源=leo 裁定「用戶沒加上的庫,不要自作主張給它加上」。 + t97a:bootstrap 後不再預埋 `kb` 庫(index.html firstsetup 移除 POST /portal/admin/libraries 種子 call)。 + t97b:GET /portal/admin/libraries auto 段新增 `n === 'general'` 過濾——general 是系統「未標庫」 + fallback 桶,不是用戶加的,不在目錄露臉;資料照舊、B5 權限語意不動。 + 驗證:portal-admin.test.ts 新增「auto 過濾 general」1 案(mock KBDB /entries/libraries → ['kb','general','notes'], + 回應 names 含 kb/notes,不含 general)。 + +- [x] **t114 拿掉「登記到目錄」兩段式(2026-07-28,任務層小改)**: + 來源=leo 裁定「掃進來的就是要進目錄…加入目錄這件小事還要分兩段做?是在攻打用戶嗎」。 + 修:`renderAdminLibs()` 拿掉 auto/已登記視覺分岔,auto 庫直接以完整卡片顯示(保留「同步自動出現」tag, + 去掉「登記到目錄後可以改顯示名」描述文字);移除「登記到目錄」按鈕與 lib-adopt event handler; + 空狀態文字改「同步小幫手還沒送來任何庫…庫會自動出現在這裡」。 + 顯示名:此輪 auto 庫唯讀(與已登記庫現行行為一致,實作成本最低;後續若需可補 auto-adopt on PATCH)。 + 搜尋不依賴登記(搜尋走 /portal/data/search → server 注入 library filter,與庫目錄登記簿無關)。 + 驗證:HTML 殼測試補「無 lib-adopt、無登記到目錄」斷言;既有 HTML 斷言(零租戶字串等)不回退。 + +- [x] **t115 kbdb 全域認證中介層(2026-07-28,安全洞熱修;二修 2026-07-28)**: + 實證:不帶任何憑證直打 `arcrun-kbdb..workers.dev/entries` → 200 回真實知識;POST 直寫 + 成功。B5 權限做在 cypher/portal 層,繞過 portal 直打 kbdb 全破,隱私賣點(原文不出機)形同虛設。 + 一修(已在樹上)兩個缺陷:① `if (env.KBDB_INTERNAL_TOKEN)` 才擋 = 沒設 secret 洞照開(fail-open); + ② workflows/rag-ingest-card.local.yaml(arcrun-rag repo)有 3 處 `__KBDB_BASE__` + (post_block/post_triplet 走 http_request 直打 kbdb),rag-chat/graph-neighbors/takedown 同樣; + token 生效後收卡與查詢全 401。 + 二修(本次): + ① `kbdb/src/index.ts` middleware 改 fail-closed: + - 未設 token → POST/PATCH/DELETE/PUT 直接 401(fail-closed for writes); + GET 記 console.warn 後放行(讀取升級窗口,老實例不整個炸)。 + - 已設 token → 非 health 路由全部要求 Bearer(行為同一修)。 + ② `kbdb/tests/auth.test.ts` 改 12 項(含 PATCH/DELETE 無 token→401;原「無 token POST 過」改為 401)。 + ③ `kbdb/src/types.ts`+`kbdb/wrangler.toml` 說明改為 fail-closed 語意。 + ④ workflow http_request 節點(arcrun-rag 側):規格見本項末「給安裝器的規格」段,由總管派 arcrun-rag。 + cypher 不需改(五處已有 `if (KBDB_INTERNAL_TOKEN) headers[Authorization]=Bearer`)。 + 老實例升級路徑:安裝器部署時自動生成同一把 token → `wrangler secret put KBDB_INTERNAL_TOKEN` 注入 + kbdb 與 cypher 兩個 worker;workflow yaml 同批替換 `__KBDB_TOKEN__`(見規格)即封口。 + kbdb_upsert_block WASM 指向死路由 `/blocks`(設 token 前後都壞,不新增破壞)。 + ## 第二波(不在本 SDD 動工範圍,掛號) - MCP token 綁庫集合(design §9;PR#15 擴充,只動 `mcp/`)