「我們做完一輪資安檢查,廠商給了一份『高/中/低優先』的清單。最高優先的 5 項全做完,總共花 2-3 小時。」
這是上週 gomylife.tw(我們自家公司形象站)的真實情況。網站跑在 Next.js 16 + Apache reverse proxy + Docker,公司形象站 + 沒登入 + 沒金流,原本以為攻擊面很小。但廠商資安檢查還是抓出 5 個值得修的項目。
這篇把 5 個項目跟我們踩到的具體坑寫出來。給其他用 Next.js 做公司網站的人參考。
我們的環境 #
- 框架:Next.js 16 App Router
- 部署:Docker container 在內網 host
- 對外:Apache 2.4 reverse proxy + Let's Encrypt HTTPS
- CDN:沒用(直接 server)
- 登入功能:無
- 金流:無
- API:只有
/api/contact(聯絡表單) +/api/chat(AI 對話 widget)
攻擊面很小,但廠商照樣抓到下面 5 項。
1. Next.js 升到 16.2.6 — 修 13 個 CVE #
package.json 鎖在 16.2.4,廠商一查就跳出 13 個官方公告漏洞(版本範圍 9.3.4-canary ~ 16.3.0-canary 都中招):
| 漏洞類別 | 對我們影響 |
|---|---|
| Middleware / Proxy bypass | 沒用 middleware 認證,N/A |
| Cache poisoning | 公開站台會被波及 |
| App Router CSP nonce XSS | 之後加 CSP 後就有影響 |
| Image Optimization API DoS | 用 next/image,有 |
| WebSocket SSRF | 沒用 ws,N/A |
修法很單純:
npm install next@16.2.6
npm install eslint-config-next@16.2.6 # 順手同步
但這一升立刻踩到下一個坑 — Docker build fail。詳見下節。
2. Docker base image:Alpine → Debian slim(Next.js 16 必要) #
升完 Next.js 16 跑 docker compose up -d --build 直接 fail:
Error: Cannot find module '../lightningcss.linux-x64-musl.node'
Require stack:
- /app/node_modules/lightningcss/node/index.js
Root cause:Next.js 16 的 Turbopack 改用 lightningcss 處理 CSS,這套需要 platform-specific native binary。Alpine 用 musl libc、Ubuntu/Debian 用 glibc,binary 不相容。
我們的 source-of-truth 在 Ubuntu dev server 上跑 npm install,產生的 lockfile 只鎖了 glibc 版的 lightningcss-linux-x64-gnu,沒鎖 musl 版。Docker 用 alpine base 時就找不到對應 binary。
修法:base image 從 node:22-alpine 換成 node:22-slim(Debian glibc):
# 改前
FROM node:22-alpine AS deps
# ...
RUN addgroup -g 1001 -S nodejs && adduser -S nextjs -u 1001
# 改後
FROM node:22-slim AS deps
# ...
RUN groupadd -g 1001 -r nodejs && useradd -u 1001 -r -g nodejs nextjs
注意:
- 三個 stage(deps / builder / runner)都要改
- Alpine 用
addgroup/adduser(busybox 語法),Debian 用groupadd/useradd(shadow-utils) - Image size 從 ~180MB 增加到 ~280MB — 換來的是相容性 + 之後所有 native binary 套件不會踩雷
結論:Next.js 16+ 之後 alpine 應該不再是預設選擇。Debian slim 才是新王道。
3. 關掉 X-Powered-By: Next.js(一行設定) #
Next.js 預設 response header 會送 X-Powered-By: Next.js — 等於主動告訴攻擊者「我用什麼框架、來鎖定 exploit」。
修法一行:
// next.config.ts
const nextConfig: NextConfig = {
output: "standalone",
poweredByHeader: false, // ← 加這行
// ...
};
驗證:
curl -sI https://www.gomylife.tw/ | grep -i x-powered
# 沒輸出 = OK
低工 / 高回報的設定。Next.js 預設不該開,但既然開了我們得自己關。
4. Apache vhost 加 Content-Security-Policy #
CSP 是 Mozilla Observatory / 各家資安評估報告的必扣分項。我們其他 security header(HSTS / X-Frame-Options / Referrer-Policy / Permissions-Policy / X-Content-Type-Options)都有,就缺這個。
但 CSP 是會「改完立刻 break 整站」的設定 — 太嚴 = GA 不送 beacon、Google Fonts 不載、shadcn UI 樣式失效。
我們在 Apache vhost 直接加的可運作版本(涵蓋 GA4 + Tag Manager + Google Fonts):
Header always set Content-Security-Policy "default-src 'self'; \
script-src 'self' 'unsafe-inline' \
https://www.googletagmanager.com \
https://www.google-analytics.com; \
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; \
font-src 'self' https://fonts.gstatic.com data:; \
img-src 'self' data: https:; \
connect-src 'self' \
https://www.google-analytics.com \
https://*.google-analytics.com \
https://*.analytics.google.com \
https://www.googletagmanager.com; \
frame-ancestors 'self'; \
base-uri 'self'; \
form-action 'self'"
幾個關鍵 insight:
'unsafe-inline'為什麼還在:Next.js inline 不少 hydration script + critical CSS。要拿掉得改用 nonce 模式(middleware 注入),那是大工程font-src包了fonts.gstatic.com但其實用不到:Next.js 用next/font/google引入 Google Fonts 時,字型檔是 build-time download 到本機/.next/static/media/...woff2,runtime 是 self URL — 不接 fonts.gstatic.com。我們保留 external font-src 比較保險connect-src一定要包*.google-analytics.com+*.analytics.google.com:GA4 的 beacon endpoint 是不固定 subdomain,wildcard 必要frame-ancestors 'self'取代舊的X-Frame-Options(兩個都加也 OK,新瀏覽器看 frame-ancestors)
驗證流程:
# 1. header 真的送出了
curl -skI https://www.gomylife.tw/ | grep -i content-security
# 2. 開 Chrome devtools / Playwright,看 console 有沒有 CSP violation
# 3. GA Realtime dashboard 看得到自己 = 沒擋到 beacon
5. Contact form 加 honeypot(防 bot 灌水) #
我們的 /api/contact 原本只靠 IP rate limit(10/min + 100/day)。問題:攻擊者用 100 個 IP 就能灌 1 萬筆假 contact 進來。
選擇:加 Cloudflare Turnstile / Google reCAPTCHA v3 — 但需要申請 site key + 前端 widget 整合。先用 honeypot 擋 80% 的 bot,之後流量爆量再上 Turnstile。
前端:加隱藏欄位 #
人類看不見(視覺 + tabindex + aria 都隱藏),但 bot 會解析 form 把它填上:
<form onSubmit={handleSubmit}>
{/* Honeypot — 真人不會看到、tab 跳不到、screen reader 忽略
⚠️ 注意:實際部署時欄位名 *不要*用 "website" 這種公開範例名,
請用獨特的、最好定期輪動的命名,避免被 bot 寫死規則繞過 */}
<div
aria-hidden
className="absolute -left-[10000px] top-0 size-px overflow-hidden"
>
<label>
[Your hidden field label]
<input
type="text"
name="[your-unique-field-name]" /* ← 換成你自己的命名 */
tabIndex={-1}
autoComplete="off"
defaultValue=""
/>
</label>
</div>
{/* 正常欄位 */}
<input name="name" />
<input name="email" />
<textarea name="message" />
...
</form>
後端:填了就靜默 return 200 #
// Honeypot:真人填不到的隱藏欄位。bot 填了 → 靜默 return 200
// (讓 bot 以為成功不再 retry。不要回 400 / 403,bot 會換策略)
if (asStr(body[HONEYPOT_FIELD])) {
console.log(
`[contact] honeypot triggered from ${ip} ua="${userAgent}"`
);
return Response.json({ ok: true });
}
為什麼回 200 而不是 400:bot 收到 400 會調整策略再試(或換 IP)。回 200 + 不存檔,bot 以為成功,不會回頭重試 — 攻擊資源浪費掉。
Honeypot OPSEC 注意 #
- 欄位名不要公開(包含這篇 blog 沒寫實際用什麼名字)
- 進階做法:server-side render 時用 hash / session 產生欄位名,bot 沒辦法寫死規則
- 更進階:搭配「填寫時間」判斷(人類不會 0.1 秒內填完表單,bot 會) → 雙保險
- 監控 log:上線後實際看哪些 IP / UA 被擋,定期檢視 → 確認沒誤殺 + 看攻擊趨勢
驗證 #
# Bot 情境(填 honeypot 欄位)
curl -X POST https://your-site/api/contact \
-H 'content-type: application/json' \
-d '{"name":"Bot","email":"x@x","message":"spam","[hidden-field]":"http://spam.com"}'
# 回 {"ok":true},但 log 顯示 honeypot triggered + 沒存檔
# 真人情境(不填 honeypot)
curl -X POST https://your-site/api/contact \
-H 'content-type: application/json' \
-d '{"name":"Real","email":"real@example.com","message":"test"}'
# 回 {"ok":true},log 顯示 saved + 存檔
實測:上線當下沒多久就抓到第一隻 bot(自動化工具 UA + 海外 IP)。
完成 checklist #
做完上面 5 件事的驗證點:
-
npm audit0 high vulnerabilities -
curl -I無X-Powered-Byheader -
curl -I有Content-Security-Policyheader - 瀏覽器 console 0 CSP violations
- GA Realtime dashboard 有看到自己(沒被 CSP 擋掉 beacon)
- Docker build 成功(lightningcss musl issue 修掉)
- Container healthcheck
(healthy) - Contact form 模擬 bot(填 honeypot)回 200 但沒存檔
- Contact form 模擬真人(空 honeypot)回 200 且存檔
我們刻意沒做的(Phase 3 之後) #
| 項目 | 為什麼先不做 |
|---|---|
| Cloudflare Turnstile / reCAPTCHA | honeypot 應該擋掉大部分 bot 了,等流量爆量再加 |
CSP 換 nonce 模式(去 unsafe-inline) |
Next.js middleware 注入 nonce 是中等工程,先有 baseline CSP 比較重要 |
| HSTS preload 申請 | 不可逆操作,確認所有 subdomain 都 HTTPS-only 再做 |
| 把 Anthropic API spend cap 加進來 | 我們有 max_tokens=1024 + max_turns=1 兩個 cost cap,先看實際攻擊量 |
重點總結 #
| # | 項目 | 工 | 風險 |
|---|---|---|---|
| 1 | Next.js 升到 16.2.6 | 5 分鐘 | 低(升小版號) |
| 2 | Docker base alpine → slim | 30 分鐘 | 低(image 大 100MB 而已) |
| 3 | poweredByHeader: false |
1 分鐘 | 低(一行設定) |
| 4 | Apache vhost 加 CSP | 30 分鐘 | 中(要驗證 GA + Fonts 沒破) |
| 5 | Contact form honeypot | 20 分鐘 | 低(純前後端 trick) |
整套 2-3 小時做完,下次資安檢查報告這 5 項全綠。
想做類似的資安強化 #
如果你有 Next.js / 其他 Web 站要上線、或被資安檢查報告卡住,我們可以幫:
- Code review + 強化清單 — 依現有 stack 給具體優先序
- 整套部署 hardening — Apache vhost / Docker / CSP / rate limit 一條龍
- OWASP Top 10 對照檢核 — 政府標案 / 金融客戶常要的合規檢查
聯絡:0912852835 / henryccy@icloud.com / LINE @3q3tw / 線上表單
延伸閱讀: