CORS 的問題根源
瀏覽器有 Same-Origin Policy:https://app.example.com 的 JS 無法讀取 https://api.example.com 的 response——即使你能送 request,response 會被瀏覽器封鎖。
CORS(Cross-Origin Resource Sharing)是讓 server 告訴瀏覽器「這個 cross-origin request 是被允許的」:
OPTIONS /api/users
Origin: https://app.example.com
→ 200
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Allow-Origin: * 的危險:
// ❌ 允許所有 origin
app.use(cors()); // 預設是 *
// 問題:任何網站都可以從瀏覽器呼叫你的 API 並讀取 response
// 如果你的 API 有敏感資料,任何人架個網站就能讀到正確做法:白名單
import cors from 'cors';
const allowedOrigins = [
'https://app.example.com',
'https://admin.example.com',
process.env.NODE_ENV === 'development' ? 'http://localhost:3000' : '',
].filter(Boolean);
app.use(cors({
origin: (origin, callback) => {
// 沒有 origin(curl、postman、server-to-server)直接允許
if (!origin) return callback(null, true);
if (allowedOrigins.includes(origin)) {
callback(null, true);
} else {
callback(new Error(`Origin ${origin} not allowed`));
}
},
credentials: true, // 允許帶 cookie(session-based auth 需要)
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'],
maxAge: 86400, // preflight cache 24 小時
}));CSRF:為什麼 API + Bearer Token 不需要
**CSRF(Cross-Site Request Forgery)**是這樣的攻擊:
- 用戶登入了
bank.com,session cookie 存在瀏覽器 - 用戶點開惡意網站
evil.com evil.com的 JS 送POST https://bank.com/transfer?to=attacker&amount=10000- 瀏覽器自動帶上
bank.com的 cookie bank.com看到合法 cookie,執行轉帳
CSRF 的前提是:憑證(cookie)由瀏覽器自動附加。
為什麼 JSON API + Bearer token 不需要 CSRF protection:
Authorization: Bearer eyJhbGci...
Bearer token 不是瀏覽器自動帶的——它是 JavaScript 明確讀出來放進 header 的。evil.com 的 JS 讀不到 app.example.com 的 localStorage 或 memory 裡的 token(Same-Origin Policy 阻止了)。
所以:
- Session cookie auth(尤其是 httpOnly cookie):需要 CSRF protection(使用
csurf或double submit cookiepattern) - Bearer token auth:不需要 CSRF protection
Security Headers(helmet 的每個 header 在做什麼)
import helmet from 'helmet';
app.use(helmet());helmet() 預設加了這些 header:
Content-Security-Policy(最重要):
Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-xxx'
告訴瀏覽器哪些資源可以載入——主要防 XSS(攻擊者注入了 <script src="evil.com/steal.js">,CSP 會拒絕載入)。
純 API(沒有 HTML response)的 API server 可以設最嚴格的 CSP:default-src 'none'。
X-Frame-Options: DENY:
防止你的頁面被嵌入 iframe——防 Clickjacking(攻擊者把你的頁面透明覆蓋在他的頁面上,誘使用戶點擊)。
X-Content-Type-Options: nosniff:
防止瀏覽器「猜測」content type。如果你回傳一個 JSON 但攻擊者讓瀏覽器誤認為是 HTML,可能觸發 script execution。
Strict-Transport-Security(HSTS):
Strict-Transport-Security: max-age=31536000; includeSubDomains
告訴瀏覽器「這個網域永遠用 HTTPS,不要試 HTTP」——防 downgrade attack(MITM 把 HTTPS 降成 HTTP 然後竊聽)。
Referrer-Policy: no-referrer:
跨 origin 請求不帶 Referer header——防止 URL 裡的敏感資訊(token、用戶 ID)洩漏給第三方。
常見的 API Security Checklist
// 完整的 Express security middleware 設置
app.use(helmet());
app.use(cors({
origin: allowedOrigins,
credentials: true,
}));
app.use(express.json({ limit: '1mb' })); // 限制 request body size,防 DoS
// Rate limiting(詳見 38-rate-limiting)
app.use('/auth', strictRateLimiter); // 登入 endpoint 更嚴格
app.use('/api', rateLimiter);
// 永遠驗證 Content-Type(防止 MIME type confusion)
app.use((req, res, next) => {
if (['POST', 'PUT', 'PATCH'].includes(req.method)) {
if (!req.is('application/json')) {
return res.status(415).json({ error: 'Unsupported Media Type' });
}
}
next();
});Request Body Size:應用層和 Nginx 要對齊
express.json({ limit: '1mb' }) 只管到 Express 這層。Nginx 在前面還有一個 client_max_body_size 設定:
# nginx.conf
http {
client_max_body_size 10m; # 預設是 1m
}兩個設定必須一致:
- Nginx 比 Express 小:request 在 Nginx 就被截斷,Express 看到的是不完整的 body,會噴難以理解的錯誤
- Nginx 比 Express 大:request 進來了,Express 才拒絕,浪費了頻寬和 Nginx 的 buffer 記憶體
慣例:Nginx 設略大(Express limit 的 1.1–1.5 倍),讓 Express 先做業務驗證,Nginx 只擋掉真正的超大 request。
IP 黑白名單
可以在兩個層做,各有適合的場景:
Nginx 層(擋在應用之前,成本最低):
# 白名單:只允許特定 IP(適合內部 admin API)
location /admin {
allow 192.168.1.0/24;
allow 10.0.0.0/8;
deny all;
}
# 黑名單:封鎖已知惡意 IP
deny 203.0.113.0/24;應用層(更靈活,可以動態更新):
// Redis 存黑名單,動態更新不需要重啟 Nginx
const ipBlocklistMiddleware = async (req, res, next) => {
const ip = req.ip;
const isBlocked = await redis.sismember('ip:blocklist', ip);
if (isBlocked) {
return res.status(403).json({ error: 'Forbidden' });
}
next();
};
// 封鎖一個 IP(可以從 admin panel 或 rate limit 超標時自動觸發)
await redis.sadd('ip:blocklist', '203.0.113.42');Nginx 適合靜態規則(已知的壞 IP、內網 CIDR 白名單);應用層適合動態規則(rate limit 超標自動封鎖、admin 手動封鎖)。兩層可以同時存在。
SQL Injection:使用 ORM 的 parameterized query(Sequelize / TypeORM / Prisma 預設就做了),不要拼 SQL 字串。
Mass Assignment:只允許 DTO 定義的欄位進 DB,不要直接 User.create(req.body):
// ❌
await User.create(req.body); // 攻擊者可以傳 { isAdmin: true }
// ✅
const dto = createUserSchema.parse(req.body); // Zod 只取允許的欄位
await User.create({ name: dto.name, email: dto.email });