強化程式碼以抵禦漏洞。適用於處理使用者輸入、驗證、資料儲存或外部整合時。適用於建構任何接受不受信任資料、管理使用者工作階段或與第三方服務互動的功能。
安全性與強化
概述
針對網頁應用程式的安全優先開發實務。將每個外部輸入視為敵意、每個機密視為神聖、每個授權檢查視為強制。安全性不是一個階段——它是每一行觸及使用者資料、驗證或外部系統的程式碼的約束。
使用時機
- 建構任何接受使用者輸入的項目
- 實作驗證或授權
- 儲存或傳輸敏感資料
- 與外部 API 或服務整合
- 新增檔案上傳、Webhook 或回呼
- 處理付款或 PII 資料
流程:先建立威脅模型
沒有威脅模型就加上控制措施只是猜測。在強化之前,花五分鐘像攻擊者一樣思考:
- 繪製信任邊界。 不受信任的資料從哪裡進入你的系統?HTTP 請求、表單欄位、檔案上傳、Webhook、第三方 API、訊息佇列,以及 LLM 輸出。每個邊界都是攻擊面。
- 列出資產。 什麼值得偷或破壞?憑證、PII、付款資料、管理員操作、資金轉移。
- 對每個邊界執行 STRIDE——快速檢視,而非儀式:
| 威脅 | 提問 | 典型緩解措施 |
|---|---|---|
| Spoofing(偽造) | 有人可以冒充使用者/服務嗎? | 驗證、簽章驗證 |
| Tampering(篡改) | 資料在傳輸或儲存時可以被更改嗎? | 完整性檢查、參數化查詢、HTTPS |
| Repudiation(否認) | 某個動作事後可以被否認嗎? | 安全事件的稽核日誌 |
| Information disclosure(資訊揭露) | 資料會外洩嗎? | 加密、欄位允許清單、通用錯誤訊息 |
| Denial of service(阻斷服務) | 系統可以被壓垮嗎? | 速率限制、輸入大小上限、逾時 |
| Elevation of privilege(權限提升) | 使用者可以獲得他們不該有的權限嗎? | 授權檢查、最小權限原則 |
- 在使用案例旁邊寫下濫用案例。 對於每個功能,問「我該如何濫用這個?」——然後讓它成為你的第一個測試。
如果你無法說出某個功能的信任邊界,你還沒準備好保護它。這是 OWASP A04:不安全設計——大多數漏洞始於設計,而非程式碼。
三層邊界系統
務必執行(無例外)
- 在系統邊界驗證所有外部輸入(API 路由、表單處理器)
- 參數化所有資料庫查詢——絕不將使用者輸入串接到 SQL 中
- 編碼輸出以防止 XSS(使用框架自動跳脫,不要繞過它)
- 對所有外部通訊使用 HTTPS
- 使用 bcrypt/scrypt/argon2 雜湊密碼(絕不儲存明文)
- 設定安全標頭(CSP、HSTS、X-Frame-Options、X-Content-Type-Options)
- 對工作階段使用 httpOnly、secure、sameSite Cookie
- 在每次發佈前執行
npm audit(或同等工具)
先詢問(需人工核准)
- 新增驗證流程或變更驗證邏輯
- 儲存新類別的敏感資料(PII、付款資訊)
- 新增外部服務整合
- 變更 CORS 設定
- 新增檔案上傳處理器
- 修改速率限制或節流
- 授予提升的權限或角色
絕不執行
- 絕不將機密提交到版本控制(API 金鑰、密碼、Token)
- 絕不記錄敏感資料(密碼、Token、完整信用卡號)
- 絕不信任客戶端驗證作為安全邊界
- 絕不為了方便而停用安全標頭
- 絕不對使用者提供的資料使用
eval()或innerHTML - 絕不將工作階段儲存在客戶端可存取的儲存空間(localStorage 中的驗證 Token)
- 絕不向使用者暴露堆疊追蹤或內部錯誤細節
OWASP Top 10 預防模式
這些是預防模式,而非排名。如需 2021 年的排序,請參閱 references/security-checklist.md 中的快速參考表。
注入(SQL、NoSQL、OS 命令)
// 錯誤:透過字串串接的 SQL 注入
const query = `SELECT * FROM users WHERE id = '${userId}'`;
// 正確:參數化查詢
const user = await db.query('SELECT * FROM users WHERE id = $1', [userId]);
// 正確:使用參數化輸入的 ORM
const user = await prisma.user.findUnique({ where: { id: userId } });
驗證失效
// 密碼雜湊
import { hash, compare } from 'bcrypt';
const SALT_ROUNDS = 12;
const hashedPassword = await hash(plaintext, SALT_ROUNDS);
const isValid = await compare(plaintext, hashedPassword);
// 工作階段管理
app.use(session({
secret: process.env.SESSION_SECRET, // 來自環境變數,而非程式碼
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true, // 無法透過 JavaScript 存取
secure: true, // 僅限 HTTPS
sameSite: 'lax', // CSRF 防護
maxAge: 24 * 60 * 60 * 1000, // 24 小時
},
}));
跨站指令碼(XSS)
// 錯誤:將使用者輸入渲染為 HTML
element.innerHTML = userInput;
// 正確:使用框架自動跳脫(React 預設這樣做)
return <div>{userInput}</div>;
// 如果你必須渲染 HTML,先清理
import DOMPurify from 'dompurify';
const clean = DOMPurify.sanitize(userInput);
存取控制失效
// 務必檢查授權,而不只是驗證
app.patch('/api/tasks/:id', authenticate, async (req, res) => {
const task = await taskService.findById(req.params.id);
// 檢查已驗證的使用者是否擁有此資源
if (task.ownerId !== req.user.id) {
return res.status(403).json({
error: { code: 'FORBIDDEN', message: '無權修改此任務' }
});
}
// 繼續更新
const updated = await taskService.update(req.params.id, req.body);
return res.json(updated);
});
安全設定錯誤
// 安全標頭(對 Express 使用 helmet)
import helmet from 'helmet';
app.use(helmet());
// 內容安全政策
app.use(helmet.contentSecurityPolicy({
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"], // 可能的話收緊
imgSrc: ["'self'", 'data:', 'https:'],
connectSrc: ["'self'"],
},
}));
// CORS — 限制為已知來源
app.use(cors({
origin: process.env.ALLOWED_ORIGINS?.split(',') || 'http://localhost:3000',
credentials: true,
}));
敏感資料暴露
// 絕不在 API 回應中回傳敏感欄位
function sanitizeUser(user: UserRecord): PublicUser {
const { passwordHash, resetToken, ...publicFields } = user;
return publicFields;
}
// 使用環境變數儲存機密
const API_KEY = process.env.STRIPE_API_KEY;
if (!API_KEY) throw new Error('STRIPE_API_KEY 未設定');
伺服器端請求偽造(SSRF)
每當伺服器擷取使用者影響的 URL 時——Webhook、「從 URL 匯入」、圖片代理、連結預覽——攻擊者可以將其指向內部服務(雲端中繼資料、localhost、私有 IP)。
// 錯誤:直接擷取使用者提供的任何內容
await fetch(req.body.webhookUrl);
// 正確:允許清單 scheme + host,若任何解析出的 IP 為私有則拒絕,禁止重新導向
import { lookup } from 'node:dns/promises';
import ipaddr from 'ipaddr.js';
const ALLOWED_HOSTS = new Set(['hooks.example.com']);
async function assertSafeUrl(raw: string): Promise<URL> {
const url = new URL(raw);
if (url.protocol !== 'https:') throw new Error('僅允許 https');
if (!ALLOWED_HOSTS.has(url.hostname)) throw new Error('主機不允許');
// 解析所有記錄;任何一個私有/保留位址都會使檢查失敗。
const addrs = await lookup(url.hostname, { all: true });
if (addrs.some((a) => ipaddr.parse(a.address).range() !== 'unicast')) {
throw new Error('私有/保留 IP');
}
return url;
}
await fetch(await assertSafeUrl(req.body.webhookUrl), { redirect: 'error' });
range() !== 'unicast' 檢查涵蓋了 IPv4 和 IPv6 的 loopback、link-local 169.254.169.254(雲端中繼資料,SSRF 的第一目標)、私有和唯一本地範圍。
注意——這仍然有 TOCTOU 差距。 fetch 在檢查後會再次解析 DNS,因此使用短 TTL 記錄的攻擊者可以在驗證和連線之間重新綁定到內部 IP。對於高風險表面,請解析一次並連線到固定的 IP,或在前端放置過濾代理(request-filtering-agent / ssrf-req-filter)。
輸入驗證模式
邊界上的 Schema 驗證
import { z } from 'zod';
const CreateTaskSchema = z.object({
title: z.string().min(1).max(200).trim(),
description: z.string().max(2000).optional(),
priority: z.enum(['low', 'medium', 'high']).default('medium'),
dueDate: z.string().datetime().optional(),
});
// 在路由處理器中驗證
app.post('/api/tasks', async (req, res) => {
const result = CreateTaskSchema.safeParse(req.body);
if (!result.success) {
return res.status(422).json({
error: {
code: 'VALIDATION_ERROR',
message: '無效的輸入',
details: result.error.flatten(),
},
});
}
// result.data 現在已型別化且驗證過
const task = await taskService.create(result.data);
return res.status(201).json(task);
});
檔案上傳安全
// 限制檔案類型和大小
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
const MAX_SIZE = 5 * 1024 * 1024; // 5MB
function validateUpload(file: UploadedFile) {
if (!ALLOWED_TYPES.includes(file.mimetype)) {
throw new ValidationError('不允許的檔案類型');
}
if (file.size > MAX_SIZE) {
throw new ValidationError('檔案太大(最大 5MB)');
}
// 不要信任副檔名——如果關鍵,請檢查魔術位元組
}
處理 npm audit 結果
並非所有稽核發現都需要立即處理。使用此決策樹:
npm audit 回報漏洞
├── 嚴重性:critical 或 high
│ ├── 易受攻擊的程式碼在你的應用程式中是否可到達?
│ │ ├── 是 --> 立即修復(更新、修補或取代相依套件)
│ │ └── 否(僅開發相依、未使用的程式碼路徑)--> 盡快修復,但非阻礙
│ └── 是否有可用的修復?
│ ├── 是 --> 更新到已修補的版本
│ └── 否 --> 檢查替代方案,考慮取代相依套件,或加入允許清單並設定審查日期
├── 嚴重性:moderate
│ ├── 在生產環境中可到達?--> 在下一個發佈週期中修復
│ └── 僅開發?--> 方便時修復,在待辦事項中追蹤
└── 嚴重性:low
└── 在定期相依套件更新時追蹤並修復
關鍵問題:
- 易受攻擊的函式是否真的在你的程式碼路徑中被呼叫?
- 相依套件是執行時期相依還是僅開發?
- 根據你的部署環境,該漏洞是否可被利用(例如,僅客戶端應用程式中的伺服器端漏洞)?
當你延後修復時,請記錄原因並設定審查日期。
供應鏈衛生
npm audit 會捕捉已知的 CVE;它不會捕捉惡意或 typosquat 套件。此外:
- 提交 lockfile 並在 CI 中使用
npm ci(而非npm install)安裝——可重複的建置,無靜默版本漂移。 - 在新增相依套件前先審查——維護狀況、下載次數,以及它們是否真正值得存在。每個相依套件都是攻擊面(OWASP A06:易受攻擊的元件、LLM03:供應鏈)。
- 對不熟悉的套件中的
postinstall指令碼保持警覺——它們在安裝時會執行任意程式碼。 - 留意 typosquat——
cross-envvscrossenv、react-domvsreactdom。
速率限制
import rateLimit from 'express-rate-limit';
// 一般 API 速率限制
app.use('/api/', rateLimit({
windowMs: 15 * 60 * 1000, // 15 分鐘
max: 100, // 每個視窗 100 個請求
standardHeaders: true,
legacyHeaders: false,
}));
// 對驗證端點更嚴格的限制
app.use('/api/auth/', rateLimit({
windowMs: 15 * 60 * 1000,
max: 10, // 每 15 分鐘 10 次嘗試
}));
機密管理
.env 檔案:
├── .env.example → 提交(包含佔位值的範本)
├── .env → 不提交(包含真實機密)
└── .env.local → 不提交(本機覆蓋)
.gitignore 必須包含:
.env
.env.local
.env.*.local
*.pem
*.key
提交前務必檢查:
# 檢查是否有意外暫存的機密
git diff --cached | grep -i "password\|secret\|api_key\|token"
如果機密曾被提交,請輪換它。 刪除該行或重寫歷史是不夠的——假設它一旦到達遠端就已遭洩露。先撤銷並重新發行金鑰,然後從歷史中清除它。
保護 AI / LLM 功能
如果你的應用程式呼叫 LLM——聊天機器人、摘要器、代理、RAG——它會繼承一個新的攻擊面。將其對應到 OWASP Top 10 for LLM Applications (2025):
- 將所有模型輸出視為不受信任的輸入(LLM05:不當輸出處理)。 絕不將 LLM 輸出直接傳入
eval、SQL、shell、innerHTML或檔案路徑。像對待原始使用者輸入一樣驗證和編碼它。 - 假設提示可能被劫持(LLM01:提示注入)。 上下文視窗中的不受信任文字——使用者訊息、擷取的網頁、PDF——可能攜帶指令。系統提示不是安全邊界;在程式碼中強制執行權限,而非在提示中。
- 將機密和其他使用者的資料排除在提示之外(LLM02 / LLM07)。 上下文中的任何內容都可能被回顯。不要將 API 金鑰、跨租戶資料或完整系統提示放在模型可以重複的地方。
- 限制工具和代理的權限(LLM06:過度代理)。 將工具範圍限定到最小,對破壞性或不可逆的操作要求確認,並驗證每個工具引數。
- 限制消耗(LLM10:無限制消耗)。 限制 Token、請求速率和迴圈/遞迴深度,以免精心設計的輸入導致成本飆升或系統掛起。
- 隔離檢索資料(LLM08:向量和嵌入弱點)。 在 RAG 中,將向量資料庫視為信任邊界:按租戶分割嵌入,以便一個使用者無法檢索另一個使用者的資料,並在索引前驗證文件,以免有毒內容引導答案。
// 錯誤:信任模型輸出作為命令或標記
const sql = await llm.generate(`Write SQL for: ${userQuestion}`);
await db.query(sql); // 任意查詢執行
container.innerHTML = await llm.reply(userMessage); // 儲存型 XSS,透過模型
// 正確:模型輸出是資料——防禦性解析,然後驗證,然後編碼
let intent;
try {
intent = CommandSchema.parse(JSON.parse(await llm.replyJson(userMessage)));
} catch {
throw new ValidationError('意外的模型輸出'); // JSON.parse 或 schema 失敗
}
await runAllowlistedAction(intent.action, intent.params);
container.textContent = await llm.reply(userMessage);
安全審查檢查清單
### 驗證
- [ ] 密碼使用 bcrypt/scrypt/argon2 雜湊(salt rounds ≥ 12)
- [ ] 工作階段 Token 為 httpOnly、secure、sameSite
- [ ] 登入有速率限制
- [ ] 密碼重設 Token 會過期
### 授權
- [ ] 每個端點檢查使用者權限
- [ ] 使用者只能存取自己的資源
- [ ] 管理員操作需要管理員角色驗證
### 輸入
- [ ] 所有使用者輸入在邊界處驗證
- [ ] SQL 查詢已參數化
- [ ] HTML 輸出已編碼/跳脫
- [ ] 伺服器端 URL 擷取已加入允許清單(無 SSRF 到內部服務)
### 資料
- [ ] 程式碼或版本控制中無機密
- [ ] 敏感欄位已從 API 回應中排除
- [ ] PII 在靜態時已加密(如適用)
### 基礎設施
- [ ] 安全標頭已設定(CSP、HSTS 等)
- [ ] CORS 限制為已知來源
- [ ] 相依套件已稽核漏洞
- [ ] 錯誤訊息不暴露內部資訊
### 供應鏈
- [ ] Lockfile 已提交;CI 使用 `npm ci` 安裝
- [ ] 新相依套件已審查(維護狀況、下載次數、postinstall 指令碼)
### AI / LLM(如使用)
- [ ] 模型輸出視為不受信任(無 eval/SQL/innerHTML/shell)
- [ ] 機密和其他使用者的資料已排除在提示之外
- [ ] 工具/代理權限已限定範圍;破壞性操作需要確認
另請參閱
如需詳細的安全檢查清單和提交前驗證步驟,請參閱 references/security-checklist.md。
常見合理化藉口
| 合理化藉口 | 現實 |
|---|---|
| 「這是內部工具,安全性不重要」 | 內部工具也會被入侵。攻擊者會針對最弱的一環。 |
| 「我們之後再加入安全性」 | 安全性改造比一開始就建構難 10 倍。現在就加入。 |
| 「沒有人會試圖利用這個」 | 自動化掃描器會找到它。隱晦式安全不是安全。 |
| 「框架會處理安全性」 | 框架提供工具,而非保證。你仍然需要正確使用它們。 |
| 「這只是原型」 | 原型會變成正式產品。從第一天就養成安全習慣。 |
| 「威脅模型在這裡小題大作」 | 五分鐘的「我該如何攻擊這個?」可以防止後續沒有任何控制措施能修補的設計缺陷。 |
| 「這只是 LLM 輸出,只是文字」 | 那個「文字」可能是 SQL 陳述式、script 標籤或 shell 命令。像對待任何不受信任的輸入一樣對待它。 |
紅旗
- 使用者輸入直接傳遞給資料庫查詢、shell 命令或 HTML 渲染
- 原始碼或提交歷史中有機密
- API 端點沒有驗證或授權檢查
- 缺少 CORS 設定或使用萬用字元(
*)來源 - 驗證端點沒有速率限制
- 向使用者暴露堆疊追蹤或內部錯誤
- 相依套件有已知的重大漏洞
- 伺服器擷取使用者提供的 URL 而沒有允許清單(SSRF)
- LLM/模型輸出傳入查詢、DOM、shell 或
eval - 機密、PII 或完整系統提示放在 LLM 上下文視窗中
驗證
在實作安全相關程式碼後:
- [ ]
npm audit顯示無 critical 或 high 漏洞 - [ ] 原始碼或 git 歷史中無機密
- [ ] 所有使用者輸入在系統邊界處驗證
- [ ] 每個受保護的端點都檢查了驗證和授權
- [ ] 回應中存在安全標頭(使用瀏覽器 DevTools 檢查)
- [ ] 錯誤回應不暴露內部細節
- [ ] 驗證端點上啟用了速率限制
- [ ] 伺服器端 URL 擷取已根據允許清單驗證(無 SSRF)
- [ ] LLM/模型輸出在使用前已驗證和編碼(如有 AI 功能)






