6.3 實戰:強化聊天室與待辦應用#
總匯演:把前五章裝進兩個應用#
以《網站設計基礎知識》的聊天室(6.4)與前書的 Todo 應用(5.1)為基礎,逐層加固 —— 每項改動都註明理論來源。
第一步:威脅建模(1.2)#
| 資產 | STRIDE | 防禦對策 |
|---|---|---|
| 訊息內容 | I(旁聽) | wss + TLS(4.1) |
| 用戶帳密 | I(外洩) | Argon2(1.4)+ 限流(3.4)+ MFA(3.2) |
| 訊息顯示 | T(注入) | textContent + CSP(2.1, 2.4) |
| 訊息頻道 | D(刷屏/DoS) | 限流 + 訊息上限(3.4) |
| 他人訊息 | I(越權) | 房間成員檢查(3.3) |
| 操作記錄 | R(否認) | 落庫日誌(5.3) |
第二步:後端加固(Node.js 聊天室)#
1. 登入 + Argon2 + 限流#
import argon2 from 'argon2'; // 1.4
import rateLimit from 'express-rate-limit';
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 10, // 3.4: 15 分鐘 10 次
keyGenerator: (req) => `${req.ip}:${req.body.email}`, // 雙維度
});
app.post('/login', loginLimiter, express.json(), async (req, res) => {
const { email, password } = req.body;
const user = await db.findUserByEmail(email);
// 3.2: 不存在的用戶也做一次假驗證(時間一致,防帳號枚舉)
const hash = user?.passwordHash ?? DUMMY_HASH;
const ok = await argon2.verify(hash, password);
if (!user || !ok) {
log.warn({ email: hashEmail(email), ip: req.ip }, 'login_failed'); // 5.3
return res.status(401).json({ error: 'invalid credentials' }); // 訊息不區分原因
}
const token = jwt.sign({ sub: user.id }, SECRET, { algorithm: 'RS256', expiresIn: '15m' }); // 3.2
res.cookie('session', token, { httpOnly: true, secure: true, sameSite: 'lax' }); // 2.2
res.json({ ok: true });
});
三個細節的理論:
- 假雜湊驗證:不存在用戶的回應時間與存在者一致 → 無法靠時間差枚舉帳號(3.2)
- 錯誤訊息不區分「帳號錯」與「密碼錯」
- RS256 + 短效期:防演算法混淆與離線爆破(3.2)
2. WebSocket 的安全化#
wss.on('connection', (ws, req) => {
// 1. 驗 Origin:拒絕跨站借用(3.3, 3.3節聊天室的清單)
if (req.headers.origin !== 'https://chat.example.com') return ws.close(1008);
// 2. 從 cookie 驗 JWT(握手走 HTTP,能帶 cookie)
const cookies = parseCookies(req.headers.cookie);
let claims;
try {
claims = jwt.verify(cookies.session, PUBLIC_KEY, { algorithms: ['RS256'] }); // 3.2 寫死演算法
} catch {
return ws.close(1008); // 未認證 → 拒絕升級
}
// 3. 應用層限流:每用戶每秒 5 則(3.4 Token Bucket)
ws.bucket = tokenBucket({ rate: 5, burst: 10 });
ws.on('message', (buf) => {
if (!ws.bucket.take()) return; // 超量靜默丟棄+記錄
const text = buf.toString().slice(0, 500); // 長度上限
if (!text.trim()) return;
broadcast({ type: 'chat', from: claims.name, text, at: Date.now() });
db.saveMessage(claims.sub, text); // 5.3 不可否認性
});
});
3. 安全標頭中介層#
app.use((req, res, next) => { // 2.4
res.setHeader('Content-Security-Policy',
"default-src 'self'; script-src 'self'; frame-ancestors 'none'");
res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
next();
});
第三步:前端加固#
// 1. XSS 防禦:textContent 永遠安全(2.1)
function appendMsg(msg) {
const li = document.createElement('li');
li.textContent = `${msg.from}: ${msg.text}`; // 不用 innerHTML
log.append(li);
}
// 2. 連線層:指數退避重連(2.4)+ 前後端一致的錯誤處理
ws.onclose = (e) => {
if (e.code === 1008) { show('無權限,請重新登入'); return; } // 不重連
setTimeout(connect, 1000 * 2 ** Math.min(retries++, 5));
};
// 3. 輸入在前端也限制(體驗層防線,真正的防線在伺服器 4.3 表單三道防線)
input.addEventListener('input', () => {
if (input.value.length > 500) input.value = input.value.slice(0, 500);
});
第四步:Todo 應用的授權加固(Rust,前書)#
// 3.3: 每個查詢強制 owner 條件 — repo 層封裝,route 層拿不到裸查詢
pub mod todo_repo {
pub async fn list(pool: &PgPool, user: Uuid) -> Result<Vec<Todo>> {
sqlx::query_as!(Todo, "SELECT * FROM todos WHERE user_id = $1", user)
.fetch_all(pool).await.map_err(Into::into)
}
// 刻意「沒有」 find_all() — API 設計即防禦(deny by default)
}
// 5.3: 安全事件日誌
tracing::warn!(user_id = %user, action = "todo_deleted", todo_id = %id, "todo 刪除");
第五步:基礎設施加固#
# docker-compose(前書 5.3)安全化
services:
backend:
build: ./backend
read_only: true # 唯讀檔案系統:webshell 難落地
cap_drop: [ALL] # 丟掉所有 Linux capabilities
environment:
DATABASE_URL: ${DATABASE_URL} # 4.3: 從 secret 注入,不寫進 compose
user: "1000:1000" # 非 root
tmpfs: [/tmp]
# CI 增加(4.2, 6.2)
- run: cargo audit --deny warnings
- run: semgrep ci --config p/owasp-top-ten
- uses: gitleaks/gitleaks-action@v2 # 4.3 秘密掃描
驗證:防禦是否真的有效#
按 6.1 的方法自我測試:
| 攻擊 | 預期結果 |
|---|---|
curl -X POST /login -d '{...}' 連打 11 次 | 第 11 次被 429(3.4) |
| WebSocket 從 evil.com 連 | 1008 關閉(Origin 檢查) |
訊息含 <script> | 原樣顯示為文字(textContent)+ CSP 兜底 |
| 每秒發 20 則 | 只放行 5 則 |
| 帳號枚舉(存在的 vs 不存在的 email) | 回應時間與訊息一致 |
GET /.env | 404 + 蜜罐告警(5.3) |
完整知識映射#
| 加固項 | 章節 |
|---|---|
| Argon2 + 假雜湊 + 帳號枚舉防禦 | 1.4, 3.2 |
| 限流(登入/WS 訊息) | 3.4 |
| Origin + JWT 驗證 + 寫死演算法 | 3.2, 3.3 |
| textContent + CSP + HSTS | 2.1, 2.4, 4.1 |
| owner 條件 + deny by default | 3.3 |
| 日誌 + 蜜罐 | 5.3 |
| 容器加固 + 依賴掃描 | 4.2, 4.3 |
| 滲透自測 | 6.1 |
習題#
- 完成「時間一致的登入」,用計時工具驗證帳號枚舉確實被堵住。
- 為聊天室加「房間」功能,完整走一遍本章五步(建模→後端→前端→基礎設施→驗證)。
- 寫一份加固前後的滲透報告對照(6.1),展示防禦如何改變了攻擊結果。