跳轉到

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

習題#

  1. 完成「時間一致的登入」,用計時工具驗證帳號枚舉確實被堵住。
  2. 為聊天室加「房間」功能,完整走一遍本章五步(建模→後端→前端→基礎設施→驗證)。
  3. 寫一份加固前後的滲透報告對照(6.1),展示防禦如何改變了攻擊結果。