# WAF 相容性 — NinjaFirewall 適用:2meet-data-optimizer v1.0.2+ / NinjaFirewall (WP Edition) 4.9 **結論:兩者可共存,且不需要開發 AddOn。** 本文記錄實測結果、symlink 多租戶部署的必要設定、WP SaaS 的開站流程與驗證清單,以及開發時必須遵守的三條約束。 > 只想開新站台的話,直接看 §3;踩到 `Cannot retrieve user options from database (#3)` 看 §2。 --- ## 1. 兩種模式的差異 NinjaFirewall 只有兩種模式(「WP+」是付費版本,不是模式): | | WordPress WAF | Full WAF | |---|---|---| | 載入方式 | mu-plugin `0-ninjafirewall.php` | `auto_prepend_file`(`.user.ini` / php.ini) | | 執行時機 | WordPress 載入中 | **WordPress 載入之前** | | 讀設定 | `get_option('nfw_options')` | 自行解析 wp-config.php,**原生 mysqli 直查 `{prefix}options`** | | symlink 安全 | ✅ 站台身分由 WordPress 決定 | ❌ 自行以 `__DIR__` 推導(見 §2) | 這個差異是後續所有注意事項的根源:**Full WAF 完全繞過 WordPress**,因此 TMDO 的任何 PHP 攔截器對它都不存在,但反過來,TMDO 對 `wp_options` 的**持久性**改動會影響它。 --- ## 2. Symlink / 多租戶部署(必讀) WP SaaS 常以 symlink 共享 codebase: ``` tenant-a/wp-content/plugins -> /shared/plugins tenant-b/wp-content/plugins -> /shared/plugins ``` Full WAF 在 `lib/firewall.php:78` 這樣推導站台位置: ```php $nfw_['wp_content'] = dirname(dirname(dirname( __DIR__ ))); ``` **PHP 的 `__DIR__` 會解析 symlink 到真實路徑**,於是每個租戶都被判定為「共享目錄所屬的那個站」。接著 `firewall.php:120-121` 讀到錯誤的 wp-config.php、連到錯誤的資料庫,撈不到該站的 `nfw_options`,最後在 `firewall.php:613` 回傳錯誤碼 6: ``` NinjaFirewall fatal error: Cannot retrieve user options from database (#3). Review your installation, your site is not protected. ``` 最後那句是字面意義的 —— 此時 WAF 已 `nfw_quit()`,**完全不做任何過濾**。 ### 解法:以 DOCUMENT_ROOT 為錨點 `.htninja` 的搜尋位置是 `$_SERVER['DOCUMENT_ROOT']`(`firewall.php:47-48`),那是 web server 的 per-site 值,不受 symlink 影響。第二順位是 `dirname(DOCUMENT_ROOT)`,因此**一份檔案即可服務所有租戶**: ```php `)不會外洩內容,但不該依賴它。 ### 3.2 每個新租戶站台(順序不可顛倒) **順序很重要**:先確保錨點與設定就緒,再安裝 Full WAF。順序顛倒會直接撞上 §2 的 `#3` 錯誤。 **Step 1 — 啟用外掛,建立該站自己的設定** ```bash wp --path=/var/www/sites/ plugin activate ninjafirewall wp --path=/var/www/sites/ option list --search='nfw*' --fields=option_name,autoload # 必須看到 nfw_options 與 nfw_rules,否則後續一定 #3 ``` `nfw_options` / `nfw_rules` 存在**該租戶自己的資料庫**,不會因為 codebase 共享而自動存在。這是 `#3` 最常見的成因 —— 路徑修對了,但那個站從沒跑過 installer。 **Step 2 — 確認目錄權限** php-fpm worker(通常 www-data)必須能在 `nfwlog/` 建立與寫入檔案: | 路徑 | 建議 | 理由 | |---|---|---| | `wp-content/nfwlog/` 及子目錄 | `2775` `wpdev:www-data` | setgid 確保新檔繼承 group;group 需 `w` 否則 WAF 寫不了 log | | `nfwlog/` 內檔案 | `664` | 同上 | | `nfwlog/session/` | 不用管 | 由 WAF 自建,它會用自己的嚴格權限(`0700`) | | `.user.ini`(裝 Full WAF 後才有) | `664` `wpdev:www-data` | www-data 需可讀,否則 auto_prepend 靜默失效 | ```bash NFWLOG=/var/www/sites//wp-content/nfwlog sudo find "$NFWLOG" -type d -exec chmod 2775 {} + sudo find "$NFWLOG" -type f -exec chmod 664 {} + ``` 若 `nfwlog/` 的 owner 本來就是 www-data(例如全程由後台建立),預設 `0755` 也可運作 —— owner 有寫入權。會出事的是「owner 是別人、group 只有 `r-x`」這種組合。 **Step 3 — 安裝 Full WAF(從後台)** 務必從 WordPress 後台操作,不要手工建檔:後台以 www-data 執行,權限天然正確;且官方安裝流程有 sandbox 驗證,會先確認 `auto_prepend_file` 真的生效才寫入設定,避免寫出讓整站 500 的 `.user.ini`。 裝完立刻檢查 `.user.ini` 權限(見上表)。`root:root 0640` 是常見的踩雷組合 —— PHP 讀不到,Full WAF 等同沒裝,而表面上一切正常。 **Step 4 — 套用 nginx 規則並 reload** ```bash sudo nginx -t && sudo systemctl reload nginx ``` ### 3.3 開站後驗證(每站必跑) 四項缺一不可。只做前兩項會漏掉最隱蔽的失效模式。 ```bash SITE=https://.example.com NFWLOG=/var/www/sites//wp-content/nfwlog # (1) 模式判別 —— 回 Forbidden 代表 Full WAF 生效(auto_prepend 已套用到所有 PHP 請求) curl -sk "$SITE/wp-content/plugins/ninjafirewall/lib/i18n.php" | head -c 9; echo # (2) 攔截能力 —— 應為 403 curl -sk -o /dev/null -w '%{http_code}\n' "$SITE/?t=../../etc/passwd" # (3) 記錄能力 —— 最容易被漏掉的一項,log 必須增長 before=$(stat -c%s "$NFWLOG/firewall_$(date +%Y-%m).php") curl -sk -o /dev/null "$SITE/?t=../../etc/passwd" after=$(stat -c%s "$NFWLOG/firewall_$(date +%Y-%m).php") [ "$after" -gt "$before" ] && echo "log OK" || echo "log FAIL — 檢查 nfwlog 權限" # (4) web 封鎖與功能未損 for p in /wp-content/nfwlog/ /wp-content/nfwlog/readme.txt /.user.ini /.htaccess; do printf "%-40s %s\n" "$p" "$(curl -sk -o /dev/null -w '%{http_code}' "$SITE$p")" # 全部應為 403 done for p in / /wp-json/ /wp-admin/; do printf "%-40s %s\n" "$p" "$(curl -sk -o /dev/null -w '%{http_code}' "$SITE$p")" # 200 / 200 / 302 done ``` **第 (3) 項為什麼不能省**:攔截與記錄是兩件事。權限不足時 WAF 照樣回 403,但一筆記錄都寫不進去 —— 你得到一個沒有稽核軌跡的防火牆,事故調查時等於沒有。更嚴重的是同一個權限問題會讓 `session/` 建不出來,而 `wl_admin` 管理員白名單靠 `NFWSESSID` session 承載 `nfw_goodguy` 旗標,於是管理員實際上是被全規則掃描的 —— 這正是「後台操作偶爾莫名 403」的來源。 ### 3.4 排查時的常見誤判 這幾項在實測中都出現過,全部會誤導判斷: | 現象 | 直覺結論 | 實際 | |---|---|---| | 首頁 200、後台沒有錯誤訊息 | WAF 正常 | 完全不能推論。WAF 失效時是 `nfw_quit()` 靜默放行,不擋也不記 | | 攻擊 payload 回 403 | Full WAF 生效 | 可能來自共享 mu-plugin 的 WP WAF。用 §3.3 第 (1) 項區分 | | `nfwlog/` 下的檔案回 403 | nginx 規則生效 | 也可能是 nginx 以 www-data 開不了 root-only 檔案。修好權限後會突然變 200 | | `/.well-known/` 回 403 | dotfile 規則誤擋了 ACME | 多半是目錄存在但 autoindex off。用實檔測:`/.well-known/acme-challenge/` 若回 404(WordPress 頁面)而非 403,代表規則有正確放行 | | `wp-login.php` 回 404 | nginx 改壞了 | 檢查是否啟用 `wps-hide-login` 之類的登入頁隱藏外掛 | | `wp tmdo doctor` 全綠 | WAF 與外掛相容 | WP-CLI 完全豁免 WAF(`firewall.php:18-23`),CLI 結果不能當相容性證據,必須另外走 HTTP 驗證 | ### 3.5 每租戶獨立的維運負擔 symlink 共享的是 codebase,**不是設定與狀態**。以下每一項都是 per-tenant: - **`nfw_options` / `nfw_rules`** 存在各自的 `wp_options`。`nfw_rules` 約 77 KB 且 `autoload=auto`,N 個租戶就是 N 份。 - **規則更新**要逐站執行,沒有集中派送機制。 - **log / cache / session** 各自獨立(前提是 §2 的 `NFW_LOG_DIR` 已正確設定,否則全部寫進共享目錄互相覆蓋)。 - **停用租戶站台時**,`nfwlog/readme.txt` 明示:解除安裝後要等 5 分鐘再刪除該目錄,否則站台可能崩潰。 規劃階段就要把這些算進成本 —— 特別是「規則更新要逐站跑」這點,租戶數量上去之後需要自動化。 --- ## 4. 開發約束(三條) ### 4.1 不得重導向 WAF 的設定選項 `TMDO_Options_Manager::register_settings_group()` 以 `pre_update_option_{key}` 回傳 `$old_value`,讓選項不再落地 `wp_options`。而 Full WAF 是用原生 mysqli 直查該表 —— 一旦 `nfw_options` / `nfw_rules` 被重導向,WAF 會讀不到設定而**靜默停止防護:不報錯、不寫 log**。 v1.0.2 起由 `PROTECTED_OPTIONS` 常數擋下,測試見 `tests/unit/OptionsManagerProtectedTest.php`。新增任何「WordPress 載入前就會以原生 SQL 讀取 `wp_options`」的第三方元件時,必須把它的設定鍵加進該清單。 **autoload 最佳化不受此限** —— `optimize_autoload()` 只改 `autoload` 欄位、不刪列,而 Full WAF 那句 `SELECT *` 不看 autoload。 ### 4.2 不得把 option 名稱字串放進 GET/POST 規則 **322**(`lev=3`,CRITICAL): ``` (^|\S['"])nfw_(?:options|rules)\b ``` 任何 GET/POST 值含 `nfw_options` 或 `nfw_rules` 字串即 **403**(why: "Attempt to modify NinjaFirewall settings")。 這對「列出 autoload 清單讓使用者勾選清理」這類 UI 是直接的地雷 —— 而 `nfw_rules` 正好是最大的 autoload 項目之一,必然出現在清單裡。若要做這種介面,用索引或 hash 當作 POST 值,或直接走 CLI。 ### 4.3 不得以 base64 傳送含 SQL 語意的 payload `nfw_check_b64()`(`firewall.php:1412-1448`,`post_b64` 選項,預設開)會把每個 POST 值 base64 解碼後再比對。明文的 SQL 規則多半需要「以數字/引號開頭」或「以註解結尾」才命中,**base64 版只要「含有」就 CRITICAL 403** —— 涵蓋 `SELECT...FROM...WHERE`、`INSERT INTO`、`UNION SELECT`、`UPDATE...SET`、以及序列化物件 `O:n:"..."`。 換言之,**編碼會讓事情變糟,不是變好**。硬編碼白名單只有 `fpd_print_order` 與 `g-recaptcha-response` 兩個欄位名。 --- ## 5. 風險矩陣(實測結果) 於 dev30(**Full WAF**、`wl_admin=1`、`no_restapi=0`、`admin_ajax` 未設)實測。**同一組項目在 WP WAF 模式下結果完全相同** —— 兩種模式都跑過: | 項目 | 結果 | |---|---| | `wp tmdo status` / `wp tmdo doctor` | ✅ 不受影響(CLI 豁免) | | `GET /wp-json/wpdo/v1/listings`(**無 cookie**) | ✅ 200,且回的是 JSON 而非 WAF 的 HTML 403 頁 | | `?hp_price_min=100&hp_featured=1` 動態欄位 filter | ✅ 200 | | 首頁 / REST 根 | ✅ 200 | | 後台 `/wp-admin/` | ✅ 302(導向登入) | | firewall log 中的 TMDO 相關攔截 | ✅ **0 筆**(log 內全部攔截皆為刻意送出的測試 payload) | 最後一項是判斷相容性的核心證據:不是「沒看到錯誤」,而是逐筆檢查 firewall log 的攔截記錄,確認沒有任何一筆來自本外掛的正常操作。 仍需留意的情境: | 風險 | 觸發條件 | 緩解 | |---|---|---| | Full WAF 靜默失效 | WAF 設定選項被重導向 | §4.1(已由 `PROTECTED_OPTIONS` 擋下) | | 規則 322 誤擋 | option 名稱字串進 GET/POST | §4.2 | | migration 中斷 | `run_sync_loop()` 的 110 秒同步 POST(`class-tmdo-migration-orchestrator.php:443` `set_time_limit(120)`)被代理切斷;鎖 `LOCK_TTL_SEC=1800` 才釋放 | 大站用 `force_async=true` 或走 CLI | | REST 回 HTML 而非 JSON | 非 administrator 角色,或以 Application Password / JWT 呼叫(無 `NFWSESSID` cookie)→ 不在 `wl_admin` 白名單。403 頁面是 HTML(`firewall.php:1585-1590`),client 端看到的是「JSON parse error」這種難查的錯 | 程式化呼叫走 CLI;或確認帶 cookie | | admin-ajax 被當 bot | `admin_ajax` 選項開啟時,缺 `HTTP_ACCEPT` / `Accept-Language` / UA 不含 `Mozilla` 的請求回 **404**(`firewall.php:1760-1802`) | curl / server-to-server 呼叫需帶完整 header;預設此選項未開 | **WP-CLI 完全豁免**:`firewall.php:18-23` 對 `defined('WP_CLI') && WP_CLI && PHP_SAPI === 'cli'` 直接 `return`。TMDO 全部 63 個 CLI 子命令零風險 —— 這也是 migration、snapshot restore、backfill 等重操作建議走 CLI 的另一個理由。 **管理員幾乎豁免**:`wl_admin=1` 時,帶 `NFWSESSID` cookie 的 administrator 只跑 3 條規則就 `nfw_quit(20)`(`firewall.php:233-252`)。 --- ## 6. 為什麼不需要 AddOn | 理由 | 證據 | |---|---| | 無對外 hook API | 全 codebase 零個 `apply_filters('nfw_*')` / `do_action('nfw_*')` | | 官方相容手段都在部署層 | `.htninja`、`exclude_waf_list` UI、wp-config 常數 —— 沒有一項是 plugin 程式碼掛得上的 | | 常見衝突模式在 TMDO 全不存在 | 無 loopback self-POST、無 `db.php`/`object-cache.php` drop-in、無 wp-config 改動、無 `auto_prepend` 操作、無 `$_FILES` 上傳 | NinjaFirewall 處理「合法外掛送 SQL 被擋」的官方做法是**在引擎裡硬編碼白名單**(例:`firewall.php:1434-1439` 的 JetPack 例外),需向 NinTechNet 回報才會納入,第三方無法自行擴充。 因此本外掛的相容性工作全部落在**核心的三條約束**(§4)與**部署設定**(§2),沒有 AddOn 的著力點。