跳到主要內容

前端資安基本盤:金鑰、XSS、CSP

金鑰為什麼會外洩、怎麼確認沒有外洩、XSS 的三個入口,以及一組實際可用的安全標頭。

約 7 分鐘 · security-basics.md

1. 金鑰:唯一一條規則

任何金鑰都不能出現在瀏覽器會下載的檔案裡。

不是「不要顯示在畫面上」,是「不能存在於 bundle 中」。前端程式碼是公開的,minify 不是加密,.env 不是保險箱。

哪些變數會被打包進前端

框架 會進前端的前綴
Vite / SvelteKit PUBLIC_VITE_
Next.js NEXT_PUBLIC_
Nuxt runtimeConfig.public 底下的一切
Create React App REACT_APP_

沒有前綴的變數只有伺服器讀得到。所以金鑰永遠不加前綴。

在 SvelteKit:

// 正確:只能在 .server.ts 檔案裡 import,import 錯地方會直接 build 失敗
import { OPENAI_API_KEY } from '$env/static/private';

// 危險:這會出現在瀏覽器
import { PUBLIC_OPENAI_API_KEY } from '$env/static/public';

怎麼確認自己沒有外洩

npm run build
grep -r "sk-" .svelte-kit/output/client/ dist/ .next/static/ 2>/dev/null
grep -rE "AKIA|AIza|ghp_|xox[baprs]-" .svelte-kit/output/client/ dist/ 2>/dev/null

有輸出就是外洩。把這段放進 CI。

已經外洩了怎麼辦

  1. 先撤銷金鑰,不是先改程式碼。推上 git 的東西永遠都在。
  2. 檢查該金鑰的使用紀錄有沒有異常。
  3. 改成走伺服器代理。
  4. 從 git 歷史移除(git filter-repo),但這不能取代第 1 步

正確的架構

瀏覽器  →  你的 /api/chat  →  第三方 API
                ↑
          金鑰只活在這裡

代理層順便要做的事:驗證使用者身分、限制速率、限制可呼叫的模型與參數。沒有這幾樣的代理,等於把金鑰換成一個免費的公開端點。

2. XSS:三個入口

XSS 是「攻擊者的字串被當成程式碼執行」。前端只有三個地方會發生:

入口一:innerHTML / {@html} / dangerouslySetInnerHTML

<!-- 危險:comment 來自使用者 -->
{@html comment}

規則:只有你自己寫的內容可以直接注入。 使用者或模型產生的內容必須先淨化。

本站的做法(lib/markdown/index.ts):

  • renderTrusted() 給自家文件用,允許原始 HTML
  • renderUntrusted() 給使用者與模型輸出用:先把 <>& 逃脫再解析,於是產生的 HTML 裡只可能有 markdown parser 自己生成的標籤

為什麼不用 DOMPurify:它需要 DOM,而 Cloudflare Workers 上沒有。先逃脫再解析是更小、更容易驗證的策略。詳見 markdown-parser

入口二:URL 屬性

<a href="javascript:alert(1)">點我</a>
<img src="data:text/html;base64,...">

hrefsrc 的值如果來自外部,必須檢查 scheme:

const DANGEROUS = /^\s*(javascript|data|vbscript|file):/i;
if (DANGEROUS.test(url)) url = '#blocked';

入口三:把使用者資料放進 <script> 或事件屬性

不要拼接。要傳資料給前端,用 JSON.stringify 並且逃脫 <

const safe = JSON.stringify(data).replace(/</g, '\\u003c');

3. 安全標頭

放在 hooks.server.ts(或 middleware):

response.headers.set('X-Content-Type-Options', 'nosniff');
response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
response.headers.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
response.headers.set('X-Frame-Options', 'SAMEORIGIN');
標頭 擋掉什麼
X-Content-Type-Options: nosniff 瀏覽器把上傳的 .txt 當成 HTML 執行
Referrer-Policy 把含 token 的完整網址洩漏給外部站台
Permissions-Policy 第三方 iframe 偷開鏡頭麥克風
X-Frame-Options 你的頁面被嵌進別人的站做點擊劫持

CSP 的務實版本

完整的 CSP 很容易把自己鎖死。務實的順序是:

  1. 先鎖 script-src(最重要,擋掉注入的 script)
  2. object-src 'none'base-uri 'self'frame-ancestors 'self'form-action 'self'
  3. 先不要設 default-srcstyle-src

第 3 點是實務經驗:設了 default-src 'self' 之後,style-src 會繼承它而缺少 unsafe-inline,於是所有 style="..." 屬性被擋掉,畫面大面積壞掉。等其他都穩了再單獨處理樣式。

在 SvelteKit 用內建設定,它會自動幫框架自己產生的 script 加上 hash:

csp: {
  mode: 'auto',
  directives: {
    'script-src': ['self'],
    'object-src': ['none'],
    'base-uri': ['self'],
    'frame-ancestors': ['self'],
    'form-action': ['self']
  }
}

4. 其他常見的洞

開放重導向?redirect= 直接 location.href = param。必須驗證是站內路徑(以 / 開頭且不是 //)。

IDOR/api/orders/123 沒檢查這張訂單是不是屬於目前使用者。每一個讀寫都要驗擁有者,不要靠「前端不會顯示別人的連結」。

信任前端驗證:前端驗證是為了體驗,伺服器驗證才是安全。兩邊都要做。

把秘密藏在前端邏輯裡:feature flag、折扣碼、內部價格算法寫在前端 = 公開。

5. 上線前的檢查清單

  • build 產物裡搜不到任何金鑰
  • 所有第三方 API 呼叫都經過自己的伺服器
  • 所有 {@html} / innerHTML 的資料來源都是自己寫的,或有淨化
  • 外部 URL 都檢查過 scheme
  • 安全標頭都設了
  • 每一個 API 端點都驗證身分與擁有權
  • 錯誤訊息不含堆疊、檔案路徑、SQL
  • cookie 有 HttpOnlySecureSameSite

顯示設定

這裡改的每一項,會即時套用到站上所有預覽。

風格

密度

圓角

動態

系統層級的「減少動態效果」永遠優先於這裡的設定。

語言