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
This commit is contained in:
2026-08-15 20:38:09 +08:00
parent 9f587c39dc
commit 9fa84845be
7 changed files with 484 additions and 5 deletions
+2 -2
View File
@@ -3,7 +3,7 @@
* Plugin Name: 2meet Data Optimizer
* Plugin URI: https://2meet.io/2meet-data-optimizer
* Description: 通用 WordPress 反 EAV 引擎:將 postmeta / usermeta / termmeta / commentmeta 自動分流至四象限扁平表(Hot / Warm / Cold / Archive),大幅提升搜尋與篩選效能。從 wp-data-optimizer v2.16.0 提煉的純核心,整合層交給 11 個 AddOn。
* Version: 1.0.1
* Version: 1.0.2
* Requires at least: 6.0
* Tested up to: 6.9.4
* Requires PHP: 8.1
@@ -26,7 +26,7 @@ if ( ! defined( 'ABSPATH' ) ) {
}
// ── Constants ──────────────────────────────────────────────────────────────
define( 'TMDO_VERSION', '1.0.1' );
define( 'TMDO_VERSION', '1.0.2' );
define( 'TMDO_DB_VERSION', '2.1.0' );
define( 'TMDO_PATH', plugin_dir_path( __FILE__ ) );
define( 'TMDO_URL', plugin_dir_url( __FILE__ ) );
+43
View File
@@ -7,6 +7,49 @@ Versioning follows [Semantic Versioning](https://semver.org/).
---
## [1.0.2] — 2026-08-15 — NinjaFirewall 相容性:WAF 設定選項保護
**性質**:防禦性修正 + 文件。無 schema 變更,無破壞性變更。
### Added
- **`docs/WAF-COMPATIBILITY.md`** — NinjaFirewall (WP Edition) 4.9 相容性評估
結論:兩者可共存,**不需要開發 AddOn**NinjaFirewall 全 codebase 零個
`apply_filters('nfw_*')` / `do_action('nfw_*')`,官方相容手段全在部署層)。
文件涵蓋 WP WAF 與 Full WAF 的模式差異、symlink 多租戶部署的必要設定、
三條開發約束,以及實測風險矩陣。
- **`TMDO_Options_Manager::PROTECTED_OPTIONS`** 常數與註冊守衛
`modules/options/class-tmdo-options-manager.php`
`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**
現以 `PROTECTED_OPTIONS``nfw_options` / `nfw_rules` / `nfw_checked`)在註冊
階段擋下,並發出 `_doing_it_wrong()`。守衛置於方法開頭,全部鍵都被擋時提前
返回,不再建立空的設定表。
註:autoload 最佳化不受影響 —— `optimize_autoload()` 只改 `autoload` 欄位、
不刪列,而該處的 `SELECT *` 不看 autoload。
- **`tests/unit/OptionsManagerProtectedTest.php`** — 4 tests / 10 assertions
鎖住「受保護鍵絕不會被掛上 `pre_option_*` / `pre_update_option_*` 攔截」這個
核心保證,並驗證全數受保護時不對資料庫發出查詢。
### Changed
- **Migration Wizard 輪詢間隔 500ms → 2s**`admin/assets/wpdo-migration-wizard.js`
原本 2 req/s 打同一個 REST endpoint,容易觸發 WAF 的 rate-limit 與 bot 偵測。
改為 2 秒,與四個 stress-test 面板既有的輪詢節奏一致。
---
## [1.0.1] — 2026-08-08 — Schema Registry 冪等性修正(cold zone 欄位重複累積)
**性質**:核心 bug 修復。無 schema 變更,無 API 變更,無破壞性變更。
+26 -1
View File
@@ -6,7 +6,7 @@
## Current state
- **Version**: 0.1.0 (scaffold + 4 phase 完成 2026-05-15)
- **Version**: 1.0.2 (2026-08-15scaffold + 4 phase 完成 2026-05-15)
- **Phase**: 全 4 phase 已完成
- **Source**: 從 `wp-data-optimizer v2.16.0` 提煉
@@ -287,3 +287,28 @@ AddOn 測試 bootstrap 直接 `require` 核心 plugin 的 `tests/bootstrap.php`
- `v0.1.0` — 本 releasePhase 0-4 完成)
- `v0.1.1` — Phase 5 follow-uptests / PHPCS / packaging
- `v0.2.0` — WPDO_ deprecation notice + wpdo → tmdo migration CLI
---
## NinjaFirewall 相容性 ✅(2026-08-15v1.0.2
計畫檔:`~/.claude/plans/2meet-data-optimizer-ninjafirewall-effervescent-lighthouse.md`
- [x] 評估與 NinjaFirewall 4.9 的相容性 → **不需要開發 AddOn**
- [x] 診斷 `Cannot retrieve user options from database (#3)` → 與本外掛無關,根因為 symlink 部署
- [x] 建立 `/var/www/Studio/.htninja`symlink-safe 站台錨點,全租戶共用)
- [x] dev30 啟用 ninjafirewall,補上缺失的 `nfw_options` / `nfw_rules`
- [x] `TMDO_Options_Manager::PROTECTED_OPTIONS` 守衛 + 4 個單元測試
- [x] Migration Wizard 輪詢 500ms → 2s
- [x] `docs/WAF-COMPATIBILITY.md`
- [x] 全套件回歸:591 tests / 1166 assertions OK
### Lessons learnedWAF
- **`__DIR__` 會解析 symlink**。NinjaFirewall Full WAF 用 `dirname(dirname(dirname(__DIR__)))``lib/firewall.php:78`)推導站台位置,在 symlink 共享 codebase 的 SaaS 架構下,所有租戶都會被判定成「共享目錄所屬的那個站」,於是連錯資料庫、撈不到自己的 `nfw_options`,回錯誤碼 6(訊息寫作 `#3`)。**per-site 的可靠錨點是 `$_SERVER['DOCUMENT_ROOT']`**web server 的 root 指令值,不受 symlink 影響),這正是 `.htninja` 的搜尋基準。
- **`.htninja` 放在 `dirname(DOCUMENT_ROOT)` 可服務全部租戶**`firewall.php:47-48` 的第二順位),因為內容以 DOCUMENT_ROOT 動態推導,一份檔案通用。切勿在其中寫 `return` —— `'ALLOW'` / `'BLOCK'` 是有意義的回傳值。
- **`NFW_LOG_DIR` 對 WP WAF 模式同樣必要**:L78 的誤判在兩種模式都存在,只是 WP WAF 不用它連 DB,但 log / cache / session 仍會全部寫進共享目錄互相覆蓋。
- **WAF 失效是靜默的**。讀不到設定時 `nfw_quit()` 直接返回,不擋任何請求也不寫 log —— 「your site is not protected」是字面意思。因此任何會讓 `nfw_options` 離開 `wp_options` 的機制(例如本外掛的 `register_settings_group()` 重導向)都必須擋在註冊階段。
- **`.user.ini` 必須讓 php-fpm worker 可讀**。dev32 那份是 `root:root 0640`www-data 讀不到 → `auto_prepend_file` 靜默失效、Full WAF 等同沒裝。排查時容易誤判成「WAF 有在跑」,實際上擋下請求的是共享 mu-plugin 的 WP WAF。
- **判斷 WAF 是否真的生效,要用會被規則擋的請求實測**(例如 `?x=../../etc/passwd` → 規則 1 → 403),光看首頁 200 或後台無錯誤訊息都不算數。
- WP-CLI 完全豁免(`firewall.php:18-23`),所以 CLI 全綠**不能**當作「WAF 與外掛相容」的證據,必須另外走 HTTP 驗證。
+2 -2
View File
@@ -3,7 +3,7 @@
*
* - Confirms options + checkbox before starting.
* - Calls REST endpoints under /wp-json/wpdo/v1/migration/.
* - Polls /status every 500ms while job is active.
* - Polls /status every 2s while job is active.
* - Streams log lines with fade-in; live-updates ratio + progress bar.
*
* @since 2.8.0
@@ -11,7 +11,7 @@
(function () {
'use strict';
const POLL_INTERVAL_MS = 500;
const POLL_INTERVAL_MS = 2000;
const config = window.wpdoMigrationWizard || {};
const i18n = config.i18n || {};
const restUrl = (config.restUrl || '').replace(/\/$/, '');
+279
View File
@@ -0,0 +1,279 @@
# 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
<?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_DIR`**WP 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`
```nginx
# 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 — 啟用外掛,建立該站自己的設定**
```bash
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 靜默失效 |
```bash
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**
```bash
sudo nginx -t && sudo systemctl reload nginx
```
### 3.3 開站後驗證(每站必跑)
四項缺一不可。只做前兩項會漏掉最隱蔽的失效模式。
```bash
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 完全豁免 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 的著力點。
@@ -46,6 +46,22 @@ final class TMDO_Options_Manager {
'wpb_backup_options',
);
/**
* 禁止重導向的選項名稱清單
*
* 這些選項會被「WordPress 載入之前」執行的元件以原生 SQL 直查 wp_options
* NinjaFirewall Full WAF 走 auto_prepend_file,以 mysqli 讀取 nfw_options
* 與 nfw_rules)。一旦被 register_settings_group() 重導向至專屬設定表,
* 該元件會讀不到設定而靜默停止運作 —— 不報錯、不寫 log。
*
* 註:autoload 最佳化不受此限,因為那些元件的 SELECT 不看 autoload 欄位。
*/
private const PROTECTED_OPTIONS = array(
'nfw_options',
'nfw_rules',
'nfw_checked',
);
/** autoload 合計閾值(MB),超過則警告 */
private const AUTOLOAD_WARNING_THRESHOLD_MB = 1;
@@ -202,6 +218,22 @@ final class TMDO_Options_Manager {
public static function register_settings_group( string $group_name, array $option_keys ): void {
global $wpdb;
// 受保護的選項不得重導向,否則會讓 WordPress 載入前讀取它的元件靜默失效
foreach ( array_intersect( $option_keys, self::PROTECTED_OPTIONS ) as $protected_key ) {
_doing_it_wrong(
__METHOD__,
esc_html( "選項 {$protected_key} 由 WordPress 載入前的元件以原生 SQL 直讀 wp_options,重導向會使其靜默失效,已略過。" ),
'1.0.2'
);
}
$option_keys = array_diff( $option_keys, self::PROTECTED_OPTIONS );
// 全部都被擋下就不必建表
if ( empty( $option_keys ) ) {
return;
}
$table = $wpdb->prefix . TMDO_TABLE_PREFIX . 'settings_' . sanitize_key( $group_name );
// 建立設定專屬表
+100
View File
@@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Unit tests for 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),因此必須在註冊階段就擋下。
*
* 註:混合「受保護 + 一般」鍵的情境需要走到 dbDelta(),而單元測試 bootstrap
* 沒有該 stub(ABSPATH 為假路徑),故此處只覆蓋純受保護鍵與常數契約 —— 這已
* 足以鎖住「受保護鍵絕不會被掛上攔截 filter」這個核心保證。
*/
class OptionsManagerProtectedTest extends TestCase {
/** 每個測試前清空靜態註冊表,避免跨測試污染。 */
protected function setUp(): void {
parent::setUp();
$prop = new ReflectionProperty( 'TMDO_Options_Manager', 'redirected_options' );
$prop->setAccessible( true );
$prop->setValue( null, array() );
}
// ── 常數契約 ────────────────────────────────────────────────────────────
public function test_protected_options_covers_ninjafirewall_keys(): void {
$ref = new ReflectionClass( 'TMDO_Options_Manager' );
$protected = $ref->getConstant( 'PROTECTED_OPTIONS' );
$this->assertIsArray( $protected );
foreach ( array( 'nfw_options', 'nfw_rules', 'nfw_checked' ) as $key ) {
$this->assertContains(
$key,
$protected,
"{$key} 必須列入 PROTECTED_OPTIONS,否則重導向後 NinjaFirewall Full WAF 會靜默失效"
);
}
}
// ── 註冊守衛 ────────────────────────────────────────────────────────────
public function test_protected_options_are_not_registered(): void {
TMDO_Options_Manager::register_settings_group(
'waf_guard_test',
array( 'nfw_options', 'nfw_rules', 'nfw_checked' )
);
$this->assertSame(
array(),
TMDO_Options_Manager::get_redirected_options(),
'受保護的選項不得進入重導向清單'
);
}
public function test_protected_options_get_no_interception_filters(): void {
TMDO_Options_Manager::register_settings_group(
'waf_guard_test',
array( 'nfw_options', 'nfw_rules' )
);
$hooks = $GLOBALS['_wp_filter_callbacks'] ?? array();
foreach ( array( 'nfw_options', 'nfw_rules' ) as $key ) {
$this->assertArrayNotHasKey(
"pre_option_{$key}",
$hooks,
"pre_option_{$key} 不得被掛上 —— 會讓 WAF 讀到轉址後的值"
);
$this->assertArrayNotHasKey(
"pre_update_option_{$key}",
$hooks,
"pre_update_option_{$key} 不得被掛上 —— 會讓設定不再寫回 wp_options"
);
}
}
/** 全部鍵都被擋下時應提前返回,不建立設定表。 */
public function test_all_protected_keys_skips_table_creation(): void {
global $wpdb;
$before = $wpdb->last_query ?? null;
TMDO_Options_Manager::register_settings_group(
'waf_guard_test',
array( 'nfw_options' )
);
$this->assertSame(
$before,
$wpdb->last_query ?? null,
'不應對資料庫發出任何查詢'
);
}
}