<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Client-Side on Tarragon</title><link>https://tarrragon.github.io/blog/tags/client-side/</link><description>Recent content in Client-Side on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Wed, 24 Jun 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/client-side/index.xml" rel="self" type="application/rss+xml"/><item><title>三層 log 設計</title><link>https://tarrragon.github.io/blog/testing/02-client-observability/three-layer-log-design/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/testing/02-client-observability/three-layer-log-design/</guid><description>&lt;p>客戶端 log 分成三層，每層記錄不同粒度的資訊，服務不同的 debug 場景。三層的區別在於回答的問題不同：連線生命週期回答「整體流程走到哪一步」，protocol 訊息回答「通訊細節是什麼」，使用者行為回答「使用者做了什麼操作」。&lt;/p>
&lt;h2 id="連線生命週期-log">連線生命週期 log&lt;/h2>
&lt;p>連線生命週期 log 記錄的是「流程走到第幾步、每步成功或失敗」。這一層的 log 粒度是步驟級 — 不記錄每一個封包或每一次函式呼叫，只記錄流程中的關鍵節點。&lt;/p>
&lt;p>以 app_tunnel 的連線流程為例，連線生命週期包含五步：biometric 認證 → credential 讀取 → WebSocket 連線 → auth token 發送 → stream 訂閱。每步完成時記一條 log，失敗時記一條包含原因的 log。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">[conn] Step 1/5: biometric auth completed (duration: 320ms)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">[conn] Step 2/5: credential loaded (user: admin)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">[conn] Step 3/5: WebSocket connected (url: wss://...)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">[conn] Step 4/5: auth token sent
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">[conn] Step 5/5: stream subscribed, ready&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>app_tunnel 在實機測試前六個核心元件中只有兩個有 log，且全是 W2 修復時事後補上的（&lt;a href="https://tarrragon.github.io/blog/testing/cases/client-log-absent-debug-cost/" data-link-title="T.C4 Client-side log 缺失導致 debug 只能靠實機盲測" data-link-desc="Flutter app 六個核心元件中只有兩個有 log（且全是 W2 hotfix 補的），連線失敗時開發者無法從任何 log 判斷失敗發生在哪一步 — 被迫用最昂貴的 debug 方式：插拔裝置反覆測試">T.C4&lt;/a>）。W2-002 auth token 問題的 debug 過程中，開發者無法從任何 log 判斷失敗發生在五步中的哪一步。如果有連線生命週期 log，第一次連線就能看到「Step 3 完成，Step 4 未執行」— 直接定位到 auth token 缺失。&lt;/p>
&lt;p>連線生命週期 log 在所有模式（debug 和 release）都應該啟用。這層 log 量小（每次連線 5-10 條），不影響效能，但在 production 問題回報時是第一手資訊來源。&lt;/p>
&lt;h2 id="protocol-訊息-log">Protocol 訊息 log&lt;/h2>
&lt;p>Protocol 訊息 log 記錄的是通訊協議層面的細節：發送和接收的 frame type、payload 前綴、handshake 參數、逾時值。這一層的粒度比連線生命週期更細 — 每一次 send/receive 都記錄。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">[proto] TX: text frame, payload: {&amp;#34;AuthToken&amp;#34;:&amp;#34;base64...&amp;#34;} (42 bytes)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">[proto] RX: text frame, payload prefix: &amp;#34;0&amp;#34; (output data, 128 bytes)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">[proto] TX: binary frame, payload: [72, 101, 108, 108, 111] (5 bytes)&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Protocol log 在 debug 時幫助確認「程式碼發送了什麼、收到了什麼」。app_tunnel 的 text/binary frame 問題（&lt;a href="https://tarrragon.github.io/blog/testing/cases/ws-text-binary-frame-mock-blindspot/" data-link-title="T.C1 WebSocket text/binary frame 被 FakeWebSocketChannel 遮蔽" data-link-desc="Flutter app 用 Uint8List 發送 WS 資料走 binary frame，ttyd 期望 text frame 靜默忽略 — FakeWebSocketChannel 的 sink.add 接受 dynamic 不區分 frame type，192 個 test 全過但實機無回應">T.C1&lt;/a>）如果有 protocol log，開發者會在 log 中看到 &lt;code>TX: binary frame&lt;/code> 而非預期的 &lt;code>TX: text frame&lt;/code> — 直接指向 frame type 問題。&lt;/p></description><content:encoded><![CDATA[<p>客戶端 log 分成三層，每層記錄不同粒度的資訊，服務不同的 debug 場景。三層的區別在於回答的問題不同：連線生命週期回答「整體流程走到哪一步」，protocol 訊息回答「通訊細節是什麼」，使用者行為回答「使用者做了什麼操作」。</p>
<h2 id="連線生命週期-log">連線生命週期 log</h2>
<p>連線生命週期 log 記錄的是「流程走到第幾步、每步成功或失敗」。這一層的 log 粒度是步驟級 — 不記錄每一個封包或每一次函式呼叫，只記錄流程中的關鍵節點。</p>
<p>以 app_tunnel 的連線流程為例，連線生命週期包含五步：biometric 認證 → credential 讀取 → WebSocket 連線 → auth token 發送 → stream 訂閱。每步完成時記一條 log，失敗時記一條包含原因的 log。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">[conn] Step 1/5: biometric auth completed (duration: 320ms)
</span></span><span class="line"><span class="ln">2</span><span class="cl">[conn] Step 2/5: credential loaded (user: admin)
</span></span><span class="line"><span class="ln">3</span><span class="cl">[conn] Step 3/5: WebSocket connected (url: wss://...)
</span></span><span class="line"><span class="ln">4</span><span class="cl">[conn] Step 4/5: auth token sent
</span></span><span class="line"><span class="ln">5</span><span class="cl">[conn] Step 5/5: stream subscribed, ready</span></span></code></pre></div><p>app_tunnel 在實機測試前六個核心元件中只有兩個有 log，且全是 W2 修復時事後補上的（<a href="/blog/testing/cases/client-log-absent-debug-cost/" data-link-title="T.C4 Client-side log 缺失導致 debug 只能靠實機盲測" data-link-desc="Flutter app 六個核心元件中只有兩個有 log（且全是 W2 hotfix 補的），連線失敗時開發者無法從任何 log 判斷失敗發生在哪一步 — 被迫用最昂貴的 debug 方式：插拔裝置反覆測試">T.C4</a>）。W2-002 auth token 問題的 debug 過程中，開發者無法從任何 log 判斷失敗發生在五步中的哪一步。如果有連線生命週期 log，第一次連線就能看到「Step 3 完成，Step 4 未執行」— 直接定位到 auth token 缺失。</p>
<p>連線生命週期 log 在所有模式（debug 和 release）都應該啟用。這層 log 量小（每次連線 5-10 條），不影響效能，但在 production 問題回報時是第一手資訊來源。</p>
<h2 id="protocol-訊息-log">Protocol 訊息 log</h2>
<p>Protocol 訊息 log 記錄的是通訊協議層面的細節：發送和接收的 frame type、payload 前綴、handshake 參數、逾時值。這一層的粒度比連線生命週期更細 — 每一次 send/receive 都記錄。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">[proto] TX: text frame, payload: {&#34;AuthToken&#34;:&#34;base64...&#34;} (42 bytes)
</span></span><span class="line"><span class="ln">2</span><span class="cl">[proto] RX: text frame, payload prefix: &#34;0&#34; (output data, 128 bytes)
</span></span><span class="line"><span class="ln">3</span><span class="cl">[proto] TX: binary frame, payload: [72, 101, 108, 108, 111] (5 bytes)</span></span></code></pre></div><p>Protocol log 在 debug 時幫助確認「程式碼發送了什麼、收到了什麼」。app_tunnel 的 text/binary frame 問題（<a href="/blog/testing/cases/ws-text-binary-frame-mock-blindspot/" data-link-title="T.C1 WebSocket text/binary frame 被 FakeWebSocketChannel 遮蔽" data-link-desc="Flutter app 用 Uint8List 發送 WS 資料走 binary frame，ttyd 期望 text frame 靜默忽略 — FakeWebSocketChannel 的 sink.add 接受 dynamic 不區分 frame type，192 個 test 全過但實機無回應">T.C1</a>）如果有 protocol log，開發者會在 log 中看到 <code>TX: binary frame</code> 而非預期的 <code>TX: text frame</code> — 直接指向 frame type 問題。</p>
<p>Protocol log 在 release mode 應該能關閉。這層 log 量大（每次鍵盤輸入一條），且 payload 可能包含敏感資訊。Debug mode 預設啟用，release mode 提供開關（例如隱藏設定頁的 toggle）讓進階使用者在回報問題時開啟。</p>
<h2 id="使用者行為-log">使用者行為 log</h2>
<p>使用者行為 log 記錄的是使用者在 UI 上的操作：按鈕點擊、畫面切換、設定變更。這層 log 的粒度是操作級 — 使用者做了一個有意義的動作記一條。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">[ui] screen: HomeScreen, action: tap Connect Terminal
</span></span><span class="line"><span class="ln">2</span><span class="cl">[ui] screen: TerminalScreen, state: connecting → connected
</span></span><span class="line"><span class="ln">3</span><span class="cl">[ui] screen: TerminalScreen, action: tap back button
</span></span><span class="line"><span class="ln">4</span><span class="cl">[ui] screen: HomeScreen, state: returned from terminal</span></span></code></pre></div><p>使用者行為 log 在兩個場景有價值：第一，debug 時還原使用者操作路徑 — 「使用者做了什麼導致問題出現」；第二，結合狀態矩陣（<a href="/blog/ux-design/01-screen-state-machine/" data-link-title="模組一：畫面狀態機設計" data-link-desc="畫面狀態矩陣（顯示 / 操作 / 進入 / 退出）— 退出路徑為空 = UX 死胡同">ux-design 模組一</a>）做狀態轉換的實際覆蓋率分析 — 哪些狀態轉換在真實使用中經常發生，哪些從未發生。</p>
<p>使用者行為 log 在 release mode 啟用時需要注意隱私。記錄「使用者切換了畫面」是合理的；記錄「使用者輸入了密碼 abc123」需要 redaction 機制（<a href="/blog/monitoring/07-security-privacy/" data-link-title="模組七：資安與隱私" data-link-desc="SDK redaction / transport 加密 / collector access control / 去識別化 — 蒐集的資料本身就是風險資產">monitoring 模組七 資安</a>）。</p>
<h2 id="三層的關係">三層的關係</h2>
<p>三層 log 各自獨立運作，debug 時通常按照從粗到細的順序使用。</p>
<p><strong>粗篩</strong>：先看連線生命週期 log，確認流程走到哪一步。如果 Step 3 失敗，問題在 WebSocket 連線層。</p>
<p><strong>細查</strong>：切到 protocol 訊息 log，看 Step 3 的連線嘗試中發送和接收了什麼。如果看到 binary frame 發送但沒有回應，問題可能在 frame type。</p>
<p><strong>還原</strong>：如果問題和使用者操作有關（例如只在特定操作順序下觸發），看使用者行為 log，還原操作路徑。</p>
<p>三層 log 用同一個時間戳和 correlation ID（例如連線 session ID），讓跨層比對可行。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>在功能規格中定義 log 點 → <a href="/blog/testing/02-client-observability/log-point-in-spec/" data-link-title="功能規格中的 log 點定義方法" data-link-desc="把 log 點設計從 debug 階段前移到功能規格階段 — 每個功能的規格文件新增可觀測性欄位，列出啟動 / 步驟 / 錯誤 / 完成四類 log 點">功能規格中的 log 點定義方法</a></li>
<li>事後補 log 和設計產物 log 的品質差異 → <a href="/blog/testing/02-client-observability/hotfix-log-vs-designed-log/" data-link-title="「事後補 log」vs「設計產物 log」的品質差異" data-link-desc="事後補的 log 是救火工具、設計產物的 log 是可觀測性基礎設施 — 從 app_tunnel 的 W2 hotfix log 拆解兩者在格式、覆蓋率、維護成本上的差異">「事後補 log」vs「設計產物 log」的品質差異</a></li>
<li>Log 收集方案選擇 → <a href="/blog/testing/02-client-observability/log-endpoint-tradeoff/" data-link-title="自架 log endpoint vs 商業方案的取捨判斷" data-link-desc="自用工具用自架 log receiver（20 行 Go &#43; grep）、商業 app 用 Sentry/Crashlytics — 判斷依據是使用者規模和 debug 需求">自架 log endpoint vs 商業方案</a></li>
<li>事件分類與收集策略 → <a href="/blog/monitoring/01-mental-model/" data-link-title="模組一：監控心智模型" data-link-desc="四類事件（event / error / metric / lifecycle）的分類與收集策略">monitoring 模組一 監控心智模型</a></li>
</ul>
]]></content:encoded></item><item><title>Client-Side LLM / Embedding</title><link>https://tarrragon.github.io/blog/llm/knowledge-cards/client-side-llm/</link><pubDate>Tue, 12 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/llm/knowledge-cards/client-side-llm/</guid><description>&lt;p>Client-side LLM / embedding 的核心概念是「&lt;strong>模型權重下載到使用者瀏覽器、用 WebGPU 或 WebAssembly 直接在 browser 內推論、不經過任何 server&lt;/strong>」。代表 runtime：WebLLM（MLC AI、用 WebGPU）、wllama（llama.cpp 的 WebAssembly port）、&lt;code>@xenova/transformers&lt;/code>（瀏覽器版 transformers）。是「靜態網站做 RAG」、「離線可用 LLM 應用」這類場景的關鍵基底。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>跟其他 LLM deployment 形態的對比：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>形態&lt;/th>
 &lt;th>模型權重位置&lt;/th>
 &lt;th>推論執行位置&lt;/th>
 &lt;th>隱私&lt;/th>
 &lt;th>適合&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>雲端 LLM API&lt;/td>
 &lt;td>雲端伺服器&lt;/td>
 &lt;td>雲端&lt;/td>
 &lt;td>視 vendor 政策&lt;/td>
 &lt;td>高品質、production&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>本地 &lt;a href="https://tarrragon.github.io/blog/llm/knowledge-cards/inference-server/" data-link-title="Inference Server" data-link-desc="載入模型權重、處理 prompt、產生 token 的常駐 process">推論伺服器&lt;/a>&lt;/td>
 &lt;td>本機磁碟&lt;/td>
 &lt;td>本機 process&lt;/td>
 &lt;td>完全本地&lt;/td>
 &lt;td>寫 code、個人 dev&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Client-side LLM&lt;/strong>&lt;/td>
 &lt;td>使用者 browser cache&lt;/td>
 &lt;td>使用者 browser&lt;/td>
 &lt;td>完全本地（不經 server）&lt;/td>
 &lt;td>靜態網站、demo、離線&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>主流 client-side runtime（2026/5）：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Runtime&lt;/th>
 &lt;th>機制&lt;/th>
 &lt;th>模型支援&lt;/th>
 &lt;th>典型體積&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;code>@xenova/transformers&lt;/code>&lt;/td>
 &lt;td>WASM、ONNX 格式&lt;/td>
 &lt;td>sentence-transformers、小型 LLM、CLIP、embedding&lt;/td>
 &lt;td>&amp;lt; 100 MB / 模型&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>WebLLM（MLC）&lt;/td>
 &lt;td>WebGPU、自家 MLC compiled&lt;/td>
 &lt;td>Llama / Qwen / Gemma / Phi 等 1-13B&lt;/td>
 &lt;td>1-8 GB / 模型&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>wllama&lt;/td>
 &lt;td>WASM、llama.cpp 編譯版&lt;/td>
 &lt;td>GGUF Q4 等量化模型、&amp;lt; 4B 為主&lt;/td>
 &lt;td>0.5-4 GB / 模型&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>transformers.js&lt;/code>&lt;/td>
 &lt;td>WASM、跟 &lt;code>@xenova/transformers&lt;/code> 同源&lt;/td>
 &lt;td>同上&lt;/td>
 &lt;td>同上&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>讀靜態網站 / 前端 RAG / 離線 LLM 教學看到「WebGPU LLM」「browser-side embedding」「offline LLM」就是這 paradigm。寫 code 場景的判讀：&lt;/p>
&lt;ol>
&lt;li>&lt;strong>首訪載入慢&lt;/strong>：browser 第一次要下載模型權重（embedding 模型 ~50MB、LLM 1-5GB）、首訪體驗差；後續訪問 cache 起來、變快&lt;/li>
&lt;li>&lt;strong>WebGPU 支援度&lt;/strong>：2026/5 仍非所有 browser / 裝置都穩定支援、Safari iOS 較弱；fallback 到 WASM 但速度降一個量級&lt;/li>
&lt;li>&lt;strong>模型完整性沒簽章&lt;/strong>：使用者下載到的模型權重沒類似 &lt;a href="https://tarrragon.github.io/blog/llm/knowledge-cards/model-card/" data-link-title="Model Card" data-link-desc="Hugging Face 等平台上模型的 metadata 文件、列出模型來源、訓練資料、能力、限制、授權">GGUF model card&lt;/a> 的官方驗證、要靠 CDN + HTTPS 信任、不像本地 Ollama 有 hash 比對&lt;/li>
&lt;li>&lt;strong>適合「embedding + 小 LLM」、不適合「30B reasoning」&lt;/strong>：browser 記憶體跟 WebGPU 算力都遠不如本地 Ollama、選 &amp;lt; 4B 模型較實際&lt;/li>
&lt;li>&lt;strong>跟資安的關係&lt;/strong>：client-side 不需要 server API key、隱私強；但模型分發鏈（CDN → browser）成為新的供應鏈面、見 &lt;a href="https://tarrragon.github.io/blog/llm/04-applications/static-and-serverless-rag-deployment/" data-link-title="4.16 靜態 / serverless RAG deployment：架構選擇與資安取捨" data-link-desc="沒 backend 的場景怎麼做 RAG：四種 deployment 方案、API key 暴露問題、CORS / abuse / 第三方信任、跟模組六的 routing">4.16 靜態 RAG deployment&lt;/a> 的資安段&lt;/li>
&lt;/ol></description><content:encoded><![CDATA[<p>Client-side LLM / embedding 的核心概念是「<strong>模型權重下載到使用者瀏覽器、用 WebGPU 或 WebAssembly 直接在 browser 內推論、不經過任何 server</strong>」。代表 runtime：WebLLM（MLC AI、用 WebGPU）、wllama（llama.cpp 的 WebAssembly port）、<code>@xenova/transformers</code>（瀏覽器版 transformers）。是「靜態網站做 RAG」、「離線可用 LLM 應用」這類場景的關鍵基底。</p>
<h2 id="概念位置">概念位置</h2>
<p>跟其他 LLM deployment 形態的對比：</p>
<table>
  <thead>
      <tr>
          <th>形態</th>
          <th>模型權重位置</th>
          <th>推論執行位置</th>
          <th>隱私</th>
          <th>適合</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>雲端 LLM API</td>
          <td>雲端伺服器</td>
          <td>雲端</td>
          <td>視 vendor 政策</td>
          <td>高品質、production</td>
      </tr>
      <tr>
          <td>本地 <a href="/blog/llm/knowledge-cards/inference-server/" data-link-title="Inference Server" data-link-desc="載入模型權重、處理 prompt、產生 token 的常駐 process">推論伺服器</a></td>
          <td>本機磁碟</td>
          <td>本機 process</td>
          <td>完全本地</td>
          <td>寫 code、個人 dev</td>
      </tr>
      <tr>
          <td><strong>Client-side LLM</strong></td>
          <td>使用者 browser cache</td>
          <td>使用者 browser</td>
          <td>完全本地（不經 server）</td>
          <td>靜態網站、demo、離線</td>
      </tr>
  </tbody>
</table>
<p>主流 client-side runtime（2026/5）：</p>
<table>
  <thead>
      <tr>
          <th>Runtime</th>
          <th>機制</th>
          <th>模型支援</th>
          <th>典型體積</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>@xenova/transformers</code></td>
          <td>WASM、ONNX 格式</td>
          <td>sentence-transformers、小型 LLM、CLIP、embedding</td>
          <td>&lt; 100 MB / 模型</td>
      </tr>
      <tr>
          <td>WebLLM（MLC）</td>
          <td>WebGPU、自家 MLC compiled</td>
          <td>Llama / Qwen / Gemma / Phi 等 1-13B</td>
          <td>1-8 GB / 模型</td>
      </tr>
      <tr>
          <td>wllama</td>
          <td>WASM、llama.cpp 編譯版</td>
          <td>GGUF Q4 等量化模型、&lt; 4B 為主</td>
          <td>0.5-4 GB / 模型</td>
      </tr>
      <tr>
          <td><code>transformers.js</code></td>
          <td>WASM、跟 <code>@xenova/transformers</code> 同源</td>
          <td>同上</td>
          <td>同上</td>
      </tr>
  </tbody>
</table>
<h2 id="設計責任">設計責任</h2>
<p>讀靜態網站 / 前端 RAG / 離線 LLM 教學看到「WebGPU LLM」「browser-side embedding」「offline LLM」就是這 paradigm。寫 code 場景的判讀：</p>
<ol>
<li><strong>首訪載入慢</strong>：browser 第一次要下載模型權重（embedding 模型 ~50MB、LLM 1-5GB）、首訪體驗差；後續訪問 cache 起來、變快</li>
<li><strong>WebGPU 支援度</strong>：2026/5 仍非所有 browser / 裝置都穩定支援、Safari iOS 較弱；fallback 到 WASM 但速度降一個量級</li>
<li><strong>模型完整性沒簽章</strong>：使用者下載到的模型權重沒類似 <a href="/blog/llm/knowledge-cards/model-card/" data-link-title="Model Card" data-link-desc="Hugging Face 等平台上模型的 metadata 文件、列出模型來源、訓練資料、能力、限制、授權">GGUF model card</a> 的官方驗證、要靠 CDN + HTTPS 信任、不像本地 Ollama 有 hash 比對</li>
<li><strong>適合「embedding + 小 LLM」、不適合「30B reasoning」</strong>：browser 記憶體跟 WebGPU 算力都遠不如本地 Ollama、選 &lt; 4B 模型較實際</li>
<li><strong>跟資安的關係</strong>：client-side 不需要 server API key、隱私強；但模型分發鏈（CDN → browser）成為新的供應鏈面、見 <a href="/blog/llm/04-applications/static-and-serverless-rag-deployment/" data-link-title="4.16 靜態 / serverless RAG deployment：架構選擇與資安取捨" data-link-desc="沒 backend 的場景怎麼做 RAG：四種 deployment 方案、API key 暴露問題、CORS / abuse / 第三方信任、跟模組六的 routing">4.16 靜態 RAG deployment</a> 的資安段</li>
</ol>
]]></content:encoded></item><item><title>模組二：客戶端可觀測性</title><link>https://tarrragon.github.io/blog/testing/02-client-observability/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/testing/02-client-observability/</guid><description>&lt;p>回答「使用者的裝置上發生了什麼事」。log 設計應在功能規格階段完成，跟 API schema 同級。&lt;/p>
&lt;h2 id="對應-findings">對應 findings&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Finding&lt;/th>
 &lt;th>來源&lt;/th>
 &lt;th>內容&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>TF-6&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/testing/cases/client-log-absent-debug-cost/" data-link-title="T.C4 Client-side log 缺失導致 debug 只能靠實機盲測" data-link-desc="Flutter app 六個核心元件中只有兩個有 log（且全是 W2 hotfix 補的），連線失敗時開發者無法從任何 log 判斷失敗發生在哪一步 — 被迫用最昂貴的 debug 方式：插拔裝置反覆測試">T.C4&lt;/a>&lt;/td>
 &lt;td>6 元件中 4 個零 log，2 個全是 W2 hotfix&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>TF-7&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/testing/cases/client-log-absent-debug-cost/" data-link-title="T.C4 Client-side log 缺失導致 debug 只能靠實機盲測" data-link-desc="Flutter app 六個核心元件中只有兩個有 log（且全是 W2 hotfix 補的），連線失敗時開發者無法從任何 log 判斷失敗發生在哪一步 — 被迫用最昂貴的 debug 方式：插拔裝置反覆測試">T.C4&lt;/a>&lt;/td>
 &lt;td>事後補的 developer.log 格式不統一&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>TF-9&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/testing/cases/client-log-absent-debug-cost/" data-link-title="T.C4 Client-side log 缺失導致 debug 只能靠實機盲測" data-link-desc="Flutter app 六個核心元件中只有兩個有 log（且全是 W2 hotfix 補的），連線失敗時開發者無法從任何 log 判斷失敗發生在哪一步 — 被迫用最昂貴的 debug 方式：插拔裝置反覆測試">T.C4&lt;/a>&lt;/td>
 &lt;td>log 設計應在功能規格階段完成 — &lt;strong>本模組主寫&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="待寫章節">待寫章節&lt;/h2>
&lt;ul>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> 三層 log 設計（連線生命週期 / protocol 訊息 / 使用者行為）&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> 功能規格中的 log 點定義方法&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> 自架 log endpoint vs 商業方案的取捨判斷&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> 「事後補 log」vs「設計產物 log」的品質差異&lt;/li>
&lt;/ul>
&lt;h2 id="跨分類引用">跨分類引用&lt;/h2>
&lt;ul>
&lt;li>→ &lt;a href="https://tarrragon.github.io/blog/monitoring/02-log-schema/" data-link-title="模組二：Log Schema 設計" data-link-desc="跨平台統一事件格式、欄位設計、版本演進策略">monitoring 模組二 Log Schema&lt;/a>：本模組教「設計 log 點」，monitoring 教「log 收集到之後怎麼處理」&lt;/li>
&lt;li>→ &lt;a href="https://tarrragon.github.io/blog/monitoring/07-security-privacy/" data-link-title="模組七：資安與隱私" data-link-desc="SDK redaction / transport 加密 / collector access control / 去識別化 — 蒐集的資料本身就是風險資產">monitoring 模組七 資安&lt;/a>：log 內容可能含 secret，SDK redaction 在這裡介入&lt;/li>
&lt;li>← &lt;a href="https://tarrragon.github.io/blog/ux-design/01-screen-state-machine/" data-link-title="模組一：畫面狀態機設計" data-link-desc="畫面狀態矩陣（顯示 / 操作 / 進入 / 退出）— 退出路徑為空 = UX 死胡同">ux-design 模組一&lt;/a>：狀態矩陣可加「可觀測性」欄位&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>回答「使用者的裝置上發生了什麼事」。log 設計應在功能規格階段完成，跟 API schema 同級。</p>
<h2 id="對應-findings">對應 findings</h2>
<table>
  <thead>
      <tr>
          <th>Finding</th>
          <th>來源</th>
          <th>內容</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>TF-6</td>
          <td><a href="/blog/testing/cases/client-log-absent-debug-cost/" data-link-title="T.C4 Client-side log 缺失導致 debug 只能靠實機盲測" data-link-desc="Flutter app 六個核心元件中只有兩個有 log（且全是 W2 hotfix 補的），連線失敗時開發者無法從任何 log 判斷失敗發生在哪一步 — 被迫用最昂貴的 debug 方式：插拔裝置反覆測試">T.C4</a></td>
          <td>6 元件中 4 個零 log，2 個全是 W2 hotfix</td>
      </tr>
      <tr>
          <td>TF-7</td>
          <td><a href="/blog/testing/cases/client-log-absent-debug-cost/" data-link-title="T.C4 Client-side log 缺失導致 debug 只能靠實機盲測" data-link-desc="Flutter app 六個核心元件中只有兩個有 log（且全是 W2 hotfix 補的），連線失敗時開發者無法從任何 log 判斷失敗發生在哪一步 — 被迫用最昂貴的 debug 方式：插拔裝置反覆測試">T.C4</a></td>
          <td>事後補的 developer.log 格式不統一</td>
      </tr>
      <tr>
          <td>TF-9</td>
          <td><a href="/blog/testing/cases/client-log-absent-debug-cost/" data-link-title="T.C4 Client-side log 缺失導致 debug 只能靠實機盲測" data-link-desc="Flutter app 六個核心元件中只有兩個有 log（且全是 W2 hotfix 補的），連線失敗時開發者無法從任何 log 判斷失敗發生在哪一步 — 被迫用最昂貴的 debug 方式：插拔裝置反覆測試">T.C4</a></td>
          <td>log 設計應在功能規格階段完成 — <strong>本模組主寫</strong></td>
      </tr>
  </tbody>
</table>
<h2 id="待寫章節">待寫章節</h2>
<ul>
<li><input checked="" disabled="" type="checkbox"> 三層 log 設計（連線生命週期 / protocol 訊息 / 使用者行為）</li>
<li><input checked="" disabled="" type="checkbox"> 功能規格中的 log 點定義方法</li>
<li><input checked="" disabled="" type="checkbox"> 自架 log endpoint vs 商業方案的取捨判斷</li>
<li><input checked="" disabled="" type="checkbox"> 「事後補 log」vs「設計產物 log」的品質差異</li>
</ul>
<h2 id="跨分類引用">跨分類引用</h2>
<ul>
<li>→ <a href="/blog/monitoring/02-log-schema/" data-link-title="模組二：Log Schema 設計" data-link-desc="跨平台統一事件格式、欄位設計、版本演進策略">monitoring 模組二 Log Schema</a>：本模組教「設計 log 點」，monitoring 教「log 收集到之後怎麼處理」</li>
<li>→ <a href="/blog/monitoring/07-security-privacy/" data-link-title="模組七：資安與隱私" data-link-desc="SDK redaction / transport 加密 / collector access control / 去識別化 — 蒐集的資料本身就是風險資產">monitoring 模組七 資安</a>：log 內容可能含 secret，SDK redaction 在這裡介入</li>
<li>← <a href="/blog/ux-design/01-screen-state-machine/" data-link-title="模組一：畫面狀態機設計" data-link-desc="畫面狀態矩陣（顯示 / 操作 / 進入 / 退出）— 退出路徑為空 = UX 死胡同">ux-design 模組一</a>：狀態矩陣可加「可觀測性」欄位</li>
</ul>
]]></content:encoded></item><item><title>Client-side SDK 認證的根本限制</title><link>https://tarrragon.github.io/blog/monitoring/07-security-privacy/client-sdk-authentication/</link><pubDate>Wed, 24 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/monitoring/07-security-privacy/client-sdk-authentication/</guid><description>&lt;p>當監控 SDK 部署在使用者裝置上（瀏覽器、手機 app、本機腳本），collector 的 ingestion endpoint 就暴露在外部網路 — 認證機制需要面對 credential 必然可被提取的前提。Client-side SDK 的認證和 server-side API 的認證面對的是結構性不同的問題。Server-side 的 API key 存在環境變數或 secret store 裡，只有 server process 能讀取。Client-side SDK 的 credential 必須嵌入到使用者手上的程式碼中 — JS bundle、APK、Python script — 使用者（或攻擊者）可以直接讀取。&lt;/p>
&lt;p>這個限制來自 architecture，和 implementation 無關。混淆 JS、ProGuard 混淆 APK、編譯 Python 成 &lt;code>.pyc&lt;/code>，都只增加提取成本，不改變「credential 在 client 端」的事實。&lt;/p>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/monitoring/07-security-privacy/collector-access-control/" data-link-title="Collector Access Control 實作" data-link-desc="認證（誰在送資料）/ 授權（允許送什麼）/ access log（誰在什麼時候送了什麼）— collector 端的三層存取控制">Collector Access Control&lt;/a> 討論了 API key 和 mTLS 的認證機制，&lt;a href="https://tarrragon.github.io/blog/monitoring/07-security-privacy/transport-security/" data-link-title="Transport 安全" data-link-desc="HTTPS / basic auth / 同區網也要加密的理由 — 監控資料在傳輸途中的保護機制">Transport 安全&lt;/a> 討論了傳輸層加密。兩者的前提是 credential 被妥善保管。本章處理的是那個前提不成立時 — credential 已被提取或必然可被提取 — 的緩解策略。&lt;/p>
&lt;h2 id="商業方案的處理方式">商業方案的處理方式&lt;/h2>
&lt;p>所有主流的 client-side telemetry 方案都面對同樣的限制。它們的共同策略是：承認 client credential 會暴露，把防線從「保護 credential」轉移到「限制 credential 被濫用的影響」。&lt;/p>
&lt;p>&lt;strong>Google Analytics 4&lt;/strong>：Measurement ID（G-XXXXXXXXXX）直接寫在網頁的 JS snippet 中，任何人檢視網頁原始碼都能取得。GA4 的防護在 server-side — Google 用 domain 白名單過濾來源，加上自動的 bot traffic 偵測剔除機器流量。Measurement Protocol（server-to-server）需要額外的 API secret，但 client-side 的 gtag.js 不需要。&lt;/p>
&lt;p>&lt;strong>Sentry&lt;/strong>：DSN（Data Source Name）包含 project ID 和 public key，直接嵌在 SDK init 的程式碼中。Sentry 官方文件明確標示 DSN 是 public 的 — 攻擊者取得 DSN 只能送事件，不能讀取已收集的資料。防護靠 rate limit（每個 project 的 events/sec 上限）、allowed domains（只接受來自白名單 domain 的事件）、和 server-side 的 event 去重。&lt;/p>
&lt;p>&lt;strong>Firebase&lt;/strong>：整個 &lt;code>google-services.json&lt;/code> / &lt;code>GoogleService-Info.plist&lt;/code> 的內容 — 包含 apiKey、projectId、appId — 都視為公開資訊。Firebase 的安全模型不依賴這些 key 的保密性；它們的功能是識別（identify）而非授權（authorize）。需要保護的資源靠 Firebase Security Rules 和 App Check（device attestation）處理。&lt;/p>
&lt;p>&lt;strong>Datadog RUM&lt;/strong>：Client token 是獨立於 API key 的 credential。API key 可以讀寫所有 Datadog 資料，必須保護在 server-side；client token 只能寫入 RUM 事件，設計上可以暴露在 client 端。Datadog 建議搭配 intake proxy（collector 前面加一層自己的 server），讓 client token 不直接出現在瀏覽器中。&lt;/p></description><content:encoded><![CDATA[<p>當監控 SDK 部署在使用者裝置上（瀏覽器、手機 app、本機腳本），collector 的 ingestion endpoint 就暴露在外部網路 — 認證機制需要面對 credential 必然可被提取的前提。Client-side SDK 的認證和 server-side API 的認證面對的是結構性不同的問題。Server-side 的 API key 存在環境變數或 secret store 裡，只有 server process 能讀取。Client-side SDK 的 credential 必須嵌入到使用者手上的程式碼中 — JS bundle、APK、Python script — 使用者（或攻擊者）可以直接讀取。</p>
<p>這個限制來自 architecture，和 implementation 無關。混淆 JS、ProGuard 混淆 APK、編譯 Python 成 <code>.pyc</code>，都只增加提取成本，不改變「credential 在 client 端」的事實。</p>
<p><a href="/blog/monitoring/07-security-privacy/collector-access-control/" data-link-title="Collector Access Control 實作" data-link-desc="認證（誰在送資料）/ 授權（允許送什麼）/ access log（誰在什麼時候送了什麼）— collector 端的三層存取控制">Collector Access Control</a> 討論了 API key 和 mTLS 的認證機制，<a href="/blog/monitoring/07-security-privacy/transport-security/" data-link-title="Transport 安全" data-link-desc="HTTPS / basic auth / 同區網也要加密的理由 — 監控資料在傳輸途中的保護機制">Transport 安全</a> 討論了傳輸層加密。兩者的前提是 credential 被妥善保管。本章處理的是那個前提不成立時 — credential 已被提取或必然可被提取 — 的緩解策略。</p>
<h2 id="商業方案的處理方式">商業方案的處理方式</h2>
<p>所有主流的 client-side telemetry 方案都面對同樣的限制。它們的共同策略是：承認 client credential 會暴露，把防線從「保護 credential」轉移到「限制 credential 被濫用的影響」。</p>
<p><strong>Google Analytics 4</strong>：Measurement ID（G-XXXXXXXXXX）直接寫在網頁的 JS snippet 中，任何人檢視網頁原始碼都能取得。GA4 的防護在 server-side — Google 用 domain 白名單過濾來源，加上自動的 bot traffic 偵測剔除機器流量。Measurement Protocol（server-to-server）需要額外的 API secret，但 client-side 的 gtag.js 不需要。</p>
<p><strong>Sentry</strong>：DSN（Data Source Name）包含 project ID 和 public key，直接嵌在 SDK init 的程式碼中。Sentry 官方文件明確標示 DSN 是 public 的 — 攻擊者取得 DSN 只能送事件，不能讀取已收集的資料。防護靠 rate limit（每個 project 的 events/sec 上限）、allowed domains（只接受來自白名單 domain 的事件）、和 server-side 的 event 去重。</p>
<p><strong>Firebase</strong>：整個 <code>google-services.json</code> / <code>GoogleService-Info.plist</code> 的內容 — 包含 apiKey、projectId、appId — 都視為公開資訊。Firebase 的安全模型不依賴這些 key 的保密性；它們的功能是識別（identify）而非授權（authorize）。需要保護的資源靠 Firebase Security Rules 和 App Check（device attestation）處理。</p>
<p><strong>Datadog RUM</strong>：Client token 是獨立於 API key 的 credential。API key 可以讀寫所有 Datadog 資料，必須保護在 server-side；client token 只能寫入 RUM 事件，設計上可以暴露在 client 端。Datadog 建議搭配 intake proxy（collector 前面加一層自己的 server），讓 client token 不直接出現在瀏覽器中。</p>
<p>這些方案的共同模式：client-side credential 的角色是「識別來源」而非「授權存取」。即使被提取，攻擊者能做的事被限縮在「寫入事件」— 影響可控。</p>
<h2 id="認證天花板識別-vs-授權">認證天花板：識別 vs 授權</h2>
<p><a href="/blog/monitoring/07-security-privacy/collector-access-control/" data-link-title="Collector Access Control 實作" data-link-desc="認證（誰在送資料）/ 授權（允許送什麼）/ access log（誰在什麼時候送了什麼）— collector 端的三層存取控制">Collector Access Control</a> 的 API key 同時承擔識別和授權 — 有 key 就能寫入，沒 key 就被拒絕。在 server-side 場景下這沒有問題，因為 key 不會暴露。</p>
<p>Client-side 場景需要拆開這兩個功能：</p>
<p><strong>識別（identification）</strong>：這個 request 來自哪個 app、哪個 SDK、哪個部署版本。識別資訊可以公開 — 它的價值是讓 collector 知道事件來自哪裡，用於 access log、per-app rate limit、和事件標記。</p>
<p><strong>授權（authorization）</strong>：這個 request 有沒有權限執行寫入操作。授權依賴 credential 的保密性 — 在 client-side 場景下，credential 保密性的天花板很低。</p>
<p>接受這個區分後，client-side SDK 的 API key 更接近「識別 token」。它的洩漏不是安全事件（像 server-side API key 洩漏那樣），而是預期中的狀態。防護的重點從「防止 key 洩漏」轉移到「限制 key 被濫用時的影響」。</p>
<h2 id="多層緩解策略">多層緩解策略</h2>
<p>以下各層按實作成本遞增排列。前面的層在多數場景下足夠，後面的層在 endpoint 暴露在公開網路且面對主動攻擊時才需要。</p>
<h3 id="第一層寫入限制collector-已有">第一層：寫入限制（collector 已有）</h3>
<p><a href="/blog/monitoring/07-security-privacy/collector-access-control/" data-link-title="Collector Access Control 實作" data-link-desc="認證（誰在送資料）/ 授權（允許送什麼）/ access log（誰在什麼時候送了什麼）— collector 端的三層存取控制">Collector Access Control</a> 的寫入限制 — rate limit、payload size limit、schema validation — 是第一層防護。這些機制不區分「合法 SDK」和「偽造 client」，對所有寫入請求一視同仁地施加約束。</p>
<p>Rate limit 限制每個 API key 的事件速率。Schema validation 拒絕不符合 <a href="/blog/monitoring/02-log-schema/event-schema-fields/" data-link-title="event.schema.json 完整欄位解說" data-link-desc="監控事件的 JSON Schema 定義 — 每個欄位的語意、必填/選填、資料型別和設計理由">event.schema.json</a> 結構的 payload。兩者合起來把偽造流量的影響限制在「每秒 N 筆符合 schema 的事件」— 這個量級的資料汙染對 error tracking 的影響有限（error 事件靠 stack trace fingerprint 去重），對 funnel 分析的影響較大（行為事件的計數會被灌水）。</p>
<h3 id="第二層origin-驗證">第二層：Origin 驗證</h3>
<p>Web SDK 的 HTTP request 帶有瀏覽器自動附加的 <code>Origin</code> header。Collector 可以檢查 Origin 是否在白名單中。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">originCheck</span><span class="p">(</span><span class="nx">next</span> <span class="nx">http</span><span class="p">.</span><span class="nx">Handler</span><span class="p">,</span> <span class="nx">allowed</span> <span class="p">[]</span><span class="kt">string</span><span class="p">)</span> <span class="nx">http</span><span class="p">.</span><span class="nx">Handler</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">allowedSet</span> <span class="o">:=</span> <span class="nb">make</span><span class="p">(</span><span class="kd">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="kt">bool</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="k">for</span> <span class="nx">_</span><span class="p">,</span> <span class="nx">o</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">allowed</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="nx">allowedSet</span><span class="p">[</span><span class="nx">o</span><span class="p">]</span> <span class="p">=</span> <span class="kc">true</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="k">return</span> <span class="nx">http</span><span class="p">.</span><span class="nf">HandlerFunc</span><span class="p">(</span><span class="kd">func</span><span class="p">(</span><span class="nx">w</span> <span class="nx">http</span><span class="p">.</span><span class="nx">ResponseWriter</span><span class="p">,</span> <span class="nx">r</span> <span class="o">*</span><span class="nx">http</span><span class="p">.</span><span class="nx">Request</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="nx">origin</span> <span class="o">:=</span> <span class="nx">r</span><span class="p">.</span><span class="nx">Header</span><span class="p">.</span><span class="nf">Get</span><span class="p">(</span><span class="s">&#34;Origin&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="k">if</span> <span class="nx">origin</span> <span class="o">!=</span> <span class="s">&#34;&#34;</span> <span class="o">&amp;&amp;</span> <span class="p">!</span><span class="nx">allowedSet</span><span class="p">[</span><span class="nx">origin</span><span class="p">]</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">            <span class="nx">http</span><span class="p">.</span><span class="nf">Error</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="s">&#34;forbidden origin&#34;</span><span class="p">,</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusForbidden</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">            <span class="k">return</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="nx">next</span><span class="p">.</span><span class="nf">ServeHTTP</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="nx">r</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="p">})</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>Origin 驗證擋住的是「從瀏覽器中跨域呼叫」的場景 — 攻擊者在自己的網站用 JS 向你的 collector 發 request，瀏覽器會帶上攻擊者網站的 Origin，被 collector 拒絕。</p>
<p><strong>天花板</strong>：Origin header 只有瀏覽器會自動附加。用 <code>curl</code>、Postman、或任何非瀏覽器 HTTP client 發 request 時，可以自行設定任意 Origin 值。Origin 驗證擋得住瀏覽器中的跨域呼叫，擋不住直接用 HTTP client 偽造的 request。</p>
<p>Mobile SDK（Flutter / native app）的 request 不帶 Origin header。Origin 驗證只對 Web SDK 有效。</p>
<h3 id="第三層request-signing">第三層：Request signing</h3>
<p>SDK 用 HMAC 對每個 request 簽章，collector 驗證簽章有效性。簽章的輸入包含 timestamp 和 payload hash，防止 replay attack 和 payload 竄改。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">X-Signature: a3f8c2e1b7d94f06...  (HMAC-SHA256 結果的 hex 編碼)
</span></span><span class="line"><span class="ln">2</span><span class="cl">X-Timestamp: 1719216000</span></span></code></pre></div><p>SDK 計算方式：<code>HMAC-SHA256(secret, timestamp + &quot;.&quot; + SHA256(body))</code>，結果轉 hex 字串放入 <code>X-Signature</code> header。</p>
<p>Collector 端的驗證邏輯：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">verifySignature</span><span class="p">(</span><span class="nx">r</span> <span class="o">*</span><span class="nx">http</span><span class="p">.</span><span class="nx">Request</span><span class="p">,</span> <span class="nx">secret</span> <span class="kt">string</span><span class="p">)</span> <span class="kt">bool</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">ts</span> <span class="o">:=</span> <span class="nx">r</span><span class="p">.</span><span class="nx">Header</span><span class="p">.</span><span class="nf">Get</span><span class="p">(</span><span class="s">&#34;X-Timestamp&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">sig</span> <span class="o">:=</span> <span class="nx">r</span><span class="p">.</span><span class="nx">Header</span><span class="p">.</span><span class="nf">Get</span><span class="p">(</span><span class="s">&#34;X-Signature&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="c1">// 拒絕超過 5 分鐘的 request timestamp（防 replay）</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="c1">// 5 分鐘容忍 client-server 時鐘漂移和網路延遲；行動裝置偏差大的環境可放寬到 10 分鐘</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="c1">// 此處的 timestamp 是 HTTP request 發出時間，和事件的 timestamp 欄位（事件產生時間）無關</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="nx">tsInt</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">strconv</span><span class="p">.</span><span class="nf">ParseInt</span><span class="p">(</span><span class="nx">ts</span><span class="p">,</span> <span class="mi">10</span><span class="p">,</span> <span class="mi">64</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="o">||</span> <span class="nf">abs</span><span class="p">(</span><span class="nx">time</span><span class="p">.</span><span class="nf">Now</span><span class="p">().</span><span class="nf">Unix</span><span class="p">()</span><span class="o">-</span><span class="nx">tsInt</span><span class="p">)</span> <span class="p">&gt;</span> <span class="mi">300</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="k">return</span> <span class="kc">false</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="nx">body</span><span class="p">,</span> <span class="nx">_</span> <span class="o">:=</span> <span class="nx">io</span><span class="p">.</span><span class="nf">ReadAll</span><span class="p">(</span><span class="nx">r</span><span class="p">.</span><span class="nx">Body</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="nx">bodyHash</span> <span class="o">:=</span> <span class="nx">sha256</span><span class="p">.</span><span class="nf">Sum256</span><span class="p">(</span><span class="nx">body</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="nx">expected</span> <span class="o">:=</span> <span class="nx">hmac</span><span class="p">.</span><span class="nf">New</span><span class="p">(</span><span class="nx">sha256</span><span class="p">.</span><span class="nx">New</span><span class="p">,</span> <span class="p">[]</span><span class="nb">byte</span><span class="p">(</span><span class="nx">secret</span><span class="p">))</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="nx">expected</span><span class="p">.</span><span class="nf">Write</span><span class="p">([]</span><span class="nb">byte</span><span class="p">(</span><span class="nx">ts</span> <span class="o">+</span> <span class="s">&#34;.&#34;</span> <span class="o">+</span> <span class="nx">hex</span><span class="p">.</span><span class="nf">EncodeToString</span><span class="p">(</span><span class="nx">bodyHash</span><span class="p">[:])))</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">
</span></span><span class="line"><span class="ln">18</span><span class="cl">    <span class="nx">sigBytes</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">hex</span><span class="p">.</span><span class="nf">DecodeString</span><span class="p">(</span><span class="nx">sig</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">        <span class="k">return</span> <span class="kc">false</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">    <span class="k">return</span> <span class="nx">hmac</span><span class="p">.</span><span class="nf">Equal</span><span class="p">(</span><span class="nx">sigBytes</span><span class="p">,</span> <span class="nx">expected</span><span class="p">.</span><span class="nf">Sum</span><span class="p">(</span><span class="kc">nil</span><span class="p">))</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>Request signing 增加偽造成本 — 攻擊者需要提取 HMAC secret 並實作簽章邏輯，而非直接複製一個 API key 貼到 curl 指令。</p>
<p>HMAC secret 和 API key 一樣嵌在 client 端程式碼中，反編譯 APK 或閱讀 JS bundle 可以提取。Signing 增加的是攻擊者的工程投入（需要理解簽章算法並正確實作），而非理論上的安全性。對 casual attacker（看到 API key 就想試試的人）有效，對 motivated attacker（願意花時間逆向工程的人）無效。</p>
<h3 id="第四層行為分析異常偵測">第四層：行為分析異常偵測</h3>
<p>Collector 端統計每個 API key（或 source.app）的事件模式，建立 baseline 後偵測偏離。</p>
<p>正常 SDK 的行為有可預測的特徵：</p>
<table>
  <thead>
      <tr>
          <th>特徵</th>
          <th>正常 SDK 的 pattern</th>
          <th>偽造流量的 pattern</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>事件類型分布</td>
          <td>error / event / lifecycle / metric 四類混合</td>
          <td>可能只有單一類型</td>
      </tr>
      <tr>
          <td>事件間隔</td>
          <td>攢批送出，interval 接近 SDK config 的 flush interval</td>
          <td>固定間隔或連續送出</td>
      </tr>
      <tr>
          <td>Payload 結構</td>
          <td><code>source.sdk</code> / <code>source.platform</code> / <code>source.app</code> 值穩定</td>
          <td>可能缺少 SDK 自動填入的欄位</td>
      </tr>
      <tr>
          <td>Session 行為</td>
          <td>有 lifecycle 事件（session.begin / session.end）</td>
          <td>可能沒有 session 邊界</td>
      </tr>
      <tr>
          <td>時間分布</td>
          <td>跟使用者活動時段相關（工作時間 / 使用高峰）</td>
          <td>可能 24 小時均勻分布</td>
      </tr>
  </tbody>
</table>
<p>Collector 可以用 rule engine 偵測異常模式：</p>
<ul>
<li>單一 API key 的事件量在 10 分鐘內超過過去 24 小時平均值的 10 倍</li>
<li>連續 N 個 request 的事件全是同一個 type</li>
<li><code>source.sdk</code> 欄位的值不在已知的 SDK 版本清單中</li>
</ul>
<p>偵測到異常後的處理方式是標記而非丟棄 — 在事件中加入 <code>_flags.suspicious = true</code> flag，讓 dashboard 和分析查詢可以過濾。直接丟棄有誤殺正常流量的風險（例如行銷活動導致的真實流量暴增）。</p>
<p>攻擊者如果研究過正常 SDK 的行為模式（事件類型分布、送出間隔、payload 結構），可以模擬出相似的流量。行為分析依賴「偽造流量和正常流量有可偵測的差異」這個前提 — 對低投入的攻擊者成立，對高投入的攻擊者不一定。</p>
<h3 id="第五層device-attestation">第五層：Device attestation</h3>
<p>由作業系統或平台層驗證 client 的合法性，提供 SDK 自身無法產生的證明。</p>
<p><strong>Firebase App Check</strong>：整合 DeviceCheck（iOS）、Play Integrity（Android）、reCAPTCHA Enterprise（Web），由裝置平台出具 attestation token。Collector 向 Firebase 驗證 token 的有效性。</p>
<p><strong>Apple DeviceCheck / App Attest</strong>：iOS 裝置向 Apple server 請求 attestation，證明 request 來自一台真實的、未被篡改的 iOS 裝置上的合法 app。</p>
<p><strong>Google Play Integrity</strong>：驗證 request 來自 Google Play 安裝的 app、在未 root 的裝置上、由合法使用者操作。</p>
<p>Device attestation 提供的保證比前四層都強 — 它依賴裝置硬體和平台服務（難以偽造），而非 SDK 嵌入的 secret（可提取）。</p>
<p><strong>天花板</strong>：</p>
<ul>
<li>平台綁定 — 每個平台（iOS / Android / Web）需要各自整合不同的 attestation 服務，跨平台 SDK 的實作成本高</li>
<li>Root / 越獄裝置上 attestation 可能失敗或被繞過</li>
<li>Web 端的 reCAPTCHA 驗證依賴 Google 服務，有隱私和可用性的考量</li>
<li>自架 collector 需要額外整合 Firebase Admin SDK 或各平台的驗證 API</li>
</ul>
<p>Device attestation 適合商業產品級的 mobile app，對自架監控工具而言實作成本通常超出收益。</p>
<h2 id="自架方案的規模對應">自架方案的規模對應</h2>
<p>不同部署規模下，需要做到哪一層取決於 endpoint 的暴露程度和偽造流量的影響大小。</p>
<table>
  <thead>
      <tr>
          <th>部署場景</th>
          <th>暴露程度</th>
          <th>建議做到的層級</th>
          <th>理由</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>自用（1 人，同機 / 同網段）</td>
          <td>低 — endpoint 不對外</td>
          <td>HTTPS + basic auth</td>
          <td>攻擊面只有同網段，認證足夠</td>
      </tr>
      <tr>
          <td>小型團隊（&lt; 100 人，VPN 內）</td>
          <td>低 — endpoint 在 VPN 後</td>
          <td>API key + rate limit</td>
          <td>VPN 已限制存取範圍，rate limit 防 SDK bug</td>
      </tr>
      <tr>
          <td>公開 endpoint（VPS / 雲端）</td>
          <td>高 — 任何人可存取</td>
          <td>第一到第四層 + WAF</td>
          <td>rate limit + origin + signing + 行為分析 + CDN/WAF 的 IP reputation 過濾</td>
      </tr>
      <tr>
          <td>商業產品（app store 發佈）</td>
          <td>高 — APK 可反編譯，JS 可檢視原始碼</td>
          <td>第一到第五層 + intake proxy</td>
          <td>需要 device attestation 和 proxy 層把 credential 從 client 端移除</td>
      </tr>
  </tbody>
</table>
<p><strong>Intake proxy 架構</strong>：在公開 endpoint 和商業產品場景下，可以在 collector 前面加一層自己的 server（proxy），SDK 送事件到 proxy，proxy 用 server-side API key 轉發到 collector。Client 端的 credential 只指向 proxy，proxy 的 API key 指向 collector — credential 分層，client 端的 key 洩漏不影響 collector 的認證。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">SDK ──(client token)──→ Intake Proxy ──(server API key)──→ Collector</span></span></code></pre></div><p>Proxy 的額外成本是多一個 server 和網路跳躍。自用場景下不需要；endpoint 公開時值得考慮。</p>
<h2 id="偽造流量的影響分析">偽造流量的影響分析</h2>
<p>偽造流量進入 collector 後，對不同類型的分析影響不同。</p>
<p><strong>Error tracking 影響較低</strong>：error 事件的價值在 stack trace 和 error message。偽造的 error 事件缺少真實的 stack trace — 即使格式正確，內容是編造的。Error 去重靠 fingerprint（error type + message + stack trace top frame），偽造事件產生的 fingerprint 不會和真實 error 碰撞，在 dashboard 上是獨立的 error group，容易識別和過濾。</p>
<p><strong>行為分析影響較高</strong>：funnel 和 cohort 分析依賴事件計數的準確性。偽造的 <code>page.view</code> 和 <code>button.click</code> 事件直接灌水計數，導致轉換率失真。偽造事件越接近真實事件的結構（正確的 event name、合理的 timestamp），影響越大。</p>
<p><strong>資源消耗是固定成本</strong>：無論事件內容是否真實，每筆事件都消耗 collector 的寫入 I/O、儲存空間、和查詢時間。Rate limit 把這個成本限制在可控範圍 — 每秒 N 筆是上限，無論來源是否合法。</p>
<h3 id="事後標記策略">事後標記策略</h3>
<p>偵測到可疑流量後，collector 在事件中加入標記欄位而非直接丟棄。丟棄有誤殺風險 — 行銷活動的流量暴增、SDK 版本升級改變了事件模式、新平台的 SDK 上線 — 這些正常場景可能觸發異常偵測。</p>
<p>標記方式是在 collector 寫入時，對符合異常條件的事件附加 metadata：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="nt">&#34;v&#34;</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="nt">&#34;type&#34;</span><span class="p">:</span> <span class="s2">&#34;event&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="nt">&#34;name&#34;</span><span class="p">:</span> <span class="s2">&#34;button.click&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">  <span class="nt">&#34;source&#34;</span><span class="p">:</span> <span class="p">{</span> <span class="nt">&#34;sdk&#34;</span><span class="p">:</span> <span class="s2">&#34;js&#34;</span><span class="p">,</span> <span class="nt">&#34;platform&#34;</span><span class="p">:</span> <span class="s2">&#34;web&#34;</span><span class="p">,</span> <span class="nt">&#34;app&#34;</span><span class="p">:</span> <span class="s2">&#34;main-site&#34;</span> <span class="p">},</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">  <span class="nt">&#34;_flags&#34;</span><span class="p">:</span> <span class="p">{</span> <span class="nt">&#34;suspicious&#34;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span> <span class="nt">&#34;reason&#34;</span><span class="p">:</span> <span class="s2">&#34;rate_anomaly&#34;</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>Dashboard 查詢預設排除 <code>_flags.suspicious = true</code> 的事件。需要調查時可以包含 — 看可疑事件的模式有助於判斷是攻擊還是誤判。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>Collector 端的認證和授權機制 → <a href="/blog/monitoring/07-security-privacy/collector-access-control/" data-link-title="Collector Access Control 實作" data-link-desc="認證（誰在送資料）/ 授權（允許送什麼）/ access log（誰在什麼時候送了什麼）— collector 端的三層存取控制">Collector Access Control 實作</a></li>
<li>Transport 層的加密保護 → <a href="/blog/monitoring/07-security-privacy/transport-security/" data-link-title="Transport 安全" data-link-desc="HTTPS / basic auth / 同區網也要加密的理由 — 監控資料在傳輸途中的保護機制">Transport 安全</a></li>
<li>Endpoint 濫用的威脅分析 → <a href="/blog/monitoring/07-security-privacy/monitoring-data-threat-model/" data-link-title="監控資料洩漏的 Threat Model" data-link-desc="監控系統本身是攻擊面 — 四個威脅場景（傳輸竊聽 / 儲存入侵 / endpoint 濫用 / 內部越權存取）的風險評估和防護措施">監控資料洩漏的 Threat Model</a></li>
<li>SDK 端的寫入速率控制 → <a href="/blog/monitoring/04-collector/ingestion-scaling/" data-link-title="Ingestion Scaling" data-link-desc="四層防線應對 ingestion 端的流量擴展 — SDK 取樣、Collector 背壓、水平擴展、Queue 解耦">Ingestion Scaling</a></li>
<li>行為分析和 rule engine → <a href="/blog/monitoring/04-collector/rule-engine/" data-link-title="Rule engine 設計" data-link-desc="條件 → 動作 → 模板的三段式規則結構 — 讓 collector 從被動儲存變成主動回應">Rule Engine 設計</a></li>
<li>偽造流量對資料完整性的影響 → <a href="/blog/monitoring/04-collector/data-integrity/" data-link-title="端到端資料完整性" data-link-desc="從 SDK 到 storage 的資料損失地圖 — 每個環節的損失類型、控制策略、完整性指標、被自己 SDK DDoS 的防護">端到端資料完整性</a></li>
<li>Error fingerprint 讓偽造 error 容易辨識 → <a href="/blog/monitoring/04-collector/error-fingerprint/" data-link-title="Error Fingerprint 與去重分群" data-link-desc="把大量 error 事件歸組成可管理的 issue 列表 — fingerprint 演算法、message normalization、error_groups 表設計、自架方案的務實邊界">Error Fingerprint 與去重分群</a></li>
</ul>
]]></content:encoded></item><item><title>Cloudflare Page Shield：用 CSP + SRI + script monitoring 防 client-side supply chain</title><link>https://tarrragon.github.io/blog/backend/07-security-data-protection/vendors/cloudflare-waf/page-shield-csp-sri/</link><pubDate>Mon, 18 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/07-security-data-protection/vendors/cloudflare-waf/page-shield-csp-sri/</guid><description>&lt;blockquote>
&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/07-security-data-protection/vendors/cloudflare-waf/" data-link-title="Cloudflare WAF" data-link-desc="Edge WAF &amp;#43; DDoS &amp;#43; Bot management 整合套件、global anycast 網路、控制面信任邊界跟客戶側補強的對照">Cloudflare WAF&lt;/a> overview 的 implementation-layer deep article。Overview 已說明 Cloudflare WAF 在入口治理譜系的定位、本文聚焦 &lt;em>Page Shield&lt;/em> 這個 client-side（browser）supply chain attack 防禦工具 — 跟 WAF 攔 server-side request 是不同層。&lt;/p>&lt;/blockquote>
&lt;h2 id="attack-pattern--defense-mechanism-對照">Attack pattern × Defense mechanism 對照&lt;/h2>
&lt;p>Client-side supply chain attack 不會被 WAF 看到 — 攻擊發生在 browser 渲染 page 時、不在 origin server 跟 client 之間的網路層。Page Shield 是 &lt;em>browser-side script execution&lt;/em> 的監測 + 防禦層、跟 WAF 處理 &lt;em>server-side request inspection&lt;/em> 互補不重疊。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Attack pattern&lt;/th>
 &lt;th>表現&lt;/th>
 &lt;th>Page Shield 對應防禦&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Magecart 信用卡 skimmer&lt;/td>
 &lt;td>第三方 JS 被注入惡意 form listener、信用卡資訊送外部 endpoint&lt;/td>
 &lt;td>CSP &lt;code>connect-src&lt;/code> + script alert&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>第三方 SDK 被 compromise&lt;/td>
 &lt;td>廠商 CDN 被攻擊、SDK 改版內含 malicious payload&lt;/td>
 &lt;td>SRI hash mismatch + script alert&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Formjacking&lt;/td>
 &lt;td>結帳頁 form action 被改、submit 送外部 server&lt;/td>
 &lt;td>CSP &lt;code>form-action&lt;/code> directive&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Inline script injection&lt;/td>
 &lt;td>XSS / DOM-based injection 插入 &lt;code>&amp;lt;script&amp;gt;&lt;/code> 跑外部 source&lt;/td>
 &lt;td>CSP &lt;code>script-src&lt;/code> + nonce&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Storage abuse&lt;/td>
 &lt;td>malicious JS 讀 localStorage / cookies 送外部&lt;/td>
 &lt;td>CSP &lt;code>connect-src&lt;/code> + CSP report&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>三層防禦對應不同 attack 階段：&lt;/p>
&lt;ol>
&lt;li>&lt;strong>CSP（Content Security Policy）&lt;/strong>：browser-enforced policy、preventive、阻止違反 policy 的 script load / network request&lt;/li>
&lt;li>&lt;strong>SRI（Subresource Integrity）&lt;/strong>：load 階段 hash 驗證、detective + preventive、廠商 CDN 上 script 被改就 browser 拒載&lt;/li>
&lt;li>&lt;strong>Script monitoring&lt;/strong>：runtime 觀測、detective only、記錄頁面 load 哪些 third-party script、變動時 alert&lt;/li>
&lt;/ol>
&lt;p>三層各有 ceiling — &lt;em>CSP 擋 inline / unauthorized source 但擋不到 allowed source 被 compromise&lt;/em>；&lt;em>SRI 擋已知 vendor 改 hash 但擋不到動態 loader&lt;/em>；&lt;em>monitoring 看得到但攔不到&lt;/em>。Production 三層疊用、不要單一 layer。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 <a href="/blog/backend/07-security-data-protection/vendors/cloudflare-waf/" data-link-title="Cloudflare WAF" data-link-desc="Edge WAF &#43; DDoS &#43; Bot management 整合套件、global anycast 網路、控制面信任邊界跟客戶側補強的對照">Cloudflare WAF</a> overview 的 implementation-layer deep article。Overview 已說明 Cloudflare WAF 在入口治理譜系的定位、本文聚焦 <em>Page Shield</em> 這個 client-side（browser）supply chain attack 防禦工具 — 跟 WAF 攔 server-side request 是不同層。</p></blockquote>
<h2 id="attack-pattern--defense-mechanism-對照">Attack pattern × Defense mechanism 對照</h2>
<p>Client-side supply chain attack 不會被 WAF 看到 — 攻擊發生在 browser 渲染 page 時、不在 origin server 跟 client 之間的網路層。Page Shield 是 <em>browser-side script execution</em> 的監測 + 防禦層、跟 WAF 處理 <em>server-side request inspection</em> 互補不重疊。</p>
<table>
  <thead>
      <tr>
          <th>Attack pattern</th>
          <th>表現</th>
          <th>Page Shield 對應防禦</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Magecart 信用卡 skimmer</td>
          <td>第三方 JS 被注入惡意 form listener、信用卡資訊送外部 endpoint</td>
          <td>CSP <code>connect-src</code> + script alert</td>
      </tr>
      <tr>
          <td>第三方 SDK 被 compromise</td>
          <td>廠商 CDN 被攻擊、SDK 改版內含 malicious payload</td>
          <td>SRI hash mismatch + script alert</td>
      </tr>
      <tr>
          <td>Formjacking</td>
          <td>結帳頁 form action 被改、submit 送外部 server</td>
          <td>CSP <code>form-action</code> directive</td>
      </tr>
      <tr>
          <td>Inline script injection</td>
          <td>XSS / DOM-based injection 插入 <code>&lt;script&gt;</code> 跑外部 source</td>
          <td>CSP <code>script-src</code> + nonce</td>
      </tr>
      <tr>
          <td>Storage abuse</td>
          <td>malicious JS 讀 localStorage / cookies 送外部</td>
          <td>CSP <code>connect-src</code> + CSP report</td>
      </tr>
  </tbody>
</table>
<p>三層防禦對應不同 attack 階段：</p>
<ol>
<li><strong>CSP（Content Security Policy）</strong>：browser-enforced policy、preventive、阻止違反 policy 的 script load / network request</li>
<li><strong>SRI（Subresource Integrity）</strong>：load 階段 hash 驗證、detective + preventive、廠商 CDN 上 script 被改就 browser 拒載</li>
<li><strong>Script monitoring</strong>：runtime 觀測、detective only、記錄頁面 load 哪些 third-party script、變動時 alert</li>
</ol>
<p>三層各有 ceiling — <em>CSP 擋 inline / unauthorized source 但擋不到 allowed source 被 compromise</em>；<em>SRI 擋已知 vendor 改 hash 但擋不到動態 loader</em>；<em>monitoring 看得到但攔不到</em>。Production 三層疊用、不要單一 layer。</p>
<h2 id="csp-配置-step-by-step">CSP 配置 step-by-step</h2>
<h3 id="從-cloudflare-dashboard-啟用--寫-policy">從 Cloudflare dashboard 啟用 + 寫 policy</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln"> 1</span><span class="cl"># Dashboard: Security → Page Shield → CSP
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"># 模式: Report-only（第一週）→ Enforced（驗證後）
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"># 範例 policy
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">default-src &#39;self&#39;;
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">script-src &#39;self&#39; &#39;nonce-{NONCE}&#39; https://cdn.trusted.com https://www.googletagmanager.com;
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">style-src &#39;self&#39; &#39;unsafe-inline&#39; https://fonts.googleapis.com;
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">img-src &#39;self&#39; data: https:;
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">connect-src &#39;self&#39; https://api.myapp.com https://*.sentry.io;
</span></span><span class="line"><span class="ln">10</span><span class="cl">form-action &#39;self&#39;;
</span></span><span class="line"><span class="ln">11</span><span class="cl">frame-ancestors &#39;none&#39;;
</span></span><span class="line"><span class="ln">12</span><span class="cl">report-uri https://csp-report.cloudflare.com/cdn-cgi/script_monitor/report;
</span></span><span class="line"><span class="ln">13</span><span class="cl">report-to default;</span></span></code></pre></div><p>關鍵直覺：</p>
<ul>
<li><strong><code>'nonce-{NONCE}'</code></strong>：origin server 每 request 生成 random nonce、注入 <code>&lt;script nonce=&quot;...&quot;&gt;</code> 跟 CSP header；script tag 沒對應 nonce 就被 browser 拒跑、擋 XSS</li>
<li><strong><code>connect-src</code> 精準寫</strong>：第三方 API endpoint 全列出；不寫 <code>*</code> 或 <code>https:</code> 是擋 exfiltration 的關鍵（Magecart 把信用卡送外部 endpoint 就是用 <code>connect-src</code> 攔）</li>
<li><strong><code>form-action</code></strong>：擋 form 被改 action attribute 送外部、formjacking 第一道防線</li>
<li><strong><code>report-uri</code> + <code>report-to</code></strong>：违反 policy 的 event 送 Cloudflare、Page Shield dashboard 看 violation report</li>
</ul>
<h3 id="report-only-mode-第一週">Report-only mode 第一週</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">Content-Security-Policy-Report-Only: &lt;policy&gt;
</span></span><span class="line"><span class="ln">2</span><span class="cl">Content-Security-Policy:             default-src &#39;self&#39;;   # 鬆 policy 仍 enforce</span></span></code></pre></div><p>Report-only 期間 browser <em>report 違反但不擋</em>、production traffic 不受影響；SOC 看 report 找：</p>
<ul>
<li>漏列的 legitimate third-party（marketing / analytics SDK 沒寫進 policy）</li>
<li>意外 inline script（dev 留下的 debug snippet）</li>
<li>跨 domain 的合法 connect（CRM / chat widget）</li>
</ul>
<p>第一週後 dashboard 看 violation 數量趨穩 + 主要違規都已 whitelist、切 Enforced。</p>
<h3 id="enforced-mode-切換--canary">Enforced mode 切換 + canary</h3>
<p>不要直接全站 enforced — 用 Cloudflare Page Rule 對 10% traffic enforced、90% report-only：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">URL pattern: example.com/*
</span></span><span class="line"><span class="ln">2</span><span class="cl">Page Rule: Add CSP header (enforced)
</span></span><span class="line"><span class="ln">3</span><span class="cl">Bypass: 90% by Cookie / IP hash</span></span></code></pre></div><p>10% traffic 跑 24-48h、確認 zero legitimate violation、再擴大到 50% → 100%。canary 期間 monitor <code>error-rate</code> metric、不只是 violation report。</p>
<h2 id="sri-配置">SRI 配置</h2>
<p>Subresource Integrity 用 hash 驗證 CDN-hosted script 沒被改：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-html" data-lang="html"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">&lt;</span><span class="nt">script</span> <span class="na">src</span><span class="o">=</span><span class="s">&#34;https://cdn.example.com/widget.v1.2.3.js&#34;</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">        <span class="na">integrity</span><span class="o">=</span><span class="s">&#34;sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC&#34;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">        <span class="na">crossorigin</span><span class="o">=</span><span class="s">&#34;anonymous&#34;</span><span class="p">&gt;&lt;/</span><span class="nt">script</span><span class="p">&gt;</span></span></span></code></pre></div><p>Browser load 時算 hash、跟 <code>integrity</code> 不符就拒跑。關鍵：</p>
<ul>
<li><strong>Hash 一定要 version-pinned</strong>：用 <code>widget.v1.2.3.js</code>、不能用 <code>widget.latest.js</code>；廠商更新 latest 時 hash 變 → SRI 拒載 → 服務中斷</li>
<li><strong>多 hash</strong>：寫 <code>integrity=&quot;sha384-... sha512-...&quot;</code> 至少一個 match 就過、可在 vendor rotate hash 時平滑遷移</li>
<li><strong><code>crossorigin=&quot;anonymous&quot;</code></strong> 必加：跨 origin script 預設 browser 不暴露 hash 失敗細節、<code>anonymous</code> 才允許 CORS-based hash check</li>
</ul>
<h3 id="page-shield-自動產-sri-提示">Page Shield 自動產 SRI 提示</h3>
<p>Dashboard → Page Shield → Scripts 列出所有偵測到的 script、含 <em>建議 SRI hash</em>；可以 export 整合進 build pipeline、自動把所有 vendor script 加 SRI。</p>
<h2 id="故障演練">故障演練</h2>
<h3 id="case-1csp-report-floodsoc-noise">Case 1：CSP report flood，SOC noise</h3>
<p><strong>徵兆</strong>：切 Enforced 後、CSP violation report 從每天 ~500 漲到每分鐘 ~50K、Page Shield dashboard 變紅、SOC 收 alert 收到 silent。</p>
<p><strong>根因</strong>：browser extension（廣告攔截 / spell checker / password manager）注入 inline script 跟 connect、被 CSP block 同時觸發 report；不是真實 attack、是 user 端 extension 行為。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>CSP <code>report-sample</code> directive 限 sampling（只 report 10%）— spec 部分支援、不是所有 browser 都認</li>
<li>Page Shield 規則：filter out extension protocol（<code>chrome-extension://</code>、<code>moz-extension://</code>、<code>safari-extension://</code>）後再 alert</li>
<li>Report endpoint 自管 + aggregation：不直接接 SIEM、先 batch + dedupe、再送 SIEM</li>
<li>接受 report flood 是 normal、focus 監測 <em>unique violation pattern</em> 不是 <em>total volume</em></li>
</ol>
<h3 id="case-2inline-script-漏舊頁面突然壞">Case 2：Inline script 漏，舊頁面突然壞</h3>
<p><strong>徵兆</strong>：切 Enforced 後 X 個舊頁面壞、user feedback 提交 form 失敗、debugger 看到 console <code>Refused to execute inline script because it violates...</code>。</p>
<p><strong>根因</strong>：legacy page 有 inline <code>&lt;script&gt;</code> 沒 nonce、CSP enforced 後 browser 拒跑；報表/管理後台/舊 admin page 常見。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>Audit 所有 inline <code>&lt;script&gt;</code>、加 nonce attribute（server-side render 時注入）</li>
<li>短期：對舊頁面用 <code>unsafe-inline</code> 寫進 CSP（接受降級）、page-specific CSP override</li>
<li>長期：legacy page 改 build-time bundle、消除 inline script</li>
</ol>
<h3 id="case-3dynamic-script-loader-繞過-sri">Case 3：Dynamic script loader 繞過 SRI</h3>
<p><strong>徵兆</strong>：vendor script load 成功、但 Page Shield monitoring 看到該 vendor script <em>load 後又動態 load 多個額外 script</em>；額外 script 沒 SRI 保護、廠商側 compromise 直接過。</p>
<p><strong>根因</strong>：第三方 SDK 用 <code>document.createElement('script')</code> + <code>script.src = '...'</code> runtime 動態 load；CSP <code>script-src</code> 可能允許這個來源、但 SRI 沒法在 runtime 注入。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>CSP <code>script-src</code> 精準到 <em>只允許特定 path</em>、不是整個 domain（例：<code>https://cdn.vendor.com/sdk/v3/</code> 而不是 <code>https://cdn.vendor.com</code>）</li>
<li>評估 vendor 是否有 <em>static-only</em> 替代（多數 marketing / analytics SDK 不需要 dynamic loader、是 legacy 設計）</li>
<li>不能消除 dynamic loader 時、Page Shield monitoring 設 <em>new script alert</em>、廠商加 sub-script 即刻通知</li>
</ol>
<h3 id="case-4sri-hash-mismatchvendor-偷偷更新">Case 4：SRI hash mismatch，vendor 偷偷更新</h3>
<p><strong>徵兆</strong>：第三方 widget 突然不顯示、Page Shield 顯示 SRI mismatch、廠商 status page 沒事故公告。</p>
<p><strong>根因</strong>：廠商在 same URL（不是 versioned）下偷偷 push minor patch、hash 變了 → SRI 拒載；不是 attack、是 vendor 不遵守 immutable URL 慣例。</p>
<p><strong>修法</strong>：</p>
<ol>
<li>強制要求廠商提供 versioned URL（<code>widget.v1.2.3.js</code>）、不收 <code>widget.latest.js</code></li>
<li>廠商不配合時、build pipeline 加 <em>daily hash check</em>、廠商偷改 SRI hash 自動更新 + Slack alert</li>
<li>評估換 vendor — 不遵守 immutable URL 的廠商 supply chain integrity 信用低</li>
</ol>
<h2 id="容量--cost">容量 + cost</h2>
<p>Page Shield 是 <em>Enterprise plan + Page Shield add-on</em>、cost 維度：</p>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>影響</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>CSP report 量</td>
          <td>Cloudflare 端聚合、不另外計費；report endpoint 自管要 sizing</td>
      </tr>
      <tr>
          <td>Script monitoring</td>
          <td>不影響 page load latency（async detection）</td>
      </tr>
      <tr>
          <td>Per-zone pricing</td>
          <td>跨子域 + apex domain 多 zone 各算一份</td>
      </tr>
      <tr>
          <td>SOC operation</td>
          <td>第一週 report 量大、需要 1-2 analyst FTE 跑 tuning；穩定後低人力</td>
      </tr>
  </tbody>
</table>
<p>Page load 影響：</p>
<ul>
<li>CSP header ~1-2KB（policy 寫越精準越長、不是越短越好）</li>
<li>SRI 比對 ~5-10ms / script、現代 browser cache decoded hash、不重複算</li>
<li>Script monitoring beacon ~100 byte / script load、async 不阻塞 page render</li>
</ul>
<p>實務 default：</p>
<ul>
<li>Critical e-commerce / fintech：CSP enforced + SRI 全 vendor + monitoring all、SOC review weekly</li>
<li>一般 SaaS：CSP report-only ongoing + SRI critical vendor only + monitoring 主域</li>
<li>Marketing / blog：CSP <code>default-src 'self'</code> minimum + monitoring only</li>
</ul>
<h2 id="整合--下一步">整合 / 下一步</h2>
<h3 id="跟-dev-workflow-整合">跟 dev workflow 整合</h3>
<p>CSP 寫進 <em>deploy pipeline</em>、不是 dashboard 手動配：</p>
<ol>
<li>Repo 內 <code>csp-policy.yml</code>、跟 code 同 lifecycle</li>
<li>CI 跑 <em>CSP linter</em>（如 <code>csp-evaluator</code>）、檢查 policy 弱點</li>
<li>Deploy 時 push 到 Cloudflare API、自動 versioning + rollback</li>
</ol>
<h3 id="跟-waf-互補">跟 WAF 互補</h3>
<p>Page Shield 跟 <a href="/blog/backend/07-security-data-protection/vendors/cloudflare-waf/" data-link-title="Cloudflare WAF" data-link-desc="Edge WAF &#43; DDoS &#43; Bot management 整合套件、global anycast 網路、控制面信任邊界跟客戶側補強的對照">Cloudflare WAF</a> 不重疊但互補：</p>
<ul>
<li>WAF 攔 <em>server-side</em> request injection（SQL / command / path traversal）</li>
<li>Page Shield 攔 <em>client-side</em> script execution（XSS / supply chain）</li>
<li>共同 dashboard + alert routing、不要分開 SOC team 看</li>
</ul>
<h3 id="跟-supply-chain-sbom">跟 supply chain SBOM</h3>
<p>Page Shield 偵測的 <em>client-side dependency</em> 可進 SBOM、跟 <a href="/blog/backend/07-security-data-protection/vendors/snyk/" data-link-title="Snyk" data-link-desc="跨 SCM 多模組 application security platform：Open Source (SCA) &#43; Code (SAST) &#43; Container &#43; IaC &#43; Cloud (CSPM)、Reachability analysis">Snyk</a> / <a href="/blog/backend/07-security-data-protection/vendors/dependabot/" data-link-title="Dependabot" data-link-desc="GitHub 原生依賴更新自動化、Version Update &#43; Security Update &#43; Alerts、Grouped Updates 減 PR noise、Auto-merge 配 branch protection">Dependabot</a> 的 server-side SBOM 合併、得到完整 dependency graph。</p>
<h3 id="下一步議題">下一步議題</h3>
<ul>
<li><strong>Trusted Types</strong>：browser-side template injection 的下一代防禦、Chrome 已支援、Firefox / Safari 進度不一</li>
<li><strong>CSP Level 3 + strict-dynamic</strong>：減少 maintenance burden、用 nonce 動態信任 nested script</li>
<li><strong>Reporting API v1</strong>：standard report endpoint + <code>Reporting-Endpoints</code> header 取代 <code>report-uri</code></li>
</ul>
<h2 id="相關連結">相關連結</h2>
<ul>
<li>上游 vendor 頁：<a href="/blog/backend/07-security-data-protection/vendors/cloudflare-waf/" data-link-title="Cloudflare WAF" data-link-desc="Edge WAF &#43; DDoS &#43; Bot management 整合套件、global anycast 網路、控制面信任邊界跟客戶側補強的對照">Cloudflare WAF</a></li>
<li>上游 chapter：<a href="/blog/backend/07-security-data-protection/entrypoint-and-server-protection/" data-link-title="7.3 入口治理與伺服器防護" data-link-desc="以問題驅動方式整理對外入口、管理平面與伺服器邊界">7.3 入口治理與伺服器防護</a>、<a href="/blog/backend/07-security-data-protection/supply-chain-integrity-and-artifact-trust/" data-link-title="7.12 供應鏈完整性與 Artifact 信任" data-link-desc="定義 build provenance、artifact 信任與交付鏈風險問題">7.12 供應鏈完整性</a></li>
<li>對照案例：British Airways 2018 Magecart / Macy&rsquo;s 2019 skimmer（公開 supply chain 案例）</li>
<li>平行 vendor：<a href="/blog/backend/07-security-data-protection/vendors/aws-waf/" data-link-title="AWS WAF" data-link-desc="AWS-internal WAF、跟 ALB / CloudFront / API Gateway 直接整合、Web ACL &#43; Managed Rule Group &#43; Rate-based Rule、Shield Standard 內含">AWS WAF</a> / <a href="/blog/backend/07-security-data-protection/vendors/fastly-ngwaf/" data-link-title="Fastly Next-Gen WAF" data-link-desc="Behavioral / 語意分析 WAF（前 Signal Sciences）、低 false positive、Edge / Agent / Cloud 三種部署模型、API &#43; ATO &#43; Bot 一體">Fastly Next-Gen WAF</a></li>
<li>平行 deep article：<a href="/blog/backend/07-security-data-protection/vendors/hashicorp-vault/dynamic-credential/" data-link-title="HashiCorp Vault Dynamic Credential：lease 治理跟 application 整合的實作層" data-link-desc="Vault database secrets engine 怎麼配、application 怎麼 renew lease、production 五大踩雷（lease 過期 race、DB max_connections 撞牆、Vault sealed、token expire、scope 過寬）、容量規劃跟 vault-agent injector 整合">Vault Dynamic Credential</a> / <a href="/blog/backend/07-security-data-protection/vendors/splunk/risk-based-alerting/" data-link-title="Splunk Risk-Based Alerting：從 alert per rule 到 score-aggregated notable" data-link-desc="Splunk Enterprise Security 的 RBA 方法論：risk score / modifier / notable 三層 model、ES 配置 step-by-step、tuning playbook（false positive / score inflation / threshold drift / decay）、capacity 成本、跟 SOAR &#43; case management 整合">Splunk RBA</a></li>
<li>Methodology：<a href="/blog/posts/vendor-%E6%B7%B1%E5%BA%A6%E6%8A%80%E8%A1%93%E6%96%87%E7%AB%A0%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84%E5%90%8C-vendor-%E7%B3%BB%E5%88%97%E7%9A%84%E9%96%8B%E5%A0%B4%E8%BC%AA%E6%9B%BF%E9%A9%97%E8%AD%89/" data-link-title="Vendor 深度技術文章方法論的演化紀錄：同 vendor 系列的開場輪替驗證" data-link-desc="vendor overview 飽和後要寫單一功能深度文章、需要選題與結構依據時回來。這套方法論的驗證來源與 cadence variant 在高風險場景（同 vendor sub-tool 系列）的實證。">Vendor 深度技術文章的寫作方法論</a></li>
</ul>
]]></content:encoded></item></channel></rss>