前端資安基本盤:金鑰、XSS、CSP
金鑰為什麼會外洩、怎麼確認沒有外洩、XSS 的三個入口,以及一組實際可用的安全標頭。
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。
已經外洩了怎麼辦
- 先撤銷金鑰,不是先改程式碼。推上 git 的東西永遠都在。
- 檢查該金鑰的使用紀錄有沒有異常。
- 改成走伺服器代理。
- 從 git 歷史移除(
git filter-repo),但這不能取代第 1 步。
正確的架構
瀏覽器 → 你的 /api/chat → 第三方 API
↑
金鑰只活在這裡
代理層順便要做的事:驗證使用者身分、限制速率、限制可呼叫的模型與參數。沒有這幾樣的代理,等於把金鑰換成一個免費的公開端點。
2. XSS:三個入口
XSS 是「攻擊者的字串被當成程式碼執行」。前端只有三個地方會發生:
入口一:innerHTML / {@html} / dangerouslySetInnerHTML
<!-- 危險:comment 來自使用者 -->
{@html comment}
規則:只有你自己寫的內容可以直接注入。 使用者或模型產生的內容必須先淨化。
本站的做法(lib/markdown/index.ts):
renderTrusted()給自家文件用,允許原始 HTMLrenderUntrusted()給使用者與模型輸出用:先把<、>、&逃脫再解析,於是產生的 HTML 裡只可能有 markdown parser 自己生成的標籤
為什麼不用 DOMPurify:它需要 DOM,而 Cloudflare Workers 上沒有。先逃脫再解析是更小、更容易驗證的策略。詳見 markdown-parser。
入口二:URL 屬性
<a href="javascript:alert(1)">點我</a>
<img src="data:text/html;base64,...">
href 和 src 的值如果來自外部,必須檢查 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 很容易把自己鎖死。務實的順序是:
- 先鎖
script-src(最重要,擋掉注入的 script) - 加
object-src 'none'、base-uri 'self'、frame-ancestors 'self'、form-action 'self' - 先不要設
default-src和style-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 有
HttpOnly、Secure、SameSite