前言:一個簡單的環境變數引發的災難
在部署 Strapi CMS 到 Kubernetes 正式環境時,只是加了一行看似無害的環境變數設定:
env:
- name: NODE_ENV
value: production # 就是這一行!
結果卻導致整個管理後台變成一片空白,連登入頁面都看不到。更詭異的是:
- ✅ API 完全正常,GraphQL 和 REST 都能回應
- ✅ Pod 狀態正常,沒有任何錯誤訊息
- ✅ 日誌顯示 Strapi 成功啟動
- ❌ 瀏覽器打開
/admin卻是一片空白
這種「Schrodinger 的服務」(同時正常又不正常)讓人抓狂。經過一番排查,終於發現罪魁禍首是 CSP (Content Security Policy) 在作怪。
本文將深入探討:
- 為什麼正式環境會出現空白頁
- CSP 的工作原理與安全機制
- 完整的問題排查步驟
- 如何正確配置 Strapi 的安全策略
- 生產環境的安全最佳實踐
問題背景:開發正常,正式環境空白
環境差異對比
graph LR
subgraph "開發環境 (Development)"
D1[NODE_ENV=development]
D1 --> D2[✅ CSP 寬鬆]
D1 --> D3[✅ 詳細錯誤訊息]
D1 --> D4[✅ Hot Reload]
D2 --> D5[✅ Admin Panel 正常]
end
subgraph "正式環境 (Production)"
P1[NODE_ENV=production]
P1 --> P2[🔒 CSP 嚴格]
P1 --> P3[⚠️ 精簡錯誤訊息]
P1 --> P4[📦 壓縮打包]
P2 --> P5[❌ Admin Panel 空白]
end
style D5 fill:#22c55e
style P5 fill:#ef4444
問題現象詳細描述
Kubernetes Deployment 設定:
apiVersion: apps/v1
kind: Deployment
metadata:
name: strapi-prod
namespace: default
spec:
replicas: 1
selector:
matchLabels:
app: strapi
template:
metadata:
labels:
app: strapi
spec:
containers:
- name: strapi
image: myregistry.com/strapi:v5.0.0
env:
- name: NODE_ENV
value: production # 問題的起點
- name: DATABASE_HOST
valueFrom:
secretKeyRef:
name: strapi-db-secret
key: host
- name: ADMIN_JWT_SECRET
valueFrom:
secretKeyRef:
name: strapi-admin-secret
key: jwt-secret
ports:
- containerPort: 1337
部署後的症狀:
Pod 狀態看起來完美
$ kubectl get pods -n default NAME READY STATUS RESTARTS AGE strapi-prod-66cb7494c5-abcde 1/1 Running 0 5m日誌顯示正常啟動
$ kubectl logs strapi-prod-66cb7494c5-abcde -n default [2025-05-07 10:15:23.456] INFO: Server started on port 1337 [2025-05-07 10:15:23.567] INFO: Database connection established [2025-05-07 10:15:23.678] INFO: Admin panel available at /admin
API 測試完全正常
# GraphQL 查詢正常 $ curl -X POST https://cms.example.com/graphql \ -H "Content-Type: application/json" \ -d '{"query": "{ articles { title } }"}' {"data":{"articles":[{"title":"Test Article"}]}} ✅ # REST API 也正常 $ curl https://cms.example.com/api/articles {"data":[{"id":1,"attributes":{"title":"Test Article"}}]} ✅但瀏覽器打開後台是空白的
https://cms.example.com/admin 顯示:一片空白(白屏)
問題排查:抽絲剝繭找出真相
排查流程圖
flowchart TD
Start[發現空白頁] --> CheckPod{Pod 狀態正常?}
CheckPod -->|No| FixPod[修復 Pod 問題]
CheckPod -->|Yes| CheckLogs{日誌有錯誤?}
CheckLogs -->|Yes| AnalyzeLogs[分析錯誤日誌]
CheckLogs -->|No| CheckAPI{API 能正常回應?}
CheckAPI -->|No| FixAPI[修復 API 設定]
CheckAPI -->|Yes| CheckBrowser[檢查瀏覽器 DevTools]
CheckBrowser --> CheckNetwork[Network 標籤]
CheckBrowser --> CheckConsole[Console 標籤]
CheckNetwork --> FindBlocked[發現 JS 被阻擋]
CheckConsole --> FindCSP[發現 CSP 錯誤]
FindBlocked --> IdentifyCSP[確認是 CSP 問題]
FindCSP --> IdentifyCSP
IdentifyCSP --> Solution[修改 CSP 設定]
Solution --> Test{問題解決?}
Test -->|No| Adjust[調整設定]
Test -->|Yes| Done[✅ 完成]
Adjust --> Solution
style Start fill:#f97316
style Done fill:#22c55e
style IdentifyCSP fill:#3b82f6
步驟 1:確認 Pod 和服務狀態
# 檢查 Pod 狀態
kubectl get pods -n default -l app=strapi
# 檢查 Pod 事件
kubectl describe pod strapi-prod-66cb7494c5-abcde -n default
# 查看 Strapi 日誌
kubectl logs strapi-prod-66cb7494c5-abcde -n default --tail=100
# 檢查 Service
kubectl get svc -n default -l app=strapi
結果:✅ 全部正常,沒有任何錯誤訊息
步驟 2:測試 API 功能
# 測試 REST API
curl -i https://cms.example.com/api/articles
# 測試 GraphQL
curl -X POST https://cms.example.com/graphql \
-H "Content-Type: application/json" \
-d '{"query": "{ articles { id title } }"}'
# 測試健康檢查端點
curl https://cms.example.com/_health
結果:✅ API 完全正常,資料庫連線正常
步驟 3:瀏覽器 DevTools 檢查(關鍵!)
打開 Chrome DevTools(F12 或 Cmd+Option+I)
1. Console 標籤顯示錯誤:
Refused to load the script 'https://cms.example.com/_content/admin/strapi-CNBo0SYx.js'
because it violates the following Content Security Policy directive:
"script-src 'self' blob:". Note that 'script-src-elem' was not explicitly set,
so 'script-src' is used as a fallback.
2. Network 標籤顯示:
strapi-CNBo0SYx.js (blocked:csp) Failed to load resource
runtime-CNBo0SYx.js (blocked:csp) Failed to load resource
vendor-CNBo0SYx.js (blocked:csp) Failed to load resource

找到了!JavaScript 檔案被 CSP (Content Security Policy) 阻擋!
CSP (Content Security Policy) 深度解析
什麼是 CSP?
CSP 是一種瀏覽器安全機制,用於防止 XSS (跨站腳本攻擊) 和其他程式碼注入攻擊。它透過 HTTP Header 告訴瀏覽器哪些資源來源是可信的。
CSP 工作原理:
sequenceDiagram
participant Browser as 瀏覽器
participant Server as Strapi Server
participant JS as JavaScript 檔案
Browser->>Server: GET /admin
Server-->>Browser: HTTP Response<br/>+ CSP Header
Note over Browser: 解析 CSP 規則
Browser->>JS: 嘗試載入 strapi-xxx.js
alt JS 來源符合 CSP 規則
JS-->>Browser: ✅ 載入成功
Browser->>Browser: 執行 JavaScript
else JS 來源違反 CSP 規則
JS--xBrowser: ❌ 被阻擋
Browser->>Browser: Console 顯示錯誤
Note over Browser: 頁面空白
end
CSP 指令說明
常見的 CSP 指令:
| 指令 | 說明 | 範例 |
|---|---|---|
default-src | 預設來源(其他未指定的都套用此規則) | 'self' |
script-src | JavaScript 來源 | 'self' 'unsafe-inline' |
style-src | CSS 樣式來源 | 'self' https://fonts.googleapis.com |
img-src | 圖片來源 | 'self' data: https: |
connect-src | AJAX/WebSocket 連線來源 | 'self' https://api.example.com |
font-src | 字型來源 | 'self' https://fonts.gstatic.com |
frame-src | iframe 來源 | 'none' |
特殊關鍵字(必須加引號):
| 關鍵字 | 說明 | 安全性 |
|---|---|---|
'none' | 不允許任何來源 | 最安全 |
'self' | 只允許同源 | 安全 |
'unsafe-inline' | 允許行內腳本/樣式 | ⚠️ 較不安全 |
'unsafe-eval' | 允許 eval() 等動態執行 | ⚠️ 不安全 |
'strict-dynamic' | 動態載入腳本(需配合 nonce) | 較安全 |
Strapi 預設的 CSP 設定
開發模式(NODE_ENV=development):
// Strapi 內建預設
{
directives: {
'script-src': ["'self'", "'unsafe-inline'", 'cdn.jsdelivr.net'],
'img-src': ["'self'", 'data:', 'blob:', 'cdn.jsdelivr.net'],
'connect-src': ["'self'", 'https:'],
// ... 較寬鬆的設定
}
}
正式模式(NODE_ENV=production):
// Strapi 自動啟用嚴格模式
{
directives: {
'script-src': ["'self'", 'blob:'], // ❌ 缺少 'unsafe-inline'
'img-src': ["'self'", 'data:', 'blob:'],
'connect-src': ["'self'"],
// ... 更嚴格的設定
}
}
問題所在:
- Strapi Admin Panel 會動態載入打包後的 JS 檔案(如
strapi-CNBo0SYx.js) - 這些檔案需要
'unsafe-inline'和可能的外部來源 - 正式環境的嚴格 CSP 阻擋了這些檔案的載入
解決方案:正確配置 CSP 設定
方案 1:修改 Strapi 中介層設定(推薦)
位置:config/middlewares.js
module.exports = [
'strapi::logger',
'strapi::errors',
{
name: 'strapi::security',
config: {
contentSecurityPolicy: {
useDefaults: true,
directives: {
// 允許從同源和行內載入腳本
'script-src': ["'self'", "'unsafe-inline'", 'cdn.jsdelivr.net'],
// 如果使用外部 CDN 或自訂域名
'script-src-elem': [
"'self'",
'cdn.jsdelivr.net',
process.env.PUBLIC_URL, // 允許從設定的 CDN 載入
],
// 允許圖片來源
'img-src': [
"'self'",
'data:',
'blob:',
'cdn.jsdelivr.net',
'strapi.io',
process.env.CDN_URL,
],
// 允許媒體來源
'media-src': ["'self'", 'data:', 'blob:', process.env.CDN_URL],
// 允許 API 連線
'connect-src': ["'self'", 'https:'],
// 允許字型來源
'font-src': ["'self'", 'data:'],
// 允許樣式來源
'style-src': ["'self'", "'unsafe-inline'", 'cdn.jsdelivr.net'],
// Frame ancestors(防止被嵌入 iframe)
'frame-ancestors': ["'self'"],
},
},
},
},
'strapi::cors',
'strapi::poweredBy',
'strapi::query',
'strapi::body',
'strapi::session',
'strapi::favicon',
'strapi::public',
];

關鍵說明:
1. 'self' 必須加引號
// ❌ 錯誤
'script-src': [self]
// ✅ 正確
'script-src': ["'self'"]
2. 'unsafe-inline' 的必要性
Strapi Admin Panel 使用了一些行內腳本和樣式,如果不加這個會被阻擋。雖然名字有 “unsafe”,但對於管理後台來說是可接受的(因為只有管理員能存取)。
3. process.env.PUBLIC_URL 動態設定
如果你的靜態資源部署在 CDN:
# Kubernetes Deployment
env:
- name: PUBLIC_URL
value: "https://cdn.myshop.com"
- name: CDN_URL
value: "https://cdn.myshop.com"
4. useDefaults: true
保留 Strapi 預設的基本規則,只覆蓋需要調整的部分。
方案 2:環境變數控制(靈活)
建立 config/middlewares.js(支援環境變數):
const isDevelopment = process.env.NODE_ENV === 'development';
module.exports = [
'strapi::logger',
'strapi::errors',
{
name: 'strapi::security',
config: {
contentSecurityPolicy: {
useDefaults: true,
directives: {
'script-src': [
"'self'",
...(isDevelopment ? ["'unsafe-inline'", "'unsafe-eval'"] : ["'unsafe-inline'"]),
'cdn.jsdelivr.net',
process.env.CDN_URL,
].filter(Boolean),
'connect-src': [
"'self'",
'https:',
...(isDevelopment ? ['http:', 'ws:'] : []), // 開發模式允許 WebSocket
].filter(Boolean),
'img-src': [
"'self'",
'data:',
'blob:',
process.env.CDN_URL,
process.env.UPLOAD_CDN_URL,
].filter(Boolean),
},
},
},
},
'strapi::cors',
'strapi::poweredBy',
'strapi::query',
'strapi::body',
'strapi::session',
'strapi::favicon',
'strapi::public',
];
方案 3:Nginx/Ingress 層級設定(不推薦)
也可以在 Nginx Ingress 設定 CSP Header,但這樣會覆蓋 Strapi 的設定:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: strapi-ingress
annotations:
nginx.ingress.kubernetes.io/configuration-snippet: |
add_header Content-Security-Policy "script-src 'self' 'unsafe-inline' cdn.jsdelivr.net;" always;
spec:
rules:
- host: cms.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: strapi-service
port:
number: 1337
不推薦的原因:
- 難以維護(設定分散在多個地方)
- Nginx 設定會覆蓋應用層設定
- 不利於開發/測試環境的一致性
部署流程與驗證
完整部署流程
flowchart TD
Start[修改 config/middlewares.js] --> Build[建置 Docker Image]
Build --> Push[推送到 Registry]
Push --> Deploy[部署到 Kubernetes]
Deploy --> Wait[等待 Pod 就緒]
Wait --> Check1{Pod 狀態正常?}
Check1 -->|No| Debug1[檢查 logs]
Check1 -->|Yes| Check2[測試 API]
Check2 --> Check3{API 正常?}
Check3 -->|No| Debug2[檢查設定]
Check3 -->|Yes| Check4[測試 Admin Panel]
Check4 --> Check5{Admin 正常?}
Check5 -->|No| CheckCSP[檢查 CSP Header]
Check5 -->|Yes| Verify[功能驗證]
CheckCSP --> Adjust[調整 CSP 設定]
Adjust --> Build
Verify --> Done[✅ 部署完成]
style Start fill:#4ade80
style Done fill:#22c55e
style Debug1 fill:#f97316
style Debug2 fill:#f97316
步驟 1:更新 Dockerfile
FROM node:18-alpine
WORKDIR /app
# 複製 package files
COPY package.json package-lock.json ./
RUN npm ci --only=production
# 複製應用程式碼
COPY . .
# 複製更新的 middlewares 設定
COPY config/middlewares.js ./config/
# 建置 Admin Panel
ENV NODE_ENV=production
RUN npm run build
# 暴露 port
EXPOSE 1337
# 啟動
CMD ["npm", "start"]
步驟 2:建置並推送
# 建置映像檔
docker build -t myregistry.com/strapi:v5.0.1 .
# 推送到 Registry
docker push myregistry.com/strapi:v5.0.1
步驟 3:更新 Kubernetes Deployment
apiVersion: apps/v1
kind: Deployment
metadata:
name: strapi-prod
namespace: default
spec:
replicas: 1
selector:
matchLabels:
app: strapi
template:
metadata:
labels:
app: strapi
version: v5.0.1
spec:
containers:
- name: strapi
image: myregistry.com/strapi:v5.0.1 # 更新版本
env:
- name: NODE_ENV
value: production
- name: PUBLIC_URL
value: "https://cms.example.com"
- name: CDN_URL
value: "https://cdn.example.com"
- name: DATABASE_HOST
valueFrom:
secretKeyRef:
name: strapi-db-secret
key: host
ports:
- containerPort: 1337
livenessProbe:
httpGet:
path: /_health
port: 1337
initialDelaySeconds: 60
periodSeconds: 30
readinessProbe:
httpGet:
path: /_health
port: 1337
initialDelaySeconds: 30
periodSeconds: 10
步驟 4:部署並驗證
# 套用設定
kubectl apply -f deployment.yaml
# 監控部署狀態
kubectl rollout status deployment/strapi-prod -n default
# 查看 Pod 狀態
kubectl get pods -n default -l app=strapi -w
# 查看日誌
kubectl logs -f deployment/strapi-prod -n default
步驟 5:驗證 CSP Header
# 檢查回應的 CSP Header
curl -I https://cms.example.com/admin
# 應該看到類似這樣的 Header
HTTP/2 200
content-security-policy: script-src 'self' 'unsafe-inline' cdn.jsdelivr.net;
img-src 'self' data: blob:;
connect-src 'self' https:;
或使用瀏覽器 DevTools:
Network → 選擇 admin 頁面 → Headers → Response Headers
查找 content-security-policy
步驟 6:功能測試檢查清單
- Admin Panel 首頁正常顯示
- 能夠登入管理後台
- Content Manager 正常載入
- Media Library 圖片正常顯示
- 可以新增/編輯/刪除內容
- GraphQL Playground 正常運作
- API 端點正常回應
- 瀏覽器 Console 沒有 CSP 錯誤
安全性最佳實踐
1. 最小權限原則
只開放必要的來源,避免使用過於寬鬆的設定:
// ❌ 不安全:允許任何 HTTPS 來源
'script-src': ["'self'", 'https:']
// ✅ 安全:明確指定可信來源
'script-src': ["'self'", "'unsafe-inline'", 'cdn.jsdelivr.net']
2. 避免 'unsafe-eval'
盡可能不要使用 'unsafe-eval',它會允許 eval() 等動態程式碼執行:
// ❌ 非常不安全
'script-src': ["'self'", "'unsafe-inline'", "'unsafe-eval'"]
// ✅ 只使用必要的 unsafe-inline
'script-src': ["'self'", "'unsafe-inline'"]
3. 使用 Nonce 或 Hash(進階)
對於更高的安全性,可以使用 nonce 或 hash:
// 使用 nonce(需要在每個請求動態生成)
module.exports = {
name: 'strapi::security',
config: {
contentSecurityPolicy: {
useDefaults: true,
directives: {
'script-src': ["'self'", (ctx) => `'nonce-${ctx.state.nonce}'`],
},
},
},
};
4. 分離 Admin 和 API
更安全的做法是將 Admin Panel 和 Public API 分開部署:
# Admin Panel (內部網路)
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: strapi-admin-ingress
spec:
rules:
- host: admin.internal.example.com
http:
paths:
- path: /admin
pathType: Prefix
backend:
service:
name: strapi-service
port:
number: 1337
---
# Public API (對外開放)
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: strapi-api-ingress
spec:
rules:
- host: api.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: strapi-service
port:
number: 1337
5. 監控 CSP 違規
設定 CSP 報告端點來追蹤違規:
module.exports = {
name: 'strapi::security',
config: {
contentSecurityPolicy: {
useDefaults: true,
directives: {
'script-src': ["'self'", "'unsafe-inline'"],
'report-uri': ['/csp-violation-report'], // 違規報告
},
},
},
};
建立接收違規報告的端點:
// api/csp-report/routes/csp-report.js
module.exports = {
routes: [
{
method: 'POST',
path: '/csp-violation-report',
handler: 'csp-report.receive',
config: {
auth: false, // CSP 報告不需要認證
},
},
],
};
// api/csp-report/controllers/csp-report.js
module.exports = {
async receive(ctx) {
const report = ctx.request.body;
strapi.log.warn('CSP Violation:', report);
// 可以儲存到資料庫或發送到監控系統
ctx.body = { received: true };
},
};
常見問題與解決方案
Q1: 為什麼只有 Admin 空白,API 正常?
A: Admin Panel 是 SPA (Single Page Application),需要載入大量 JavaScript 檔案。API 端點只是 JSON 回應,不需要載入任何 JS,所以不受 CSP 影響。
Q2: 加了 'unsafe-inline' 還是不行?
可能原因:
引號問題
// ❌ 錯誤:缺少引號 'script-src': ['self', 'unsafe-inline'] // ✅ 正確:必須加引號 'script-src': ["'self'", "'unsafe-inline'"]設定沒有生效
# 確認 Pod 已使用新的設定 kubectl delete pod -l app=strapi -n default # 檢查 CSP Header curl -I https://cms.example.com/admin | grep -i content-securityNginx/Ingress 覆蓋了設定
# 檢查 Ingress annotations kubectl get ingress strapi-ingress -o yaml | grep -A 10 annotations
Q3: 可以完全關閉 CSP 嗎?
可以,但不推薦:
module.exports = [
'strapi::logger',
'strapi::errors',
{
name: 'strapi::security',
config: {
contentSecurityPolicy: false, // 完全關閉 CSP
},
},
// ...
];
風險:
- 容易遭受 XSS 攻擊
- 不符合安全最佳實踐
- 可能違反資安稽核要求
Q4: 如何為不同環境設定不同的 CSP?
const isDevelopment = process.env.NODE_ENV === 'development';
const isProduction = process.env.NODE_ENV === 'production';
const cspDirectives = {
development: {
'script-src': ["'self'", "'unsafe-inline'", "'unsafe-eval'", 'cdn.jsdelivr.net'],
'connect-src': ["'self'", 'https:', 'http:', 'ws:', 'wss:'],
},
production: {
'script-src': ["'self'", "'unsafe-inline'", 'cdn.jsdelivr.net'],
'connect-src': ["'self'", 'https:'],
},
};
module.exports = [
{
name: 'strapi::security',
config: {
contentSecurityPolicy: {
useDefaults: true,
directives: isProduction ? cspDirectives.production : cspDirectives.development,
},
},
},
// ...
];
Q5: 如何測試 CSP 設定而不阻擋?
使用 Content-Security-Policy-Report-Only Header:
module.exports = {
name: 'strapi::security',
config: {
contentSecurityPolicy: {
useDefaults: true,
reportOnly: true, // 只報告,不阻擋
directives: {
'script-src': ["'self'"],
'report-uri': ['/csp-violation-report'],
},
},
},
};
這樣可以在不影響功能的情況下,收集違規報告並調整設定。
總結與收穫
關鍵要點
問題診斷:
- ✅ 空白頁不一定是程式錯誤:可能是安全策略阻擋
- ✅ 瀏覽器 DevTools 是最佳夥伴:Console 和 Network 標籤能揭露真相
- ✅ 環境差異要注意:開發和正式環境的預設設定可能完全不同
CSP 理解:
- ✅ CSP 是重要的安全機制:防止 XSS 和程式碼注入攻擊
- ✅
'self'必須加引號:CSP 語法規定 - ✅
'unsafe-inline'並非絕對不安全:對管理後台來說是可接受的 - ✅ 最小權限原則:只開放必要的來源
Strapi 配置:
- ✅
config/middlewares.js是關鍵:控制中介層行為 - ✅
useDefaults: true保留基本設定:只覆蓋需要調整的部分 - ✅ 環境變數靈活控制:不同環境使用不同策略
檢查清單
部署前:
- 確認
config/middlewares.js設定正確 - 本地測試 production 模式
- 檢查所有外部資源來源
- 確認環境變數設定
部署後:
- 檢查 Pod 狀態和日誌
- 測試 API 端點
- 測試 Admin Panel
- 檢查 CSP Header
- 瀏覽器 Console 無錯誤
- 功能測試通過
安全性:
- 不使用
'unsafe-eval' - 明確指定可信來源
- 設定 CSP 違規報告
- 定期檢查安全更新
- 考慮分離 Admin 和 API
延伸閱讀
這次的經驗讓我深刻體會到:
- 環境變數的影響比想像中大:一個簡單的
NODE_ENV=production會觸發許多隱藏的行為變化 - 安全性與可用性的平衡:CSP 提供了重要的保護,但需要正確配置才不會影響功能
- 瀏覽器工具的重要性:DevTools 是前端開發者最好的朋友
希望這篇分享能幫助你少走一些彎路!🚀
參考資源
官方文件:
- Strapi Security Middleware
- MDN - Content Security Policy (CSP)
- CSP Evaluator - Google 的 CSP 評估工具
實用工具:
- CSP Generator - 線上 CSP 產生器
- CSP Validator - 驗證 CSP 設定
- SecurityHeaders.com - 檢查網站安全 Headers
延伸閱讀: