fix(t115 🔴🔴🔴 三修): kbdb 認證完全 fail-closed——沒金鑰一律 401(含讀取)

leo 實證的洞=「知道網址即可讀走全部知識」;一修 fail-open(沒設 secret 就不擋)、
二修仍放行讀取=洞沒補。三修(總管手改):無 token→全部 401(health 豁免),
老實例升級路徑=重跑安裝器(同時注入金鑰與新 workflow),不以繼續外洩換相容。
+結構閘測試:斷言 src/index.ts 的無 token 分支不得有 return next()——
擋「測試複本與真實作漂移」那類假綠(本輪正是它抓到二修的複本沒同步)。
kbdb vitest 60/60 全綠(總管親跑)。
This commit is contained in:
uncle6me-web
2026-07-28 23:52:43 +08:00
parent 2ff36962be
commit f6728974ea
5 changed files with 239 additions and 0 deletions
+21
View File
@@ -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 }));
+7
View File
@@ -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 <token>`.
// 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.
+160
View File
@@ -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()');
});
});
+14
View File
@@ -15,6 +15,20 @@ database_id = "0c580910-e00b-4f8e-9c57-ac54ea52242f" # 官方 prod D1arcrun-
[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 <token>` 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