webagent — Security
TL;DR:browser app 的 API key 只有兩個正確答案 — (A) 用戶自己提供且只存自己機器,(B) backend proxy 用戶完全不見 key。
.env不是萬靈丹 — Vite / Next.js client bundles 內的import.meta.env.*是 build 時 inline,跟硬寫一樣不安全。
四種 BYOK 模式(trade-off)
| 模式 | client 看得到 key? | 適合 |
|---|---|---|
| A. localStorage(用戶填) | 是(只在他自己機器) | 純本機 demo / 內部工具 / 用戶完全是自己 |
B. .env build-time inject |
是(任何用戶都看得到,被 bundle 進 JS) | ❌ 永遠不要用在 client-only app 的祕密 |
| C. Backend proxy | 否 | production SaaS(推薦) |
| D. OAuth + 短期 token | 短期 token,非長期 key | 用戶用 Google / OpenAI OAuth |
webagent 提供三種 LLMProvider 對應上述:
| Provider | 對應模式 | API key 在哪 |
|---|---|---|
OpenAIProvider({ apiKey }) |
A / C* | client(傳入時) |
GoogleProvider({ apiKey }) |
A / C* | client |
ProxyProvider({ endpoint }) |
C | 完全 server-side,client 不見 |
* 把 OpenAIProvider 包在 backend 也行,但更常見 host 自己寫 fetch wrapper。
為什麼 .env 對 client app 不安全
很多人以為「我把 key 放 .env 就安全」— 這是誤解。先分清楚:
| 環境 | .env 在哪 |
client 看得到? |
|---|---|---|
Node.js server(process.env) |
只在 server 機器 | 不會 ✅ |
Vite + 前綴 VITE_ |
build 時 inline 到 JS bundle | 會 ❌(DevTools 一打開就看到) |
Next.js + 前綴 NEXT_PUBLIC_ |
同上 | 會 ❌ |
| Next.js 無前綴 | 只 server component / API route 看得到 | 不會 ✅ |
| Vite 無前綴 | client 看不到,但 SSR / build script 用 | 不會 ✅ |
規則:
如果有「前綴 (VITE_ / NEXT_PUBLIC_ / etc.)」→ 等於公開
如果是 secret → 永遠走 server endpoint
推薦做法:ProxyProvider + 你的 backend
Client 端
import { WebAgent, ProxyProvider } from '@perhapxin/webagent';
const llm = new ProxyProvider({
endpoint: '/api/llm/complete',
// 如果是跨網域 + cookie 認證
credentials: 'include',
// 加 CSRF token / Bearer token 等
headers: { 'X-CSRF-Token': window.__csrf },
});
const agent = new WebAgent({ llm });
agent.run('幫我把標題改成「年度報告」');
Client 看不到任何 API key — 只看到一個 endpoint。
Backend reference(Node.js + Express)
import express from 'express';
import { z } from 'zod';
const app = express();
app.use(express.json());
const CompleteRequest = z.object({
messages: z.array(z.object({ role: z.string(), content: z.any() })),
tools: z.array(z.any()).optional(),
temperature: z.number().optional(),
maxTokens: z.number().optional(),
model: z.string().optional(),
});
app.post('/api/llm/complete', async (req, res) => {
// 1. AUTH —— 驗用戶有權限呼叫
const user = await authenticate(req);
if (!user) return res.status(401).send('unauthorized');
// 2. RATE LIMIT —— 防濫用
if (await isRateLimited(user.id)) return res.status(429).send('too many');
// 3. QUOTA —— 計費 / 額度檢查
if (await isQuotaExceeded(user.id)) return res.status(402).send('quota exceeded');
// 4. VALIDATE —— 不要直接信任 client payload
const parsed = CompleteRequest.safeParse(req.body);
if (!parsed.success) return res.status(400).send(parsed.error.message);
// 5. FORWARD —— key 從 server env 拿
const upstream = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${process.env.OPENAI_API_KEY}`, // ← server-only secret
},
body: JSON.stringify({
model: parsed.data.model ?? 'gpt-4o-mini',
messages: parsed.data.messages,
tools: parsed.data.tools ? parsed.data.tools.map(t => ({ type: 'function', function: t })) : undefined,
temperature: parsed.data.temperature,
max_tokens: parsed.data.maxTokens,
}),
});
if (!upstream.ok) {
return res.status(502).send(await upstream.text());
}
const data = await upstream.json();
// 6. RECORD —— 計費 / log
await recordUsage(user.id, data.usage);
// 7. RETURN —— webagent CompleteResult shape
const choice = data.choices[0];
res.json({
content: choice.message.content ?? '',
toolCalls: choice.message.tool_calls?.map(tc => ({
id: tc.id,
name: tc.function.name,
arguments: JSON.parse(tc.function.arguments),
})),
usage: data.usage && {
promptTokens: data.usage.prompt_tokens,
completionTokens: data.usage.completion_tokens,
},
finishReason: choice.finish_reason,
});
});
localStorage 何時可接受
只在以下全部成立時:
- ✅ 純本機 demo(不對外)
- ✅ 用戶 100% 知道並同意(明確 UI 警告)
- ✅ 不會跨用戶 / 不會被 phishing 站偷
- ✅ XSS 風險已可控(你的 host 沒有任何用戶輸入 inject 的地方)
且需要:
- 顯眼 UI 告訴用戶「key 存在你瀏覽器,請勿在公用電腦輸入」
- 提供「登出 / 清除 key」按鈕
dddk demo 就是這個用法 — dddk/example_website/ 有明確警示。正式產品請走 ProxyProvider。
其他安全 checklist
Action 安全
- ✅
ActionDefinition.requireConfirmation = true— destructive action 必加(送 email / 刪訂單 / 付款) - ✅
clear_input/set_text不能對 password input 動 - ✅
eval_jsaction 永遠不要實作 - ✅ host 註冊的 custom action 自己負責 input validation
XSS / CSP
- webagent 不 eval、不 innerHTML 用戶內容(除非 immersive-translate 的
preserveHtml模式 — 那來自 LLM 翻譯) - A2UI catalog 元件必須 escape 所有 user-supplied props(內建 catalog 已做)
- host CSP 建議:
script-src 'self'; connect-src 'self' https://api.openai.com https://generativelanguage.googleapis.com;
Storage scope
sessionStorage(我們 session 用) — 跨 tab 不共享,關 tab 即清,較安全localStorage(host 用 storage adapter 時)— 跨 tab 共享,需自己權衡
Network
- A2UI / agent message 內容不要包用戶 PII 之外的隱私
- LLM provider 預設不 retain user data 設定(OpenAI 有 API 層的 zero-retention 選項,Gemini 預設不收)— host 自己跟 provider 簽合約
結論
| 部署場景 | 建議 |
|---|---|
| 本地單機 demo | OpenAIProvider({ apiKey }) + localStorage + 明確警告 |
| 內部企業工具 | ProxyProvider → 自己 backend → server-side key + 公司 SSO |
| 對外 SaaS | ProxyProvider → 自己 backend + auth + quota + log |
| 用戶完全 BYOK 的 SaaS(Raycast-like) | 加密 key 存 server,用戶 OAuth 進來解密 |