[安全程式設計] 001 安全編碼基礎:輸入驗證與輸出編碼——永不信任使用者輸入的防禦心法

「你可以相信使用者會正確操作你的系統嗎?不行。
你可以相信攻擊者不會亂塞東西進來嗎?更不行。
安全編碼的第一課,就是學會對所有輸入說:『我不信你,但我會好好處理你。』」
— SSDLC by 飛飛


一、安全編碼是什麼?為什麼它是你的防火建材?

在 SSDLC 的「蓋房子」旅程中,我們已經走過了安全需求定義(確認要防震防火防盜)、安全設計(畫好建築藍圖、標好消防通道)。現在,我們來到了階段三:安全實作(Implementation)——真正動手施工的階段。

想像你是一個建築工人。建築師已經畫好了完美的藍圖,結構技師也算好了承重。但如果你施工時用了劣質的電線、沒有接地線的插座、不防火的隔間材料——那再好的設計也白搭。一場電線走火,整棟房子就毀了。

安全編碼就是軟體開發中的「使用合格建材」。 不管你的需求寫得多完整、設計做得多周全,如果程式碼本身有漏洞,攻擊者照樣長驅直入。

而在所有安全編碼的原則中,有兩條是最基礎、最重要、卻也最常被忽略的:

  1. 輸入驗證(Input Validation)——永不信任任何來自外部的資料
  2. 輸出編碼(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 流程中,加入以下安全問題:

  1. 這段程式碼有接受外部輸入嗎?有做驗證嗎?
  2. 有使用字串拼接組合 SQL 嗎?
  3. 使用者資料有被輸出到 HTML/JS/URL 嗎?有做編碼嗎?
  4. 錯誤處理有洩露系統內部資訊嗎?

建議四:使用 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 防禦實戰指南。當你掌握了輸入驗證與輸出編碼的基本功,就可以進一步了解更多攻擊手法與對應的防禦策略。


延伸閱讀