fix(2meet-data-optimizer): keep registered fields out of the public listings REST endpoints
Anti-EAV Lint + Quality Gate / anti-eav-lint (pull_request) Successful in 15s
Tests / PHP Lint (pull_request) Successful in 17s
Tests / Unit Tests (pull_request) Successful in 51s
Tests / Integration Tests (pull_request) Successful in 1m22s
Tests / PHPCS (pull_request) Successful in 1m12s
Tests / PHPStan (pull_request) Successful in 1m10s
Anti-EAV Lint + Quality Gate / anti-eav-lint (pull_request) Successful in 15s
Tests / PHP Lint (pull_request) Successful in 17s
Tests / Unit Tests (pull_request) Successful in 51s
Tests / Integration Tests (pull_request) Successful in 1m22s
Tests / PHPCS (pull_request) Successful in 1m12s
Tests / PHPStan (pull_request) Successful in 1m10s
- show_in_rest defaulted to true, so GET /wpdo/v1/listings and /listings/{id} (public by design) returned every registered hot / cold field to anonymous visitors — e.g. infocards tmeetic_ig_access_token_enc / _token_expiry / _user_id and WooCommerce _purchase_note (confirmed on dev36)
- Fields are private unless registered with show_in_rest => true; protected (underscore) and credential-like keys (token, secret, password, api key, private key, credential, salt, nonce) are never exposed
- Zone A list path no longer returns whole rows when nothing is public, and filters / orderby accept public columns only
- Version 1.1.1; CHANGELOG, readme
- Verify: unit 690 (+3), integration 525 (+3 private-column tests, RED on 1.1.0), live suite rest 2/2 (RED on 1.1.0), all live 49/49, phpcs 0, stan OK
This commit is contained in:
@@ -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' );
|
||||
|
||||
@@ -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/`)
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
/**
|
||||
* Suite: the public listings endpoints never return private fields to anonymous users.
|
||||
*
|
||||
* Bug (dev36, 2026-09-26, fixed in 1.1.1): GET /wpdo/v1/listings/{id} is public and
|
||||
* returned every registered hot / cold field, because show_in_rest defaulted to true —
|
||||
* e.g. the infocards IG token fields on hp_vendor and WooCommerce's _purchase_note.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* GET a wpdo REST route as an anonymous visitor.
|
||||
*
|
||||
* @param string $route Route, e.g. /wpdo/v1/listings/12.
|
||||
* @param array<string, mixed> $params Query parameters.
|
||||
* @return array<int|string, mixed> 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" );
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -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<int, array<string, mixed>> $fields Field configs (post_type added).
|
||||
* @param array<string, string> $meta Stored meta.
|
||||
* @return array<string, mixed> 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();
|
||||
|
||||
Reference in New Issue
Block a user