| @@ -370,14 +370,18 @@ | ||
| 370 | 370 | if ( empty( $row ) ) { |
| 371 | 371 | return false; |
| 372 | 372 | } |
| 373 | 373 | |
| 374 | - // IPN confirmed — return verified DB data. | |
| 374 | + // IPN confirmed — return verified DB data. The payer's e-mail and the PayPal transaction | |
| 375 | + // id only for an unguessable order id: before 2.4.0 ids were `paypal_` . uniqid(), which is | |
| 376 | + // time-based and can be enumerated, so anyone could read them off the return page. | |
| 375 | 377 | if ( $row->status === 'Completed' ) { |
| 378 | + $unguessable = self::paypal_order_id_is_unguessable( $item_number ); | |
| 379 | + | |
| 376 | 380 | return [ |
| 377 | 381 | 'amount' => floatval( $row->amount ), |
| 378 | - 'email' => sanitize_email( $row->email ), | |
| 379 | - 'transaction_id' => sanitize_text_field( $row->transaction_id ), | |
| 382 | + 'email' => $unguessable ? sanitize_email( $row->email ) : '', | |
| 383 | + 'transaction_id' => $unguessable ? sanitize_text_field( $row->transaction_id ) : '', | |
| 380 | 384 | 'currency' => sanitize_text_field( $row->currency ), |
| 381 | 385 | 'method' => 'paypal', |
| 382 | 386 | ]; |
| 383 | 387 | } |
| @@ -386,8 +390,34 @@ | ||
| 386 | 390 | return [ 'pending' => true, 'order_id' => $item_number ]; |
| 387 | 391 | } |
| 388 | 392 | |
| 389 | 393 | /** |
| 394 | + * Whether a PayPal order id came from new_order_id() (24 random characters), not the | |
| 395 | + * enumerable `paypal_` . uniqid() of releases before 2.4.0. | |
| 396 | + * | |
| 397 | + * @since 2.4.1 | |
| 398 | + * | |
| 399 | + * @param mixed $order_id The order id. | |
| 400 | + * @return bool | |
| 401 | + */ | |
| 402 | + public static function paypal_order_id_is_unguessable( $order_id ) { | |
| 403 | + return is_string( $order_id ) && 1 === preg_match( '/^paypal_[0-9a-z]{24}$/', $order_id ); | |
| 404 | + } | |
| 405 | + | |
| 406 | + /** | |
| 407 | + * Whether an order id of any gateway came from new_order_id() (`{gateway}_` + 24 random | |
| 408 | + * characters), not the enumerable `{gateway}_` . uniqid() of releases before 2.4.0. | |
| 409 | + * | |
| 410 | + * @since 2.4.1 | |
| 411 | + * | |
| 412 | + * @param mixed $order_id The order id. | |
| 413 | + * @return bool | |
| 414 | + */ | |
| 415 | + public static function order_id_is_unguessable( $order_id ) { | |
| 416 | + return is_string( $order_id ) && 1 === preg_match( '/^[a-z0-9-]+_[0-9a-z]{24}$/', $order_id ); | |
| 417 | + } | |
| 418 | + | |
| 419 | + /** | |
| 390 | 420 | * AJAX: check whether a PayPal order has been confirmed by IPN. |
| 391 | 421 | * |
| 392 | 422 | * Called by the frontend polling loop after the user lands on the return URL |
| 393 | 423 | * before the IPN fires. Returns {status:'pending'} or {status:'completed'}. |
| @@ -472,8 +502,35 @@ | ||
| 472 | 502 | exit; |
| 473 | 503 | } |
| 474 | 504 | |
| 475 | 505 | /** |
| 506 | + * Whether a notification's PayPal environment matches the order's. | |
| 507 | + * | |
| 508 | + * Orders since 2.4.1 record `paypal_mode` (`live` | `sandbox`) when created, and must match | |
| 509 | + * exactly. An older order has no record, so a live notification is accepted (a real payment | |
| 510 | + * must never be stranded) and a sandbox one only while the site's PayPal setting is not live. | |
| 511 | + * | |
| 512 | + * @since 2.4.1 | |
| 513 | + * | |
| 514 | + * @param mixed $form_info The order's `form_fields_info`, unserialized. | |
| 515 | + * @param string $ipn_mode `live` or `sandbox` (from the notification's `test_ipn`). | |
| 516 | + * @return bool | |
| 517 | + */ | |
| 518 | + public static function paypal_ipn_mode_matches( $form_info, $ipn_mode ) { | |
| 519 | + $order_mode = is_array( $form_info ) && isset( $form_info['paypal_mode'] ) ? $form_info['paypal_mode'] : ''; | |
| 520 | + | |
| 521 | + if ( 'live' === $order_mode || 'sandbox' === $order_mode ) { | |
| 522 | + return $order_mode === $ipn_mode; | |
| 523 | + } | |
| 524 | + | |
| 525 | + if ( 'live' === $ipn_mode ) { | |
| 526 | + return true; | |
| 527 | + } | |
| 528 | + | |
| 529 | + return 'yes' !== DB::get_settings( 'better_payment_settings_payment_paypal_live_mode' ); | |
| 530 | + } | |
| 531 | + | |
| 532 | + /** | |
| 476 | 533 | * Apply a PayPal IPN to the order it names. |
| 477 | 534 | * |
| 478 | 535 | * Call ONLY with a notification handle_paypal_ipn() posted back and PayPal answered VERIFIED — |
| 479 | 536 | * nothing here authenticates the data. Changes nothing unless the notification is a |
| @@ -516,8 +573,16 @@ | ||
| 516 | 573 | if ( $expected_email && $receiver && $expected_email !== $receiver ) { |
| 517 | 574 | return 'refused'; |
| 518 | 575 | } |
| 519 | 576 | |
| 577 | + // A sandbox notification is VERIFIED by PayPal's sandbox, which pays in play money to an | |
| 578 | + // account anyone can create under any address. It must never confirm a live order. | |
| 579 | + $ipn_mode = ! empty( $data['test_ipn'] ) ? 'sandbox' : 'live'; | |
| 580 | + | |
| 581 | + if ( ! self::paypal_ipn_mode_matches( $form_info, $ipn_mode ) ) { | |
| 582 | + return 'refused'; | |
| 583 | + } | |
| 584 | + | |
| 520 | 585 | // Validate the paid amount is not less than the expected order amount. |
| 521 | 586 | $paid_amount = ! empty( $data['mc_gross'] ) ? floatval( $data['mc_gross'] ) : 0; |
| 522 | 587 | if ( $paid_amount < floatval( $results->amount ) ) { |
| 523 | 588 | return 'refused'; |
| @@ -1161,9 +1226,9 @@ | ||
| 1161 | 1226 | "Cache-Control: no-cache", |
| 1162 | 1227 | ); |
| 1163 | 1228 | |
| 1164 | 1229 | $response = wp_safe_remote_get( |
| 1165 | - 'https://api.paystack.co/transaction/verify/'. $reference, | |
| 1230 | + 'https://api.paystack.co/transaction/verify/' . rawurlencode( $reference ), | |
| 1166 | 1231 | array( |
| 1167 | 1232 | 'headers' => $header_info, |
| 1168 | 1233 | 'timeout' => 70, |
| 1169 | 1234 | ) |
| @@ -1829,9 +1894,11 @@ | ||
| 1829 | 1894 | return [ |
| 1830 | 1895 | 'amount' => floatval( $result->amount ), |
| 1831 | 1896 | 'currency' => sanitize_text_field( $result->currency ), |
| 1832 | 1897 | 'currency_symbol' => $helper_obj->get_currency_symbol( $result->currency ), |
| 1833 | - 'transaction_id' => !empty( $result->transaction_id ) ? sanitize_text_field( $result->transaction_id ) : 'N/A', | |
| 1898 | + // The order id is request data, and ids before 2.4.0 were `{gateway}_` . uniqid() — | |
| 1899 | + // enumerable — so the gateway's transaction id is shown only for an unguessable id. | |
| 1900 | + 'transaction_id' => ! empty( $result->transaction_id ) && self::order_id_is_unguessable( $order_id ) ? sanitize_text_field( $result->transaction_id ) : 'N/A', | |
| 1834 | 1901 | 'method' => sanitize_text_field( $result->source ), |
| 1835 | 1902 | ]; |
| 1836 | 1903 | } |
| 1837 | 1904 | |