Files
2meet-data-optimizer/docs/WAF-COMPATIBILITY.md
wpdev 9fa84845be fix: NinjaFirewall 相容性 — 保護 WAF 設定選項不被重導向
評估 NinjaFirewall (WP Edition) 4.9 的相容性,結論是兩者可共存且
不需要開發 AddOn(該外掛全 codebase 零個 apply_filters('nfw_*') /
do_action('nfw_*'),官方相容手段全在部署層)。但核心有一個隱患必須補。

TMDO_Options_Manager::register_settings_group() 以 pre_update_option_{key}
回傳 $old_value,讓選項不再落地 wp_options、改存專屬設定表。而
NinjaFirewall 的 Full WAF 走 auto_prepend_file,在 WordPress 載入前就以
原生 mysqli 直查 wp_options 取 nfw_options / nfw_rules。一旦這些鍵被
重導向,WAF 會讀不到設定而靜默停止防護 —— 不報錯、不寫 log。

nfw_rules 約 77KB 且 autoload=auto,正是 autoload 瘦身最誘人的目標,
因此這條路徑並非理論風險。

Added
- PROTECTED_OPTIONS 常數與註冊守衛(nfw_options / nfw_rules / nfw_checked),
  命中時發出 _doing_it_wrong()。守衛置於方法開頭,全部鍵都被擋時提前返回,
  不再建立空的設定表。
- tests/unit/OptionsManagerProtectedTest.php(4 tests / 10 assertions),
  鎖住「受保護鍵絕不會被掛上 pre_option_* / pre_update_option_* 攔截」。
- docs/WAF-COMPATIBILITY.md:模式差異、symlink 多租戶部署、WP SaaS 開站
  流程與驗證清單、三條開發約束、實測風險矩陣。

Changed
- Migration Wizard 輪詢 500ms → 2s,與四個 stress-test 面板一致。
  原本 2 req/s 打同一 REST endpoint,易觸發 WAF rate-limit 與 bot 偵測。

autoload 最佳化不受影響:optimize_autoload() 只改 autoload 欄位、不刪列,
而 Full WAF 的 SELECT * 不看 autoload。

驗證:591 tests / 1166 assertions 通過,PHPCS 零違規,版本一致性 1.0.2。
dev30 於 Full WAF 與 WP WAF 兩種模式下實測,firewall log 中 TMDO 相關
攔截 0 筆。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdjXAU473eekjPBB8vPVRS
2026-08-15 20:38:09 +08:00

16 KiB
Raw Permalink Blame History

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持久性改動會影響它。


WP SaaS 常以 symlink 共享 codebase

tenant-a/wp-content/plugins -> /shared/plugins
tenant-b/wp-content/plugins -> /shared/plugins

Full WAF 在 lib/firewall.php:78 這樣推導站台位置:

$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
// 置於各站 DOCUMENT_ROOT 的共同父目錄,例如 /var/www/Studio/.htninja
$nfw_site_root = rtrim( $_SERVER['DOCUMENT_ROOT'] ?? '', '/' );

if ( '' !== $nfw_site_root && is_file( $nfw_site_root . '/wp-config.php' ) ) {
	if ( ! defined( 'NFW_LOG_DIR' ) ) {
		define( 'NFW_LOG_DIR', $nfw_site_root . '/wp-content' );  // firewall.php:82-83
	}
	$wp_config = $nfw_site_root . '/wp-config.php';                // firewall.php:120-122
}

unset( $nfw_site_root );

覆寫時序:.htninja 於 L50 載入 → NFW_LOG_DIR 於 L82 生效 → NFWSESSION_DIR 跟著 log_dir → $wp_config 於 L120 生效。

注意事項

  • 不要在 .htninja 使用 return —— 回傳 'ALLOW' / 'BLOCK' 會改變過濾行為(firewall.php:54-71)。
  • NFW_LOG_DIRWP WAF 模式同樣重要firewall.php:78 在該模式下一樣會誤判,導致所有租戶的 firewall log、cache/db_hash.N.php、session 全寫進共享目錄、互相覆蓋。
  • 每個租戶的 DB 都要有自己的 nfw_options。修好路徑後 WAF 會連到正確的租戶資料庫,但該站若從未啟用過 NinjaFirewall,那一列不存在,照樣 #3。開站流程必須包含啟用步驟。
  • nfw_rules 約 77KB、autoload=auto,每個租戶一份,規則更新也要逐站執行。
  • 若租戶 DB 憑證不在 wp-config.php,可改用 firewall.php:466-470:在 .htninja$GLOBALS['nfw_mysqli']$GLOBALS['nfw_table_prefix'] 直接提供連線。代價是每 request 多一條 mysqli 連線。
  • .user.ini 必須讓 php-fpm worker(通常是 www-data)可讀,否則 auto_prepend_file 靜默失效、Full WAF 等同未安裝。root:root 0640 是常見的踩雷組合。

3. WP SaaS 開站流程

以 symlink 共享 codebase 的多租戶環境,NinjaFirewall 的每一項設定都分成「平台層做一次」與「每站都要做」兩類。混淆這兩者是最常見的失誤來源。

3.1 平台層(全租戶共用,只做一次)

A. 站台錨點 .htninja — 見 §2。放在各站 DOCUMENT_ROOT 的共同父目錄(例如 /var/www/Studio/.htninja),內容以 $_SERVER['DOCUMENT_ROOT'] 動態推導,因此一份即可服務所有租戶,新增租戶時不需修改。

B. nginx 規則 — 這是 per-site 設定檔,但內容對所有站相同,建議做成 snippet 讓各站 include

# NinjaFirewall 的 log / cache / loader 目錄。
# 它自帶的 .htaccess 在 nginx 下完全無效,且 nginx 與 php-fpm 同為 www-data
# 檔案權限無法區分「WAF 寫入」與「對外 serve」,因此只能在這裡封鎖。
# ^~ 是必要的:讓前綴匹配優先於 \.php$ 正則,否則 .php 請求會落到 PHP handler。
location ^~ /wp-content/nfwlog/ { deny all; }

# 所有 dotfile.htaccess / .htninja / .user.ini / .env / .git ...
# 放行 .well-known,否則 Certbot 的 ACME challenge 會失敗、憑證無法續期。
location ~ /\.(?!well-known) { deny all; }

漏掉這段的後果:nfwlog/ 下的 readme.txt 會被公開讀取(等於告訴掃描器這站跑 NinjaFirewall),未來的 session/sess_* 檔也不是 .php、同樣裸奔。.php 檔本身有雙層保護(引擎開頭的 die('Forbidden') + 檔內 <?php exit; ?>)不會外洩內容,但不該依賴它。

3.2 每個新租戶站台(順序不可顛倒)

順序很重要:先確保錨點與設定就緒,再安裝 Full WAF。順序顛倒會直接撞上 §2 的 #3 錯誤。

Step 1 — 啟用外掛,建立該站自己的設定

wp --path=/var/www/sites/<tenant> plugin activate ninjafirewall
wp --path=/var/www/sites/<tenant> 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 確保新檔繼承 groupgroup 需 w 否則 WAF 寫不了 log
nfwlog/ 內檔案 664 同上
nfwlog/session/ 不用管 由 WAF 自建,它會用自己的嚴格權限(0700
.user.ini(裝 Full WAF 後才有) 664 wpdev:www-data www-data 需可讀,否則 auto_prepend 靜默失效
NFWLOG=/var/www/sites/<tenant>/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

sudo nginx -t && sudo systemctl reload nginx

3.3 開站後驗證(每站必跑)

四項缺一不可。只做前兩項會漏掉最隱蔽的失效模式。

SITE=https://<tenant>.example.com
NFWLOG=/var/www/sites/<tenant>/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/<file> 若回 404WordPress 頁面)而非 403,代表規則有正確放行
wp-login.php 回 404 nginx 改壞了 檢查是否啟用 wps-hide-login 之類的登入頁隱藏外掛
wp tmdo doctor 全綠 WAF 與外掛相容 WP-CLI 完全豁免 WAFfirewall.php:18-23),CLI 結果不能當相容性證據,必須另外走 HTTP 驗證

3.5 每租戶獨立的維運負擔

symlink 共享的是 codebase不是設定與狀態。以下每一項都是 per-tenant

  • nfw_options / nfw_rules 存在各自的 wp_optionsnfw_rules 約 77 KB 且 autoload=autoN 個租戶就是 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

規則 322lev=3CRITICAL):

(^|\S['"])nfw_(?:options|rules)\b

任何 GET/POST 值含 nfw_optionsnfw_rules 字串即 403why: "Attempt to modify NinjaFirewall settings")。

這對「列出 autoload 清單讓使用者勾選清理」這類 UI 是直接的地雷 —— 而 nfw_rules 正好是最大的 autoload 項目之一,必然出現在清單裡。若要做這種介面,用索引或 hash 當作 POST 值,或直接走 CLI。

4.3 不得以 base64 傳送含 SQL 語意的 payload

nfw_check_b64()firewall.php:1412-1448post_b64 選項,預設開)會把每個 POST 值 base64 解碼後再比對。明文的 SQL 規則多半需要「以數字/引號開頭」或「以註解結尾」才命中,base64 版只要「含有」就 CRITICAL 403 —— 涵蓋 SELECT...FROM...WHEREINSERT INTOUNION SELECTUPDATE...SET、以及序列化物件 O:n:"..."

換言之,編碼會讓事情變糟,不是變好。硬編碼白名單只有 fpd_print_orderg-recaptcha-response 兩個欄位名。


5. 風險矩陣(實測結果)

於 dev30Full WAFwl_admin=1no_restapi=0admin_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 秒同步 POSTclass-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 頁面是 HTMLfirewall.php:1585-1590),client 端看到的是「JSON parse error」這種難查的錯 程式化呼叫走 CLI;或確認帶 cookie
admin-ajax 被當 bot admin_ajax 選項開啟時,缺 HTTP_ACCEPT / Accept-Language / UA 不含 Mozilla 的請求回 404firewall.php:1760-1802 curl / server-to-server 呼叫需帶完整 header;預設此選項未開

WP-CLI 完全豁免firewall.php:18-23defined('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_*')
官方相容手段都在部署層 .htninjaexclude_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 的著力點。