Commit 189e2768186 for woocommerce
commit 189e2768186e8030d1726757d4999bb51203ac2c
Author: Alex López <45979455+alopezari@users.noreply.github.com>
Date: Tue Oct 13 15:40:35 2026 +0200
Add AI review guidelines for order storage access and payment callbacks (#69715)
* Add an AI review guideline for order reads through the posts tables, and scope security exemptions to the weakness they cover
* Add a payment-callbacks guideline and move the cast exemption scope to the security persona's guard step
* Correct the payment guideline's capture sources, scope the nonce rule to admin, AJAX and form handlers, and narrow the data-store exemption
* Select the order guideline on legacy column maps, describe stale posts under sync off, and scope the shared-helper exemption to the writes it guards
* Restore the postmeta signal, select OrdersTableQuery arg maps, and keep the cast exemption to numeric casts
diff --git a/.ai/review/guidelines/order-storage-access.md b/.ai/review/guidelines/order-storage-access.md
new file mode 100644
index 00000000000..f56107dc5a3
--- /dev/null
+++ b/.ai/review/guidelines/order-storage-access.md
@@ -0,0 +1,20 @@
+# Order reads and writes through the posts tables
+
+**Source:**
+
+- #46715: `wc/v3/reports/orders/totals` counted orders with `wp_count_posts( 'shop_order' )` and returned zeros with HPOS on and sync off.
+- #65493: legacy admin reports and `wc/v3/reports/sales` built their SQL on `{$wpdb->posts}` and returned empty results on HPOS-authoritative sites with sync off.
+- #67015: the #65493 fix translated only some legacy `posts` columns, so a bare `post_status` reached `wc_orders` and reports rendered zeros with no error.
+
+**What broke:** Code outside the order data stores read orders from `wp_posts` / `wp_postmeta`. With HPOS authoritative and compatibility sync off, those tables hold `shop_order_placehold` posts, nothing, or stale copies from before sync was turned off, so counts, lists, and totals came back empty or wrong without an error.
+
+**Pattern to catch:**
+
+- `wp_count_posts()`, `get_posts()`, `WP_Query`, or `get_post()` / `get_post_status()` for `shop_order` or `shop_order_refund`.
+- `get_post_meta()` / `update_post_meta()` / `delete_post_meta()` on an order or refund ID.
+- SQL that selects orders from `$wpdb->posts` or their meta from `$wpdb->postmeta`.
+- An HPOS translation layer that maps only some legacy columns or query args.
+
+**How to confirm:** Is the ID or post type an order or refund? Trace the read with HPOS authoritative and sync off: does it reach `wc_orders`? The safe forms are `wc_get_order()`, `wc_get_orders()`, `$order->get_meta()`, `OrderUtil::get_count_for_type()`, and SQL on `OrderUtil::get_table_for_orders()` that uses that table's own column names. For a translation layer, check that every legacy column the callers use is mapped, qualified and bare.
+
+**Don't flag when:** Posts-table access inside the order data stores, the sync, or the migration, where it is the job; a legacy query-arg mapping there (`OrdersTableQuery`) is still a translation layer. A posts branch that runs only when `OrderUtil::custom_orders_table_usage_is_enabled()` is false, next to an HPOS branch. Non-order post types (products, coupons, pages).
diff --git a/.ai/review/guidelines/payment-callbacks.md b/.ai/review/guidelines/payment-callbacks.md
new file mode 100644
index 00000000000..982a40a3f35
--- /dev/null
+++ b/.ai/review/guidelines/payment-callbacks.md
@@ -0,0 +1,23 @@
+# Payment callbacks and capture requests
+
+**Source:**
+
+- #63834: a PayPal webhook payload whose `links` was not an array fatalled the handler.
+- #68307: an array order key on the PayPal return path reached `hash_equals()` and fatalled on PHP 8.
+- #62304, #63464: capture on order status change asked PayPal again for an authorization or order that had returned 404, on every later status change, until a meta flag recorded the check.
+- #62582: capturing an authorization PayPal had already captured returned `AUTHORIZATION_ALREADY_CAPTURED`, and the gateway treated it as a failure.
+- #63203: a capture failed on PayPal's duplicate invoice ID check; the fix patches a new invoice ID and retries once.
+
+**What broke:** Gateway code assumed one clean, well-formed exchange. Real callbacks arrive malformed and repeated, and capture runs again on every order status change, so a permanent error or an already-done result kept coming back.
+
+**Pattern to catch:**
+
+- A handler that changes order state (`payment_complete()`, `update_status()`, refunds, stock) without first proving the sender: a PayPal post-back that returns `VERIFIED`, a signed-request check in the REST `permission_callback`, or a server-side lookup of the payment.
+- Amount or currency taken from the payload without comparing it with the order (`validate_amount()`, `validate_currency()`).
+- A state change or gateway call that a repeated event or a later status change can trigger again, with no record that it already ran or already failed for good.
+- A gateway error handled as one generic failure: a permanent error (404) retried on every trigger, an "already done" answer reported as failed, or a retry with no bound.
+- Payload or request fields read without a type or shape check.
+
+**How to confirm:** Send the handler the same event twice, then an older event after a newer one. Does the order change state again? Move the order through processing and completed twice: how many gateway requests go out? Remove the sender proof: can an anonymous request reach the state change? Send a field with the wrong type: does the handler log and return, or fatal?
+
+**Don't flag when:** A request that no callback or status change can repeat (building the checkout redirect). A duplicate check in a shared helper that guards this event and every write it makes (status, transaction ID, meta, notes). A missing nonce on an IPN, webhook, or gateway return: sender proof is the guard there.
diff --git a/.ai/review/personas/security.md b/.ai/review/personas/security.md
index ce646eedf35..3c2cd2b9cd2 100644
--- a/.ai/review/personas/security.md
+++ b/.ai/review/personas/security.md
@@ -18,6 +18,9 @@ costs more trust than a missed `low`.
the sink: a query, output, redirect, file call, remote request, unserialize, or a state change.
3. Look for the guard on that path. A correct guard in any standard form (below) means no finding.
If the guard is elsewhere, Read it and confirm it covers this exact input before you rely on it.
+ A guard clears only the weakness it stops: `(int)` or `absint()` on a numeric SQL value clears
+ injection there, not authorization or SSRF; on admin, AJAX, and form handlers a capability check
+ does not replace a nonce.
4. Judge only the post-change code. If the diff adds the guard, there is no finding unless the guard
is wrong or incomplete.
@@ -92,6 +95,8 @@ never the value.
- A guard that is present and correct, in any standard form.
- An attack that needs a capability that already grants the same power (an admin with
`unfiltered_html` injecting script; a `manage_woocommerce` user changing store settings).
+ This clears only an attacker who holds that capability. A missing nonce on the same admin, AJAX,
+ or form action is still CSRF: a third party forges the privileged user's request.
- A theoretical issue with no reachable path from an entry point.
- Hardening ideas on unchanged code.
diff --git a/ai-review-config.json b/ai-review-config.json
index e1772bdb91d..88a3ddde290 100644
--- a/ai-review-config.json
+++ b/ai-review-config.json
@@ -447,6 +447,50 @@
"regression"
]
},
+ {
+ "name": "order-storage-access",
+ "file": ".ai/review/guidelines/order-storage-access.md",
+ "path": [
+ "plugins/woocommerce/includes/**/*.php",
+ "plugins/woocommerce/src/**/*.php",
+ "plugins/woocommerce/templates/**/*.php"
+ ],
+ "signals": [
+ "wp_count_posts",
+ "get_posts(",
+ "WP_Query",
+ "get_post(",
+ "get_post_status",
+ "get_post_meta",
+ "update_post_meta",
+ "delete_post_meta",
+ "$wpdb->posts",
+ "$wpdb->postmeta",
+ "shop_order",
+ "post_status",
+ "post_parent",
+ "'post_date",
+ "'post_modified",
+ "'post_type'",
+ "'orders."
+ ],
+ "personas": [
+ "regression"
+ ]
+ },
+ {
+ "name": "payment-callbacks",
+ "file": ".ai/review/guidelines/payment-callbacks.md",
+ "path": [
+ "plugins/woocommerce/includes/gateways/**/*.php",
+ "plugins/woocommerce/src/Gateways/**/*.php",
+ "plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-paypal-*.php"
+ ],
+ "personas": [
+ "security",
+ "regression"
+ ]
+ },
{
"name": "migration-upgrade-state",
"file": ".ai/review/guidelines/migration-upgrade-state.md",