跳到主要內容

【2026 最新】json-render 安全實戰:讓 Claude 只選元件,10 題驗收生成式 UI

最後更新: ·
json-render 安全實戰:catalog、runtime gate 與 last-known-good 生成式 UI 門禁

你請 Claude 產生一張會員儀表板,它幾秒內就能吐出按鈕、圖表和表單。問題是:它會不會順手加進你沒批准的 HTML、塞一個危險網址,或把「匯出」按鈕接到不存在的 action?這正是 json-render 安全實戰真正要處理的事。

這篇不是重抄五分鐘安裝文件,而是給第一次做生成式 UI 的開發者一套可落地的門禁:從 Zod catalog、React registry、render 前驗證,到串流中斷時保留上一個可用畫面。你會拿到一份 10 題驗收表,以及 AlphaLab 在 json-render v0.21.0 上跑過的可重現結果。

先說結論:Prompt 是菜單,不是門鎖

安全生成式 UI = 白名單菜單(catalog)+不可繞過的門禁(runtime gate)+壞流回退(last-known-good)。

  • catalog 告訴模型「可以點什麼菜」,降低它亂造元件的機率。
  • registry 把自訂名稱接到你自己寫的 React 元件與 action handler;模型只表達意圖,不提供可執行程式。
  • runtime gate 在 render 前重新檢查每個 component、props、action、路徑與網址。
  • last-known-good 只在整份串流通過驗證後換畫面;半截 JSON 不覆蓋正常 UI。

如果只做第一層,你得到的是「比較守規矩的模型輸出」;四層都做,才得到應用程式能執行的安全邊界。這個思路也和 AI Agent Harness 一樣:模型負責提案,執行層負責權限與驗收。

json-render 安全實戰在解什麼問題?

json-render 是 Vercel Labs 的開源生成式 UI 工具組。它讓模型輸出 JSON 規格,再由你的應用程式把規格轉成真正元件。與直接顯示 raw HTML 相比,這像把「讓陌生人自行蓋房子」改成「只能用你倉庫裡編號過的積木」。

但積木清單本身不是保全。官方 API 把 catalog.validate()validateSpec()Renderer 分開提供;你必須決定何時擋下壞規格。尤其在 v0.21.0 的多元件 catalog schema 裡,整體 schema 對 props 採寬鬆 record,不能假設它已替每個 type 完成嚴格的 props 配對。白話說:「Button 是合法元件」不等於「Button 收到的每個參數都合法」。

同一題:raw HTML 和受控 JSON 差在哪?

假設提示都是「做一張可以匯出報表的卡片」。raw HTML 路線可能同時帶回標籤、inline event、外部資源網址與任意 attribute;若應用程式直接用 dangerouslySetInnerHTML 顯示,就得另外承擔 HTML sanitizer、CSP、URL policy 與事件隔離。安全的 HTML pipeline 當然做得到,但驗收面比較大。

catalog-constrained JSON 路線只讓模型回傳 CardButton 和名為 export_data 的意圖。真正的 DOM、事件 listener 與資料匯出都由 registry 擁有。這不是「JSON 天生安全」,而是把無限多種 HTML 行為縮成一份可枚舉、可逐項測試的能力清單;之後的 runtime gate 才負責拒絕越界內容。

json-render 安全實戰的四層生成式 UI 門禁:catalog、registry、runtime gate、last-known-good
模型產生的是提案;只有通過四層門禁的規格,才進入正式畫面。

第一層:用 Zod catalog 把能力白名單化

先從最小權限開始。不要一上來就把整套設計系統開給模型,而是只放這個任務真的需要的元件、props 與 actions。官方 catalog 文件使用 Zod 描述規格;實務上可再用 z.strictObject() 拒絕多餘欄位。

import { defineCatalog } from "@json-render/core";
import { schema } from "@json-render/react/schema";
import { z } from "zod";

const httpsUrl = z.string().refine((value) => {
  try { return new URL(value).protocol === "https:"; }
  catch { return false; }
}, "Only https URLs are allowed");

export const catalog = defineCatalog(schema, {
  components: {
    Card: {
      props: z.strictObject({
        title: z.string(),
        tone: z.enum(["neutral", "success", "warning"]),
      }),
      slots: ["default"],
      description: "Approved container",
    },
    Link: {
      props: z.strictObject({ label: z.string(), href: httpsUrl }),
      slots: [],
      description: "HTTPS link",
    },
  },
  actions: {
    export_data: {
      params: z.strictObject({ format: z.enum(["csv", "json"]) }),
      description: "Export an approved format",
    },
  },
});

這裡同時做了三件事:tone 只能三選一、Link 只能收 https:、匯出格式只能是 CSV 或 JSON。若你的資料繫結只應讀公開狀態,也可以把 $state 路徑限制在 /public/ 命名空間。請注意,這些是應用程式自己的政策,不是框架替所有專案預設的安全規則。

第二層:registry 是自訂能力邊界之一

registry 把 catalog 裡的自訂名字連到你擁有的 React 程式碼。模型可以說「執行 export_data」,但不能把一段 JavaScript 當 handler 塞進來;真正的副作用仍由你的程式控制。React schema 另有 setStatepushStateremoveStatevalidateForm 等 built-in actions,因此 gate 也要明確決定哪些內建能力可以出現。

import { defineRegistry } from "@json-render/react";

export const { registry, handlers: createHandlers } = defineRegistry(catalog, {
  components: {
    Card: ({ props, children }) => (
      <section data-tone={props.tone}>
        <h2>{props.title}</h2>{children}
      </section>
    ),
    Link: ({ props }) => (
      <a href={props.href} rel="noopener noreferrer">{props.label}</a>
    ),
  },
  actions: {
    export_data: async (params) => {
      const safe = z.strictObject({
        format: z.enum(["csv", "json"]),
      }).parse(params);
      return exportApprovedData(safe.format);
    },
  },
});
const stateRef = useRef(state);
const setStateRef = useRef(setState);
stateRef.current = state;
setStateRef.current = setState;

const actionHandlers = useMemo(
  () => createHandlers(
    () => setStateRef.current,
    () => stateRef.current,
  ),
  [],
);

<ActionProvider handlers={actionHandlers}>
  <Renderer spec={spec} registry={registry} />
</ActionProvider>

createHandlers 還要透過 ref getter 讀取畫面的最新 statesetState,再把 handlers 傳給 <ActionProvider handlers={actionHandlers}> 包住 Renderer;少了這段 wiring,自訂 action 不會接上執行路徑。敏感 action 要在 handler 裡再驗一次參數,並在伺服器端做登入、資源所有權與速率限制。catalog 是介面契約,不是授權系統。想了解為什麼執行層不能完全信任模型,可接著看 如何自己做 AI Agent Harness

第三層:render 前串起三種驗證

真正的門禁應放在不可繞過的共用函式,而不是散落在某個按鈕事件。最小流程如下:

  1. catalog.validate(candidate):檢查候選 spec 的 schema 形狀與允許的 component type;失敗就立刻 return。
  2. validateSpec(candidate):檢查 root、children、slots、repeat 等樹結構完整性。
  3. 依每個 element 的 type 找回 component 定義,對原始 props 執行該定義的 safeParse()
  4. 最小版只允許扁平的 on action+params;格式錯誤、watch、built-ins、onSuccessonError、navigate/set callback 一律 fail closed。
  5. 最後套用你的商業規則,例如 URL protocol、狀態路徑、資料列 ID 與使用者權限。
function parseFlatOn(on) {
  if (on === undefined) return { success: true, bindings: [] };
  if (!on || typeof on !== "object" || Array.isArray(on)) {
    return { success: false, bindings: [] };
  }

  const bindings = [];
  for (const value of Object.values(on)) {
    const list = Array.isArray(value) ? value : [value];
    for (const binding of list) {
      if (
        !binding ||
        typeof binding !== "object" ||
        Array.isArray(binding) ||
        typeof binding.action !== "string" ||
        Object.keys(binding).some((key) => !["action", "params"].includes(key))
      ) return { success: false, bindings: [] };
      bindings.push(binding);
    }
  }
  return { success: true, bindings };
}

function validateGeneratedSpec(input) {
  const catalogResult = catalog.validate(input);
  if (!catalogResult.success) return { ok: false, errors: ["catalog"] };

  if (
    typeof input?.root !== "string" ||
    !input?.elements ||
    typeof input.elements !== "object" ||
    Array.isArray(input.elements)
  ) return { ok: false, errors: ["shape"] };

  const errors = [];
  try {
    if (!validateSpec(input).valid) errors.push("tree");
  } catch {
    return { ok: false, errors: ["tree"] };
  }

  for (const [key, element] of Object.entries(input.elements ?? {})) {
    if (!element || typeof element !== "object" || Array.isArray(element)) {
      errors.push(`${key}:element-shape`);
      continue;
    }
    const component = catalog.data.components[element.type];
    if (!component) { errors.push(`${key}:type`); continue; }
    if (!component.props.safeParse(element.props).success) {
      errors.push(`${key}:props`);
    }

    if (element.watch !== undefined) errors.push(`${key}:watch-not-allowed`);
    const actionResult = parseFlatOn(element.on);
    if (!actionResult.success) {
      errors.push(`${key}:binding-shape`);
      continue;
    }
    for (const binding of actionResult.bindings) {
      const action = catalog.data.actions[binding.action];
      if (!action) { errors.push(`${key}:action`); continue; }
      if (!action.params.safeParse(binding.params ?? {}).success) {
        errors.push(`${key}:params`);
      }
    }
  }

  return { ok: errors.length === 0, errors };
}

這是刻意縮小能力面的 fail-closed 起點:若產品真的需要 watch、built-ins 或 callback,先替完整 ActionBinding 寫 strict schema,再加入 navigation destination、state-write prefix、最大 chain depth 與循環檢查,不能只把它們塞回上面的 collector。範例另省略 expression 檢查、授權上下文與完整 TypeScript 型別。也不要拿 Zod parse 後「已刪掉未知欄位」的物件掩蓋原始輸入;應對原始 props 使用 strict schema,讓越權欄位明確失敗。

第四層:串流可以預覽,但不能提前承諾

json-render 的 SpecStream 是逐行送達的 JSON Patch。使用者會很快看到介面長出來,但途中可能斷線、停在半個 element,或先建立 root、還沒補到 child。比較穩健的做法是維護兩份狀態:

  • previewSpec:隔離顯示中的串流結果,不允許敏感 action。
  • lastKnownGood:上一份已完成、已通過所有 gate 的正式規格。

串流入口本身也要先做 policy filter:只允許預期的 JSON Patch operation 與可寫 root,拒絕危險 object-property segment;再限制總 bytes、patch 數、element 數、深度、repeat 次數與處理時間。不要把模型 patch 原封不動交給 mutable compiler,也不要在「規格仍在長」的階段開放 action。

let lastKnownGood = initialSafeSpec;

async function commitCompletedStream(candidate) {
  const result = validateGeneratedSpec(candidate);
  if (!result.ok) return { spec: lastKnownGood, errors: result.errors };

  lastKnownGood = candidate;
  return { spec: lastKnownGood, errors: [] };
}

Renderer fallback={...} 只處理 registry 找不到 component type 時要畫什麼;它不是網路重試,也不會替你做整份串流回滾。中斷時是否保留舊畫面,是應用層必須明確設計的政策。

10 題驗收:預設兩道檢查只答對 4 題

AlphaLab 在 2026 年 9 月 22 日以 @json-render/core 0.21.0@json-render/react 0.21.0Zod 4.3.6 跑了 10 組固定輸入。這是 validator/runtime contract 測試,沒有呼叫 Claude,也不是模型品質 benchmark。判定標準是每題應接受或拒絕,預設組合指 catalog.validate() && validateSpec();補上逐型別 strict props、action params、路徑與 URL 政策後,結果由 4/10 變成 10/10。

json-render 10 題安全驗收結果:預設兩道驗證 4 比 10,加固門禁 10 比 10
本機固定輸入測試顯示:框架提供必要積木,但應用程式仍要補自己的逐型別與政策驗證。
  1. 合法 Card+Text:gate 接受。
  2. 要求 RawHTML:gate 拒絕未知 component。
  3. 無效 enum prop:例如 tone: "admin",gate 拒絕。
  4. 額外事件 prop:例如 onClick: "steal()",strict schema 拒絕。
  5. 未知 action:例如 delete_all,gate 拒絕;依本文 dispatch policy 不呼叫 handler。
  6. 錯誤 action params:例如要求匯出 exe,gate 拒絕。
  7. 越界 state path:讀取 /secrets/token,gate 拒絕。
  8. 危險 URL:javascript: protocol,gate 拒絕。
  9. 遺失 child:樹結構驗證拒絕。
  10. 截斷 stream:gate 拒絕;依本文 commit policy 應維持 last-known-good。

完整走一次:模型想多做一步時會發生什麼?

假設你要求「做一張報表卡片,按下去匯出 CSV」。模型回傳合法的 CardButton,卻額外放了 onClick: "fetch('/admin')",又把 action 改成 delete_all

  1. catalog 看見 Card 與 Button 都在菜單裡,第一眼可能通過。
  2. validateSpec() 發現 root、children 都連得起來,因此樹也可能通過。
  3. 逐型別 z.strictObject() 抓到多出的 onClick
  4. action gate 又抓到 catalog 沒有 delete_all
  5. 應用程式不把規格交給正式 Renderer,不執行任何 handler;畫面保留上一版,並把精確錯誤交給修復流程。

重點不是期待模型永遠不犯錯,而是讓犯錯變成可觀察、可拒絕、可重試的普通事件。這也是 AI Coding Agent 外連稽核Browser Skill 安全驗收共同使用的思路:把安全要求寫成測試,不寫成願望。

從零上手:一個下午能完成的最小專案

若專案已經有 React,可依官方安裝文件加入以下三個套件。v0.21.0 的 React manifest 要求 React peer dependency ^19.2.3core manifest 使用 Zod 4。若你從空專案開始,還要先安裝 React;既有專案也先讓套件管理器確認 peer dependency。

npm install @json-render/react @json-render/core zod
  1. 建立 catalog.ts:只列出本次功能需要的 component 與 action。
  2. 建立 registry.tsx:把名稱接到 app-owned React 元件與 handler。
  3. 建立 validate-generated-spec.ts:串起 catalog、tree、per-type 與 business-rule gate。
  4. API route 把 catalog.prompt() 放進 system prompt,接收模型串流。
  5. client 先更新隔離的 preview;只有完成事件通過 gate,才更新 lastKnownGood
  6. 把下方 10 題加入 CI;每次新增 component、prop 或 action 都再補一題。

如果你還不熟 AI 應用的「模型—工具—驗證」分工,先讀 Claude Code vs Codex 的共通 Agentic Loop,再回來做這套門禁會更順。

最常見的 5 個坑

1. 把 catalog prompt 當安全沙箱

Prompt 只能提高遵循率,不能取代程式檢查。任何來自模型的 JSON 都先視為不可信輸入。

2. 只跑 validateSpec()

它很適合抓斷掉的 root、child 與 slot,但不是 props、URL、權限或商業規則 validator。

3. action 名稱白名單化,params 卻照單全收

同一個 export_data 仍可能收到錯誤格式或別人的資源 ID。handler 邊界要重新 parse,伺服器端還要重新授權。

4. 串流一來就覆蓋正式畫面

partial spec 天生暫時不完整。預覽與正式狀態分開,完成後才 commit,錯誤時留在 last-known-good。

5. fallback 直接顯示原始內容

安全 fallback 應是你自己寫的靜態元件,例如「此區塊無法顯示」和重試按鈕;不要把未知 type、raw HTML 或錯誤 payload 原樣插回 DOM。

你該用 json-render,還是自己定一個 JSON schema?

  • 選 json-render:你需要 catalog prompt、React registry、data binding、actions 與 JSON Patch streaming 這整套組件,而且願意補應用層 gate。
  • 先用自己的 schema:畫面只有一兩種固定卡片、不需要串流與通用 registry;較小的攻擊面與較少抽象通常更好維護。
  • 先不要上生成式 UI:流程涉及轉帳、刪除、權限調整等高風險動作,且團隊還沒有 server-side authorization、稽核記錄與失敗復原。

json-render 幫你把「模型描述 UI」變成結構化問題,卻不會替你的產品決定信任政策。它最適合用在可控元件很多、版面組合很多,但真正副作用很少而且能嚴格授權的場景。

FAQ:json-render 安全實戰常見問題

1. json-render 會阻止 Claude 輸出任意 HTML 嗎?

可以把它擋在可接受規格之外,但前提是你真的執行驗證。catalog 降低模型產生 RawHTML 的機率;未知 type gate 與不提供 RawHTML registry,才讓它無法進入正式 render。

2. 有 catalog.validate() 就夠了嗎?

不夠。在本文鎖定的 v0.21.0,多元件 catalog 還要依 type 對原始 props 執行對應 Zod schema;另需 tree、actions 與商業規則檢查。

3. validateSpec() 主要檢查什麼?

樹結構完整性。例如 root 是否存在、child/slot 參照是否有效、repeat 等設定是否放在正確位置;它不等於權限或 URL 安全檢查。

4. 可以邊串流邊顯示嗎?

可以。但把 streaming preview 與已承諾的正式狀態分開,預覽階段停用敏感 action,完成並驗證後才 commit。

5. 未知 action 會怎樣?

不要讓它走到執行階段。v0.21.0 的 ActionProvider 對未知 handler 會警告並略過;更好的產品體驗是在 render 前 gate 就拒絕,回傳可診斷錯誤。

6. Zod strict 就等於安全嗎?

不等於。它能限制資料形狀,不能判斷目前使用者能否刪除某筆資料。形狀驗證與授權是兩層不同責任。

7. 10/10 代表 production-ready 嗎?

不代表。它只證明本文列出的 10 個固定案例符合預期。你的產品還要加入自己的權限、資料外洩、流量、可用性與瀏覽器測試。

8. 這套方法只適用 Claude 嗎?

不是。任何能依指令產生 JSON/JSONL 的模型都能採用同一個邊界;安全性來自應用層的白名單與驗證,不來自模型品牌。

給新手的 5 個重點

  1. 把模型輸出當不可信資料,不當成可直接 render 的畫面。
  2. catalog 要小而明確,props 用 strict schema,action params 在 handler 再驗。
  3. catalog.validate()validateSpec() 解不同問題,不能互相取代。
  4. 串流完成前只做隔離預覽;正式畫面始終有 last-known-good。
  5. 把每個安全承諾寫成固定 eval,新增能力時同步新增攻擊案例。

接著閱讀

左右滑動查看更多推薦

結語:先寫 gate,再接模型

json-render 最有價值的地方,不是讓 AI 更自由地畫 UI,而是把自由度壓進你能檢查的結構。記住本文的錨點:白名單菜單+不可繞過的門禁+壞流回退。Prompt 是菜單,不是門鎖。

你的下一步很具體:先只做 CardTextButton 三種元件,複製本文 10 題,確定每一題都在 render 前得到預期答案,再增加第四種能力。想系統化補齊 AI 應用開發,可從 AlphaLab AI 專區繼續讀,或查看 AlphaLab 線上課程

ALPHALAB 社群

有問題?來 Telegram 聊

和 Terry、編輯、其他網友一起討論這篇文章。提問、分享觀點,回覆更即時。

加入 Telegram 討論

📩 訂閱 AlphaLab 電子報

每週最多三封:一封 Weekly 週報與最多兩封關鍵 Alpha Signal。

我們不會 spam,隨時可退訂。