從 nginx 接到請求,到回 403 或轉給被保護網站、再把回應送回去,中間有五個檢查階段。本篇說明每個階段看什麼、在哪一點決定攔下、放行時往後端帶了什麼標頭、以及為什麼 real_ip 模組被刻意設成不會生效。
ModSecurity 把一筆交易切成五個 phase,connector 把它們掛在 nginx 處理請求的不同時點上。規則各自宣告自己屬於哪個 phase;同一個 phase 內的規則依載入順序執行。
| 階段 | 引擎拿到什麼 | 本安裝包的規則在這裡做什麼 |
|---|---|---|
| phase 1 | 請求標頭 | 方法、URI、查詢字串、所有請求標頭、Cookie。本文還沒讀 |
| phase 2 | 請求本文 | ARGS(查詢字串與表單參數合併)、JSON 解析後的鍵值、multipart 各段、原始本文 |
| 轉送 | — | nginx 依 includes/proxy_backend.conf 把請求送到 BACKEND,第五節有實際內容 |
| phase 3 | 回應標頭 | 後端回的狀態碼與標頭 |
| phase 4 | 回應本文 | text/plain、text/html、text/xml 的前 1 MB |
| phase 5 | 記錄 | 整筆交易 |
949110 的動作是 deny,引擎把交易標成 interrupted,connector 讓 nginx 以 403 結束這個請求。三件事值得寫清楚:
| 事實 | 說明 |
|---|---|
| 後端完全不知道 | 被擋的請求沒有到過 proxy_pass。被保護網站的 access log 不會有這筆,平台自己的限流、稽核也不會看到它。要查被擋的請求只能看 WAF 的 audit.log 或 ClickHouse |
| 403 頁面是 nginx 的,不是後端的 | 映像檔在 cors.conf 用 more_set_headers -s 403 對 403 補了 Content-Type: text/html 與幾個 CORS 標頭,本文是 nginx 內建的 403 頁。攻擊者看到的回應裡沒有任何後端的痕跡,也沒有 CRS 規則編號 |
| audit.log 的 is_interrupted 會是 true | 這是分辨「命中規則但放行」與「真的被擋」最直接的欄位。Vector 目前沒有把它抽出來,平台端要看被擋與否得看 response.http_code 是不是 403 或 messages 裡有沒有 949110 |
有些請求連 phase 1 都到不了,因為 nginx 在解析階段就拒絕了。這類請求不會出現在 audit.log,也就不會變成平台事件。實測到的一個:
| 請求 | 回應 | 誰回的、為什麼 log 沒有 |
|---|---|---|
| TRACE / | 405 | nginx 本身不支援 TRACE,解析到方法就回 405,ModSecurity 沒有介入。CRS 的 911100 對 TRACE 本來會記 CRITICAL,但輪不到它。2026-09-26 實測 audit.log 沒有這筆 |
| 請求本文 > 12.5 MB | 413 | 這條是引擎回的(SecRequestBodyLimitAction Reject),會進 audit.log,因為 413 符合 RelevantStatus |
| 畸形到 nginx 解析不了的 HTTP | 400 | nginx 回,不進 audit.log。走 Cloudflare 時這類請求多半在 edge 就被擋了 |
這是容器內 includes/proxy_backend.conf 生效中的內容(後端位址已換成文件用的示範值):
proxy_set_header Host $host;
proxy_set_header Proxy "";
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Port $server_port;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_buffering off;
proxy_connect_timeout 60s;
proxy_read_timeout 36000s;
proxy_redirect off;
proxy_pass_header Authorization;
proxy_pass http://192.168.0.111:8000;
set_real_ip_from 127.0.0.2;
real_ip_header CF-Connecting-IP;
real_ip_recursive on;
| 標頭 | 經 Cloudflare 時的值 | 意義 |
|---|---|---|
| Host | 對外 hostname(如 app.example.com) | 原樣轉給後端。後端若依 Host 判斷 vhost 或組對外網址,看到的是對外名稱,不是 WAF 的內網 IP |
| X-Real-IP | 172.18.0.250(cloudflared 容器) | $remote_addr,也就是直連 WAF 的對象。因為 real_ip 模組被設成不生效(第六節),這裡不是攻擊者 IP |
| X-Forwarded-For | 203.0.113.42, 172.18.0.250 | nginx 把自己看到的 $remote_addr 附加到已有的 XFF 後面。Cloudflare 已經放了真實訪客,所以後端看到「訪客, cloudflared」兩段 |
| X-Forwarded-Proto | http | WAF 收到的是 tunnel 內的明文 HTTP,所以是 http,不是 https。後端若據此組 redirect 網址會得到 http://,BeakPlatform 用系統設定的對外網址而不是這個標頭,正是為了避開這件事 |
| CF-Connecting-IP、Cf-Ipcountry 等原始標頭 | 203.0.113.42、TW | nginx 預設把沒有被 proxy_set_header 覆寫的請求標頭原樣透傳,所以後端仍拿得到 Cloudflare 寫的真實來源 |
| Authorization | 原樣 | proxy_pass_header 確保 Basic/Bearer 認證能過 |
| Upgrade / Connection | 依請求 | WebSocket 升級可以通過。proxy_read_timeout 36000s 是為了長連線不被切 |
被保護網站看到的 TCP 來源永遠是主機 B(docker MASQUERADE 後的實體網卡 IP)。要知道真實訪客,後端得讀 XFF 或 CF-Connecting-IP,而且只該在來源是主機 B 時才信。BeakPlatform 這一側的做法是 TRUSTED_PROXY_IPS 設成防禦節點的 IP、只信任該來源送的 CF-Connecting-IP;其他後端要自己做等價的事。
nginx 的 real_ip 模組原本的用途是:來源在 set_real_ip_from 清單內時,把 $remote_addr 換成 real_ip_header 指定的標頭值。直覺的設法是 set_real_ip_from 172.18.0.250(cloudflared),這樣 nginx 眼中的來源就會變成真實訪客。本安裝包沒有這樣做,而是設成 127.0.0.2,一個永遠不會是來源的位址。三段推理:
| # | 推理 |
|---|---|
| 1 | ModSecurity 記進 audit.log 的 client_ip 來自 connector 餵給它的連線來源。如果 real_ip 生效,這個值會變成標頭裡的 IP,原始的直連來源就消失了,log 裡再也看不出這筆是 cloudflared 送的還是內網某台機器送的 |
| 2 | 「要不要相信標頭裡的 IP」是 Vector 在做的判斷:client_ip 在可信直連來源清單(TRUSTED_INGRESS_LIST,預設只有 cloudflared)內,才採信 CF-Connecting-IP/XFF;否則誰打的就記誰。這個判斷需要原始的 client_ip 還在,所以 nginx 這一層不能先換掉它 |
| 3 | 如果改成信 cloudflared 的 IP,且 real_ip 也吃 XFF,那任何能打到 8080 的內網機器帶一個假的 CF-Connecting-IP,就能讓 WAF 事件的攻擊者 IP 變成任意第三方,進而讓平台去封一個無辜的位址。nftables 的 8080 來源管制是第一道,Vector 的歸因比對是第二道,兩道都靠「client_ip 是原始值」才成立 |
代價是 nginx 的 access log 與 X-Real-IP 都是 cloudflared 的 IP,看起來像「所有請求都來自同一個位址」。這是刻意的,攻擊者歸因在 Vector 做、證據在 audit.log 的 request.headers 裡,兩邊都沒有損失。原系列第 5 篇第一節從整體信任邊界的角度談同一件事。
| 來源 | 路徑 | 管制 |
|---|---|---|
| cloudflared 容器 | docker 網段內直連 waf-nginx:8080 | 不經實體網卡、不經 nftables forward chain。這是正式流量唯一的入口,攻擊者從 Internet 只到得了 Cloudflare |
| 平台主機、ADMIN_IPS 內的機器 | 實體網卡 → DNAT → 容器 | nftables ingest_guard_forward 放行。安裝後的內網驗證走這條 |
| 其他內網來源 | 同上 | 同一條 chain drop。症狀是連線逾時,不是 403;看到 403 代表已經進到 WAF 了 |
| IPv6 來源 | — | compose 把埠綁在 0.0.0.0,只有 IPv4 會被發布,IPv6 連不進來(這是修過的坑:不寫位址時 docker-proxy 會同時聽 [::],繞過 forward chain 的來源管制) |
| 任何來源打 8443 | — | 容器內有 TLS 監聽,但 compose 沒有發布,外面連不到 |
容器內另有兩個 nginx 自己回應、不經後端的路徑:/healthz(回 200 OK,compose 的 healthcheck 用)與 /metrics/nginx(stub_status,只允許 127.0.0.0/24)。它們在 location_common.conf 裡、關掉了 access log,但仍然經過 ModSecurity,用它們做驗證時一樣會有規則命中。
2026-09-26 從 ADMIN_IPS 內的管理機用 IP 直打防禦節點 8080,被保護網站是 BeakPlatform。原始輸出在 /opt/tmp/verify/20260926-waf-doc-probe.log。
| 請求 | 回應 | 走到哪 |
|---|---|---|
| GET /beakplatform/auth/login | 200 | phase 1 命中 920350(用 IP 打),3 分未達門檻 → 轉後端 → 後端回登入頁 → phase 4 無外洩 → audit.log 一行(有命中就記) |
| GET /beakplatform/?id=1' OR 1=1-- | 403 | phase 2 命中 942100,總分 8 → 949110 deny → 沒到後端 → audit.log 一行,is_interrupted: true |
| GET /beakplatform/?q=alert(1) | 403 | phase 2 命中 941100/941110/941160/941390,總分 23 → 同上 |
| TRACE /beakplatform/ | 405 | nginx 解析階段拒絕,ModSecurity 沒跑,audit.log 沒有 |
| PATCH /beakplatform/auth/login | 405 | 覆寫檔的 900200 把 PATCH 列為允許 → 911 不加分 → 轉後端 → 後端回 405(Allow: HEAD, GET, OPTIONS, POST,回應標頭裡有平台自己的 CSP)→ 405 符合 RelevantStatus → audit.log 一行。這一列證明兩件事:方法白名單有生效,以及 4xx 即使不是 WAF 擋的也會記 |
cloudflared 的 ingress 規則決定請求進哪個 WAF 容器。cf_tunnel.py 產生的規則依序比對,同一個 hostname 帶 path 的排前面:
| 部署方式 | ingress 規則(依序) |
|---|---|
| 主站與歡迎頁不同 hostname | app.example.com → http://waf-nginx:8080;www.example.com → http://waf-welcome:8080;其餘 → 404 |
| 同一個 hostname,用路徑前綴分(--backend-path /beakplatform) | www.example.com 且 path 符合 ^/beakplatform(/或$) → http://waf-nginx:8080;www.example.com 其餘路徑 → http://waf-welcome:8080;其餘 hostname → 404 |
| 沒有歡迎頁 | app.example.com → http://waf-nginx:8080;其餘 → 404 |
兩個容器都不做 hostname 比對(SERVER_NAME "_"),分流責任完全在 cloudflared。內網直打時沒有 cloudflared,8080 一律是主站、8082 一律是歡迎頁。