[安全設計] 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:我們是小團隊,哪些原則應該優先?
如果你的團隊資源有限,建議優先落實這三個:
- 預設安全:成本最低,效果最大。確保框架和工具的預設設定是安全的,一勞永逸。
- 最小權限:花一個下午盤點帳號權限,把過大的權限收回來。
- 失敗安全:檢查所有 <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。在你掌握了設計原則之後,我們來看看如何設計一個「確認你是誰」和「決定你能做什麼」的系統。