fix: NinjaFirewall 相容性 — 保護 WAF 設定選項不被重導向 #2

Open
wpdev wants to merge 1 commits from fix/ninjafirewall-waf-compatibility into fix/schema-registry-cold-idempotency
Owner

Stacked PR — base 是 fix/schema-registry-cold-idempotency(#1),不是 main
這樣 diff 只含本次的 1 個 commit。待 #1 merge 後,本 PR 的 base 會自動落到 main。

起因

評估 2meet-data-optimizer 與 NinjaFirewall (WP Edition) 4.9 的相容性,判斷是否需要開發 AddOn。

結論:兩者可共存,不需要 AddOn。 NinjaFirewall 全 codebase 零個 apply_filters('nfw_*') / do_action('nfw_*'),官方相容手段(.htninjaexclude_waf_list、wp-config 常數)全在部署層,AddOn 沒有著力點。

但評估過程中找到一個核心必須修補的隱患。

核心變更:PROTECTED_OPTIONS

TMDO_Options_Manager::register_settings_group()pre_update_option_{key} 回傳 $old_value,讓選項不再落地 wp_options、改存專屬設定表。

而 NinjaFirewall 的 Full WAF 走 auto_prepend_file,在 WordPress 載入前就以原生 mysqli 直查 wp_optionsnfw_options / nfw_ruleslib/firewall.php:573,588)。

一旦這些鍵被重導向,WAF 讀不到設定就 nfw_quit() —— 靜默停止防護:不擋任何請求、不報錯、也不寫 log。更糟的是它會定義 NFW_STATUS,連帶讓 mu-plugin 的 fallback 也 return,兩層防護同時消失。

這不是理論風險:nfw_rules 約 77KB 且 autoload=auto,正是 autoload 瘦身工具最想動的目標。

修法是在 register_settings_group() 開頭以 PROTECTED_OPTIONSnfw_options / nfw_rules / nfw_checked)攔下,並發出 _doing_it_wrong()。守衛置於方法開頭而非迴圈內,因此全部鍵都被擋時會提前返回,不再建立空的設定表。

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

其他變更

  • Migration Wizard 輪詢 500ms → 2sadmin/assets/wpdo-migration-wizard.js)。原本 2 req/s 打同一個 REST endpoint,易觸發 WAF 的 rate-limit 與 bot 偵測。改為與四個 stress-test 面板既有的節奏一致。
  • docs/WAF-COMPATIBILITY.md(新增,279 行):模式差異、symlink 多租戶部署、WP SaaS 開站流程與驗證清單、三條開發約束、實測風險矩陣。
  • tests/unit/OptionsManagerProtectedTest.php(新增):4 tests / 10 assertions。
  • 版本 bump 至 1.0.2、CHANGELOG 與 PLAN.md 同步。

附帶發現:symlink 下的 Full WAF(不在本 PR 範圍)

排查時發現 Cannot retrieve user options from database (#3) 的根因與本外掛無關,是部署層問題:Full WAF 用 dirname(dirname(dirname(__DIR__)))firewall.php:78)推導站台位置,而 PHP 的 __DIR__ 會解析 symlink,於是共享 codebase 的多租戶環境中所有站都連到同一個(錯的)資料庫。

解法是以 $_SERVER['DOCUMENT_ROOT'] 為錨點的 .htninja(per-site 且不受 symlink 影響)。屬伺服器設定,已在 docs/WAF-COMPATIBILITY.md §2 完整記錄,不含在本 PR 的程式碼變更中。

Test plan

  • 全套件回歸:591 tests / 1166 assertions 通過
  • 新測試涵蓋:受保護鍵不進重導向清單、不被掛上 pre_option_* / pre_update_option_*、全數受保護時不對 DB 發查詢
  • PHPCS 零違規(modules/options/tests/unit/
  • PHP lint 全通過(排除 vendor/tests)
  • 版本一致性:header 與 TMDO_VERSION 皆 1.0.2
  • 打包 10 項終檢全 PASS(含 Schema drift 34 CREATE / 34 DROP 對齊)
  • dev30 實機驗證,Full WAF 與 WP WAF 兩種模式都跑過
    • wp tmdo status / doctor 不受影響(CLI 豁免)
    • GET /wp-json/wpdo/v1/listings(無 cookie)→ 200,回 JSON 而非 WAF 的 HTML 403 頁
    • 動態欄位 filter ?hp_price_min=100&hp_featured=1 → 200
    • 首頁 / REST 根 200、/wp-admin/ 302
    • firewall log 中 TMDO 相關攔截 0 筆(逐筆檢查,log 內全部攔截皆為刻意送出的測試 payload)

Review 重點

主要看 modules/options/class-tmdo-options-manager.php 的守衛位置 —— 放在方法開頭(過濾 + 早退)而非迴圈內,是為了避免「全部鍵都被擋時仍 dbDelta 建一張空表」。這也讓單元測試不必依賴 dbDelta stub(測試 bootstrap 沒有)。

🤖 Generated with Claude Code

https://claude.ai/code/session_01SdjXAU473eekjPBB8vPVRS

> **Stacked PR** — base 是 `fix/schema-registry-cold-idempotency`(#1),不是 `main`。 > 這樣 diff 只含本次的 1 個 commit。待 #1 merge 後,本 PR 的 base 會自動落到 main。 ## 起因 評估 2meet-data-optimizer 與 NinjaFirewall (WP Edition) 4.9 的相容性,判斷是否需要開發 AddOn。 **結論:兩者可共存,不需要 AddOn。** NinjaFirewall 全 codebase 零個 `apply_filters('nfw_*')` / `do_action('nfw_*')`,官方相容手段(`.htninja`、`exclude_waf_list`、wp-config 常數)全在部署層,AddOn 沒有著力點。 但評估過程中找到一個核心必須修補的隱患。 ## 核心變更:`PROTECTED_OPTIONS` `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`(`lib/firewall.php:573,588`)。 一旦這些鍵被重導向,WAF 讀不到設定就 `nfw_quit()` —— **靜默停止防護:不擋任何請求、不報錯、也不寫 log**。更糟的是它會定義 `NFW_STATUS`,連帶讓 mu-plugin 的 fallback 也 return,兩層防護同時消失。 這不是理論風險:`nfw_rules` 約 77KB 且 `autoload=auto`,正是 autoload 瘦身工具最想動的目標。 修法是在 `register_settings_group()` 開頭以 `PROTECTED_OPTIONS`(`nfw_options` / `nfw_rules` / `nfw_checked`)攔下,並發出 `_doing_it_wrong()`。守衛置於方法開頭而非迴圈內,因此全部鍵都被擋時會提前返回,不再建立空的設定表。 **autoload 最佳化不受影響** —— `optimize_autoload()` 只改 `autoload` 欄位、不刪列,而 Full WAF 那句 `SELECT *` 不看 autoload。 ## 其他變更 - **Migration Wizard 輪詢 500ms → 2s**(`admin/assets/wpdo-migration-wizard.js`)。原本 2 req/s 打同一個 REST endpoint,易觸發 WAF 的 rate-limit 與 bot 偵測。改為與四個 stress-test 面板既有的節奏一致。 - **`docs/WAF-COMPATIBILITY.md`**(新增,279 行):模式差異、symlink 多租戶部署、WP SaaS 開站流程與驗證清單、三條開發約束、實測風險矩陣。 - **`tests/unit/OptionsManagerProtectedTest.php`**(新增):4 tests / 10 assertions。 - 版本 bump 至 1.0.2、CHANGELOG 與 PLAN.md 同步。 ## 附帶發現:symlink 下的 Full WAF(不在本 PR 範圍) 排查時發現 `Cannot retrieve user options from database (#3)` 的根因與本外掛無關,是部署層問題:Full WAF 用 `dirname(dirname(dirname(__DIR__)))`(`firewall.php:78`)推導站台位置,而 **PHP 的 `__DIR__` 會解析 symlink**,於是共享 codebase 的多租戶環境中所有站都連到同一個(錯的)資料庫。 解法是以 `$_SERVER['DOCUMENT_ROOT']` 為錨點的 `.htninja`(per-site 且不受 symlink 影響)。屬伺服器設定,已在 `docs/WAF-COMPATIBILITY.md` §2 完整記錄,不含在本 PR 的程式碼變更中。 ## Test plan - [x] 全套件回歸:**591 tests / 1166 assertions 通過** - [x] 新測試涵蓋:受保護鍵不進重導向清單、不被掛上 `pre_option_*` / `pre_update_option_*`、全數受保護時不對 DB 發查詢 - [x] PHPCS 零違規(`modules/options/`、`tests/unit/`) - [x] PHP lint 全通過(排除 vendor/tests) - [x] 版本一致性:header 與 `TMDO_VERSION` 皆 1.0.2 - [x] 打包 10 項終檢全 PASS(含 Schema drift 34 CREATE / 34 DROP 對齊) - [x] dev30 實機驗證,**Full WAF 與 WP WAF 兩種模式都跑過**: - `wp tmdo status` / `doctor` 不受影響(CLI 豁免) - `GET /wp-json/wpdo/v1/listings`(無 cookie)→ 200,回 JSON 而非 WAF 的 HTML 403 頁 - 動態欄位 filter `?hp_price_min=100&hp_featured=1` → 200 - 首頁 / REST 根 200、`/wp-admin/` 302 - **firewall log 中 TMDO 相關攔截 0 筆**(逐筆檢查,log 內全部攔截皆為刻意送出的測試 payload) ## Review 重點 主要看 `modules/options/class-tmdo-options-manager.php` 的守衛位置 —— 放在方法開頭(過濾 + 早退)而非迴圈內,是為了避免「全部鍵都被擋時仍 `dbDelta` 建一張空表」。這也讓單元測試不必依賴 `dbDelta` stub(測試 bootstrap 沒有)。 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01SdjXAU473eekjPBB8vPVRS
wpdev added 1 commit 2026-08-15 21:33:45 +08:00
評估 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
This pull request can be merged automatically.
You are not authorized to merge this pull request.
View command line instructions

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin fix/ninjafirewall-waf-compatibility:fix/ninjafirewall-waf-compatibility
git checkout fix/ninjafirewall-waf-compatibility
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: wpdev/2meet-data-optimizer#2