
1. 从“文档变知识库问答”到前端明文 Key风险不在模型在浏览器把文档变成知识库问答的开源项目最近又在社区刷屏但安全审查视角下最先暴露的往往不是向量检索而是前端里一枚明文 LLM API Key在给知识库问答服务填 LLM API Key 和 API 地址时去 TaoToken 官网 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_contentkb_intro 拿 TaoToken KeyBase URL 用 https://taotoken.net/api。很多项目 README 只告诉你“填 API Key 即可”却没告诉你这个 Key 如果写进 Vite、Next.js 或 React 前端环境变量构建后就是公开字符串。本文按开源应用安全审查者视角把“文档变知识库问答”的接入拆成四个可复现产物前端密钥泄露检查表、服务端代理代码、问答请求脱敏样例以及 Claude Code / Codex / CC Switch 的本地配置边界。先说结论知识库问答服务可以开源文档导入可以公开向量检索可以放在浏览器附近但调用大模型的那把 Key 不能放在浏览器里。浏览器的 DevTools、Source Map、localStorage、IndexedDB、前端错误上报、Performance 面板甚至一张截图都可能把 Key 带出去。尤其当项目把 LLM 调用封装成fetch(https://taotoken.net/api/chat/completions, { headers: { Authorization: Bearer key } })时审查者只需要在 Network 面板里看一次请求头就能拿到完整鉴权信息。所以接入顺序要反过来先在 TaoToken 控制台创建 Key再把它放到服务端环境变量最后让前端只请求你自己的/api/qa。Base URL 统一写https://taotoken.net/apiKey 占位符统一用YOUR_API_KEY但真实值永远不进入前端构建产物。下面从检查表开始每一步都可以直接跟做。2. 前端密钥泄露检查表开源知识库问答上线前逐项过安全审查不是只搜sk-开头字符串。TaoToken 的 Key 形态以控制台实际显示为准所以检查表要覆盖“命名、位置、构建、运行时、传输、日志、历史”七类。建议在项目根目录执行一次再把结果贴进发布单。TaoToken 官网入口仍然放在这里备用https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_contentkb_checklist 。检查项危险信号正确处理前端环境变量名VITE_*_KEY、NEXT_PUBLIC_*_KEY、REACT_APP_*_KEY、PUBLIC_*_TOKEN只保留服务端变量如TAOTOKEN_API_KEY源码硬编码const key YOUR_API_KEY、Authorization: Bearer ...移到服务端前端只调本地代理构建产物dist/assets/*.js出现真实 Key 或https://taotoken.net/api直连重新构建前先改代码再轮换 Key浏览器存储localStorage.taotoken_key、sessionStorage.apiKey不存 Key会话凭证用 HttpOnly Cookie 或短期 token网络请求浏览器直接请求https://taotoken.net/api/*请求发到/api/qa由服务端转发日志与监控Sentry/Bugfink 把请求头、env、request body 全量上报配置beforeSend脱敏禁用 Header 采集Git 历史.env曾提交后期只删文件不轮换 Key用git filter-repo清理并立即轮换 KeyDocker 镜像前端镜像构建参数--build-arg TAOTOKEN_API_KEY...前端镜像不接收 Key后端运行时注入CDN/Source Map生产环境开放.map文件关闭 public source map 或上传到私有监控平台浏览器插件/代理插件读取页面请求代理记录明文 Header生产环境强制 HTTPS减少第三方脚本一个可复制的扫描脚本如下。它不保证覆盖所有情况但能快速发现最常见的前端泄露路径。注意脚本只在你本地或 CI 中执行不要对生产数据库、生产实例做未授权扫描。#!/usr/bin/env bash set -euo pipefail TARGET${1:-.} echo [1] 扫描前端环境变量命名 grep -RInE (VITE|NEXT_PUBLIC|REACT_APP|PUBLIC)_.*(KEY|TOKEN|SECRET|API) $TARGET \ --exclude-dirnode_modules --exclude-dir.git --exclude-dirdist --exclude-dirbuild || true echo [2] 扫描构建产物中的疑似 Key grep -RInE (sk-[A-Za-z0-9_-]{8,}|Bearer[[:space:]][A-Za-z0-9._-]{8,}|YOUR_API_KEY) \ $TARGET/dist $TARGET/build 2/dev/null || true echo [3] 扫描前端直连 LLM Base URL grep -RInE https?://[^ ]/(chat/completions|v1/messages|api/chat) $TARGET \ --exclude-dirnode_modules --exclude-dir.git || true echo [4] 扫描 .env 是否被跟踪 git -C $TARGET ls-files | grep -E (^|/)\.env(\.|$) || true如果第 1 步命中VITE_TAOTOKEN_API_KEY不要心存侥幸。Vite 在构建时会把import.meta.env.VITE_*替换为字面量最终 JS 文件里能看到完整值。Next.js 的NEXT_PUBLIC_*同理。React CRA 的REACT_APP_*也会进入 bundle。正确的前端环境变量只应该放公开配置例如VITE_API_BASE_URL/api而不是 Key。错误示例# 错误前端构建时会暴露 VITE_TAOTOKEN_API_KEYYOUR_API_KEY NEXT_PUBLIC_TAOTOKEN_API_KEYYOUR_API_KEY REACT_APP_TAOTOKEN_API_KEYYOUR_API_KEY正确示例# 服务端 .env仅后端进程或容器运行时可见 TAOTOKEN_API_KEYYOUR_API_KEY TAOTOKEN_BASE_URLhttps://taotoken.net/api TAOTOKEN_MODELyour-chat-model还要检查.gitignore.env .env.* !.env.example.env.example只放占位符TAOTOKEN_API_KEYYOUR_API_KEY TAOTOKEN_BASE_URLhttps://taotoken.net/api审查者还应关注前端错误监控。很多知识库问答项目会接入 Sentry默认可能采集request.headers和extra。如果服务端代理没有正确屏蔽Sentry 可能在后端异常里记录Authorization。因此脱敏不仅是日志问题也是监控 SDK 配置问题。3. 服务端代理代码让浏览器只打自己的 /api/qa真正安全的架构是浏览器只请求同源/api/qa服务端负责检索、拼上下文、调用 TaoToken、返回答案。TaoToken Key 放在服务端环境变量里。如果你还没有 Key去 TaoToken 官网 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_contentkb_proxy 创建Base URL 保持https://taotoken.net/api。下面是一个最小可运行的 Node.js Express 代理示例。它假设你的知识库问答项目已经有一个检索函数retrieveContext你可以把它替换成向量库、全文检索或数据库查询。注意SQL 和命令由读者本地执行不要让 MCP/Agent 直接连生产库。// server.js import express from express; import rateLimit from express-rate-limit; const app express(); app.use(express.json({ limit: 1mb })); app.use( rateLimit({ windowMs: 60 * 1000, max: 30, standardHeaders: true, legacyHeaders: false }) ); const TAOTOKEN_BASE_URL process.env.TAOTOKEN_BASE_URL || https://taotoken.net/api; const TAOTOKEN_API_KEY process.env.TAOTOKEN_API_KEY; const TAOTOKEN_MODEL process.env.TAOTOKEN_MODEL || your-chat-model; async function retrieveContext(docIds, question, topK 5) { // 替换为你的检索实现向量库、全文检索、数据库查询均可 // 返回数组每项包含 text 和 source return [ { text: 这里是命中的文档片段请替换为真实检索结果。, source: doc-1#chunk-3 } ]; } function buildCitations(context) { return context.map((item, index) ({ id: index 1, source: item.source })); } app.post(/api/qa, async (req, res) { if (!TAOTOKEN_API_KEY) { return res.status(500).json({ error: server_key_missing }); } const { question, docIds [], topK 5 } req.body || {}; if (typeof question ! string || question.trim().length 0) { return res.status(400).json({ error: question_required }); } if (question.length 2000) { return res.status(400).json({ error: question_too_long }); } const context await retrieveContext(docIds, question, topK); const contextText context .map((item, index) [${index 1}] ${item.text}) .join(\n\n); const upstreamPayload { model: TAOTOKEN_MODEL, messages: [ { role: system, content: 你是知识库问答助手。只依据给定资料回答资料不足时明确说明无法回答不要编造。 }, { role: user, content: 资料\n${contextText}\n\n问题${question} } ], temperature: 0.2, stream: false }; try { const upstream await fetch(${TAOTOKEN_BASE_URL}/chat/completions, { method: POST, headers: { Content-Type: application/json, Authorization: Bearer ${TAOTOKEN_API_KEY} }, body: JSON.stringify(upstreamPayload) }); if (!upstream.ok) { const text await upstream.text(); console.error( taotoken_upstream_error, upstream.status, text.slice(0, 200) ); return res.status(502).json({ error: upstream_error }); } const data await upstream.json(); const answer data?.choices?.[0]?.message?.content || ; res.json({ answer, citations: buildCitations(context) }); } catch (error) { console.error(taotoken_request_failed, error.message); res.status(502).json({ error: upstream_unreachable }); } }); app.listen(3000, () { console.log(kb api listening on http://localhost:3000); });启动时只注入服务端环境变量export TAOTOKEN_API_KEYYOUR_API_KEY export TAOTOKEN_BASE_URLhttps://taotoken.net/api export TAOTOKEN_MODELyour-chat-model node server.js如果你使用 Python FastAPI也可以这样写。关键是TAOTOKEN_API_KEY只存在于服务端。# main.py import os import httpx from fastapi import FastAPI, HTTPException from pydantic import BaseModel, Field app FastAPI() TAOTOKEN_BASE_URL os.getenv(TAOTOKEN_BASE_URL, https://taotoken.net/api) TAOTOKEN_API_KEY os.getenv(TAOTOKEN_API_KEY) TAOTOKEN_MODEL os.getenv(TAOTOKEN_MODEL, your-chat-model) class QARequest(BaseModel): question: str Field(min_length1, max_length2000) doc_ids: list[str] Field(default_factorylist) top_k: int Field(default5, ge1, le20) def retrieve_context(doc_ids: list[str], question: str, top_k: int): # 替换为你的检索实现 return [ { text: 命中的文档片段替换为真实检索结果。, source: doc-1#chunk-3, } ] app.post(/api/qa) async def qa(req: QARequest): if not TAOTOKEN_API_KEY: raise HTTPException(status_code500, detailserver_key_missing) context retrieve_context(req.doc_ids, req.question, req.top_k) context_text \n\n.join( f[{i 1}] {item[text]} for i, item in enumerate(context) ) payload { model: TAOTOKEN_MODEL, messages: [ { role: system, content: 你是知识库问答助手。只依据给定资料回答资料不足时明确说明无法回答。, }, { role: user, content: f资料\n{context_text}\n\n问题{req.question}, }, ], temperature: 0.2, stream: False, } headers { Content-Type: application/json, Authorization: fBearer {TAOTOKEN_API_KEY}, } async with httpx.AsyncClient(timeout60) as client: resp await client.post( f{TAOTOKEN_BASE_URL}/chat/completions, headersheaders, jsonpayload, ) if resp.status_code 400: raise HTTPException(status_code502, detailupstream_error) data resp.json() answer data.get(choices, [{}])[0].get(message, {}).get(content, ) return { answer: answer, citations: [ {id: i 1, source: item[source]} for i, item in enumerate(context) ], }Docker Compose 也要体现边界前端服务不拿 TaoToken Key后端服务才拿。services: kb-api: build: . environment: TAOTOKEN_API_KEY: ${TAOTOKEN_API_KEY} TAOTOKEN_BASE_URL: https://taotoken.net/api TAOTOKEN_MODEL: your-chat-model ports: - 3000:3000 kb-web: build: ./web environment: API_BASE_URL: /api ports: - 8080:80 depends_on: - kb-api如果前端项目原本是纯静态站点也可以把/api反代到kb-api。Nginx 示例server { listen 80; server_name kb.example.com; location / { root /usr/share/nginx/html; try_files $uri /index.html; } location /api/ { proxy_pass http://kb-api:3000/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }这样浏览器地址栏、Network 面板、前端 bundle 里都看不到 TaoToken Key只能看到你自己域名下的/api/qa。4. 问答请求脱敏样例日志、审计、导出三处别留原文把 Key 移到服务端只是第一步。知识库问答请求里经常包含用户上传的合同、工单、简历、财务数据。如果你把完整messages写进应用日志等于把知识库原文和用户问题二次落盘。安全审查要覆盖三处服务端运行日志、审计事件、调试导出。脱敏原则Key、Cookie、Authorization 永不记录完整值。手机号、邮箱、身份证号、银行卡号、订单号等按业务需要掩码。文档片段只记 hash、docId、chunkId、字符数和前若干字不记全文。用户问题可以记长度、语言、命中意图原文按需短期加密保存。调试开关默认关闭生产环境禁止DEBUG*。下面是一段可复用的 Python 脱敏函数import re import hashlib import json PATTERNS { phone: re.compile(r(?!\d)1[3-9]\d{9}(?!\d)), email: re.compile(r[A-Za-z0-9._%-][A-Za-z0-9.-]\.[A-Za-z]{2,}), idcard: re.compile(r(?!\d)\d{17}[\dXx](?!\d)), bankcard: re.compile(r(?!\d)\d{16,19}(?!\d)), api_key: re.compile(r(sk-[A-Za-z0-9_\-]{8,}|Bearer\s[A-Za-z0-9_\-\.]{8,}), re.I), } def mask_text(text: str) - str: if not text: return masked text for name, pattern in PATTERNS.items(): if name api_key: masked pattern.sub([REDACTED_API_KEY], masked) elif name phone: masked pattern.sub(lambda m: m.group(0)[:3] **** m.group(0)[-4:], masked) elif name email: masked pattern.sub([REDACTED_EMAIL], masked) elif name idcard: masked pattern.sub(lambda m: m.group(0)[:6] ******** m.group(0)[-4:], masked) else: masked pattern.sub([REDACTED_NUMBER], masked) return masked def short_hash(text: str) - str: return hashlib.sha256(text.encode(utf-8)).hexdigest()[:16] def safe_log_payload(payload: dict) - dict: clone json.loads(json.dumps(payload, ensure_asciiFalse)) messages clone.get(messages, []) safe_messages [] for msg in messages: content msg.get(content, ) safe_messages.append({ role: msg.get(role, unknown), content_preview: mask_text(content)[:120], content_hash: short_hash(content), content_length: len(content), }) clone[messages] safe_messages return clone调用示例payload { model: your-chat-model, messages: [ {role: system, content: 你是知识库问答助手。}, {role: user, content: 联系人邮箱 aexample.com电话 13800138000发票号 1234567890123456} ] } print(json.dumps(safe_log_payload(payload), ensure_asciiFalse, indent2))输出类似{ model: your-chat-model, messages: [ { role: system, content_preview: 你是知识库问答助手。, content_hash: 7f3c..., content_length: 11 }, { role: user, content_preview: 联系人邮箱 [REDACTED_EMAIL]电话 138****8000发票号 [REDACTED_NUMBER], content_hash: a91b..., content_length: 48 } ] }如果是审计事件不要直接存question原文。可以存{ event: kb.qa.requested, user_id: u_1024, kb_id: kb_finance, question_hash: a91b..., question_length: 48, doc_ids: [doc-1, doc-7], top_k: 5, model: your-chat-model, upstream: taotoken, base_url: https://taotoken.net/api, latency_ms: 842, status: ok }注意Authorization不应出现在任何审计字段。若必须记录供应商请求 ID记录 TaoToken 返回的 request id而不是 Key。若业务要求保存问答原文建议单独加密存储、设置 TTL、限制访问角色并在界面中明确告知用户。5. Claude Code、Codex、CC Switch 的配置边界本地开发与生产隔离安全审查不仅看生产服务也看你本地怎么连模型。很多团队用 Claude Code 辅助读代码用 Codex 写配置用 CC Switch 切供应商。这里最容易犯的错是把 Claude Code 的ANTHROPIC_*环境变量复制到 Codex 的config.toml或者把本地 Key 提交到仓库。Claude Code 使用settings.json或ANTHROPIC_*环境变量。示例{ env: { ANTHROPIC_BASE_URL: https://taotoken.net/api, ANTHROPIC_AUTH_TOKEN: YOUR_API_KEY, ANTHROPIC_MODEL: claude-sonnet-4-5 } }也可以放在 shell 环境变量中但不要提交到 Gitexport ANTHROPIC_BASE_URLhttps://taotoken.net/api export ANTHROPIC_AUTH_TOKENYOUR_API_KEY export ANTHROPIC_MODELclaude-sonnet-4-5Codex 使用config.toml不是ANTHROPIC_*。示例model gpt-5-codex model_provider taotoken [model_providers.taotoken] name TaoToken base_url https://taotoken.net/api env_key TAOTOKEN_API_KEY wire_api chat然后设置环境变量export TAOTOKEN_API_KEYYOUR_API_KEY再次强调不要把ANTHROPIC_BASE_URL、ANTHROPIC_AUTH_TOKEN写进 Codex 的config.toml。Claude Code 和 Codex 的协议、配置项、环境变量名不同混用会导致 401、404、模型不存在或流式解析失败。排查时先确认当前工具读的是哪套配置。CC Switch 的三件套可以理解为Provider 名称、Base URL、API Key。切换到 Claude Code 时检查settings.json中的ANTHROPIC_*切换到 Codex 时检查~/.codex/config.toml中的model_provider和model_providers.*。三件套建议这样填配置项Claude CodeCodex供应商名称taotokentaotokenBase URLhttps://taotoken.net/apihttps://taotoken.net/apiAPI Key 环境变量ANTHROPIC_AUTH_TOKENTAOTOKEN_API_KEY模型字段ANTHROPIC_MODELmodel配置入口settings.json~/.codex/config.toml本地开发还要注意不要把生产 Key 配到本地 Coding 工具。生产服务端 Key 和本地开发 Key 分开创建权限分开、额度分开、轮换分开。这样即使本地机器被入侵也不会直接影响线上知识库问答。需要创建独立 Key 时走 TaoToken 的 API Keys 页面需要查看 Claude Code 接入细节时走 Claude Code 文档。6. 上线验收与轮换把“没泄露”变成可回归的测试最后给出上线验收清单。安全不是一次性检查而是可回归测试。前端 bundle 扫描dist/、build/、.next/中不存在真实 Key、不存在直连https://taotoken.net/api的请求。Network 面板检查浏览器只请求/api/qa没有直接请求 TaoToken。服务端环境变量检查TAOTOKEN_API_KEY只存在于后端进程、容器 Secret 或密钥管理服务。日志检查Authorization、Cookie、完整messages不入日志。监控检查Sentry、OpenTelemetry、APM 不上报请求头。Docker 检查前端镜像构建参数不包含 Key。Git 检查.env未被跟踪历史提交无真实 Key。轮换预案Key 泄露后 10 分钟内可禁用旧 Key、创建新 Key、重启服务。权限最小化知识库问答服务只使用一个独立 Key不与其他生产应用共用。回归测试每次发版自动执行前端泄露扫描脚本。一个验收 curlcurl -sS http://localhost:3000/api/qa \ -H Content-Type: application/json \ -d {question:报销流程是什么,docIds:[doc-1],topK:3}预期返回{ answer: 根据资料报销流程是……, citations: [ {id: 1, source: doc-1#chunk-3} ] }如果返回中包含YOUR_API_KEY、Authorization、taotoken上游地址堆栈就说明错误处理泄露了内部信息。服务端异常只返回通用错误码把详细错误写进脱敏日志。Key 轮换步骤在 TaoToken 控制台创建新 Key。更新服务端 Secret 或环境变量。重启后端服务确认/api/qa正常。禁用旧 Key。检查旧 Key 最近调用日志确认无异常来源。如果旧 Key 曾出现在前端构建产物清理 CDN 缓存并重新发布前端。现在如果你正准备把一个开源知识库问答项目接到可用的大模型 API建议按这个顺序操作先打开模型对话页面验证模型连通性再确认 Coding Plan 是否适合你的开发用量然后创建独立 API Key最后把 Key 放进服务端环境变量Base URL 保持https://taotoken.net/api。需要跟做的入口如下模型对话https://taotoken.net/models/detail/chat?utm_sourcetaotoken_aicg_blog_endutm_contentkb_chatCoding Planhttps://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentkb_plan创建 API Keyhttps://taotoken.net/console/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentkb_keysClaude Code 文档https://taotoken.net/doc/ClaudeCodeAnthropic?utm_sourcetaotoken_aicg_blog_endutm_contentkb_claudecodeTaoToken 的 Key 别写进前端。把浏览器到模型的链路收回到服务端把脱敏做进日志把本地工具和生产密钥隔离知识库问答才能真正从“能跑”变成“敢上线”。