[安全程式設計] 003 密碼雜湊與機敏資料處理實戰指南:bcrypt vs Argon2、AES-256 加密與金鑰管理

「密碼存明碼,就像把金庫密碼寫在便利貼上貼在螢幕旁邊——
你不是沒鎖門,你是把鑰匙插在門上。」
— SSDLC by 飛飛


一、加密與雜湊是什麼?為什麼搞混了會出大事?

在 SSDLC 的「蓋房子」旅程中,我們已經走過了安全需求定義(確認房子要防什麼)、安全設計(畫好建築藍圖)、安全編碼基礎(學會用防火建材和安全電線)、OWASP Top 10 防禦(認識十大常見工安事故)。

現在,我們要處理蓋房子時最珍貴的東西——保險箱裡的財物

在軟體系統中,「保險箱裡的財物」就是密碼、信用卡號、個人身分證字號、API 金鑰這些機敏資料(Sensitive Data)。這些資料如果被偷走或外洩,後果不堪設想:使用者帳號被盜、信用卡被盜刷、公司面臨個資法罰款(最高 NT$1,500 萬)。

而保護這些財物的兩個核心工具,就是加密(Encryption)雜湊(Hashing)

很多開發者會搞混這兩個概念,甚至互相混用——這就像把保險箱當碎紙機用,或拿碎紙機當保險箱。先用一個生活比喻釐清:

概念 英文 生活比喻 核心特性 適用場景
加密 Encryption 把信件放進上鎖的保險箱,有鑰匙的人可以打開 可逆——加密後可以解密還原 傳輸中的資料、儲存需要讀取的機敏資料
雜湊 Hashing 把文件丟進碎紙機,變成固定大小的碎片 不可逆——無法從碎片還原文件 密碼儲存、資料完整性驗證

一句話記住:需要還原的用加密,不需要還原的用雜湊。

密碼為什麼要用雜湊而不是加密?因為你永遠不需要知道使用者的原始密碼。登入時,你只需要把使用者輸入的密碼做雜湊,跟資料庫裡存的雜湊值比對就好。如果你用加密存密碼,代表你的系統「有能力解密看到所有人的密碼」——萬一這把鑰匙被偷了,全部密碼一次外洩。

飛飛觀點:
我見過太多台灣新創把密碼用 AES 加密存在資料庫裡,覺得「有加密就安全了」。問題是:加密的金鑰放哪裡?如果金鑰跟密碼存在同一台伺服器上,那攻擊者只要入侵一次就全拿走了。密碼用雜湊,就是讓攻擊者就算拿到資料庫,也拿不到原始密碼。


二、密碼雜湊:bcrypt vs Argon2——別再用 MD5 了

2.1 為什麼 MD5 和 SHA-256 不能用來雜湊密碼?

很多教科書的範例還在用 <code>md5(password)</code> 或 <code>sha256(password)</code>,但這些通用雜湊函數根本不是設計來保護密碼的。原因有三:

問題 說明 後果
太快了 MD5 每秒可以計算數十億次,SHA-256 也差不多 攻擊者用 GPU 暴力破解,8 碼密碼幾小時就破完
沒有鹽(Salt) 同樣的密碼永遠產生同樣的雜湊 攻擊者用彩虹表(Rainbow Table)秒查
不能調整難度 運算量固定,無法隨硬體進步而增加 十年前安全的設定,現在可能已經不夠

什麼是彩虹表?簡單說,就是有人事先把常見密碼(如 <code>123456</code>、<code>password</code>、<code>qwerty123</code>)的 MD5/SHA-256 雜湊值全部算好,建成一個超大的對照表。攻擊者拿到你資料庫裡的雜湊值,直接查表就知道原始密碼。中國甚至有商業網站(如 CMD5)專門提供這種反查服務。

密碼專用雜湊函數(如 bcrypt、Argon2)專門解決這三個問題:加鹽、故意設計得很慢、可以調整難度。

2.2 bcrypt:老牌可靠的密碼守衛

bcrypt 誕生於 1999 年,是目前使用最廣泛的密碼雜湊演算法。它有三個關鍵特性:

  • 自動加鹽:每次雜湊都會產生隨機鹽值,同樣的密碼會得到不同的雜湊值
  • 可調整工作因子(Cost Factor):數字每加 1,計算時間翻倍
  • 經過 25 年以上的實戰考驗
// Node.js — 使用 bcrypt 雜湊密碼
const bcrypt = require('bcrypt');

// ✅ 註冊時:雜湊密碼
async function hashPassword(plainPassword) {
  const saltRounds = 12; // 工作因子:2^12 = 4096 次迭代
  // saltRounds 每加 1,計算時間約翻倍
  // 10 ≈ 100ms, 12 ≈ 300ms, 14 ≈ 1s(在現代硬體上)
  return await bcrypt.hash(plainPassword, saltRounds);
}

// ✅ 登入時:驗證密碼
async function verifyPassword(plainPassword, hashedPassword) {
  // bcrypt.compare 會自動從雜湊值中提取鹽值來比對
  return await bcrypt.compare(plainPassword, hashedPassword);
}

// 使用範例
const hashed = await hashPassword('MyP@ssw0rd!2024');
// 結果類似:$2b$12$LJ3m5x2Y8v4Z... (每次都不同,因為鹽值不同)

const isValid = await verifyPassword('MyP@ssw0rd!2024', hashed);
// true

2.3 Argon2:新一代密碼雜湊冠軍

Argon2 是 2015 年密碼雜湊競賽(Password Hashing Competition, PHC)的冠軍,被認為是目前最安全的密碼雜湊演算法。它比 bcrypt 多了一個超能力:抵抗 GPU 暴力破解

bcrypt 主要消耗 CPU 運算時間,但現代 GPU(如 NVIDIA RTX 4090)有數千個核心可以平行計算,能大幅加速暴力破解。Argon2 不只消耗 CPU,還會消耗大量記憶體,而 GPU 的記憶體相對有限且昂貴,這讓 GPU 暴力破解變得極不划算。

Argon2 有三種變體:

變體 特性 建議場景
Argon2d 抗 GPU 攻擊,但可能受 side-channel 攻擊影響 加密貨幣挖礦
Argon2i 抗 side-channel 攻擊 密碼雜湊(較保守)
Argon2id 結合 d 和 i 的優點 密碼雜湊的首選(OWASP 推薦)
// Node.js — 使用 Argon2id 雜湊密碼(推薦)
const argon2 = require('argon2');

// ✅ 註冊時:雜湊密碼
async function hashPassword(plainPassword) {
  return await argon2.hash(plainPassword, {
    type: argon2.argon2id,  // 使用 Argon2id 變體
    memoryCost: 65536,      // 64 MB 記憶體(讓 GPU 暴力破解變得昂貴)
    timeCost: 3,            // 迭代次數
    parallelism: 4          // 平行度(使用 4 個執行緒)
  });
}

// ✅ 登入時:驗證密碼
async function verifyPassword(plainPassword, hashedPassword) {
  return await argon2.verify(hashedPassword, plainPassword);
}

// ✅ 進階:根據伺服器效能動態調整參數
async function hashPasswordWithTuning(plainPassword) {
  const startTime = Date.now();
  const hashed = await argon2.hash(plainPassword, {
    type: argon2.argon2id,
    memoryCost: 65536,
    timeCost: 3,
    parallelism: 4
  });
  const elapsed = Date.now() - startTime;

  // 目標:雜湊一次花費 200ms ~ 500ms
  // 太快 = 攻擊者也能快速暴力破解
  // 太慢 = 使用者登入體驗差
  console.log(<code>雜湊耗時:${elapsed}ms</code>);

  return hashed;
}

2.4 bcrypt vs Argon2:到底該選哪個?

比較項目 bcrypt Argon2id
問世年份 1999 年 2015 年
抗 GPU 攻擊 中等(主要消耗 CPU) (消耗 CPU + 大量記憶體)
成熟度 25 年以上實戰,非常成熟 較新,但已獲廣泛認可
OWASP 推薦 ✅ 推薦(備選) 首選推薦
生態系支援 幾乎所有語言都有穩定套件 主流語言都有,但部分套件需要原生編譯
密碼長度限制 最多 72 bytes(超過會被截斷) 無實質限制
參數調整彈性 只能調工作因子 可調記憶體、時間、平行度
建議使用場景 既有系統、部署環境受限 新專案首選

飛飛觀點:
如果你在做新專案,選 Argon2id。如果你的系統已經在用 bcrypt 而且 cost factor 夠高(至少 12),不用急著遷移——bcrypt 依然是安全的。最重要的是:不管你選哪個,都比 MD5、SHA-256、甚至 SHA-512 好一萬倍。

2.5 密碼雜湊的 OWASP 建議參數

// OWASP 2024 推薦的最低參數設定

// Argon2id(首選)
const argon2Config = {
  type: argon2.argon2id,
  memoryCost: 19456,    // OWASP 最低建議:19 MB
  timeCost: 2,          // OWASP 最低建議:2 次迭代
  parallelism: 1        // OWASP 最低建議:1 執行緒
};
// 如果伺服器資源允許,建議提高到:memoryCost: 65536, timeCost: 3, parallelism: 4

// bcrypt(備選)
const bcryptConfig = {
  saltRounds: 12        // OWASP 最低建議:10,建議用 12 以上
};

三、對稱加密 vs 非對稱加密:什麼時候該用哪一把鎖?

密碼用雜湊解決了,但有些資料你需要加密後還能解密還原——比如使用者的信用卡號、身分證字號、醫療記錄。這時候就要用「加密」了。

加密分成兩大類:對稱加密非對稱加密。用一個生活比喻來區分:

3.1 對稱加密:同一把鑰匙開同一把鎖

比喻:你跟室友共用一把門鑰匙。你鎖門,他也能開門,因為你們用的是同一把鑰匙。

對稱加密的「加密」和「解密」用的是同一把金鑰。速度快、效率高,適合加密大量資料。

演算法 安全等級 金鑰長度 狀態
DES ❌ 已不安全 56 bit 已淘汰,別用
3DES ⚠️ 逐步淘汰 168 bit NIST 2023 年後禁用
AES-128 ✅ 安全 128 bit 目前標準
AES-256 ✅ 非常安全 256 bit 推薦使用
// Node.js — 使用 AES-256-GCM 加密(推薦)
const crypto = require('crypto');

// ✅ 加密函數
function encrypt(plainText, secretKey) {
  // GCM 模式提供加密 + 完整性驗證(比 CBC 更安全)
  const iv = crypto.randomBytes(12);  // GCM 建議 12 bytes IV
  const cipher = crypto.createCipheriv('aes-256-gcm', secretKey, iv);

  let encrypted = cipher.update(plainText, 'utf8', 'hex');
  encrypted += cipher.final('hex');

  const authTag = cipher.getAuthTag(); // 認證標籤,防止密文被竄改

  // 將 IV + 認證標籤 + 密文一起存儲
  return {
    iv: iv.toString('hex'),
    authTag: authTag.toString('hex'),
    encryptedData: encrypted
  };
}

// ✅ 解密函數
function decrypt(encryptedObj, secretKey) {
  const decipher = crypto.createDecipheriv(
    'aes-256-gcm',
    secretKey,
    Buffer.from(encryptedObj.iv, 'hex')
  );

  decipher.setAuthTag(Buffer.from(encryptedObj.authTag, 'hex'));

  let decrypted = decipher.update(encryptedObj.encryptedData, 'hex', 'utf8');
  decrypted += decipher.final('utf8');

  return decrypted;
}

// 使用範例
const key = crypto.randomBytes(32); // 256 bit 金鑰
const encrypted = encrypt('4311-9522-0000-1234', key);
const decrypted = decrypt(encrypted, key);
// decrypted === '4311-9522-0000-1234'

為什麼用 GCM 而不是 CBC?

模式 加密 完整性驗證 說明
AES-CBC 只加密,不驗證。攻擊者可以偷偷修改密文(Padding Oracle Attack)
AES-GCM 加密 + 驗證。密文被竄改會直接解密失敗

3.2 非對稱加密:一把鑰匙鎖門,另一把鑰匙開門

比喻:你家門口有一個投信口(公鑰),任何人都可以把信塞進去。但只有你拿著門鑰匙(私鑰)才能打開信箱拿信。

非對稱加密使用一對金鑰:公鑰(Public Key)和私鑰(Private Key)。公鑰加密的東西只有私鑰能解密,反之亦然。

演算法 金鑰長度 速度 適用場景
RSA 2048+ bit 較慢 數位簽章、金鑰交換(傳統方案)
ECDSA 256 bit 數位簽章(現代首選)
ECDH 256 bit 金鑰交換
Ed25519 256 bit 很快 數位簽章(最新推薦)
// Node.js — 使用 RSA 非對稱加密
const crypto = require('crypto');

// ✅ Step 1:產生金鑰對
const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', {
  modulusLength: 2048,
  publicKeyEncoding: { type: 'spki', format: 'pem' },
  privateKeyEncoding: { type: 'pkcs8', format: 'pem' }
});

// ✅ Step 2:用公鑰加密(任何人都可以加密)
function encryptWithPublicKey(plainText, pubKey) {
  const encrypted = crypto.publicEncrypt(
    {
      key: pubKey,
      padding: crypto.constants.RSA_OAEP_PADDING,
      oaepHash: 'sha256'
    },
    Buffer.from(plainText)
  );
  return encrypted.toString('base64');
}

// ✅ Step 3:用私鑰解密(只有持有私鑰的人能解密)
function decryptWithPrivateKey(encryptedBase64, privKey) {
  const decrypted = crypto.privateDecrypt(
    {
      key: privKey,
      padding: crypto.constants.RSA_OAEP_PADDING,
      oaepHash: 'sha256'
    },
    Buffer.from(encryptedBase64, 'base64')
  );
  return decrypted.toString('utf8');
}

3.3 什麼時候用對稱、什麼時候用非對稱?

場景 用什麼 原因
加密資料庫中的信用卡號 對稱加密(AES-256-GCM) 加解密都在自己的伺服器,速度快
加密 API 傳輸中的資料 非對稱 + 對稱(HTTPS/TLS) 先用非對稱交換對稱金鑰,再用對稱加密資料
JWT Token 簽章 非對稱(RS256 / ES256) 多個服務需要驗證 Token,但只有一個服務能簽發
加密設定檔中的密碼 對稱加密(AES-256) 單機使用,速度快
驗證軟體更新的真實性 非對稱(數位簽章) 發布者用私鑰簽章,使用者用公鑰驗證

飛飛觀點:
在實務上,很少「只用對稱」或「只用非對稱」。HTTPS(TLS)就是一個完美的混合案例:先用非對稱加密安全地交換一組「對話金鑰」(Session Key),之後的通訊就用這把對稱金鑰來加密。這叫做混合加密(Hybrid Encryption)——用非對稱解決「怎麼安全傳遞金鑰」的問題,用對稱解決「怎麼高效加密大量資料」的問題。


四、金鑰管理:最容易被忽略的致命問題

你可以用最強的 AES-256 加密、最安全的 Argon2id 雜湊,但如果金鑰管理沒做好,一切都是白搭。

這就像你買了一把世界上最堅固的鎖,但把鑰匙藏在門口的花盆底下——小偷第一個就翻那裡。

4.1 金鑰管理最常犯的五大錯誤

錯誤 有多常見 後果
金鑰硬寫在程式碼裡 非常常見 推上 GitHub = 全世界都看得到
金鑰跟資料存在同一台伺服器 常見 伺服器被入侵 = 金鑰 + 資料一起外洩
所有環境用同一組金鑰 常見 開發環境洩漏 = 正式環境也完蛋
金鑰永遠不換(No Rotation) 非常常見 金鑰洩漏卻不知道,損害持續擴大
沒有金鑰備份與恢復計畫 常見 金鑰遺失 = 資料永遠無法解密
// ❌ 最常見的致命錯誤:金鑰硬寫在程式碼裡
const SECRET_KEY = 'my-super-secret-key-12345';  // 推上 GitHub 就完蛋
const DB_PASSWORD = 'admin123';                    // 更不用說這種
const API_KEY = 'sk-abc123def456';                 // 第三方 API 金鑰也是

// ❌ 稍微好一點但還是不夠:寫在設定檔裡
// config.json(也被推上了 GitHub)
// { "secretKey": "my-super-secret-key" }

// ✅ 正確做法:從環境變數讀取
const SECRET_KEY = process.env.JWT_SECRET;
const DB_PASSWORD = process.env.DB_PASSWORD;
const API_KEY = process.env.STRIPE_API_KEY;

// ✅ 加上驗證:確保金鑰存在且符合最低要求
function validateEnvSecrets() {
  const required = ['JWT_SECRET', 'DB_PASSWORD', 'ENCRYPTION_KEY'];

  for (const key of required) {
    if (!process.env[key]) {
      throw new Error(<code>環境變數 ${key} 未設定,拒絕啟動</code>);
    }
    if (process.env[key].length < 32) {
      throw new Error(<code>環境變數 ${key} 長度不足 32 字元</code>);
    }
  }
}

// 在應用程式啟動時立即檢查
validateEnvSecrets();

4.2 金鑰管理的四個層級

從「堪用」到「企業級」,金鑰管理可以分成四個層級。你可以根據專案的規模和敏感程度,選擇適合的層級:

層級 做法 適合場景 安全程度
Level 1 環境變數 + <code>.env</code> 檔(不進版控) Side project、個人專案 ⭐⭐
Level 2 CI/CD 的 Secrets 管理(GitHub Secrets、GitLab CI Variables) 小型團隊、新創 ⭐⭐⭐
Level 3 專用的 Secrets Manager(AWS Secrets Manager、HashiCorp Vault) 中型以上企業 ⭐⭐⭐⭐
Level 4 HSM 硬體安全模組(Hardware Security Module) 金融業、政府機關、醫療體系 ⭐⭐⭐⭐⭐

Level 1:環境變數(最低要求)

# .env 檔案(加入 .gitignore,絕對不能進版控)
JWT_SECRET=a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6
ENCRYPTION_KEY=q7r8s9t0u1v2w3x4y5z6a7b8c9d0e1f2
DB_PASSWORD=your-strong-database-password-here
# .gitignore — 這幾行是必須的
.env
.env.local
.env.production
*.key
*.pem

Level 3:AWS Secrets Manager 範例

// Node.js — 使用 AWS Secrets Manager
const { SecretsManagerClient, GetSecretValueCommand } = require('@aws-sdk/client-secrets-manager');

const client = new SecretsManagerClient({ region: 'ap-northeast-1' }); // 東京區域

async function getSecret(secretName) {
  const command = new GetSecretValueCommand({ SecretId: secretName });
  const response = await client.send(command);
  return JSON.parse(response.SecretString);
}

// 應用程式啟動時載入所有 Secrets
async function loadSecrets() {
  const secrets = await getSecret('my-app/production');
  // secrets = { JWT_SECRET: '...', DB_PASSWORD: '...', ENCRYPTION_KEY: '...' }
  return secrets;
}

4.3 金鑰輪換(Key Rotation):定期換鎖

金鑰跟密碼一樣,不是設了就永遠不動。定期更換金鑰(Key Rotation)是金鑰管理的核心實踐。

為什麼要輪換?因為你不一定知道金鑰什麼時候已經洩漏了。定期更換,可以限制洩漏的影響範圍。

// Node.js — 金鑰輪換策略:同時支援新舊金鑰
const ENCRYPTION_KEYS = {
  // 金鑰版本管理:新資料用最新版加密,解密時根據版本號選擇對應金鑰
  'v3': process.env.ENCRYPTION_KEY_V3,  // 目前使用(加密用)
  'v2': process.env.ENCRYPTION_KEY_V2,  // 前一版(解密用,過渡期保留)
  // 'v1' 已廢棄,不再保留
};

const CURRENT_KEY_VERSION = 'v3';

// ✅ 加密時:永遠使用最新版金鑰,並記錄版本號
function encryptWithVersion(plainText) {
  const key = Buffer.from(ENCRYPTION_KEYS[CURRENT_KEY_VERSION], 'hex');
  const iv = crypto.randomBytes(12);
  const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);

  let encrypted = cipher.update(plainText, 'utf8', 'hex');
  encrypted += cipher.final('hex');

  return {
    version: CURRENT_KEY_VERSION,  // 記錄用了哪個版本的金鑰
    iv: iv.toString('hex'),
    authTag: cipher.getAuthTag().toString('hex'),
    data: encrypted
  };
}

// ✅ 解密時:根據版本號找到對應的金鑰
function decryptWithVersion(encryptedObj) {
  const key = Buffer.from(ENCRYPTION_KEYS[encryptedObj.version], 'hex');
  if (!key) {
    throw new Error(<code>金鑰版本 ${encryptedObj.version} 已廢棄,無法解密</code>);
  }

  const decipher = crypto.createDecipheriv(
    'aes-256-gcm', key, Buffer.from(encryptedObj.iv, 'hex')
  );
  decipher.setAuthTag(Buffer.from(encryptedObj.authTag, 'hex'));

  let decrypted = decipher.update(encryptedObj.data, 'hex', 'utf8');
  decrypted += decipher.final('utf8');
  return decrypted;
}

// ✅ 背景任務:逐步將舊版金鑰加密的資料重新加密
async function reEncryptOldRecords() {
  const oldRecords = await db.query(
    "SELECT id, encrypted_data FROM sensitive_data WHERE encrypted_data->>'version' != $1",
    [CURRENT_KEY_VERSION]
  );

  for (const record of oldRecords.rows) {
    const decrypted = decryptWithVersion(record.encrypted_data);
    const reEncrypted = encryptWithVersion(decrypted);
    await db.query(
      'UPDATE sensitive_data SET encrypted_data = $1 WHERE id = $2',
      [JSON.stringify(reEncrypted), record.id]
    );
  }
}

飛飛觀點:
金鑰輪換最怕的就是「換了新鑰匙,舊鎖打不開了」。所以一定要做版本管理——加密時記錄用了哪個版本的金鑰,解密時根據版本號去找對應的金鑰。過渡期內新舊金鑰並存,等所有舊資料都重新加密完了,再廢棄舊金鑰。


五、實戰案例:台灣電商會員系統的機敏資料處理

讓我們用一個完整的案例,走過從頭到尾的機敏資料處理流程。

場景描述

你的團隊要為一間台灣電商平台處理以下機敏資料:

資料類型 範例 處理方式 原因
密碼 <code>MyP@ss123</code> Argon2id 雜湊 不需要還原,只要比對
信用卡號 <code>4311-9522-0000-1234</code> AES-256-GCM 加密 需要解密用於扣款
身分證字號 <code>A123456789</code> AES-256-GCM 加密 法規要求可查詢
Email <code>user@example.com</code> 明碼存儲 + 存取控制 需要經常讀取,用權限控制保護
手機號碼 <code>0912-345-678</code> AES-256-GCM 加密 或遮蔽 依業務需求決定
JWT Secret <code>a1b2c3…</code> 環境變數 / Secrets Manager 金鑰,不進資料庫

完整程式碼範例

// /src/services/crypto.service.js
// 台灣電商平台:機敏資料處理服務

const argon2 = require('argon2');
const crypto = require('crypto');

class CryptoService {
  constructor() {
    // 從環境變數載入金鑰(啟動時驗證)
    this.encryptionKey = Buffer.from(process.env.ENCRYPTION_KEY, 'hex');
    if (this.encryptionKey.length !== 32) {
      throw new Error('ENCRYPTION_KEY 必須是 64 個 hex 字元(32 bytes)');
    }
  }

  // ===== 密碼處理(雜湊,不可逆)=====

  async hashPassword(plainPassword) {
    return await argon2.hash(plainPassword, {
      type: argon2.argon2id,
      memoryCost: 65536,
      timeCost: 3,
      parallelism: 4
    });
  }

  async verifyPassword(plainPassword, hashedPassword) {
    try {
      return await argon2.verify(hashedPassword, plainPassword);
    } catch {
      return false; // 格式錯誤也回傳 false,不洩漏錯誤資訊
    }
  }

  // ===== 機敏資料加密(可逆)=====

  encrypt(plainText) {
    const iv = crypto.randomBytes(12);
    const cipher = crypto.createCipheriv('aes-256-gcm', this.encryptionKey, iv);

    let encrypted = cipher.update(plainText, 'utf8', 'hex');
    encrypted += cipher.final('hex');

    // 將 IV + AuthTag + 密文合併為一個字串,方便存儲
    return [
      iv.toString('hex'),
      cipher.getAuthTag().toString('hex'),
      encrypted
    ].join(':');
  }

  decrypt(encryptedString) {
    const [ivHex, authTagHex, encryptedData] = encryptedString.split(':');

    const decipher = crypto.createDecipheriv(
      'aes-256-gcm',
      this.encryptionKey,
      Buffer.from(ivHex, 'hex')
    );
    decipher.setAuthTag(Buffer.from(authTagHex, 'hex'));

    let decrypted = decipher.update(encryptedData, 'hex', 'utf8');
    decrypted += decipher.final('utf8');
    return decrypted;
  }

  // ===== 資料遮蔽(顯示用)=====

  maskCreditCard(cardNumber) {
    // 4311-9522-0000-1234 → **** **** **** 1234
    const clean = cardNumber.replace(/[-\s]/g, '');
    return <code class="kb-btn">**** **** **** ${clean.slice(-4)}</code>;
  }

  maskPhone(phone) {
    // 0912-345-678 → 09XX-XXX-678
    return phone.replace(/(\d{2})\d{2}-\d{3}-(\d{3})/, '$1XX-XXX-$2');
  }

  maskIdNumber(idNumber) {
    // A123456789 → A12*****89
    return idNumber.slice(0, 3) + '*****' + idNumber.slice(-2);
  }
}

module.exports = new CryptoService();
// /src/routes/user.routes.js
// 註冊與登入流程

const cryptoService = require('../services/crypto.service');

// ✅ 註冊:密碼雜湊 + 機敏資料加密
app.post('/api/register', async (req, res) => {
  const { email, password, phone, idNumber } = req.body;

  // 密碼 → Argon2id 雜湊(不可逆)
  const passwordHash = await cryptoService.hashPassword(password);

  // 手機 → AES-256-GCM 加密(可逆,需要發簡訊驗證)
  const encryptedPhone = cryptoService.encrypt(phone);

  // 身分證 → AES-256-GCM 加密(可逆,法規要求可查詢)
  const encryptedId = cryptoService.encrypt(idNumber);

  await db.query(
    `INSERT INTO users (email, password_hash, phone_encrypted, id_number_encrypted) 
     VALUES ($1, $2, $3, $4)`,
    [email, passwordHash, encryptedPhone, encryptedId]
  );

  res.status(201).json({ message: '註冊成功' });
});

// ✅ 登入:密碼驗證 + 防止 Timing Attack
app.post('/api/login', async (req, res) => {
  const { email, password } = req.body;

  const user = await db.query(
    'SELECT id, password_hash, login_attempts, locked_until FROM users WHERE email = $1',
    [email]
  );

  // 不管帳號存不存在,都花相同時間做雜湊比對(防 Timing Attack)
  if (!user.rows[0]) {
    await cryptoService.hashPassword('dummy-password');
    return res.status(401).json({ error: '帳號或密碼錯誤' });
  }

  const userData = user.rows[0];

  // 檢查是否被鎖定
  if (userData.locked_until && new Date(userData.locked_until) > new Date()) {
    return res.status(423).json({ error: '帳號暫時鎖定,請稍後再試' });
  }

  const isValid = await cryptoService.verifyPassword(password, userData.password_hash);

  if (!isValid) {
    // 登入失敗:增加嘗試次數
    const newAttempts = (userData.login_attempts || 0) + 1;
    const lockUntil = newAttempts >= 5 
      ? new Date(Date.now() + 15 * 60 * 1000)  // 5 次失敗鎖 15 分鐘
      : null;

    await db.query(
      'UPDATE users SET login_attempts = $1, locked_until = $2 WHERE id = $3',
      [newAttempts, lockUntil, userData.id]
    );

    return res.status(401).json({ error: '帳號或密碼錯誤' });
  }

  // 登入成功:重設嘗試次數
  await db.query(
    'UPDATE users SET login_attempts = 0, locked_until = NULL WHERE id = $1',
    [userData.id]
  );

  // ... 簽發 JWT Token
});

// ✅ 查看個人資料:解密 + 遮蔽
app.get('/api/profile', authenticate, async (req, res) => {
  const user = await db.query(
    'SELECT email, phone_encrypted, id_number_encrypted FROM users WHERE id = $1',
    [req.user.id]
  );

  const userData = user.rows[0];

  // 解密後遮蔽顯示(前端只看到遮蔽版本)
  const phone = cryptoService.decrypt(userData.phone_encrypted);
  const idNumber = cryptoService.decrypt(userData.id_number_encrypted);

  res.json({
    email: userData.email,
    phone: cryptoService.maskPhone(phone),         // 09XX-XXX-678
    idNumber: cryptoService.maskIdNumber(idNumber)  // A12*****89
  });
});

六、密碼與機敏資料處理 Checklist

以下是一份可以直接用於專案的檢核清單:

## 密碼安全
- [ ] 密碼使用 Argon2id(首選)或 bcrypt(備選)雜湊
- [ ] 絕對沒有使用 MD5、SHA-1、SHA-256 雜湊密碼
- [ ] bcrypt 的 cost factor ≥ 12
- [ ] Argon2id 的 memoryCost ≥ 19456(OWASP 最低要求)
- [ ] 雜湊一次的耗時在 200ms ~ 500ms 之間
- [ ] 登入錯誤訊息統一為「帳號或密碼錯誤」
- [ ] 有防止 Timing Attack 的措施
- [ ] 登入失敗 5 次鎖定帳號 15 分鐘

## 加密機制
- [ ] 對稱加密使用 AES-256-GCM(不是 CBC)
- [ ] 每次加密使用隨機的 IV(不可重複使用)
- [ ] 加密演算法沒有使用 DES、3DES、RC4
- [ ] 傳輸中的資料全程使用 TLS 1.2 以上
- [ ] 信用卡號、身分證等機敏資料已加密存儲

## 金鑰管理
- [ ] 金鑰沒有硬寫在程式碼裡
- [ ] 金鑰沒有存在版本控制(Git)中
- [ ] .env 檔案已加入 .gitignore
- [ ] 開發 / 測試 / 正式環境使用不同的金鑰
- [ ] 有金鑰輪換計畫(至少每年更換一次)
- [ ] 金鑰輪換時有版本管理(新舊金鑰並存)
- [ ] 有金鑰備份與恢復計畫

## 資料遮蔽
- [ ] 信用卡號顯示為 **** **** **** 末四碼
- [ ] 手機號碼部分遮蔽
- [ ] 身分證字號部分遮蔽
- [ ] 日誌中不記錄任何機敏資料的明碼
- [ ] API Response 中不回傳不必要的機敏欄位

七、團隊落地建議:讓加密不再是一個人的事

建議一:建立團隊的機敏資料分類表

在專案一開始,就列出所有會經手的機敏資料,並決定每種資料的處理方式。這份表格應該放在專案文件中,所有開發者都能看到。

| 資料類型 | 分類等級 | 存儲方式 | 傳輸方式 | 顯示方式 | 負責人 |
|---------|---------|---------|---------|---------|-------|
| 密碼 | 極高 | Argon2id 雜湊 | TLS | 不顯示 | 後端 |
| 信用卡號 | 極高 | AES-256 加密 | TLS | 遮蔽末四碼 | 後端 |
| 身分證 | 高 | AES-256 加密 | TLS | 部分遮蔽 | 後端 |
| 手機號碼 | 中 | AES-256 加密 | TLS | 部分遮蔽 | 後端 |
| Email | 中 | 明碼 + 存取控制 | TLS | 完整顯示 | 後端 |
| 姓名 | 低 | 明碼 + 存取控制 | TLS | 完整顯示 | 前端 |

建議二:封裝共用的加密服務

不要讓每個開發者自己寫加密邏輯。把加密、解密、雜湊都封裝成共用的服務(就像本文的 <code>CryptoService</code>),確保全團隊用同一套標準。

建議三:在 Code Review 加入加密檢查

在 Code Review 時,加入以下安全問題:

  • 這段程式碼有處理機敏資料嗎?用了什麼加密方式?
  • 金鑰從哪裡來?有沒有硬寫在程式碼裡?
  • 日誌中有沒有不小心記錄了機敏資料?
  • API Response 有沒有回傳不必要的機敏欄位?

建議四:用 Git Hook 防止金鑰外洩

# 安裝 git-secrets(由 AWS 開發的工具)
# brew install git-secrets  (macOS)
# 或手動安裝:https://github.com/awslabs/git-secrets

# 初始化
git secrets --install
git secrets --register-aws  # 偵測 AWS 金鑰

# 自訂規則:偵測常見的金鑰格式
git secrets --add 'password\s*=\s*.+'
git secrets --add 'secret\s*=\s*.+'
git secrets --add 'PRIVATE.KEY'

# 之後每次 git commit 都會自動檢查,發現金鑰就阻止提交

八、常見問題 FAQ

Q1:我的專案很小,真的需要這麼複雜的加密機制嗎?

規模小不代表可以偷懶。 只要你的系統有處理密碼和個人資料,就需要做好基本的保護。好消息是,基礎的保護並不複雜:密碼用 bcrypt/Argon2 雜湊(一個函式呼叫的事)、金鑰不要硬寫在程式碼裡(用 <code>.env</code> 就好)、傳輸用 HTTPS。這三件事花不了你一天的時間,但能幫你省下未來幾個月的災難處理。

Q2:我的系統已經用 MD5 雜湊密碼了,怎麼遷移到 Argon2?

這是很多既有系統會遇到的問題。你不需要強制所有使用者重設密碼。一個常見的漸進式遷移策略:在使用者下次登入成功時(表示他輸入了正確的密碼),趁這個機會用 Argon2 重新雜湊並更新資料庫。同時在資料庫加一個欄位記錄雜湊演算法版本,登入驗證時根據版本選擇不同的比對方式。

// 漸進式遷移範例
async function loginWithMigration(email, password) {
  const user = await db.query('SELECT * FROM users WHERE email = $1', [email]);

  if (user.hash_version === 'md5') {
    // 舊格式:用 MD5 驗證
    const md5Hash = crypto.createHash('md5').update(password).digest('hex');
    if (md5Hash !== user.password_hash) return false;

    // 驗證成功!趁機升級到 Argon2
    const newHash = await argon2.hash(password, { type: argon2.argon2id });
    await db.query(
      'UPDATE users SET password_hash = $1, hash_version = $2 WHERE id = $3',
      [newHash, 'argon2id', user.id]
    );
  } else {
    // 新格式:用 Argon2 驗證
    return await argon2.verify(user.password_hash, password);
  }
  return true;
}

Q3:AES 加密的金鑰應該是多少 bytes?怎麼產生?

AES-256 需要 32 bytes(256 bits)的金鑰。用密碼學安全的隨機數產生器來產生,絕對不要自己亂打一串字。

// ✅ 正確:用 crypto 產生隨機金鑰
const key = crypto.randomBytes(32).toString('hex');
console.log(key); // 64 個 hex 字元,例如:a1b2c3d4...

// ❌ 錯誤:用自己想的字串
const key = 'my-secret-key';  // 太短、可預測、不夠隨機

Q4:JWT Token 裡可以放密碼或信用卡號嗎?

絕對不行。 JWT 預設只做簽章(signature),不做加密。也就是說,任何人拿到你的 JWT,都可以用 Base64 解碼看到裡面的內容。JWT 裡應該只放使用者 ID 和角色等「就算被看到也不會造成直接危害」的資訊。密碼、信用卡號、身分證等機敏資料,永遠不應該出現在 JWT 裡。


九、結語:加密是手藝,不是魔法

回到蓋房子的比喻。

保險箱再堅固,鑰匙管理不好也白搭。加密演算法再強大,金鑰硬寫在程式碼裡也沒用。密碼雜湊再安全,不加鹽就形同虛設。

密碼與機敏資料處理的核心不是「用了什麼演算法」,而是「有沒有在每一個環節都做對」。從資料的產生、傳輸、存儲、到顯示,每一步都需要正確的保護措施。就像蓋房子的保險箱,不只是買一個好保險箱就夠了——你還要把它固定在牆裡、保管好鑰匙、定期更換密碼、只讓該看的人看。

安全不是一次性的動作,而是持續的紀律。

飛飛觀點:
記住,完美的加密機制不存在,但「夠好的加密」加上「正確的管理」,可以讓攻擊者的成本高到不值得攻擊你。安全不是恐懼,而是創造的基礎——當你知道使用者的資料被好好保護,你才能安心地創造更好的產品。


十、延伸閱讀

官方資源

工具推薦

工具 用途 說明
argon2 Node.js Argon2 雜湊 OWASP 推薦首選
bcrypt Node.js bcrypt 雜湊 老牌穩定
git-secrets 防止金鑰提交到 Git AWS 開發的工具
HashiCorp Vault 金鑰管理 開源 Secrets Manager
AWS Secrets Manager 雲端金鑰管理 AWS 原生服務

[安全程式設計] 002 OWASP Top 10 2025 防禦實戰指南:十大風險逐一擊破,從觀念到程式碼的完整攻防手冊

「知道鎖壞了不夠,你得知道怎麼換一把好鎖。
OWASP Top 10 不只是一張清單,它是你防禦的施工藍圖。」
— SSDLC by 飛飛


一、OWASP Top 10 是什麼?為什麼每個開發者都該懂?

在 SSDLC 蓋房子的旅程中,我們走過了安全需求定義(確認要防什麼)、安全設計(畫好藍圖)、安全編碼基礎(學會用防火建材)。現在,是時候面對真正的敵人了。

OWASP Top 10 就像建築界的「十大常見工安事故報告」。每隔幾年,OWASP(Open Worldwide Application Security Project)會根據全球數十萬個應用程式的真實漏洞數據,整理出最危險的十大安全風險。2025 年版本是第八版,基於超過 175,000 筆 CVE 紀錄和 589 個 CWE 分析而來。

你可能會問:「我已經學了輸入驗證和輸出編碼,為什麼還需要這個?」

輸入驗證和輸出編碼是「基本功」——就像學會了怎麼接電線和鋪防火磚。但真正蓋房子的時候,你會遇到各種不同類型的風險:有人從窗戶爬進來(權限控制失效)、有人複製了你的鑰匙(認證失敗)、有人在你的建材裡摻了劣質品(軟體供應鏈攻擊)。OWASP Top 10 就是告訴你:這十種風險最常發生,你得優先防好。

飛飛觀點:
我常跟團隊說,OWASP Top 10 不是「考試範圍」,而是「體檢報告」。它告訴你全世界的開發者最常在哪裡跌倒。如果你能把這十項都防好,你的系統就已經比八成的網站更安全了。


二、2025 年版有什麼不同?從 2021 到 2025 的演進

在進入逐條解析之前,先快速看看 2025 年版的重大變化:

2021 年版 2025 年版 變化說明
A01: Broken Access Control A01: Broken Access Control 維持第一,SSRF 併入此類
A05: Security Misconfiguration A02: Security Misconfiguration 從第五躍升至第二
A06: Vulnerable and Outdated Components A03: Software Supply Chain Failures 🆕 擴大為供應鏈安全
A02: Cryptographic Failures A04: Cryptographic Failures 下降兩位
A03: Injection A05: Injection 下降兩位
A04: Insecure Design A06: Insecure Design 下降兩位
A07: Identification and Authentication Failures A07: Authentication Failures 微調名稱
A08: Software and Data Integrity Failures A08: Software or Data Integrity Failures 微調名稱
A09: Security Logging and Monitoring Failures A09: Security Logging & Alerting Failures 強調「告警」
A10: Server-Side Request Forgery A10: Mishandling of Exceptional Conditions 🆕 全新類別

兩個重點趨勢:供應鏈安全異常處理成為新焦點——這代表現代攻擊已經從「找你程式碼的洞」演進到「從你的依賴和失敗路徑下手」。


三、十大風險逐一擊破:觀念 + 程式碼

A01:2025 — Broken Access Control(權限控制失效)

用生活比喻理解: 你住的大樓,每戶都有各自的門鎖。但如果管理員不小心把所有房卡都設成通用的,任何住戶都能進別人家——這就是權限控制失效。

為什麼排第一? 根據數據,平均 3.73% 的受測應用程式存在此類漏洞,涵蓋 40 個 CWE。2025 年版還把 SSRF(伺服器端請求偽造)也併入了這個類別。

常見攻擊情境:

  • 修改 URL 中的 ID 就能看到別人的訂單:<code>/api/orders/1234</code> → <code>/api/orders/5678</code>
  • 普通使用者直接存取管理員 API
  • 前端隱藏了按鈕,但後端沒有檢查權限

防禦程式碼範例:

// Node.js + Express — 資源層級的權限檢查
async function getOrder(req, res) {
  const order = await Order.findById(req.params.orderId);

  if (!order) {
    return res.status(404).json({ error: '訂單不存在' });
  }

  // ✅ 關鍵:檢查這筆資料是不是屬於當前使用者
  if (order.userId !== req.user.id && req.user.role !== 'admin') {
    return res.status(403).json({ error: '無權存取此訂單' });
  }

  res.json(order);
}
# Python + Django — 使用裝飾器做權限檢查
from django.core.exceptions import PermissionDenied

def check_order_owner(view_func):
    def wrapper(request, order_id, *args, **kwargs):
        order = Order.objects.get(id=order_id)
        # ✅ 確認資源擁有者
        if order.user_id != request.user.id and not request.user.is_staff:
            raise PermissionDenied("你沒有權限存取此訂單")
        return view_func(request, order_id, *args, **kwargs)
    return wrapper
// Java + Spring Boot — 方法層級的權限控制
@PreAuthorize("hasRole('ADMIN') or @orderSecurity.isOwner(#orderId, authentication)")
@GetMapping("/api/orders/{orderId}")
public ResponseEntity<Order> getOrder(@PathVariable Long orderId) {
    return ResponseEntity.ok(orderService.findById(orderId));
}

飛飛觀點:
權限控制的黃金法則——預設拒絕(Deny by Default)。不是「這個 API 誰不能存取」,而是「這個 API 誰可以存取」。先全部鎖上,再一扇一扇開門。


A02:2025 — Security Misconfiguration(安全設定錯誤)

用生活比喻理解: 你買了一扇頂級防盜門,但安裝時忘了把預設密碼改掉,門鎖出廠密碼是 <code>0000</code>——這就是安全設定錯誤。

為什麼從第五升到第二? 現代系統的行為越來越依賴設定檔(configuration),從雲端服務到容器到 API Gateway,每一層都有大量的設定選項。設定錯一個,門就大開。

常見攻擊情境:

  • 開啟了 Debug 模式上線,錯誤訊息洩露完整堆疊追蹤
  • 雲端 S3 Bucket 設定為公開存取
  • 預設帳號密碼沒有修改(admin/admin)
  • 不必要的 HTTP 方法(PUT、DELETE)沒有關閉

防禦程式碼範例:

// Node.js + Express — 安全標頭與生產環境設定
const helmet = require('helmet');
const app = express();

// ✅ 使用 helmet 一次設定多個安全標頭
app.use(helmet());

// ✅ 根據環境切換設定
if (process.env.NODE_ENV === 'production') {
  app.set('trust proxy', 1);
  app.disable('x-powered-by');  // 不洩露技術棧

  // ❌ 永遠不要在生產環境啟用
  // app.use(errorHandler({ dumpExceptions: true, showStack: true }));
}

// ✅ 安全的錯誤處理:只回傳通用訊息
app.use((err, req, res, next) => {
  console.error(err.stack);  // 記錄到日誌
  res.status(500).json({ 
    error: '系統處理時發生錯誤,請稍後再試'  // 不洩露細節
  });
});
# Docker — 安全設定範例
# ✅ 不要用 root 執行應用程式
FROM node:20-alpine
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser

# ✅ 只開放必要的 port
EXPOSE 3000

# ✅ 設定唯讀檔案系統
# docker run --read-only --tmpfs /tmp myapp

安全設定 Checklist:

- [ ] 移除所有預設帳號密碼
- [ ] 關閉 Debug 模式和詳細錯誤頁面
- [ ] 移除不必要的功能、port、服務
- [ ] 設定適當的 CORS 白名單(不要用 *)
- [ ] HTTP 安全標頭已正確設定
- [ ] 雲端服務權限已最小化
- [ ] 定期審查設定變更

A03:2025 — Software Supply Chain Failures(軟體供應鏈失敗)🆕

用生活比喻理解: 你蓋房子用的水泥是跟供應商買的。如果供應商偷偷在水泥裡摻了劣質材料,你蓋出來的房子表面看不出問題,但結構已經不安全了——這就是供應鏈攻擊。

為什麼是新類別? 2021 年版只關注「過時和有漏洞的元件」,但現代攻擊者已經進化了。他們會入侵上游套件、在 npm 上發布惡意套件、甚至直接汙染 CI/CD 流程。一個被入侵的套件可以在幾小時內影響數百萬個專案。

防禦程式碼範例:

# ✅ 使用 npm audit 檢查已知漏洞
npm audit

# ✅ 鎖定依賴版本(使用 lock 檔案)
npm ci  # 而非 npm install

# ✅ 使用 OWASP Dependency-Check 掃描
dependency-check --project "MyApp" --scan ./

# ✅ 產生 SBOM(軟體物料清單)
npx @cyclonedx/cyclonedx-npm --output-file sbom.json
// package.json — 鎖定版本範圍,避免自動升級到惡意版本
{
  "dependencies": {
    // ❌ 危險:允許任何 minor/patch 版本自動升級
    "some-package": "^1.0.0",

    // ✅ 安全:鎖定精確版本
    "some-package": "1.2.3"
  }
}
# Python — 使用 pip-audit 檢查依賴漏洞
# pip install pip-audit
# pip-audit

# ✅ 鎖定依賴版本
# pip freeze > requirements.txt
# pip install -r requirements.txt --require-hashes

飛飛觀點:
供應鏈安全的核心問題是「你信任誰」。你用的每一個 npm 套件、每一個 Docker image,背後都是別人寫的程式碼。不是說不能用,而是你要知道自己用了什麼、驗證它是安全的、並且持續監控它


A04:2025 — Cryptographic Failures(加密失敗)

用生活比喻理解: 你在日記上加了一把鎖,但鑰匙就貼在日記封面——這就是加密失敗。不是你沒加密,而是你加密的方式有問題。

常見攻擊情境:

  • 密碼用 MD5 或 SHA1 雜湊(已可被破解)
  • 金鑰硬寫在程式碼裡
  • 傳輸資料沒有用 HTTPS
  • 使用已知不安全的加密演算法(如 DES、RC4)

防禦程式碼範例:

// Node.js — 正確的密碼雜湊
const bcrypt = require('bcrypt');

// ✅ 使用 bcrypt 雜湊密碼(cost factor 至少 12)
async function hashPassword(plainPassword) {
  const saltRounds = 12;
  return await bcrypt.hash(plainPassword, saltRounds);
}

// ✅ 驗證密碼
async function verifyPassword(plainPassword, hashedPassword) {
  return await bcrypt.compare(plainPassword, hashedPassword);
}

// ❌ 永遠不要這樣做
// const hash = crypto.createHash('md5').update(password).digest('hex');
# Python — 使用 Argon2 雜湊密碼
from argon2 import PasswordHasher

ph = PasswordHasher(
    time_cost=3,       # 迭代次數
    memory_cost=65536,  # 記憶體用量 (KB)
    parallelism=4       # 平行度
)

# ✅ 雜湊
hashed = ph.hash("user_password")

# ✅ 驗證
try:
    ph.verify(hashed, "user_password")
except Exception:
    print("密碼錯誤")
// Node.js — 金鑰管理:從環境變數讀取,不寫在程式碼裡
// ❌ 硬寫金鑰
// const SECRET_KEY = 'my-super-secret-key-12345';

// ✅ 從環境變數讀取
const SECRET_KEY = process.env.JWT_SECRET;
if (!SECRET_KEY || SECRET_KEY.length < 32) {
  throw new Error('JWT_SECRET 未設定或長度不足');
}

A05:2025 — Injection(注入攻擊)

用生活比喻理解: 你開了一家餐廳,客人在點餐單上寫「滷肉飯一碗」,你的服務生照做。但有一天,有人寫了「把金庫打開」——如果你的服務生照做了,那就是注入攻擊。

注入攻擊雖然從第三名降到第五名,但它仍然是CWE 數量最多的類別(38 個),涵蓋 SQL Injection、XSS、Command Injection 等經典攻擊。

防禦程式碼範例:

// Node.js — SQL Injection 防禦:參數化查詢
const { Pool } = require('pg');
const pool = new Pool();

// ❌ 危險:字串拼接
// const result = await pool.query(
//   <code>SELECT * FROM users WHERE email = '${email}'</code>
// );

// ✅ 安全:參數化查詢
const result = await pool.query(
  'SELECT * FROM users WHERE email = $1',
  [email]
);
# Python + SQLAlchemy — 使用 ORM 防止 SQL Injection
from sqlalchemy import select

# ✅ 安全:ORM 自動處理參數化
stmt = select(User).where(User.email == user_input_email)
result = session.execute(stmt).scalars().first()

# ❌ 危險:raw SQL 字串拼接
# session.execute(f"SELECT * FROM users WHERE email = '{email}'")
// Java — PreparedStatement 防止 SQL Injection
String sql = "SELECT * FROM users WHERE email = ?";
try (PreparedStatement stmt = connection.prepareStatement(sql)) {
    stmt.setString(1, userEmail);  // ✅ 參數化
    ResultSet rs = stmt.executeQuery();
}
// Node.js — Command Injection 防禦
const { execFile } = require('child_process');

// ❌ 危險:使用 exec 拼接指令
// exec(<code class="kb-btn">ping ${userInput}</code>);
// 攻擊者輸入: 127.0.0.1; rm -rf /

// ✅ 安全:使用 execFile,參數陣列分離
execFile('ping', ['-c', '4', validatedHost], (error, stdout) => {
  // 安全處理...
});

A06:2025 — Insecure Design(不安全的設計)

用生活比喻理解: 你蓋了一棟房子,牆壁用防火磚、門裝了三道鎖,但你把保險箱設計在大門旁邊——問題不在施工品質,而在設計本身就有缺陷

不安全的設計跟程式碼漏洞不同。它是架構層級的問題,再好的程式碼也救不了糟糕的設計。

防禦策略:

## 安全設計 Checklist

### 認證設計
- [ ] 登入失敗有速率限制(Rate Limiting)
- [ ] 密碼重設流程使用一次性 Token + 過期時間
- [ ] 敏感操作需要二次驗證(如轉帳、修改密碼)

### 業務邏輯
- [ ] 優惠券不能無限使用(伺服器端驗證使用次數)
- [ ] 金額計算在伺服器端進行(不信任前端傳來的價格)
- [ ] 競態條件已處理(如庫存扣減使用資料庫交易)

### 架構設計
- [ ] 使用威脅建模(STRIDE)識別風險
- [ ] 敏感資料有分級保護策略
- [ ] 有 Abuse Case 分析(攻擊者會怎麼用這個功能?)
// Node.js — 防止業務邏輯漏洞:伺服器端驗證優惠券
app.post('/api/checkout', async (req, res) => {
  const { cartItems, couponCode } = req.body;

  // ✅ 伺服器端重新計算價格(不信任前端傳來的金額)
  const serverPrice = await calculatePrice(cartItems);

  // ✅ 伺服器端驗證優惠券
  if (couponCode) {
    const coupon = await Coupon.findOne({ 
      code: couponCode, 
      usedCount: { $lt: '$maxUses' },  // 檢查使用次數
      expiresAt: { $gt: new Date() }   // 檢查過期時間
    });

    if (!coupon) {
      return res.status(400).json({ error: '優惠券無效或已過期' });
    }

    serverPrice.discount = coupon.discountAmount;
  }

  // 用伺服器端計算的金額結帳
  await processPayment(serverPrice);
});

A07:2025 — Authentication Failures(認證失敗)

用生活比喻理解: 大樓的門禁系統有問題——有人忘了設定密碼長度限制,有人的門卡永遠不會過期,有人被鎖在門外卻發現旁邊有個沒上鎖的側門。

防禦程式碼範例:

// Node.js — 安全的 Session 管理
const session = require('express-session');
const RedisStore = require('connect-redis').default;

app.use(session({
  store: new RedisStore({ client: redisClient }),
  secret: process.env.SESSION_SECRET,
  name: '__Host-sid',           // ✅ 使用安全的 cookie 前綴
  resave: false,
  saveUninitialized: false,
  cookie: {
    secure: true,               // ✅ 僅 HTTPS
    httpOnly: true,             // ✅ 禁止 JavaScript 存取
    sameSite: 'strict',         // ✅ 防止 CSRF
    maxAge: 30 * 60 * 1000,    // ✅ 30 分鐘過期
  }
}));
// Node.js — 登入速率限制,防止暴力破解
const rateLimit = require('express-rate-limit');

const loginLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,  // 15 分鐘
  max: 5,                     // 最多 5 次嘗試
  message: { error: '登入嘗試次數過多,請 15 分鐘後再試' },
  standardHeaders: true,
  legacyHeaders: false,
  // ✅ 以帳號為單位限制,而非只看 IP
  keyGenerator: (req) => req.body.email || req.ip,
});

app.post('/api/login', loginLimiter, loginHandler);
# Python + Flask — JWT Token 驗證
import jwt
from functools import wraps

def require_auth(f):
    @wraps(f)
    def decorated(*args, **kwargs):
        token = request.headers.get('Authorization', '').replace('Bearer ', '')

        if not token:
            return jsonify({'error': '未提供認證 Token'}), 401

        try:
            # ✅ 驗證 Token 並指定演算法(防止 Algorithm Confusion 攻擊)
            payload = jwt.decode(
                token, 
                current_app.config['JWT_SECRET'],
                algorithms=['HS256']  # 明確指定演算法
            )
        except jwt.ExpiredSignatureError:
            return jsonify({'error': 'Token 已過期'}), 401
        except jwt.InvalidTokenError:
            return jsonify({'error': 'Token 無效'}), 401

        return f(*args, **kwargs)
    return decorated

A08:2025 — Software or Data Integrity Failures(軟體或資料完整性失敗)

用生活比喻理解: 你在網路上下載了裝修軟體,但沒有驗證這個軟體是不是被人動過手腳。裝完之後,你的電腦就被植入了後門。

跟 A03(供應鏈安全)不同的是,A08 更關注你自己系統內部的完整性驗證——你信任了不該信任的資料或程式碼。

防禦程式碼範例:

// Node.js — 安全的反序列化:不要直接信任外部 JSON
// ❌ 危險:直接反序列化不可信任的資料
// const data = JSON.parse(untrustedInput);
// Object.assign(user, data);  // Prototype Pollution 風險!

// ✅ 安全:只取需要的欄位(白名單)
function safeUpdate(untrustedInput) {
  const parsed = JSON.parse(untrustedInput);
  return {
    name: typeof parsed.name === 'string' ? parsed.name : undefined,
    email: typeof parsed.email === 'string' ? parsed.email : undefined,
    // 只允許特定欄位,忽略其他所有東西
  };
}
// Node.js — 使用 Subresource Integrity (SRI) 驗證 CDN 資源
// 在 HTML 中引入外部腳本時,加上 integrity 屬性
const scriptTag = `
<script 
  src="https://cdn.example.com/library.min.js"
  integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8w"
  crossorigin="anonymous">
</script>`;

A09:2025 — Security Logging & Alerting Failures(安全日誌與告警失敗)

用生活比喻理解: 你家裝了監視器,但從來不看畫面、也沒設警報。等小偷來了又走了,你才發現錄影檔三天前就因為硬碟滿了而停止錄影。

2025 年版特別把名稱從「Monitoring」改為「Alerting」,強調光有 Log 不夠,你需要即時告警

防禦程式碼範例:

// Node.js — 安全日誌的正確做法
const winston = require('winston');

const logger = winston.createLogger({
  level: 'info',
  format: winston.format.combine(
    winston.format.timestamp(),
    winston.format.json()
  ),
  transports: [
    new winston.transports.File({ filename: 'security.log' })
  ],
});

// ✅ 記錄安全相關事件
function logSecurityEvent(event) {
  logger.warn('SECURITY_EVENT', {
    type: event.type,           // 'LOGIN_FAILURE', 'ACCESS_DENIED', etc.
    userId: event.userId,
    ip: event.ip,
    userAgent: event.userAgent,
    timestamp: new Date().toISOString(),
    // ❌ 永遠不記錄這些
    // password: event.password,
    // creditCard: event.creditCard,
    // sessionToken: event.token,
  });
}

// ✅ 應該記錄的事件
// - 登入成功/失敗(特別是連續失敗)
// - 權限被拒絕的存取嘗試
// - 輸入驗證失敗(可能是攻擊探測)
// - 敏感操作(密碼變更、權限修改、資料匯出)
# Python — 異常登入偵測與告警
import redis
from datetime import datetime, timedelta

r = redis.Redis()

def check_login_anomaly(user_id: str, ip: str):
    key = f"login_failures:{user_id}"
    failures = r.incr(key)
    r.expire(key, 900)  # 15 分鐘視窗

    # ✅ 連續失敗超過閾值,觸發告警
    if failures >= 5:
        send_alert(
            level="HIGH",
            message=f"帳號 {user_id} 在 15 分鐘內登入失敗 {failures} 次",
            metadata={"ip": ip, "timestamp": datetime.utcnow().isoformat()}
        )

A10:2025 — Mishandling of Exceptional Conditions(異常狀況處理不當)🆕

用生活比喻理解: 你家的自動門在正常情況下很安全——刷卡才能進。但有一天停電了,自動門的預設行為是「打開」而不是「鎖上」——這就是「失敗時開放(Failing Open)」,也是這個新類別的核心問題。

這是 2025 年全新加入的類別,涵蓋 24 個 CWE,專注於系統在遇到異常時的行為:錯誤處理不當、邏輯錯誤、失敗時開放等。

防禦程式碼範例:

// Node.js — Fail Secure:異常時預設拒絕
async function checkPermission(userId, resource) {
  try {
    const permission = await permissionService.check(userId, resource);
    return permission.allowed;
  } catch (error) {
    // ✅ Fail Secure:出錯時預設拒絕存取
    logger.error('權限檢查失敗', { userId, resource, error: error.message });
    return false;  // 拒絕存取

    // ❌ Fail Open:出錯時預設允許(危險!)
    // return true;
  }
}
// Node.js — 安全的錯誤處理:不洩露內部資訊
app.use((err, req, res, next) => {
  // ✅ 記錄完整的錯誤資訊到日誌
  logger.error('Unhandled error', {
    message: err.message,
    stack: err.stack,
    path: req.path,
    method: req.method,
  });

  // ✅ 回傳給使用者的只有通用訊息
  const statusCode = err.statusCode || 500;
  res.status(statusCode).json({
    error: statusCode === 500 
      ? '系統處理時發生錯誤,請稍後再試'
      : err.userMessage || '請求處理失敗',
    // ❌ 永遠不要回傳這些
    // stack: err.stack,
    // query: err.sql,
    // config: err.config,
  });
});
# Python — 資源清理:確保異常時也能正確釋放資源
import contextlib

# ✅ 使用 context manager 確保資源被釋放
@contextlib.contextmanager
def secure_db_connection():
    conn = None
    try:
        conn = database.connect()
        yield conn
    except DatabaseError as e:
        logger.error(f"資料庫錯誤: {e}")
        raise  # 重新拋出,讓上層處理
    finally:
        # ✅ 無論成功或失敗,都要關閉連線
        if conn:
            conn.close()

飛飛觀點:
這個新類別讓我特別興奮。很多開發者只測試「快樂路徑(Happy Path)」——功能正常時怎麼跑。但攻擊者專門找的是「不快樂路徑」——系統出錯時會發生什麼。你的 catch 區塊和 else 分支,才是安全的戰場。


四、2021 vs. 2025 對照速查表

排名 2025 類別 核心防禦策略 對應 SSDLC 階段
A01 Broken Access Control 預設拒絕 + 資源層級權限檢查 設計 + 實作
A02 Security Misconfiguration 安全基線 + 自動化設定掃描 部署 + 維運
A03 Software Supply Chain Failures 🆕 SBOM + 依賴掃描 + 簽章驗證 實作 + 驗證
A04 Cryptographic Failures 強雜湊 + 金鑰管理 + TLS 設計 + 實作
A05 Injection 參數化查詢 + 輸入驗證 + 輸出編碼 實作
A06 Insecure Design 威脅建模 + Abuse Case + 安全設計審查 需求 + 設計
A07 Authentication Failures MFA + 速率限制 + 安全 Session 設計 + 實作
A08 Software or Data Integrity SRI + 簽章 + 安全反序列化 實作 + 部署
A09 Logging & Alerting Failures 結構化日誌 + 即時告警 + 不記錄敏感資料 實作 + 維運
A10 Mishandling of Exceptional Conditions 🆕 Fail Secure + 安全錯誤處理 + 資源清理 實作

五、團隊落地建議:如何把 OWASP Top 10 融入日常開發

建議一:把 Top 10 變成 Code Review Checklist

不要把 OWASP Top 10 當成培訓教材看一次就好。把它變成每次 Code Review 時的檢查項目:

## OWASP Top 10 Code Review 快速檢查

### 每次 PR 必查
- [ ] A01: 新的 API 端點有做權限檢查嗎?
- [ ] A05: SQL 查詢有用參數化嗎?使用者輸入有驗證嗎?
- [ ] A10: catch 區塊的處理安全嗎?不會洩露資訊或 Fail Open?

### 設定變更時查
- [ ] A02: 新的設定是否遵循最小權限?有沒有預設不安全的值?

### 加入新依賴時查
- [ ] A03: 這個套件可信嗎?最後更新時間?有已知漏洞嗎?

### 涉及認證/加密時查
- [ ] A04: 使用的加密演算法是否足夠強?金鑰管理方式安全嗎?
- [ ] A07: Session 管理是否正確?有速率限制嗎?

建議二:從風險最高的三項開始

不要試圖一次做完十項。根據數據,先搞定前三名就能大幅降低風險:

  1. A01 Broken Access Control(影響最廣)
  2. A02 Security Misconfiguration(最容易發生)
  3. A05 Injection(後果最嚴重)

建議三:在 CI/CD 中自動化檢測

# GitHub Actions — 自動化安全掃描範例
name: Security Scan
on: [push, pull_request]

jobs:
  security:
    runs-on: ubuntu-latest
    steps:
      # A03: 依賴漏洞掃描
      - name: Dependency Check
        run: npm audit --audit-level=high

      # A05: 靜態程式碼分析(找 Injection 漏洞)
      - name: SAST Scan
        uses: returntocorp/semgrep-action@v1
        with:
          config: p/owasp-top-ten

      # A02: 基礎設施設定掃描
      - name: IaC Scan
        run: docker run --rm -v $(pwd):/src checkmarx/kics:latest scan -p /src

六、常見問題 FAQ

Q1:OWASP Top 10 2025 跟 2021 的差別大嗎?需要重新學嗎?

核心概念沒有大變,但有兩個重要新增:A03 軟體供應鏈失敗和 A10 異常狀況處理不當。如果你已經熟悉 2021 版,重點學習這兩個新類別,再注意各項目排名變化背後的趨勢即可。

Q2:我是前端工程師,OWASP Top 10 跟我有關嗎?

當然有。XSS(包含在 A05 Injection 中)跟前端直接相關,A01 的權限控制前端也有責任(不要在前端儲存敏感的權限判斷邏輯),A02 的 CORS 和安全標頭設定也需要前端配合。而且,前端也有自己的依賴供應鏈(npm 套件),A03 也是你的事。

Q3:做了 OWASP Top 10 的防禦就夠了嗎?

不夠,但已經很好了。OWASP Top 10 只涵蓋最常見的十大風險。你的系統可能還有特定於業務邏輯的漏洞、特定技術棧的問題等。但如果你能把這十項都做到位,你已經超越了大部分的應用程式安全水準。想更進一步,可以參考 OWASP ASVS(更詳細的驗證標準)。

Q4:Vibe Coding 時代,AI 生成的程式碼要注意哪些 OWASP 風險?

AI 生成的程式碼最常出現的問題包括:SQL 字串拼接(A05)、缺少權限檢查(A01)、硬編碼金鑰(A04)、以及缺少錯誤處理(A10)。建議把 AI 當成一個「很快但不懂安全的實習生」——它寫完之後,你要用 OWASP Top 10 Checklist 過一遍。


七、結語:OWASP Top 10 不是終點,而是起點

OWASP Top 10 已經發展了超過二十年,從 2003 年的第一版到 2025 年的第八版,它一直在反映一個事實:軟體安全的挑戰不斷演進,但防禦的基本功永遠重要。

2025 年版告訴我們幾件事:

  • 權限控制永遠是最大挑戰(A01 連續兩屆第一名)
  • 你的依賴就是你的攻擊面(A03 供應鏈安全成為新類別)
  • 系統怎麼失敗比怎麼成功更重要(A10 異常處理成為新焦點)
  • 安全不只是寫好程式碼,更是設好設定(A02 躍升至第二名)

回到我們蓋房子的比喻——OWASP Top 10 就是建築界的「十大必檢安全項目」。你不一定要拿到完美的分數,但你至少要知道這些檢查項目是什麼,然後一項一項地做好。

安全不是恐懼,而是創造的基礎。
當你知道怎麼防禦這十大風險,你就能更有信心地打造產品——因為你知道,你的房子不會因為一個小疏忽就倒塌。


延伸閱讀

[安全程式設計] 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 防禦實戰指南。當你掌握了輸入驗證與輸出編碼的基本功,就可以進一步了解更多攻擊手法與對應的防禦策略。


延伸閱讀