diff --git a/2meet-data-optimizer.php b/2meet-data-optimizer.php index de1693f..defb69a 100644 --- a/2meet-data-optimizer.php +++ b/2meet-data-optimizer.php @@ -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.1.0 + * Version: 1.1.1 * 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.1.0' ); +define( 'TMDO_VERSION', '1.1.1' ); define( 'TMDO_DB_VERSION', '2.1.0' ); define( 'TMDO_PATH', plugin_dir_path( __FILE__ ) ); define( 'TMDO_URL', plugin_dir_url( __FILE__ ) ); diff --git a/CHANGELOG.md b/CHANGELOG.md index 227350e..472e8d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,28 @@ Versioning follows [Semantic Versioning](https://semver.org/). --- +## [1.1.1] — 2026-09-26 — Security:公開 REST 不再回傳私有欄位 + +**性質**:安全修正(Critical)。1.0.x 起即存在;1.1.0 zip 已撤回(`dist/withdrawn/`)。 + +### Security + +- **公開的 `GET /wpdo/v1/listings`、`/wpdo/v1/listings/{id}` 會把所有已註冊的 hot / cold + 欄位回傳給未登入者**:`TMDO_Schema_Registry::register()` 的 `show_in_rest` 預設為 true, + 而 addon 都沒有設定它。dev36 實測可取得 infocards 的 `tmeetic_ig_access_token_enc`、 + `tmeetic_ig_token_expiry`、`tmeetic_ig_user_id`,以及 WooCommerce 的 `_purchase_note`。 + - `show_in_rest` 預設改為 **false**:要公開的欄位必須明確宣告。 + - 受保護的 meta key(底線開頭)與名稱像憑證的 key(token、secret、password、api key、 + private key、credential、salt、nonce)即使宣告公開也一律不輸出 + (`TMDO_Schema_Registry::rest_exposable()`)。 + - Zone A 列表路徑在沒有任何公開欄位時不再回傳整列(fail-closed);篩選參數與 orderby + 只接受公開欄位,避免藉由篩選 / 排序推測隱藏值。 +- **相容性**:依賴預設公開的欄位,現在需在註冊時加上 `'show_in_rest' => true`,或使用 + 既有的 `tmdo_rest_listing_visible_fields` filter。 +- 測試:`RestApiTest` 新增 3 個;live suite `rest`(未登入 GET 不得出現私有 / 憑證類 key)。 + +--- + ## [1.1.0] — 2026-09-26 — HivePress / WooCommerce 搜尋索引模式(hardening 2.1) **性質**:資料正確性修正 + 查詢改寫補強。無 schema 變更。決策與設計見 diff --git a/includes/class-tmdo-rest-api.php b/includes/class-tmdo-rest-api.php index 0f0735d..bdd554c 100644 --- a/includes/class-tmdo-rest-api.php +++ b/includes/class-tmdo-rest-api.php @@ -1396,8 +1396,13 @@ class TMDO_REST_API { ): WP_REST_Response { global $wpdb; + // Filters and ordering only on public columns: hidden values must not be + // inferable by filtering or sorting on them. $table = TMDO_Zone_Hot::table( $post_type ); - $columns = TMDO_Schema_Registry::instance()->get_hot_columns( $post_type ); + $columns = array_intersect_key( + TMDO_Schema_Registry::instance()->get_hot_columns( $post_type ), + array_flip( TMDO_Schema_Registry::instance()->get_rest_visible_hot_columns( $post_type ) ) + ); // Build WHERE clauses from numeric filter params. $where = array(); @@ -1443,8 +1448,8 @@ class TMDO_REST_API { 'post_type' => $post_type, ); unset( $row['post_id'], $row['updated_at'] ); - // Keep only columns with show_in_rest => true (or all if no registry entry). - $row = $visible_cols ? array_intersect_key( $row, $visible_cols ) : $row; + // Keep only public columns (none when nothing is declared public). + $row = array_intersect_key( $row, $visible_cols ); return array_merge( $item, $row ); }, $rows diff --git a/includes/class-tmdo-schema-registry.php b/includes/class-tmdo-schema-registry.php index 93b390c..442af5d 100644 --- a/includes/class-tmdo-schema-registry.php +++ b/includes/class-tmdo-schema-registry.php @@ -103,7 +103,9 @@ class TMDO_Schema_Registry { * - ttl (int) TTL in seconds (Zone B only, null = no expiry). * - cache_group (string) Object cache group (Zone C only). * - cache_ttl (int) Cache TTL in seconds (Zone C only). - * - show_in_rest (bool) Whether to expose via GET /wpdo/v1/listings. Default true. + * - show_in_rest (bool) Whether to expose via the public GET /wpdo/v1/listings + * endpoints. Default false; protected (underscore) keys and + * credential-like keys are never exposed (see rest_exposable()). */ public function register( string $provider, array $config ): void { $config = wp_parse_args( @@ -120,7 +122,7 @@ class TMDO_Schema_Registry { 'cache_group' => '', 'cache_ttl' => HOUR_IN_SECONDS, 'provider' => $provider, - 'show_in_rest' => true, + 'show_in_rest' => false, ) ); @@ -276,6 +278,22 @@ class TMDO_Schema_Registry { return array_unique( $this->cold_fields[ $post_type ] ?? array() ); } + /** + * Whether a field may appear in the public listings endpoints: declared + * show_in_rest, not a protected meta key (leading underscore — WordPress + * hides those from public output, e.g. _purchase_note), and not named like a + * credential (tokens, secrets, passwords, API keys), whatever it declares. + * + * @param array $field Registered field. + * @return bool + */ + private static function rest_exposable( array $field ): bool { + $key = (string) ( $field['meta_key'] ?? '' ); + return ! empty( $field['show_in_rest'] ) + && 0 !== strpos( $key, '_' ) + && 1 !== preg_match( '/token|secret|passw|api_?key|private_?key|credential|salt|nonce/i', $key . ' ' . ( $field['column'] ?? '' ) ); + } + /** * Returns hot column names for a post type where show_in_rest is true. * Used by REST endpoints to restrict publicly exposed fields. @@ -286,7 +304,7 @@ class TMDO_Schema_Registry { public function get_rest_visible_hot_columns( string $post_type ): array { $visible = array(); foreach ( $this->fields as $field ) { - if ( 'hot' === $field['zone'] && $field['post_type'] === $post_type && ! empty( $field['show_in_rest'] ) ) { + if ( 'hot' === $field['zone'] && $field['post_type'] === $post_type && self::rest_exposable( $field ) ) { $visible[] = $field['column']; } } @@ -303,7 +321,7 @@ class TMDO_Schema_Registry { public function get_rest_visible_cold_keys( string $post_type ): array { $visible = array(); foreach ( $this->fields as $field ) { - if ( 'cold' === $field['zone'] && $field['post_type'] === $post_type && ! empty( $field['show_in_rest'] ) ) { + if ( 'cold' === $field['zone'] && $field['post_type'] === $post_type && self::rest_exposable( $field ) ) { $visible[] = $field['meta_key']; } } diff --git a/readme.txt b/readme.txt index 2ef9863..4abaa85 100644 --- a/readme.txt +++ b/readme.txt @@ -4,7 +4,7 @@ Tags: performance, database, postmeta, optimization, hivepress Requires at least: 6.0 Tested up to: 6.9 Requires PHP: 8.1 -Stable tag: 1.1.0 +Stable tag: 1.1.1 License: GPLv2 or later License URI: https://www.gnu.org/licenses/gpl-2.0.html @@ -107,6 +107,10 @@ The Classifier tab analyzes your existing `wp_postmeta` data (access frequency, == Changelog == += 1.1.1 = +* Security: the public GET /wpdo/v1/listings endpoints returned every registered field (show_in_rest defaulted to true), including private values such as integration tokens and WooCommerce purchase notes. Fields are now private unless registered with show_in_rest => true, and protected (underscore) or credential-like keys are never exposed +* Security: the listings endpoint no longer returns whole rows when no field is public, and only filters / sorts on public columns + = 1.1.0 = * Search-index mode for HivePress / WooCommerce sites (docs/adr-004): native meta stays the source of truth, flat tables are its search index * Promotion Guard: aeav_only and cleanup are refused while HivePress or WooCommerce is active (filter `tmdo_promotion_guard_allow`) diff --git a/tests/integration/RestApiIntegrationTest.php b/tests/integration/RestApiIntegrationTest.php index 4580cd2..d90c1ee 100644 --- a/tests/integration/RestApiIntegrationTest.php +++ b/tests/integration/RestApiIntegrationTest.php @@ -33,6 +33,7 @@ class RestApiIntegrationTest extends TestCase { 'zone' => 'hot', 'column' => 'rp_price', 'type' => 'decimal', + 'show_in_rest' => true, ] ); $registry->register( 'integration_rest', [ 'post_type' => 'restapi', @@ -40,11 +41,21 @@ class RestApiIntegrationTest extends TestCase { 'zone' => 'hot', 'column' => 'rp_featured', 'type' => 'tinyint', + 'show_in_rest' => true, ] ); $registry->register( 'integration_rest', [ 'post_type' => 'restapi', 'meta_key' => 'rp_description', 'zone' => 'cold', + 'show_in_rest' => true, + ] ); + // Registered but not public (1.1.1: fields are private by default). + $registry->register( 'integration_rest', [ + 'post_type' => 'restapi', + 'meta_key' => 'rp_cost', + 'zone' => 'hot', + 'column' => 'rp_cost', + 'type' => 'decimal', ] ); // Create hot table. @@ -53,6 +64,7 @@ class RestApiIntegrationTest extends TestCase { post_id BIGINT UNSIGNED NOT NULL, rp_price DECIMAL(10,2) DEFAULT NULL, rp_featured TINYINT(1) DEFAULT NULL, + rp_cost DECIMAL(10,2) DEFAULT NULL, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (post_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" @@ -91,7 +103,8 @@ class RestApiIntegrationTest extends TestCase { $featured = $i % 2; $status = $statuses[ $i ] ?? 'publish'; $wpdb->query( "INSERT INTO `{$wpdb->posts}` (ID, post_type, post_status) VALUES ($i, 'restapi', '$status')" ); - $wpdb->query( "INSERT INTO `" . self::$hot_table . "` (post_id, rp_price, rp_featured) VALUES ($i, $price, $featured)" ); + $cost = 1000 - $price; + $wpdb->query( "INSERT INTO `" . self::$hot_table . "` (post_id, rp_price, rp_featured, rp_cost) VALUES ($i, $price, $featured, $cost)" ); } $wpdb->query( "INSERT INTO `{$wpdb->posts}` (ID, post_type, post_status) VALUES (9, 'post', 'publish')" ); $wpdb->query( "INSERT INTO `" . self::$hot_table . "` (post_id, rp_price, rp_featured) VALUES (9, 900, 1)" ); @@ -194,6 +207,33 @@ class RestApiIntegrationTest extends TestCase { $this->assertTrue( true ); } + public function test_listings_never_return_a_private_column(): void { + $req = new WP_REST_Request( 'GET', '/wpdo/v1/listings' ); + $req->set_param( 'post_type', 'restapi' ); + + foreach ( self::$api->get_listings( $req )->get_data() as $item ) { + $this->assertArrayNotHasKey( 'rp_cost', $item ); + } + } + + public function test_private_column_cannot_be_used_as_a_filter(): void { + $req = new WP_REST_Request( 'GET', '/wpdo/v1/listings' ); + $req->set_param( 'post_type', 'restapi' ); + $req->set_param( 'rp_cost_min', 800 ); + + $this->assertSame( '5', self::$api->get_listings( $req )->get_headers()['X-WP-Total'], 'filter on a private column is ignored' ); + } + + public function test_private_column_cannot_be_used_for_ordering(): void { + $req = new WP_REST_Request( 'GET', '/wpdo/v1/listings' ); + $req->set_param( 'post_type', 'restapi' ); + $req->set_param( 'orderby', 'rp_cost' ); + $req->set_param( 'order', 'ASC' ); + + // rp_cost descends as post_id rises; ignoring it falls back to post_id ASC. + $this->assertSame( array( 1, 2, 3, 4, 5 ), array_column( self::$api->get_listings( $req )->get_data(), 'id' ) ); + } + public function test_listings_filter_price_min(): void { $req = new WP_REST_Request( 'GET', '/wpdo/v1/listings' ); $req->set_param( 'post_type', 'restapi' ); diff --git a/tests/live/README.md b/tests/live/README.md index 3d772db..602d5e3 100644 --- a/tests/live/README.md +++ b/tests/live/README.md @@ -40,6 +40,7 @@ Exit code 0 when every test passed, 1 otherwise. Output per test: | `serialized` | array meta values are stored serialized in flat tables, never "Array" | | `resync` | dual_write / shadow_read: flat follows native after every write (4 entities × 2 modes: update, same value, delete, refused writes, stale flat, key rename by meta_id) | | `router` | a routed WP_Query returns the same ids, in order, as native (OR, several post types, untyped numeric compares, named orderby, LIKE, keys of two groups, NOT EXISTS with the status/date copy route, meta orderby: HivePress sort shape, named clause, meta_key + meta_value_num); routed SQL has no GROUP BY | +| `rest` | anonymous GET of the public listings endpoints returns no protected or credential-like field (1.1.1) | | `zero_side_effect` | zone tables that were never created are never queried on save / delete | ## Tools (`tools/`) diff --git a/tests/live/suites/rest.php b/tests/live/suites/rest.php new file mode 100644 index 0000000..22e6e45 --- /dev/null +++ b/tests/live/suites/rest.php @@ -0,0 +1,76 @@ + $params Query parameters. + * @return array Response data. + */ +function tmdo_live_rest_anonymous( string $route, array $params = array() ): array { + $user = get_current_user_id(); + $request = new WP_REST_Request( 'GET', $route ); + $request->set_query_params( $params ); + wp_set_current_user( 0 ); + try { + $response = rest_do_request( $request ); + } finally { + wp_set_current_user( $user ); + } + tmdo_live_assert_same( 200, $response->get_status(), "GET {$route}" ); + return (array) $response->get_data(); +} + +/** + * Keys that must never reach an anonymous visitor. + * + * @return string[] + */ +function tmdo_live_rest_private_keys(): array { + return array( 'tmeetic_ig_access_token_enc', 'tmeetic_ig_token_expiry', 'tmeetic_ig_user_id', '_purchase_note' ); +} + +TMDO_Live::test( + 'GET /wpdo/v1/listings/{id} returns no private field to an anonymous visitor', + static function () { + $checked = 0; + foreach ( array( 'hp_vendor', 'product' ) as $post_type ) { + $ids = get_posts( array( 'post_type' => $post_type, 'post_status' => 'publish', 'numberposts' => 1, 'fields' => 'ids' ) ); + if ( ! $ids ) { + continue; + } + $data = tmdo_live_rest_anonymous( '/wpdo/v1/listings/' . (int) $ids[0] ); + tmdo_live_assert_same( array(), array_values( array_intersect( tmdo_live_rest_private_keys(), array_keys( $data ) ) ), "{$post_type} #{$ids[0]} private keys" ); + tmdo_live_assert_same( array(), array_values( preg_grep( '/^_|token|secret|passw|api_?key/i', array_keys( $data ) ) ), "{$post_type} #{$ids[0]} protected / credential-like keys" ); + ++$checked; + } + if ( 0 === $checked ) { + throw new TMDO_Live_Skip( 'no published hp_vendor or product on this site' ); + } + } +); + +TMDO_Live::test( + 'GET /wpdo/v1/listings returns no private field to an anonymous visitor', + static function () { + foreach ( array( 'hp_vendor', 'product', 'hp_listing' ) as $post_type ) { + if ( ! post_type_exists( $post_type ) ) { + continue; + } + foreach ( tmdo_live_rest_anonymous( '/wpdo/v1/listings', array( 'post_type' => $post_type, 'per_page' => 5 ) ) as $item ) { + tmdo_live_assert_same( array(), array_values( preg_grep( '/^_|token|secret|passw|api_?key/i', array_keys( (array) $item ) ) ), "{$post_type} list item keys" ); + } + } + } +); diff --git a/tests/unit/RestApiTest.php b/tests/unit/RestApiTest.php index d17c9cf..64d947f 100644 --- a/tests/unit/RestApiTest.php +++ b/tests/unit/RestApiTest.php @@ -95,11 +95,13 @@ class RestApiTest extends TestCase { 'zone' => 'hot', 'column' => 'hp_price', 'type' => 'decimal', + 'show_in_rest' => true, ] ); $registry->register( 'test', [ 'post_type' => 'hp_listing', 'meta_key' => 'hp_description', 'zone' => 'cold', + 'show_in_rest' => true, ] ); $req = new WP_REST_Request( 'GET', '/wpdo/v1/listings/42' ); @@ -115,6 +117,62 @@ class RestApiTest extends TestCase { $this->assertSame( 'Test desc', $data['hp_description'] ); } + // ── public field exposure (1.1.1) ───────────────────────────────────────── + + /** + * Register fields on hp_listing #43 and GET it anonymously. + * + * @param array> $fields Field configs (post_type added). + * @param array $meta Stored meta. + * @return array Response data. + */ + private function listing_with( array $fields, array $meta ): array { + $GLOBALS['_wp_post_types'][43] = 'hp_listing'; + $GLOBALS['_wp_postmeta'][43] = $meta; + foreach ( $fields as $field ) { + WPDO_Schema_Registry::instance()->register( 'test', $field + array( 'post_type' => 'hp_listing' ) ); + } + $req = new WP_REST_Request( 'GET', '/wpdo/v1/listings/43' ); + $req->set_param( 'id', 43 ); + return $this->api->get_listing( $req )->get_data(); + } + + public function test_registered_fields_are_not_public_unless_declared(): void { + $data = $this->listing_with( + array( + array( 'meta_key' => 'hp_price', 'zone' => 'hot', 'column' => 'hp_price' ), + array( 'meta_key' => 'hp_notes', 'zone' => 'cold' ), + ), + array( 'hp_price' => '500', 'hp_notes' => 'internal' ) + ); + + $this->assertSame( array( 'id', 'post_type' ), array_keys( $data ) ); + } + + public function test_protected_and_credential_keys_stay_private_even_when_declared_public(): void { + $data = $this->listing_with( + array( + array( 'meta_key' => 'hp_price', 'zone' => 'hot', 'column' => 'hp_price', 'show_in_rest' => true ), + array( 'meta_key' => '_purchase_note', 'zone' => 'cold', 'show_in_rest' => true ), + array( 'meta_key' => 'tmeetic_ig_access_token_enc', 'zone' => 'cold', 'show_in_rest' => true ), + array( 'meta_key' => 'hp_api_key', 'zone' => 'hot', 'column' => 'hp_api_key', 'show_in_rest' => true ), + array( 'meta_key' => 'hp_client_secret', 'zone' => 'cold', 'show_in_rest' => true ), + ), + array( 'hp_price' => '500', '_purchase_note' => 'x', 'tmeetic_ig_access_token_enc' => 'y', 'hp_api_key' => 'z', 'hp_client_secret' => 'w' ) + ); + + $this->assertSame( array( 'id', 'post_type', 'hp_price' ), array_keys( $data ) ); + } + + public function test_rest_visible_columns_exclude_protected_and_credential_keys(): void { + $registry = WPDO_Schema_Registry::instance(); + $registry->register( 'test', array( 'post_type' => 'hp_listing', 'meta_key' => 'hp_price', 'zone' => 'hot', 'column' => 'hp_price', 'show_in_rest' => true ) ); + $registry->register( 'test', array( 'post_type' => 'hp_listing', 'meta_key' => '_hp_token', 'zone' => 'hot', 'column' => 'hp_token', 'show_in_rest' => true ) ); + $registry->register( 'test', array( 'post_type' => 'hp_listing', 'meta_key' => 'hp_draft', 'zone' => 'hot', 'column' => 'hp_draft' ) ); + + $this->assertSame( array( 'hp_price' ), $registry->get_rest_visible_hot_columns( 'hp_listing' ) ); + } + // ── get_stats ───────────────────────────────────────────────────────────── public function test_get_stats_404_when_post_not_found(): void { @@ -185,7 +243,7 @@ class RestApiTest extends TestCase { public function test_listings_fallback_only_returns_show_in_rest_columns(): void { $registry = WPDO_Schema_Registry::instance(); - $registry->register( 'unit_rest', array( 'post_type' => 'hp_listing', 'meta_key' => 'hp_price', 'zone' => 'hot', 'column' => 'hp_price' ) ); + $registry->register( 'unit_rest', array( 'post_type' => 'hp_listing', 'meta_key' => 'hp_price', 'zone' => 'hot', 'column' => 'hp_price', 'show_in_rest' => true ) ); $registry->register( 'unit_rest', array( 'post_type' => 'hp_listing', 'meta_key' => 'hp_secret', 'zone' => 'hot', 'column' => 'hp_secret', 'show_in_rest' => false ) ); $post = new stdClass();