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

第一層:用 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 另有 setState、pushState、removeState、validateForm 等 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 讀取畫面的最新 state/setState,再把 handlers 傳給 <ActionProvider handlers={actionHandlers}> 包住 Renderer;少了這段 wiring,自訂 action 不會接上執行路徑。敏感 action 要在 handler 裡再驗一次參數,並在伺服器端做登入、資源所有權與速率限制。catalog 是介面契約,不是授權系統。想了解為什麼執行層不能完全信任模型,可接著看 如何自己做 AI Agent Harness。
第三層:render 前串起三種驗證
真正的門禁應放在不可繞過的共用函式,而不是散落在某個按鈕事件。最小流程如下:
catalog.validate(candidate):檢查候選 spec 的 schema 形狀與允許的 component type;失敗就立刻 return。validateSpec(candidate):檢查 root、children、slots、repeat 等樹結構完整性。- 依每個 element 的
type找回 component 定義,對原始props執行該定義的safeParse()。 - 最小版只允許扁平的
onaction+params;格式錯誤、watch、built-ins、onSuccess/onError、navigate/set callback 一律 fail closed。 - 最後套用你的商業規則,例如 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.0、Zod 4.3.6 跑了 10 組固定輸入。這是 validator/runtime contract 測試,沒有呼叫 Claude,也不是模型品質 benchmark。判定標準是每題應接受或拒絕,預設組合指 catalog.validate() && validateSpec();補上逐型別 strict props、action params、路徑與 URL 政策後,結果由 4/10 變成 10/10。

- 合法 Card+Text:gate 接受。
- 要求 RawHTML:gate 拒絕未知 component。
- 無效 enum prop:例如
tone: "admin",gate 拒絕。 - 額外事件 prop:例如
onClick: "steal()",strict schema 拒絕。 - 未知 action:例如
delete_all,gate 拒絕;依本文 dispatch policy 不呼叫 handler。 - 錯誤 action params:例如要求匯出
exe,gate 拒絕。 - 越界 state path:讀取
/secrets/token,gate 拒絕。 - 危險 URL:
javascript:protocol,gate 拒絕。 - 遺失 child:樹結構驗證拒絕。
- 截斷 stream:gate 拒絕;依本文 commit policy 應維持 last-known-good。
完整走一次:模型想多做一步時會發生什麼?
假設你要求「做一張報表卡片,按下去匯出 CSV」。模型回傳合法的 Card 和 Button,卻額外放了 onClick: "fetch('/admin')",又把 action 改成 delete_all。
- catalog 看見 Card 與 Button 都在菜單裡,第一眼可能通過。
validateSpec()發現 root、children 都連得起來,因此樹也可能通過。- 逐型別
z.strictObject()抓到多出的onClick。 - action gate 又抓到 catalog 沒有
delete_all。 - 應用程式不把規格交給正式 Renderer,不執行任何 handler;畫面保留上一版,並把精確錯誤交給修復流程。
重點不是期待模型永遠不犯錯,而是讓犯錯變成可觀察、可拒絕、可重試的普通事件。這也是 AI Coding Agent 外連稽核與 Browser Skill 安全驗收共同使用的思路:把安全要求寫成測試,不寫成願望。
從零上手:一個下午能完成的最小專案
若專案已經有 React,可依官方安裝文件加入以下三個套件。v0.21.0 的 React manifest 要求 React peer dependency ^19.2.3;core manifest 使用 Zod 4。若你從空專案開始,還要先安裝 React;既有專案也先讓套件管理器確認 peer dependency。
npm install @json-render/react @json-render/core zod
- 建立
catalog.ts:只列出本次功能需要的 component 與 action。 - 建立
registry.tsx:把名稱接到 app-owned React 元件與 handler。 - 建立
validate-generated-spec.ts:串起 catalog、tree、per-type 與 business-rule gate。 - API route 把
catalog.prompt()放進 system prompt,接收模型串流。 - client 先更新隔離的 preview;只有完成事件通過 gate,才更新
lastKnownGood。 - 把下方 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 個重點
- 把模型輸出當不可信資料,不當成可直接 render 的畫面。
- catalog 要小而明確,props 用 strict schema,action params 在 handler 再驗。
catalog.validate()與validateSpec()解不同問題,不能互相取代。- 串流完成前只做隔離預覽;正式畫面始終有 last-known-good。
- 把每個安全承諾寫成固定 eval,新增能力時同步新增攻擊案例。
接著閱讀
左右滑動查看更多推薦
結語:先寫 gate,再接模型
json-render 最有價值的地方,不是讓 AI 更自由地畫 UI,而是把自由度壓進你能檢查的結構。記住本文的錨點:白名單菜單+不可繞過的門禁+壞流回退。Prompt 是菜單,不是門鎖。
你的下一步很具體:先只做 Card、Text、Button 三種元件,複製本文 10 題,確定每一題都在 render 前得到預期答案,再增加第四種能力。想系統化補齊 AI 應用開發,可從 AlphaLab AI 專區繼續讀,或查看 AlphaLab 線上課程。






