Repository navigation
PRE-3742: harden Apple Pay AJAX endpoints (rate limit, contact sanitising, error handling) - #1240
hdelaforce-payplug wants to merge 2 commits into
Conversation
…order contacts and remove unused Apple Pay methods
…ated and validate the Apple Pay add-to-cart product id and quantity
jhoaraupp
left a comment
There was a problem hiding this comment.
Review de PRE-3742, à approuver après les deux points Moyen :
- IPv6 compté par /64 (ou risque accepté explicitement avec le PO) ;
- PHPUnit exécuté au moins une fois, plus le QA sandbox.
Vérifié de mon côté sur 6e409b9 :
php -l(PHP 7.4) : OK ;php-cs-fixer --dry-run: 0 fichier ;- PHPStan : 138 erreurs, identique à la baseline annoncée, aucune dans le code ajouté ;
- consommateurs JS (cart, product, Blocks) relus : compatibles avec les nouvelles réponses (
msg/message, réponse deupdate_applepay_orderjamais lue).
Bon travail sur les logs sans donnée client et la validation stricte des entrées.
| return '' !== $customer_id ? 's:' . $customer_id : ''; | ||
| } | ||
|
|
||
| private static function remote_addr(): string |
There was a problem hiding this comment.
[Moyen] La limite par IP se contourne en IPv6, et la limite par session ne protège pas d'un attaquant
remote_addr() compte par adresse exacte. En IPv6, un client dispose en général d'au moins un /64, soit 2⁶⁴ adresses.
La limite par sujet ne compense pas : sans cookie, un attaquant obtient une nouvelle session WC à chaque appel applepay_add_to_cart, puis enchaîne place_order_with_dummy_data avec ce cookie (2 requêtes par commande).
Scénario : un script qui change d'adresse IPv6 dans son /64 à chaque paire de requêtes crée des commandes pending sans limite. C'est précisément le flood que PRE-3742 veut bloquer.
Proposition : pour l'IPv6, compter par préfixe /64 (inet_pton, mettre à zéro les 8 derniers octets, inet_ntop comme identité), et ajouter un test « deux adresses du même /64 partagent le compteur ».
|
|
||
| $key = self::transient_key($scope, $identity); | ||
| $now = time(); | ||
| $bucket = get_transient($key); |
There was a problem hiding this comment.
[Faible] Une salve parallèle peut dépasser la limite
get_transient() puis set_transient() ne sont pas atomiques : N requêtes simultanées lisent toutes count < limit et passent toutes.
C'est acceptable pour une limite « best effort », mais il vaut mieux l'écrire dans le docblock de hit(). Avec un cache objet persistant, wp_cache_add + wp_cache_incr donneraient un compteur atomique.
| } | ||
|
|
||
| if ((int) $bucket['count'] >= $limit['limit']) { | ||
| PayplugGateway::log(sprintf( |
There was a problem hiding this comment.
[Faible] Un log warning est écrit à chaque requête refusée
Sous flood, avec les logs activés, on obtient une ligne par requête refusée dans payplug_gateway. Logguer seulement le premier refus de chaque fenêtre (par exemple avec un drapeau dans le bucket) suffirait au diagnostic.
| @@ -0,0 +1,148 @@ | |||
| <?php | |||
There was a problem hiding this comment.
[Nit] Pas de garde defined('ABSPATH') || exit;
C'est cohérent avec Front/ApplePay.php, et le dépôt ne l'a que dans environ un tiers des fichiers de src/. À ajouter seulement si on veut converger vers la règle.
| return; | ||
| } | ||
|
|
||
| try { |
There was a problem hiding this comment.
[Faible] Le try / catch (\Throwable) englobe aussi empty_cart() et process_cart_payment()
- En production,
wp_send_json()termine par undie, donc pas d'effet. Mais l'AjaxDieExceptiondu harnais de test étend\Error. Tout futur test qui ne surcharge pasprocess_cart_payment()verra ce « die » avalé par cecatch, puis un second JSON concaténé. - Une exception levée dans
process_cart_payment()(paiement PayPlug éventuellement créé, panier déjà vidé) répond « Your order was cancelled. », ce qui est faux pour l'acheteur.
Proposition : limiter le try à $this->create_cart_order($apple_pay) et laisser la suite hors du catch.
| */ | ||
| private function create_cart_order(ApplePayGateway $apple_pay): ?\WC_Order | ||
| { | ||
| $order_id = WC()->checkout()->create_order(['payment_method' => $apple_pay->id]); |
There was a problem hiding this comment.
[Faible] Une exception après create_order() laisse une commande pending orpheline
create_order() a déjà sauvegardé la commande. Si une étape suivante lève une exception (calculate_taxes, calculate_totals, save…), le meta CART_ORDER_META n'a été posé qu'en mémoire et n'est jamais persisté. applepay_cancel_order refusera donc de supprimer cette commande.
Comme le panier est conservé, chaque nouvel essai crée un orphelin de plus (au plus 10 par fenêtre grâce à la limite). Le test ..._when_a_step_after_create_order_throws fige ce comportement : il se contente de faire le ménage via track_order.
C'est préexistant et listé hors scope, mais la correction est simple : dans create_cart_order(), entourer l'après-création d'un try/catch qui fait $order->delete(true) (ou passe la commande en failed), puis relance l'exception.
| @@ -197,66 +242,8 @@ public function update_applepay_order(): void | |||
| } | |||
|
|
|||
| $selected_shipping_method = !empty($_POST['shipping_method']) ? $_POST['shipping_method'] : null; | |||
There was a problem hiding this comment.
[Nit, hors scope] $_POST['shipping_method'] reste brut
Il ne sert qu'à une comparaison stricte avec $id_shipping_method, donc c'est inoffensif. Pour être homogène avec le reste de la PR : wc_clean(wp_unslash(...)) avec un contrôle is_string.
| msgid "The total amount of your order should be between %s€ and %s€ to pay with Scalapay." | ||
| msgstr "" | ||
|
|
||
| #: src/Front/ApplePay.php:593 |
There was a problem hiding this comment.
[Nit] Référence de ligne périmée
La chaîne est à src/Front/ApplePay.php:612 sur le head (le second commit l'a décalée). C'est aussi le cas dans payplug-fr_FR.po. C'est cosmétique ; un wp i18n make-pot la corrigerait.
| use Payplug\PayplugWoocommerce\Tests\phpunit\src\Support\WooCommerceAjaxFixtures; | ||
| use PHPUnit\Framework\TestCase; | ||
|
|
||
| class ApplePayRateLimiter_test extends TestCase |
There was a problem hiding this comment.
[Moyen] Aucun des 48 nouveaux tests n'a encore tourné
La CI de ce dépôt ne lance pas PHPUnit, et l'exécution locale n'a pas été faite. Les tests du rate limiter, du chemin WP_Error et de la validation add-to-cart ne garantissent donc rien pour l'instant.
À faire avant le merge :
- lancer
make test(avecPHP_VERSION=7.4, en contournant le platform check) ou une suite WP-tests au moins une fois ; - dans la checklist de la PR, décocher « verified these changes locally on a native WooCommerce environment » tant que le QA sandbox Apple Pay (panier, produit, Blocks) n'est pas fait, puisque les notes indiquent qu'il ne l'est pas encore.
ilajili
left a comment
There was a problem hiding this comment.
Compléments de review, à lire avec la review existante. Les points déjà relevés (atomicité du compteur, commande orpheline, catch \Throwable, IPv6) ne sont pas répétés.
|
|
||
| private const DEFAULT_LIMITS = [ | ||
| 'subject' => ['limit' => 10, 'window' => 600], | ||
| 'ip' => ['limit' => 100, 'window' => 600], |
There was a problem hiding this comment.
[Moyen] La limite par IP peut bloquer Apple Pay pour toute une boutique derrière un proxy
Elle est active par défaut (100 / 600 s sur REMOTE_ADDR). Derrière un reverse proxy ou un CDN qui ne restaure pas l'IP réelle, tous les visiteurs partagent la même REMOTE_ADDR. Au bout de 100 tentatives en 10 min, le bouton est refusé à tout le monde, et seul le changelog le signale.
Un blocage silencieux du paiement coûte plus cher que des commandes pending. Pistes : un log ou une notice admin dédiés quand la limite IP se déclenche, ou une limite IP désactivée par défaut. À trancher avec le PO.
| $limits = self::limits(); | ||
|
|
||
| $ip = self::remote_addr(); | ||
| if ('' !== $ip && !self::hit('ip', $ip, $limits['ip'])) { |
There was a problem hiding this comment.
[Faible] L'IP est comptée même quand la limite par sujet refuse ensuite
allow() incrémente le compteur IP, puis hit('subject', …) peut refuser. La tentative est donc comptée côté IP alors que le docblock dit « a refused attempt is not counted ». Soit on ajuste la doc, soit on vérifie les deux limites avant d'écrire.
| $cart = WC()->cart; | ||
|
|
||
| if (!$cart->is_empty()) { | ||
| if (!ApplePayRateLimiter::allow()) { |
There was a problem hiding this comment.
[Faible, hors scope] Pas de nonce sur place_order_with_dummy_data
L'endpoint crée une commande sans nonce. Un site tiers peut donc déclencher des créations depuis le navigateur d'un visiteur. À mettre en suivi (nonce par session, aussi côté JS cart / product / Blocks).
| } else { | ||
| $product_id = $_POST['product_variation_id']; | ||
| } | ||
| WC()->cart->add_to_cart($product_id, $quantity); |
There was a problem hiding this comment.
[Moyen] applepay_add_to_cart répond « succès » quand l'ajout échoue
WC()->cart->add_to_cart() renvoie false pour un produit inexistant, en rupture ou non achetable, mais la valeur de retour est ignorée : la réponse est success avec le total du panier. Le titre du changelog (« rejects invalid product ids ») ne couvre que le format de l'id.
Le point est listé hors périmètre dans la description. Comme ce bloc est déjà modifié ici, le fix tient en quelques lignes : if (false === WC()->cart->add_to_cart(...)) { $this->send_order_not_created(); return; }.
| private function get_posted_quantity() | ||
| { | ||
| if (!isset($_POST['product_quantity']) || '' === $_POST['product_quantity']) { | ||
| return 1; |
There was a problem hiding this comment.
[Faible] Changement de comportement sur la quantité 0, et pas de borne haute
Avant, !empty('0') valait faux et la quantité retombait à 1. Maintenant 0 est refusé. C'est cohérent avec la description, mais absent du changelog. Il n'y a pas non plus de borne maximale : un entier énorme passe wc_stock_amount.
| } | ||
| } | ||
|
|
||
| if (isset($contact['emailAddress']) && is_string($contact['emailAddress'])) { |
There was a problem hiding this comment.
[Faible] Un champ invalide est écarté sans trace et les placeholders restent
Un e-mail invalide (ici) ou des addressLines toutes vides sont ignorés en silence. La commande garde alors payplug_applepay_email@payplug.com ou payplug_applepay_address, qui partent à PayPlug, et le client ne reçoit pas son e-mail de confirmation. Un log warning quand un champ est écarté (sans donnée client) suffirait au diagnostic.
Description
Follow-up hardening of the Apple Pay
wc-ajaxendpoints after PRE-3734:pendingorders from the Apple Pay cart and product buttons.update_applepay_orderno longer returns the whole order to the browser.Rate limit on
place_order_with_dummy_data(Apple Pay cart and product page buttons)Front\ApplePayRateLimiter, checked only when the cart is not empty, before the order is created.REMOTE_ADDR.X-Forwarded-Forand other client-supplied headers are never read.wp_send_json_errorcarrying bothmsg(classic cart/product JS) andmessage(Blocks). It creates no order and keeps the cart. A 429 would break the classic JS, which has no fail branch.payplug_applepay_dummy_order_rate_limitreceives['subject' => ['limit' => 10, 'window' => 600], 'ip' => ['limit' => 100, 'window' => 600]]:limitorwindowof0orfalsedisables that scope;warninglevel with the scope and a truncatedwp_hashof the identity only. No IP or session id appears in clear..po/.mo,.potupdated).Order creation failures in
place_order_with_dummy_datacreate_cart_order().WC_Checkout::create_order()returns aWP_Error;\Throwableis thrown while the order is created and prepared, fromcreate_order()tosave()(previously only\Exceptionwas caught).wp_send_json_errorcarrying bothmsgandmessage("Your order was cancelled."). It does not empty the cart or start a payment, and creates no order in the first two cases. The attempt still counts toward the rate limit.WP_Errorfromcreate_order()caused a fatal error on$order->set_address(). The classic JS then never got a response it could read, and the page stayed blocked under its loading layer.warninglevel. They carry only theWP_Errorcode,order not foundor the exception class, and never the exception message or customer data.Minimal hardening of
applepay_add_to_cart(product page button)product_id/product_variation_idgo throughwp_unslashand must be numeric and greater than 0 beforeabsint. Negative, non-numeric and non-scalar (array) values are refused.An invalid
product_idwith a validproduct_variation_idfalls back to the variation. A validproduct_idis still preferred over the variation id.product_quantity:''→ 1;wc_stock_amount(wp_unslash());With no valid id or no valid quantity, the endpoint answers the same
msg+messageerror and adds nothing to the cart.A
\Throwablethrown while adding to the cart answers the same error.Otherwise the
WC()->cart->add_to_cart($product_id, $quantity)call and the{ total }success response are unchanged.Not part of this PR:
add_to_cart();woocommerce_add_to_cart_validation;add_to_cart().See the follow-ups below.
Sanitised contacts in
update_applepay_orderPayplugWoocommerceHelper::sanitize_apple_pay_contact():wc_clean(wp_unslash())and length limits (names, city, country 100; postcode 20; phone 30; address line and email 255);sanitize_email+is_email;addressLinessent as a string.switchblocks are replaced by setter maps (SHIPPING_CONTACT_SETTERS/BILLING_CONTACT_SETTERS) applied by a privateapply_contact(). The mapping is unchanged: the shipping contact still provides the billing email, and the billing contact provides no email or phone.O'Brien, notO\'Brien).Response of
update_applepay_order{"success":true}(wp_send_json_success()) instead of serialising the wholeWC_Order(personal data, order key). No first-party caller reads the response: classic cart and product JS and the Blocks source were checked.Dead code
PayplugWoocommerceRequest::applepay_empty_cart()and::applepay_add_to_cart(). Nothing hooks or calls them. The live handlers ofFront\ApplePayand theajax_url_applepay_*JS parameters stay.Payment methods and flows touched: Apple Pay only (cart button, product page button, Blocks express via the same endpoints). No JS or Blocks bundle change, no UPC change, no dependency change.
Related Issue
Ticket: PRE-3742
Type of Change
[x] 🐛 Bug fix
[ ] ✨ New feature
[x] 💥 Breaking change
[x] ♻️ Refactor
[ ] 🔧 Configuration / CI
[ ] 🚀 Release (
release/*branch targetingmaster)[ ] 📦 Dependency update
[x] 🔒 Security fix
[ ] 📝 Documentation update
✅ Quality Checklist
Local Environment & Hooks
(PRE|SMP)-XXXX: descriptionpattern.phpstan.neon/.php-cs-fixer.php) were generated successfully from.disttemplates.Testing & Code Quality
composer cs:fix).vendor/bin/phpstan).CI/CD Deployment Context
release/*branch, I am targeting the correct base branch to allow the automatedapply-releaseversion bumping job to run.Screenshots (if applicable)
N/A: no UI change. Two visible differences:
Notes for Reviewer
Impact / BC
PayplugWoocommerceRequest(applepay_empty_cart(),applepay_add_to_cart()). They are unhooked and have no callers in the plugin;wc_ajax_update_applepay_orderchanges shape (no first-party consumer). A third-party script that read the order from it would break.place_order_with_dummy_data(order not created) andapplepay_add_to_cartnow carrymsgin addition tomessage. This is additive, andmessageis unchanged.REMOTE_ADDRput all visitors in the same 100 / 600 s IP bucket. They can raise or disable the IP scope withpayplug_applepay_dummy_order_rate_limit. This is stated in the changelog line.Fix:and twoChange:lines for PRE-3742 inrelease.json(3.1.1):update_applepay_orderresponse;payplug.pot,payplug-fr_FR.poandpayplug-fr_FR.moupdated for the new rate-limit string. Besides the new entry, the.modiff only drops thePOT-Creation-Dateheader. The order-creation error reuses WooCommerce's own "Your order was cancelled." string, so no new string is added for it.Testing
make test(Docker) fails on the composer platform check. The GitHub CI of this repo does not run PHPUnit either (it runscomposer cs-lint, PHPCompatibility, the QA zip build and SonarCloud), so none of the new tests has run yet.tests/phpunit/src: 48 test methods, 119 cases with data providers (counted in the files):Front/ApplePayRateLimiter_test.php(23 methods, 41 cases): defaults, fixed window and restart, refused attempts not counted, user vs session vs IP counters, forwarded headers never read, unusableREMOTE_ADDR, no WC session, filter disable/lower/malformed values, hashed-only warning logs, transient key length;Front/ApplePay_test.php(22 methods, 44 cases). Rate limit and contacts (13 methods): refusal in HTTP 200 without creating an order, the IP cap across sessions, an empty cart not using the quota, a failed order creation counted, a normal buyer retrying up to the limit, the filter disabling the limit,update_applepay_orderanswering success only, apostrophes, garbage contacts, partial contacts, invalid email, billing contact email/phone ignored, a full valid payload stored as before;Front/ApplePay_test.php, order creation errors and add-to-cart (9 methods, 27 cases):create_order()returning aWP_Error(forced through an exception onwoocommerce_checkout_create_order, the real WooCommerce path);Error/TypeError/Exceptionthrown after creation;msg+message, keeping the cart and logging no customer data;add_to_cart().WP_Errorpath instead of an exception thrown after creation;PayplugWoocommerceHelper_test.php(1 method, 32 cases) forsanitize_apple_pay_contact();PayplugWoocommerceRequest_test.php(2 methods): the removed methods are gone and theFront\ApplePayhandlers stay hooked;Support/WooCommerceAjaxFixtures.php.php -lunder PHP 7.4.33: exit 0 on the 9 PHP files of the first commit (also under 8.3) and on the 2 PHP files of the second commit (src/Front/ApplePay.php,tests/phpunit/src/Front/ApplePay_test.php);php-cs-fixerdry-run on the touched PHP files: exit 0, 0 files to fix;developbaseline (0 new, none insrc/Front/ApplePay.php);msgfmt --checkon the FR catalogue: exit 0;release.jsonparses as valid JSON.6e409b9f:quality(composer cs-lint, PHP 7.4), PHP Compatibility 7.4-8.1, QA zip build, SonarCloud;task-list-completedpassed.Checklist items left unticked
captainhook.jsonis present,.git/hooksis not). The commit messages follow the pattern anyway.develop.composer cs:fixis ticked on the basis of a php-cs-fixer dry-run on the touched files, which reported 0 files to fix.Suggested QA (sandbox)
add_filter('payplug_applepay_dummy_order_rate_limit', '__return_false');disables both limits.add_action('woocommerce_checkout_create_order', function () { throw new Exception('test'); });: the button shows "Your order was cancelled.", the page is not left blocked, no order is created and the cart is kept.countryvalue: Apple Pay sends a country name, not an ISO code. This PR passes it through unchanged, as before.Risk / focus
ApplePayRateLimiter::hit(): window anchoring, the transient expiry kept on each write, and no write on refusal.ApplePayRateLimiter::limits(): normalisation of filter values (0/false/ non-array / malformed).place_order_with_dummy_data/create_cart_order():\Throwablecatch also wrapsempty_cart()andprocess_cart_payment(). A throw there answers the same error, but the order already exists and the cart is already emptied. This matches the previous behaviour for\Exception.get_posted_id()/get_posted_quantity(): the variation fallback and the quantity default of 1.sanitize_apple_pay_contact()+apply_contact(): valid Apple Pay payloads must store the same values as before, apart from the removed backslashes.ApplePayRateLimiterstates thatX-Forwarded-Foris never read. It was kept as security documentation (Low review note, left as is).Open points
Out of scope, to ticket separately
needs_payment()check onajax_create_payment(update_applepay_orderalready has it since PRE-3734).applepay_add_to_cartignores the return value ofWC()->cart->add_to_cart(). When WooCommerce refuses the product (out of stock, not purchasable), the endpoint still answers success.place_order_with_dummy_dataanswers a barewp_send_json_error(). After an add-to-cart refusal, the product page JS then throws onresults.data.msg(pre-existing, one-line fix).create_order()has saved the order, that order stayspending. If the throw comes fromprocess_cart_payment(), the cart has also already been emptied (pre-existing).🤖 Generated with Claude Code