CORS 的問題根源

瀏覽器有 Same-Origin Policyhttps://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)**是這樣的攻擊:

  1. 用戶登入了 bank.com,session cookie 存在瀏覽器
  2. 用戶點開惡意網站 evil.com
  3. evil.com 的 JS 送 POST https://bank.com/transfer?to=attacker&amount=10000
  4. 瀏覽器自動帶上 bank.com 的 cookie
  5. 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(使用 csurfdouble submit cookie pattern)
  • 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 });

延伸閱讀