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 serverprocess.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_js action 永遠不要實作
  • ✅ 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 進來解密