[安全程式設計] 001 安全編碼基礎:輸入驗證與輸出編碼——永不信任使用者輸入的防禦心法
「你可以相信使用者會正確操作你的系統嗎?不行。
你可以相信攻擊者不會亂塞東西進來嗎?更不行。
安全編碼的第一課,就是學會對所有輸入說:『我不信你,但我會好好處理你。』」
— SSDLC by 飛飛
一、安全編碼是什麼?為什麼它是你的防火建材?
在 SSDLC 的「蓋房子」旅程中,我們已經走過了安全需求定義(確認要防震防火防盜)、安全設計(畫好建築藍圖、標好消防通道)。現在,我們來到了階段三:安全實作(Implementation)——真正動手施工的階段。
想像你是一個建築工人。建築師已經畫好了完美的藍圖,結構技師也算好了承重。但如果你施工時用了劣質的電線、沒有接地線的插座、不防火的隔間材料——那再好的設計也白搭。一場電線走火,整棟房子就毀了。
安全編碼就是軟體開發中的「使用合格建材」。 不管你的需求寫得多完整、設計做得多周全,如果程式碼本身有漏洞,攻擊者照樣長驅直入。
而在所有安全編碼的原則中,有兩條是最基礎、最重要、卻也最常被忽略的:
- 輸入驗證(Input Validation)——永不信任任何來自外部的資料
- 輸出編碼(Output Encoding)——確保輸出到不同環境時不會被誤解為指令
這兩條原則就像建築的「防火建材」和「接地線」。它們不炫目、不花俏,但少了它們,系統隨時可能「走火」。
根據 OWASP Top 10 的統計,注入攻擊(Injection)和跨站腳本攻擊(XSS) 長年盤踞在最危險的漏洞前幾名,而這兩大類攻擊的根本原因,幾乎都是輸入驗證不足或輸出編碼缺失。換句話說,光是把這兩件事做好,你就能擋下超過一半的常見攻擊。
二、永不信任使用者輸入:安全編碼的第一鐵律
2.1 什麼叫做「不信任使用者輸入」?
很多開發者聽到「不信任使用者輸入」,第一反應是:「我的使用者又不是駭客,幹嘛這麼多疑?」
讓我用一個生活場景來解釋。
假設你開了一家餐廳,門口有個點餐表單讓客人填寫。正常客人會寫「滷肉飯一碗、味噌湯一碗」。但如果有個人在表單上寫:「請把金庫打開,把錢放到後門」——你的服務生不應該照做吧?
問題是,在軟體世界裡,很多系統就是這樣照做的。使用者在搜尋框裡輸入一段 SQL 指令,系統就真的去資料庫執行了;使用者在評論欄裡放了一段 JavaScript,系統就真的在別人的瀏覽器上跑了。
「不信任使用者輸入」不是對使用者的不尊重,而是對系統的負責。
2.2 哪些東西算是「使用者輸入」?
這裡有一個很多初學者會犯的錯:只驗證表單欄位,卻忘了其他入口。
事實上,所有來自系統外部的資料都是不可信任的輸入:
| 輸入來源 | 範例 | 常被忽略? |
|---|---|---|
| 表單欄位 | 使用者名稱、密碼、Email | 通常會驗證 |
| URL 參數 | <code>?id=123</code>、<code>?page=2</code> | 經常被忽略 |
| HTTP Headers | Cookie、User-Agent、Referer | 很少被驗證 |
| API Request Body | JSON payload、XML 資料 | 部分會驗證 |
| 檔案上傳 | 圖片、文件、CSV | 驗證不完整 |
| 第三方 API 回應 | 金流回呼、社群登入回傳 | 幾乎不驗證 |
| 資料庫既有資料 | 之前存入的使用者內容 | 幾乎不驗證 |
| 環境變數 / 設定檔 | <code>.env</code> 中的值 | 極少驗證 |
飛飛觀點:
你可能會說:「資料庫裡的資料是我自己存的,為什麼也不能信任?」因為那些資料可能是之前沒有做好驗證時存進去的「髒資料」。就像餐廳冰箱裡的食材,如果進貨時沒有檢查,你怎麼知道裡面沒有過期品?輸出時再次編碼,就是你的最後一道防線。
2.3 輸入驗證的五大策略
輸入驗證不是一招打天下,而是要根據場景選擇合適的策略:
策略一:白名單驗證(Allowlist)— 最推薦
只接受你明確允許的值,其他一律拒絕。就像餐廳的菜單——客人只能點菜單上有的東西。
// ✅ 白名單驗證:只接受預定義的排序欄位
const ALLOWED_SORT_FIELDS = ['name', 'price', 'created_at', 'rating'];
function validateSortField(field) {
if (!ALLOWED_SORT_FIELDS.includes(field)) {
throw new Error('不支援的排序欄位');
}
return field;
}
// ✅ 白名單驗證:只接受特定的商品分類
const ALLOWED_CATEGORIES = ['electronics', 'clothing', 'food', 'books'];
app.get('/api/products', (req, res) => {
const category = req.query.category;
if (category && !ALLOWED_CATEGORIES.includes(category)) {
return res.status(400).json({ error: '無效的商品分類' });
}
// 安全地查詢...
});
策略二:型別與格式驗證(Type & Format)
確認輸入的資料型別和格式符合預期。
const { body, param, query, validationResult } = require('express-validator');
// ✅ 驗證各種資料格式
app.post('/api/users',
body('email')
.isEmail().withMessage('Email 格式不正確')
.normalizeEmail(),
body('age')
.isInt({ min: 0, max: 150 }).withMessage('年齡必須是 0-150 的整數'),
body('phone')
.matches(/^09\d{8}$/).withMessage('請輸入有效的台灣手機號碼'),
body('username')
.isAlphanumeric().withMessage('使用者名稱只能包含英文字母和數字')
.isLength({ min: 3, max: 20 }).withMessage('使用者名稱長度須為 3-20 字'),
(req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
// 通過驗證,安全處理...
}
);
策略三:長度限制(Length Restriction)
限制輸入的長度,防止緩衝區溢位或資源耗盡攻擊。
// ✅ 針對不同欄位設定合理的長度限制
const LENGTH_LIMITS = {
username: { min: 3, max: 20 },
password: { min: 12, max: 128 },
email: { min: 5, max: 254 },
comment: { min: 1, max: 2000 },
searchQuery: { min: 1, max: 100 },
address: { min: 5, max: 200 },
};
function validateLength(field, value) {
const limit = LENGTH_LIMITS[field];
if (!limit) throw new Error(<code class="kb-btn">未定義的欄位: ${field}</code>);
if (value.length < limit.min || value.length > limit.max) {
throw new Error(<code>${field} 長度須為 ${limit.min}-${limit.max} 字</code>);
}
return value;
}
策略四:範圍與邊界驗證(Range & Boundary)
確認數值在合理範圍內。
// ✅ 商品價格與數量的邊界驗證
app.post('/api/orders',
body('quantity')
.isInt({ min: 1, max: 99 })
.withMessage('購買數量須為 1-99'),
body('price')
.isFloat({ min: 0.01, max: 9999999.99 })
.withMessage('價格範圍不正確'),
body('discount')
.optional()
.isFloat({ min: 0, max: 1 })
.withMessage('折扣須為 0-1 之間'),
(req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
// ✅ 業務邏輯層的額外驗證
const { quantity, price, discount } = req.body;
const totalAmount = quantity * price * (1 - (discount || 0));
// 防止計算後金額異常(如負數或超大數值)
if (totalAmount <= 0 || totalAmount > 10000000) {
return res.status(400).json({ error: '訂單金額異常' });
}
// 安全處理...
}
);
策略五:黑名單過濾(Denylist)— 最不推薦,但有時是補充
阻擋已知的危險模式。這是最弱的防禦,因為攻擊者總能找到新的繞過方式,但作為補充手段仍有價值。
// ⚠️ 黑名單只能作為「補充」,不能作為唯一防線
const DANGEROUS_PATTERNS = [
/(<script[\s>])/gi, // 基本 XSS
/(javascript\s*:)/gi, // JavaScript 偽協定
/(on\w+\s*=)/gi, // 事件處理器
/(union\s+select)/gi, // SQL Injection
/(\.\.\/)|(\.\.\\)/g, // Path Traversal
];
function containsDangerousPattern(input) {
return DANGEROUS_PATTERNS.some(pattern => pattern.test(input));
}
// ⚠️ 注意:這不能取代參數化查詢和輸出編碼!
// 它只是多一層「告警」機制
if (containsDangerousPattern(userInput)) {
logger.warn('偵測到可疑輸入', { input: userInput, ip: req.ip });
// 可以選擇拒絕,或繼續處理但加強監控
}
2.4 前端驗證 vs. 後端驗證:為什麼只做前端不夠?
這是新手最常犯的錯誤之一:「我前端有用 JavaScript 驗證了,應該沒問題吧?」
| 面向 | 前端驗證 | 後端驗證 |
|---|---|---|
| 目的 | 提升使用者體驗 | 真正的安全防線 |
| 可被繞過? | 可以(用 DevTools、Postman、curl) | 不行(除非伺服器有漏洞) |
| 速度 | 即時回饋,不需等伺服器 | 需要一次請求 |
| 必要性 | 建議有,但非必須 | 絕對必須 |
// ❌ 只有前端驗證 = 沒有驗證
// 攻擊者可以直接用 curl 繞過前端
// curl -X POST http://your-api.com/login -d '{"email":"' OR 1=1--"}'
// ✅ 前端驗證 + 後端驗證 = 正確做法
// 前端:即時回饋使用者
// 後端:真正的安全關卡
飛飛觀點:
把前端驗證想成餐廳門口的菜單提示:「本店不賣酒」。正常客人看到會自動遵守。但如果有人硬要帶酒進來,你總不能只靠門口的告示牌擋吧?你需要服務生在入場時真正檢查——這就是後端驗證。
三、輸出編碼:防止你的資料被「誤解」為指令
3.1 為什麼輸入驗證做好了,還需要輸出編碼?
想像你收到一封信,內容寫著:「請把門打開」。如果這封信是你朋友寫的,你可能會照做。但如果這封信是有人偽造的呢?
在軟體世界裡,資料和指令的邊界非常脆弱。你以為存在資料庫裡的是一段文字,但當這段文字被放到 HTML 頁面上時,瀏覽器可能把它當成程式碼來執行。
這就是 XSS(跨站腳本攻擊)的本質:攻擊者的輸入被當成了指令。
輸出編碼的目的,就是確保資料永遠被當作資料,而不會被誤解為指令。
3.2 不同的輸出環境,需要不同的編碼方式
這是一個非常重要但經常被忽略的觀念:同一筆資料,放在不同的環境中,需要不同的編碼方式。
| 輸出環境 | 危險所在 | 編碼方式 | 範例 |
|---|---|---|---|
| HTML 內容 | <code><script></code> 會被執行 | HTML Entity 編碼 | <code><</code> → <code><</code> |
| HTML 屬性 | <code>" onmouseover="alert(1)</code> 可注入事件 | HTML Attribute 編碼 | <code>"</code> → <code>"</code> |
| JavaScript | 字串中的 <code>'</code> 可跳脫字串邊界 | JavaScript 編碼 | <code>'</code> → <code>\x27</code> |
| URL | 特殊字元改變 URL 結構 | URL 編碼 | <code>&</code> → <code>%26</code> |
| CSS | <code>expression()</code> 可執行 JS | CSS 編碼 | 移除 <code>expression</code>、<code>url()</code> |
| SQL | <code>'</code> 可跳脫字串邊界 | 參數化查詢(不是編碼) | 使用 Prepared Statement |
讓我用蓋房子的比喻來說明:你買了一桶水泥(資料),要把它用在不同地方。砌牆的水泥要加粗砂(HTML 編碼),填地基的水泥要加碎石(SQL 參數化),做裝飾面的水泥要加細砂(JavaScript 編碼)。同一種原料,用途不同,處理方式就不同。
3.3 HTML 輸出編碼:防止 XSS 的第一道防線
const he = require('he');
// ✅ 方法一:使用 he 套件做 HTML Entity 編碼
function escapeHTML(input) {
return he.encode(input);
}
// 範例
const userComment = '<script>alert("XSS")</script>';
const safeComment = escapeHTML(userComment);
// 輸出:<script>alert("XSS")</script>
// 瀏覽器會顯示文字,而非執行腳本
// ✅ 方法二:使用模板引擎的自動轉義(推薦)
// EJS 預設會轉義
// <%= userComment %> ← 會自動 HTML 編碼(安全)
// <%- userComment %> ← 不會編碼(危險!除非你確定內容安全)
// ✅ 方法三:使用 DOMPurify 做 HTML 清理(允許部分 HTML 時使用)
const createDOMPurify = require('dompurify');
const { JSDOM } = require('jsdom');
const window = new JSDOM('').window;
const DOMPurify = createDOMPurify(window);
function sanitizeHTML(dirty) {
return DOMPurify.sanitize(dirty, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'br'],
ALLOWED_ATTR: ['href', 'title'],
});
}
// 範例:允許部分格式化標籤,但移除危險內容
const richComment = '<b>好評</b><script>alert("hack")</script><a href="javascript:alert(1)">連結</a>';
const cleanComment = sanitizeHTML(richComment);
// 輸出:<b>好評</b><a>連結</a>
// script 被移除,javascript: 偽協定被清除
3.4 SQL 注入防禦:參數化查詢,不是字串拼接
SQL Injection 是史上最經典的注入攻擊。防禦方式不是「編碼」,而是從根本上把資料和指令分開——使用參數化查詢。
const { Pool } = require('pg');
const pool = new Pool();
// ❌ 危險寫法:字串拼接(SQL Injection 的溫床)
app.get('/api/products', async (req, res) => {
const category = req.query.category;
// 攻擊者可以輸入:' OR 1=1; DROP TABLE products; --
const result = await pool.query(
<code>SELECT * FROM products WHERE category = '${category}'</code>
);
res.json(result.rows);
});
// ✅ 安全寫法:參數化查詢
app.get('/api/products', async (req, res) => {
const category = req.query.category;
const result = await pool.query(
'SELECT * FROM products WHERE category = $1',
[category] // 資料和指令完全分離
);
res.json(result.rows);
});
// ✅ 使用 ORM(Sequelize)也是安全的
const products = await Product.findAll({
where: { category: req.query.category } // ORM 會自動參數化
});
// ❌ 但注意!ORM 的 raw query 一樣危險
// 避免這樣寫:
const [results] = await sequelize.query(
<code>SELECT * FROM products WHERE category = '${category}'</code> // 又回到字串拼接了!
);
// ✅ ORM 的 raw query 安全寫法
const [results] = await sequelize.query(
'SELECT * FROM products WHERE category = :category',
{ replacements: { category: req.query.category } }
);
3.5 JavaScript 輸出環境的編碼
當你需要將使用者資料嵌入 JavaScript 程式碼中時:
// ❌ 危險寫法:直接嵌入
app.get('/profile', (req, res) => {
const username = req.user.name; // 可能包含 '; alert('XSS'); //
res.send(`
<script>
var username = '${username}'; // 攻擊者可以跳脫字串
</script>
`);
});
// ✅ 安全寫法:使用 JSON.stringify + HTML 編碼
app.get('/profile', (req, res) => {
const username = req.user.name;
// JSON.stringify 會正確轉義引號和特殊字元
const safeData = JSON.stringify(username)
.replace(/</g, '\\u003c') // 防止 </script> 閉合標籤
.replace(/>/g, '\\u003e')
.replace(/&/g, '\\u0026');
res.send(`
<script>
var username = ${safeData};
</script>
`);
});
// ✅ 更推薦:使用 data attribute + JavaScript 讀取
app.get('/profile', (req, res) => {
const username = he.encode(req.user.name);
res.send(`
<div id="profile" data-username="${username}"></div>
<script>
var username = document.getElementById('profile').dataset.username;
</script>
`);
});
3.6 URL 編碼
// ❌ 危險寫法:直接拼接 URL
const redirectUrl = req.query.returnTo;
res.redirect(redirectUrl);
// 攻擊者可以輸入:javascript:alert(1) 或 https://evil.com
// ✅ 安全寫法:驗證 + 編碼
function safeRedirect(req, res) {
const returnTo = req.query.returnTo;
// 1. 白名單驗證:只允許自家網域
const ALLOWED_HOSTS = ['www.mystore.com.tw', 'mystore.com.tw'];
try {
const url = new URL(returnTo, 'https://mystore.com.tw');
if (!ALLOWED_HOSTS.includes(url.hostname)) {
return res.redirect('/'); // 不在白名單,導回首頁
}
// 2. 只允許 HTTPS
if (url.protocol !== 'https:') {
return res.redirect('/');
}
res.redirect(url.toString());
} catch (e) {
res.redirect('/'); // URL 解析失敗,導回首頁
}
}
四、實戰案例:台灣電商平台的商品評論功能
讓我們用一個完整的台灣場景,從頭到尾走一遍輸入驗證與輸出編碼的實作。
場景描述
你正在為一個台灣電商平台開發「商品評論」功能。使用者可以為購買過的商品留下文字評論和星等評分。
Step 1:列出所有輸入來源
| 輸入 | 型別 | 範圍 | 來源 |
|---|---|---|---|
| productId | 整數 | > 0 | URL 參數 |
| rating | 整數 | 1-5 | Request Body |
| comment | 字串 | 1-2000 字 | Request Body |
| userId | 整數 | > 0 | JWT Token(伺服器端解析) |
Step 2:定義驗證規則
const express = require('express');
const { body, param, validationResult } = require('express-validator');
const he = require('he');
const createDOMPurify = require('dompurify');
const { JSDOM } = require('jsdom');
const DOMPurify = createDOMPurify(new JSDOM('').window);
const app = express();
app.use(express.json({ limit: '10kb' })); // 限制 body 大小
// 評論的驗證規則
const reviewValidation = [
param('productId')
.isInt({ min: 1 })
.withMessage('商品 ID 格式錯誤'),
body('rating')
.isInt({ min: 1, max: 5 })
.withMessage('評分須為 1-5 的整數'),
body('comment')
.trim()
.isLength({ min: 1, max: 2000 })
.withMessage('評論內容須為 1-2000 字')
.customSanitizer(value => {
// 移除所有 HTML 標籤(評論不需要 HTML 格式)
return DOMPurify.sanitize(value, { ALLOWED_TAGS: [] });
}),
];
Step 3:完整的 API 實作
const { Pool } = require('pg');
const pool = new Pool();
app.post('/api/products/:productId/reviews',
authenticateToken, // 驗證 JWT(你是誰)
reviewValidation, // 輸入驗證(你給的資料合法嗎)
async (req, res) => {
// 檢查驗證結果
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({
error: '輸入驗證失敗',
details: errors.array().map(e => e.msg)
});
}
const productId = parseInt(req.params.productId);
const { rating, comment } = req.body;
const userId = req.user.id; // 從已驗證的 JWT 取得
try {
// 業務邏輯驗證:使用者是否購買過此商品
const purchaseCheck = await pool.query(
'SELECT id FROM orders WHERE user_id = $1 AND product_id = $2 AND status = $3',
[userId, productId, 'completed']
);
if (purchaseCheck.rows.length === 0) {
return res.status(403).json({ error: '只有購買過的客戶可以評論' });
}
// 業務邏輯驗證:是否已經評論過
const existingReview = await pool.query(
'SELECT id FROM reviews WHERE user_id = $1 AND product_id = $2',
[userId, productId]
);
if (existingReview.rows.length > 0) {
return res.status(409).json({ error: '您已經評論過此商品' });
}
// ✅ 使用參數化查詢寫入資料庫
const result = await pool.query(
`INSERT INTO reviews (user_id, product_id, rating, comment, created_at)
VALUES ($1, $2, $3, $4, NOW())
RETURNING id, rating, comment, created_at`,
[userId, productId, rating, comment]
);
// 記錄日誌(不包含評論全文,避免日誌過大)
logger.info('新評論建立', {
reviewId: result.rows[0].id,
userId,
productId,
rating
});
res.status(201).json({
message: '評論已送出',
review: result.rows[0]
});
} catch (error) {
logger.error('評論建立失敗', { error: error.message, userId, productId });
// ✅ 不洩露內部錯誤細節
res.status(500).json({ error: '系統處理時發生錯誤,請稍後再試' });
}
}
);
Step 4:安全地顯示評論(輸出編碼)
// ✅ API 回傳評論列表時的輸出處理
app.get('/api/products/:productId/reviews', async (req, res) => {
const productId = parseInt(req.params.productId);
if (isNaN(productId) || productId < 1) {
return res.status(400).json({ error: '商品 ID 格式錯誤' });
}
const result = await pool.query(
`SELECT r.id, r.rating, r.comment, r.created_at, u.username
FROM reviews r
JOIN users u ON r.user_id = u.id
WHERE r.product_id = $1
ORDER BY r.created_at DESC
LIMIT 50`,
[productId]
);
// ✅ 輸出時再次編碼,作為最後一道防線
const safeReviews = result.rows.map(review => ({
id: review.id,
rating: review.rating,
comment: he.encode(review.comment), // HTML 編碼
username: he.encode(review.username), // HTML 編碼
createdAt: review.created_at,
}));
res.json({ reviews: safeReviews });
});
Step 5:前端安全顯示
// ✅ 前端使用 textContent 而非 innerHTML
function renderReview(review) {
const reviewDiv = document.createElement('div');
reviewDiv.className = 'review-card';
const username = document.createElement('span');
username.textContent = review.username; // textContent 不會解析 HTML
const comment = document.createElement('p');
comment.textContent = review.comment; // 安全!不會執行任何腳本
const rating = document.createElement('span');
rating.textContent = '⭐'.repeat(review.rating);
reviewDiv.appendChild(username);
reviewDiv.appendChild(rating);
reviewDiv.appendChild(comment);
return reviewDiv;
}
// ❌ 危險寫法:永遠不要這樣做
// reviewDiv.innerHTML = <code><p>${review.comment}</p></code>;
// 如果 comment 包含 <script>,就會被執行!
五、安全編碼 Checklist:輸入驗證與輸出編碼
把這份清單貼在你的工位上(或加進你的 Code Review checklist):
輸入驗證 Checklist
## 輸入驗證 Checklist
### 基本原則
- [ ] 所有輸入都在**伺服器端**進行驗證(前端驗證只是輔助)
- [ ] 使用**白名單**策略,而非黑名單
- [ ] 驗證失敗時,回傳通用錯誤訊息(不洩露系統細節)
### 各欄位驗證
- [ ] 驗證資料型別(字串、整數、布林)
- [ ] 驗證長度/大小限制
- [ ] 驗證格式(Email、電話、身分證、日期)
- [ ] 驗證數值範圍(最小值、最大值)
- [ ] 使用白名單限定可接受的值(下拉選單、排序欄位)
### 特殊輸入
- [ ] 檔案上傳:驗證 MIME Type + Magic Number(不只看副檔名)
- [ ] 檔案上傳:限制檔案大小
- [ ] URL 參數:驗證並限定可接受的路徑
- [ ] JSON/XML Body:限制 Request Body 大小
- [ ] JSON/XML Body:驗證資料結構(schema validation)
### 進階防護
- [ ] 對同一 IP 或帳號實施速率限制(Rate Limiting)
- [ ] 記錄驗證失敗的日誌(供資安監控使用)
- [ ] 多層驗證:API Gateway → 中間件 → 業務邏輯層
輸出編碼 Checklist
## 輸出編碼 Checklist
### 基本原則
- [ ] 根據**輸出環境**選擇正確的編碼方式
- [ ] 在資料**輸出時**編碼,而非輸入時(保留原始資料)
- [ ] 使用經過驗證的編碼函式庫,不要自己寫
### HTML 環境
- [ ] 使用者資料放入 HTML 內容時,進行 HTML Entity 編碼
- [ ] 使用者資料放入 HTML 屬性時,進行屬性編碼且加上引號
- [ ] 如需允許部分 HTML,使用 DOMPurify 做白名單清理
- [ ] 設定 Content-Security-Policy Header 限制 inline script
### JavaScript 環境
- [ ] 使用者資料放入 JS 字串時,用 JSON.stringify + 額外轉義
- [ ] 優先使用 data attribute 傳遞資料,避免直接嵌入 <script>
### SQL 環境
- [ ] 所有 SQL 查詢使用參數化查詢(Prepared Statement)
- [ ] ORM 的 raw query 也要使用參數化
- [ ] 絕對不使用字串拼接組合 SQL
### URL 環境
- [ ] 使用者資料放入 URL 時,進行 URL 編碼
- [ ] 重導向 URL 使用白名單驗證
### HTTP Header
- [ ] 設定 Content-Type 為正確的 MIME Type
- [ ] 設定 X-Content-Type-Options: nosniff
- [ ] 設定 Content-Security-Policy
六、團隊落地建議:讓安全編碼變成日常習慣
建議一:建立團隊的安全編碼規範文件
不要只是口頭說「大家要注意安全」。把規範寫成文件,放在 Git 裡面版本控制:
/project
/docs
secure-coding-standard.md ← 團隊的安全編碼規範
/src
/middleware
input-validator.js ← 共用的輸入驗證中間件
output-encoder.js ← 共用的輸出編碼工具
/config
validation-rules.js ← 各欄位的驗證規則定義
建議二:封裝共用的安全工具
把輸入驗證和輸出編碼封裝成團隊共用的工具,降低每個開發者「重新發明輪子」的機會:
// /src/utils/security.js — 團隊共用的安全工具
const he = require('he');
const createDOMPurify = require('dompurify');
const { JSDOM } = require('jsdom');
const DOMPurify = createDOMPurify(new JSDOM('').window);
const security = {
// HTML 編碼
encodeHTML(input) {
if (typeof input !== 'string') return '';
return he.encode(input);
},
// HTML 清理(允許部分標籤)
sanitizeHTML(input, options = {}) {
if (typeof input !== 'string') return '';
return DOMPurify.sanitize(input, {
ALLOWED_TAGS: options.allowedTags || ['b', 'i', 'em', 'strong', 'p', 'br'],
ALLOWED_ATTR: options.allowedAttrs || [],
...options,
});
},
// 純文字清理(移除所有 HTML)
stripHTML(input) {
if (typeof input !== 'string') return '';
return DOMPurify.sanitize(input, { ALLOWED_TAGS: [] });
},
// 安全的 JSON 嵌入 HTML
safeJSONEmbed(data) {
return JSON.stringify(data)
.replace(/</g, '\\u003c')
.replace(/>/g, '\\u003e')
.replace(/&/g, '\\u0026')
.replace(/'/g, '\\u0027');
},
};
module.exports = security;
建議三:在 Code Review 時加入安全檢查項目
在團隊的 Code Review 流程中,加入以下安全問題:
- 這段程式碼有接受外部輸入嗎?有做驗證嗎?
- 有使用字串拼接組合 SQL 嗎?
- 使用者資料有被輸出到 HTML/JS/URL 嗎?有做編碼嗎?
- 錯誤處理有洩露系統內部資訊嗎?
建議四:使用 ESLint 安全規則自動檢查
// .eslintrc.js
module.exports = {
plugins: ['security'],
extends: ['plugin:security/recommended'],
rules: {
'security/detect-object-injection': 'warn',
'security/detect-non-literal-regexp': 'warn',
'security/detect-unsafe-regex': 'error',
'security/detect-buffer-noassert': 'error',
'security/detect-eval-with-expression': 'error',
'security/detect-no-csrf-before-method-override': 'error',
'security/detect-possible-timing-attacks': 'warn',
},
};
建議五:設定 HTTP 安全標頭作為額外防線
const helmet = require('helmet');
app.use(helmet());
// 或者手動設定關鍵的安全標頭
app.use((req, res, next) => {
// 防止 XSS:限制腳本來源
res.setHeader('Content-Security-Policy',
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'");
// 防止 MIME Type 嗅探
res.setHeader('X-Content-Type-Options', 'nosniff');
// 防止 Clickjacking
res.setHeader('X-Frame-Options', 'DENY');
// 強制 HTTPS
res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
next();
});
飛飛觀點:
安全編碼不是某一個人的責任,而是整個團隊的文化。當團隊有共用的安全工具、有 Code Review 的安全檢查項目、有自動化的靜態分析,安全編碼就不再是「額外的工作」,而是開發流程的自然一環。就像工地的安全帽——不是因為你膽小才戴,而是因為這是專業的基本。
七、常見問題 FAQ
Q1:輸入驗證應該在哪一層做?Controller?Service?還是 Middleware?
簡短回答:多層都做,各司其職。
- Middleware / Controller 層:做格式驗證(型別、長度、格式),用 express-validator 之類的工具
- Service / 業務邏輯層:做業務規則驗證(這個使用者有沒有購買過?庫存夠不夠?)
- Database 層:用資料庫的 constraint(NOT NULL、UNIQUE、CHECK)作為最後一道防線
每一層的驗證目的不同。Middleware 擋住明顯的惡意輸入,Service 確保業務邏輯正確,Database 防止資料不一致。
Q2:輸出編碼應該在存入資料庫前做,還是輸出時再做?
答案是:輸出時再做。
存入資料庫時,盡量保留原始資料。原因有兩個:一是你不知道這筆資料未來會輸出到什麼環境(HTML?Email?PDF?手機 App?),不同環境需要不同的編碼方式;二是如果你在存入時就編碼了,日後要搜尋、排序、分析這些資料會很困難。
唯一的例外是:如果你確定這筆資料永遠只會在特定環境使用,且存入前做清理有明確的安全需求(例如用 DOMPurify 移除惡意 HTML),那可以在存入時先做一次清理。
Q3:我用了 React / Vue 這類前端框架,還需要擔心 XSS 嗎?
React 和 Vue 確實會在預設情況下對輸出做 HTML 編碼,這大幅降低了 XSS 的風險。但有幾個陷阱要注意:
- React 的 <code>dangerouslySetInnerHTML</code> 不會做編碼(名字裡都有 dangerous 了)
- Vue 的 <code>v-html</code> 不會做編碼
- 將資料放入 <code>href</code>、<code>src</code> 等屬性時,<code>javascript:</code> 偽協定仍然危險
- Server-Side Rendering(SSR)時的處理方式可能不同
所以,前端框架幫你擋了大部分,但不代表你可以完全不管。特別是後端 API 回傳的資料,不要假設前端一定會正確處理。
Q4:Vibe Coding 時代,AI 生成的程式碼安全嗎?
不一定安全。 AI 生成的程式碼經常會出現安全問題,特別是:
- 直接用字串拼接 SQL(AI 為了「簡單」常這樣做)
- API 回傳完整的資料庫物件(包含密碼欄位)
- 缺少輸入驗證(AI 可能只生成「happy path」的程式碼)
- 使用已知有漏洞的套件版本
建議:AI 生成的每一段程式碼,都要用本文的 Checklist 檢查一遍。把 AI 當成一個「很快但粗心的實習生」——它寫得很快,但你要負責審查。
八、結語:安全編碼是手藝,不是負擔
回到蓋房子的比喻。
沒有人會說:「用防火建材好麻煩,我們直接用保麗龍隔間比較快。」因為每個人都知道,建材不合格,房子蓋再快也沒用——一把火就全沒了。
安全編碼也是一樣。輸入驗證和輸出編碼看起來是「多做的工作」,但它們其實是在保護你已經投入的所有心血。你花了幾個月開發的功能,不應該因為一個 SQL Injection 就全部白費。
而且,安全編碼做得越多,你會發現一件事:它會讓你的程式碼品質變好。 當你養成驗證輸入的習慣,你自然會把邊界條件想得更清楚;當你養成輸出編碼的習慣,你自然會更理解不同環境的差異。
這些都是好工程師的基本功。
安全編碼不是給程式碼加鎖鏈,而是給程式碼穿上工作服——
它不會讓你動作變慢,反而會讓你更專業、更可靠。
下一篇,我們將繼續探索安全實作階段的進階主題——OWASP Top 10 防禦實戰指南。當你掌握了輸入驗證與輸出編碼的基本功,就可以進一步了解更多攻擊手法與對應的防禦策略。