Security review fixes: keep pending-capture ids, ship uninstall.php (+verify step), webhook guard, IPv6 /64 + global order throttle, token-safe redirects, honest refund wording, request-id and overlay-token hardening
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
68bd917660
commit
473fe192cd
28 files changed
+747
-34
No files matched your search
@@ -93,6 +93,7 @@ jobs:
|
||||
PKG=/tmp/streamgoals-donate
|
||||
mkdir -p "$PKG"
|
||||
cp streamgoals-donate.php "$PKG"/
|
||||
cp uninstall.php "$PKG"/
|
||||
cp -r includes assets "$PKG"/
|
||||
[ -d languages ] && cp -r languages "$PKG"/ || true
|
||||
[ -f LICENSE ] && cp LICENSE "$PKG"/ || true
|
||||
@@ -101,6 +102,18 @@ jobs:
|
||||
(cd /tmp && zip -r -q "$GITHUB_WORKSPACE/streamgoals-donate.zip" streamgoals-donate)
|
||||
unzip -l streamgoals-donate.zip | tail -n 15
|
||||
|
||||
- name: Verify package contents
|
||||
run: |
|
||||
set -e
|
||||
LIST=$(unzip -l streamgoals-donate.zip)
|
||||
for f in streamgoals-donate/streamgoals-donate.php streamgoals-donate/uninstall.php streamgoals-donate/includes/Core/Uninstaller.php streamgoals-donate/LICENSE; do
|
||||
if ! echo "$LIST" | grep -qE "[[:space:]]${f}\$"; then
|
||||
echo "::error::release zip is missing $f"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
echo "package contents verified"
|
||||
|
||||
- name: Create release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
|
||||
@@ -76,8 +76,8 @@ server sees.
|
||||
- Optional message approval queue and a streamer-controlled profanity filter (whole-word, your own word list).
|
||||
|
||||
## Not supported
|
||||
- **Refunds and chargebacks are not tracked.** If you refund someone in PayPal, goal totals, timers and
|
||||
counters are not adjusted.
|
||||
- **Refunds and chargebacks are not tracked.** Refunds and chargebacks done in PayPal are NOT reflected in goal
|
||||
totals, timers, counters or tiers. The donation keeps counting as if it had been paid.
|
||||
|
||||
## Transparent alerts
|
||||
In StreamGoals > Alerts, tick **Frameless** on an alert to drop the dark box, border and glow so only your picture or
|
||||
|
||||
@@ -32,7 +32,7 @@ Money::fromDecimalString(string $amount, string $currency): Money; // "10.50" -
|
||||
FeeCalculator::grossUp(int $baseMinor, FeeRates $rates): int; // total so recipient nets base
|
||||
// total = ceil((base + fixed) / (1 - pct)) — rates configurable, default US 3.49% + 49c
|
||||
```
|
||||
Rules engine entry point (Agent 2 implements, Agent 1 calls after a capture is recorded and on refund):
|
||||
Rules engine entry point (Agent 2 implements, Agent 1 calls after a capture is recorded; refunds/chargebacks are NOT tracked - nothing calls reverseDonation, and refunds done in PayPal are not reflected in goal totals, timers, counters or tiers):
|
||||
```php
|
||||
Rules\Engine::applyDonation(Donation $d): void; // idempotent via sg_rule_events UNIQUE
|
||||
Rules\Engine::reverseDonation(Donation $d): void; // honors per-rule "reverse on refund"
|
||||
|
||||
@@ -171,7 +171,7 @@ final class Admin {
|
||||
echo '<div class="wrap sg-admin"><h1>' . esc_html( $title ) . '</h1>';
|
||||
self::notices();
|
||||
if ( PayPalConnector::isConnected() && '' === (string) Settings::get( 'paypal_webhook_id', '' ) ) {
|
||||
echo '<div class="notice notice-warning"><p>' . esc_html( 'PayPal is connected, but the webhook is not set up yet, so refunds/reversals will not be noticed. Open Settings and press "Save and test connection" to register it.' ) . '</p></div>';
|
||||
echo '<div class="notice notice-warning"><p>' . esc_html( 'PayPal is connected, but the webhook is not set up yet, so payments that PayPal confirms later (for example under review) will not be completed automatically. Note that refunds and chargebacks done in PayPal are NOT reflected in goal totals, timers, counters or tiers, with or without the webhook. Open Settings and press "Save and test connection" to register it.' ) . '</p></div>';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ final class OverlaysPage {
|
||||
|
||||
/** @return array{url:string,base:string,sep:string} */
|
||||
public static function widgetUrl( string $widget ): array {
|
||||
$token = OverlayController::token();
|
||||
$token = OverlayController::ensure_token();
|
||||
if ( get_option( 'permalink_structure' ) ) {
|
||||
$base = home_url( '/streamgoals-overlay/' . rawurlencode( $token ) . '/' . rawurlencode( $widget ) . '/' );
|
||||
return array( 'url' => $base, 'base' => $base, 'sep' => '?' );
|
||||
|
||||
@@ -54,6 +54,29 @@ final class ClientIp {
|
||||
return $fromHeader ?? $remote ?? 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Rate-limit BUCKET KEY for a resolved address (never use it as the "real" IP): IPv4 unchanged, IPv4-mapped IPv6
|
||||
* becomes its IPv4, any other IPv6 collapses to its /64 (one subscriber/host can rotate freely inside a /64),
|
||||
* 'unknown' stays, anything unparsable becomes 'unknown'.
|
||||
*/
|
||||
public static function bucketKey( string $ip ): string {
|
||||
$ip = trim( $ip );
|
||||
if ( 'unknown' === $ip || false === filter_var( $ip, FILTER_VALIDATE_IP ) ) {
|
||||
return 'unknown';
|
||||
}
|
||||
if ( false === filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 ) ) {
|
||||
return $ip;
|
||||
}
|
||||
$bin = inet_pton( $ip );
|
||||
if ( false === $bin || 16 !== strlen( $bin ) ) {
|
||||
return 'unknown';
|
||||
}
|
||||
if ( str_repeat( "\0", 10 ) . "\xff\xff" === substr( $bin, 0, 12 ) ) {
|
||||
return (string) inet_ntop( substr( $bin, 12 ) );
|
||||
}
|
||||
return bin2hex( substr( $bin, 0, 8 ) ) . '/64';
|
||||
}
|
||||
|
||||
private static function valid( mixed $v ): ?string {
|
||||
if ( ! is_string( $v ) ) {
|
||||
return null;
|
||||
|
||||
@@ -15,6 +15,7 @@ final class Installer {
|
||||
if ( false === get_option( 'sg_settings' ) ) {
|
||||
add_option( 'sg_settings', Settings::defaults(), '', false );
|
||||
}
|
||||
Settings::ensure_overlay_token(); // never generated by a public request (see OverlayController)
|
||||
}
|
||||
|
||||
/** Run on plugins_loaded so upgrades apply without re-activation. */
|
||||
|
||||
@@ -25,6 +25,25 @@ final class Maintenance {
|
||||
wp_clear_scheduled_hook( self::HOOK );
|
||||
}
|
||||
|
||||
/**
|
||||
* The single definition of "abandoned checkout", mirrored by the DELETE below (MaintenancePurgeTest checks both).
|
||||
* Never purgeable: not pending, younger than MAX_AGE_DAYS, a test row (goal_id 0), or ANY row carrying PayPal capture
|
||||
* evidence (a capture id, even from a PENDING/under-review capture: PayPal may still finalize it by webhook).
|
||||
*
|
||||
* @param array|object $row status, goal_id, paypal_capture_id, created_at (UTC 'Y-m-d H:i:s')
|
||||
*/
|
||||
public static function isPurgeable( array|object $row, int $now ): bool {
|
||||
$r = (array) $row;
|
||||
if ( 'pending' !== (string) ( $r['status'] ?? '' ) || (int) ( $r['goal_id'] ?? 0 ) <= 0 ) {
|
||||
return false;
|
||||
}
|
||||
if ( '' !== trim( (string) ( $r['paypal_capture_id'] ?? '' ) ) ) {
|
||||
return false;
|
||||
}
|
||||
$created = strtotime( (string) ( $r['created_at'] ?? '' ) . ' UTC' );
|
||||
return false !== $created && $created < $now - self::MAX_AGE_DAYS * DAY_IN_SECONDS;
|
||||
}
|
||||
|
||||
/** @return int Number of rows removed. */
|
||||
public static function purge_abandoned(): int {
|
||||
global $wpdb;
|
||||
|
||||
@@ -59,6 +59,16 @@ final class Settings {
|
||||
update_option( 'sg_settings', array_merge( self::all(), $allowed ), false );
|
||||
}
|
||||
|
||||
/** Generates the overlay token if none is stored (a DB write: only from activation or an authenticated admin screen). */
|
||||
public static function ensure_overlay_token(): string {
|
||||
$token = (string) self::get( 'overlay_token', '' );
|
||||
if ( strlen( $token ) < 32 ) {
|
||||
$token = bin2hex( random_bytes( 16 ) );
|
||||
self::update( array( 'overlay_token' => $token ) );
|
||||
}
|
||||
return $token;
|
||||
}
|
||||
|
||||
public static function fee_calculator(): FeeCalculator {
|
||||
return new FeeCalculator( (int) self::get( 'fee_percent_bps' ), (int) self::get( 'fee_fixed_minor' ) );
|
||||
}
|
||||
|
||||
@@ -51,9 +51,9 @@ final class Uninstaller {
|
||||
return array( 'sg_update_release' );
|
||||
}
|
||||
|
||||
/** @return string[] Prefixes of transients whose names carry a suffix (rate limiter hashes, per-user notices). */
|
||||
/** @return string[] Prefixes of transients whose names carry a suffix (rate limiter hashes, per-user notices, cached PayPal access token, webhook de-dupe markers). */
|
||||
public static function transient_prefixes(): array {
|
||||
return array( 'sg_rl_', 'sg_notice_' );
|
||||
return array( 'sg_rl_', 'sg_notice_', 'sg_pp_token_', 'sg_wh_' );
|
||||
}
|
||||
|
||||
/** Whether this site's saved settings ask for all data to be deleted. */
|
||||
|
||||
@@ -23,17 +23,20 @@ final class CaptureFinalizer {
|
||||
* @param object|array $row Donation row (status, total_minor, currency).
|
||||
* @param callable $markCompleted fn(int $id, string $captureId): bool atomic pending->completed
|
||||
* @param callable $notifyApplied fn(int $id): void
|
||||
* @param callable|null $recordCapture fn(int $id, string $captureId): void Called when PayPal returned a capture id but the
|
||||
* donation is NOT finalized (PENDING/under review/amount mismatch). Persists the id so the
|
||||
* row is never mistaken for an abandoned checkout and the later webhook can find it.
|
||||
*/
|
||||
public static function finalize( object|array $row, string $paypalStatus, string $captureId, ?Money $amount, callable $markCompleted, callable $notifyApplied ): string {
|
||||
public static function finalize( object|array $row, string $paypalStatus, string $captureId, ?Money $amount, callable $markCompleted, callable $notifyApplied, ?callable $recordCapture = null ): string {
|
||||
$r = (object) $row;
|
||||
if ( 'pending' !== (string) $r->status ) {
|
||||
return self::DUPLICATE;
|
||||
}
|
||||
if ( 'COMPLETED' !== $paypalStatus || '' === $captureId ) {
|
||||
return self::PENDING;
|
||||
return self::pendingWith( $r, $captureId, $recordCapture );
|
||||
}
|
||||
if ( ! $amount || $amount->minor !== (int) $r->total_minor || $amount->currency !== (string) $r->currency ) {
|
||||
return self::PENDING;
|
||||
return self::pendingWith( $r, $captureId, $recordCapture );
|
||||
}
|
||||
if ( $markCompleted( (int) $r->id, $captureId ) ) {
|
||||
$notifyApplied( (int) $r->id );
|
||||
@@ -41,4 +44,11 @@ final class CaptureFinalizer {
|
||||
}
|
||||
return self::DUPLICATE;
|
||||
}
|
||||
|
||||
private static function pendingWith( object $r, string $captureId, ?callable $recordCapture ): string {
|
||||
if ( '' !== $captureId && null !== $recordCapture ) {
|
||||
$recordCapture( (int) $r->id, $captureId );
|
||||
}
|
||||
return self::PENDING;
|
||||
}
|
||||
}
|
||||
@@ -116,9 +116,10 @@ final class Client {
|
||||
* @param array<string,mixed> $headers Incoming webhook request headers (any case; '-' or '_').
|
||||
*/
|
||||
public function verifyWebhook( array $headers, string $rawBody, string $webhookId ): bool {
|
||||
$h = array();
|
||||
foreach ( $headers as $k => $v ) {
|
||||
$h[ strtolower( str_replace( '-', '_', (string) $k ) ) ] = is_array( $v ) ? (string) ( $v[0] ?? '' ) : (string) $v;
|
||||
$h = WebhookGuard::normalizeHeaders( $headers );
|
||||
// Local pre-checks (headers present, paypal.com cert_url, replay window, size): never spend an outbound call on junk.
|
||||
if ( null !== WebhookGuard::check( $headers, $rawBody, time() ) ) {
|
||||
return false;
|
||||
}
|
||||
$event = json_decode( $rawBody );
|
||||
if ( ! is_object( $event ) || '' === $webhookId ) {
|
||||
|
||||
@@ -84,6 +84,20 @@ final class DonationStore {
|
||||
return 1 === $n;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remember the PayPal capture id of a capture that is not final yet (PENDING / under review). Only touches a
|
||||
* still-pending row that has no capture id; never changes the status. This is the "PayPal took the money" evidence
|
||||
* that keeps Maintenance::purge_abandoned away and lets the COMPLETED webhook find the row by capture id.
|
||||
*/
|
||||
public static function record_capture_id( int $id, string $captureId ): bool {
|
||||
global $wpdb;
|
||||
if ( '' === $captureId ) {
|
||||
return false;
|
||||
}
|
||||
$n = $wpdb->query( $wpdb->prepare( 'UPDATE ' . self::t( 'donations' ) . " SET paypal_capture_id = %s WHERE id = %d AND status = 'pending' AND (paypal_capture_id IS NULL OR paypal_capture_id = '')", $captureId, $id ) );
|
||||
return 1 === $n;
|
||||
}
|
||||
|
||||
/** Hand a freshly completed donation to the rules engine (if present). Never throws. */
|
||||
public static function notify_applied( int $id ): void {
|
||||
self::call_engine( 'applyDonation', $id );
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
declare( strict_types=1 );
|
||||
|
||||
namespace StreamGoals\PayPal;
|
||||
|
||||
/**
|
||||
* PayPal-Request-Id (idempotency key) builder. Not derivable from the reusable auto-increment id alone: it is an HMAC
|
||||
* keyed with the site's secret salt over kind + donation id + created_at, so a re-used id (after rows are deleted and
|
||||
* AUTO_INCREMENT resets) gets a different key, while a retry for the SAME donation stays identical.
|
||||
*/
|
||||
final class RequestId {
|
||||
|
||||
public const MAX_LENGTH = 108;
|
||||
|
||||
public static function make( string $kind, int $donationId, string $createdAt, string $salt ): string {
|
||||
$kind = preg_replace( '/[^a-z]/', '', strtolower( $kind ) ) ?: 'req';
|
||||
$h = hash_hmac( 'sha256', $kind . '|' . $donationId . '|' . $createdAt, $salt );
|
||||
return substr( 'sg-' . $kind . '-' . $h, 0, self::MAX_LENGTH );
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
declare( strict_types=1 );
|
||||
|
||||
namespace StreamGoals\PayPal;
|
||||
|
||||
/**
|
||||
* Cheap, local, pure pre-checks for the public webhook endpoint. They run BEFORE any outbound PayPal call so an
|
||||
* anonymous flood cannot make this site hit PayPal's API. They are only a filter: PayPal's own
|
||||
* verify-webhook-signature call stays the authority on whether an event is genuine.
|
||||
*/
|
||||
final class WebhookGuard {
|
||||
|
||||
public const MAX_BODY_BYTES = 65536;
|
||||
public const MAX_SKEW_SECONDS = 3600;
|
||||
public const IP_LIMIT = 120; // per visitor bucket per minute (PayPal retries and uses several IPs)
|
||||
public const GLOBAL_LIMIT = 600; // whole site per minute
|
||||
public const WINDOW = 60;
|
||||
|
||||
public const REQUIRED = array( 'paypal_transmission_id', 'paypal_transmission_time', 'paypal_transmission_sig', 'paypal_cert_url', 'paypal_auth_algo' );
|
||||
|
||||
/** Per-bucket then global fixed-window limit. True when the request may proceed. */
|
||||
public static function admit( RateLimiter $limiter, string $ipBucket ): bool {
|
||||
return $limiter->allow( 'webhook|' . $ipBucket, self::IP_LIMIT, self::WINDOW )
|
||||
&& $limiter->allow( 'webhook|*global*', self::GLOBAL_LIMIT, self::WINDOW );
|
||||
}
|
||||
|
||||
/** Lower-cases header names and maps '-' to '_'. @param array<string,mixed> $headers @return array<string,string> */
|
||||
public static function normalizeHeaders( array $headers ): array {
|
||||
$h = array();
|
||||
foreach ( $headers as $k => $v ) {
|
||||
$h[ strtolower( str_replace( '-', '_', (string) $k ) ) ] = is_array( $v ) ? (string) ( $v[0] ?? '' ) : (string) $v;
|
||||
}
|
||||
return $h;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $headers
|
||||
* @return array{status:int,code:string}|null Null when the request may be sent on to PayPal for verification.
|
||||
*/
|
||||
public static function check( array $headers, string $rawBody, int $now ): ?array {
|
||||
if ( strlen( $rawBody ) > self::MAX_BODY_BYTES ) {
|
||||
return array( 'status' => 413, 'code' => 'sg_webhook_too_large' );
|
||||
}
|
||||
$h = self::normalizeHeaders( $headers );
|
||||
foreach ( self::REQUIRED as $n ) {
|
||||
if ( '' === trim( $h[ $n ] ?? '' ) ) {
|
||||
return array( 'status' => 401, 'code' => 'sg_webhook_headers' );
|
||||
}
|
||||
}
|
||||
if ( ! self::isPayPalCertUrl( $h['paypal_cert_url'] ) ) {
|
||||
return array( 'status' => 401, 'code' => 'sg_webhook_cert_url' );
|
||||
}
|
||||
if ( ! self::timeWithinWindow( $h['paypal_transmission_time'], $now ) ) {
|
||||
return array( 'status' => 401, 'code' => 'sg_webhook_time' );
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** https, no credentials/odd port, host is paypal.com or a subdomain of it. */
|
||||
public static function isPayPalCertUrl( string $url ): bool {
|
||||
$p = parse_url( trim( $url ) );
|
||||
if ( ! is_array( $p ) || 'https' !== strtolower( (string) ( $p['scheme'] ?? '' ) ) || isset( $p['user'] ) || isset( $p['pass'] ) ) {
|
||||
return false;
|
||||
}
|
||||
if ( isset( $p['port'] ) && 443 !== (int) $p['port'] ) {
|
||||
return false;
|
||||
}
|
||||
$host = strtolower( (string) ( $p['host'] ?? '' ) );
|
||||
return 'paypal.com' === $host || ( strlen( $host ) > 11 && str_ends_with( $host, '.paypal.com' ) );
|
||||
}
|
||||
|
||||
/** ISO-8601 timestamp within +-MAX_SKEW_SECONDS of $now; unparsable is rejected. */
|
||||
public static function timeWithinWindow( string $iso, int $now ): bool {
|
||||
$iso = trim( $iso );
|
||||
if ( 1 !== preg_match( '/^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:?\d{2})$/', $iso ) ) {
|
||||
return false;
|
||||
}
|
||||
$t = strtotime( $iso );
|
||||
return false !== $t && abs( $now - $t ) <= self::MAX_SKEW_SECONDS;
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ use StreamGoals\PayPal\DonationStore;
|
||||
use StreamGoals\PayPal\OrderValidator;
|
||||
use StreamGoals\PayPal\PayPalException;
|
||||
use StreamGoals\PayPal\RateLimiter;
|
||||
use StreamGoals\PayPal\RequestId;
|
||||
use StreamGoals\PayPal\ValidationException;
|
||||
use StreamGoals\PayPal\WpCache;
|
||||
|
||||
@@ -20,6 +21,9 @@ final class DonateController {
|
||||
|
||||
private const NS = 'streamgoals/v1';
|
||||
|
||||
/** Generous site-wide ceiling for POST /orders (all visitors together). */
|
||||
public const GLOBAL_ORDERS_PER_MIN = 120;
|
||||
|
||||
public static function register(): void {
|
||||
add_action( 'rest_api_init', array( self::class, 'routes' ) );
|
||||
add_shortcode( 'streamgoals_donate', array( self::class, 'shortcode' ) );
|
||||
@@ -61,6 +65,11 @@ final class DonateController {
|
||||
if ( $limited = self::limit( 'order', 10, 300 ) ) {
|
||||
return $limited;
|
||||
}
|
||||
// Site-wide ceiling (checked after the per-visitor limit so one noisy visitor cannot burn it): rotating addresses
|
||||
// (botnet, IPv6) still cannot flood the pending table or PayPal.
|
||||
if ( ! ( new RateLimiter( new WpCache() ) )->allow( 'order|*global*', self::GLOBAL_ORDERS_PER_MIN, 60 ) ) {
|
||||
return new \WP_Error( 'sg_rate_limited', __( 'Too many requests. Please wait a moment.', 'streamgoals-donate' ), array( 'status' => 429 ) );
|
||||
}
|
||||
$goal = DonationStore::goal( (int) $req->get_param( 'goal_id' ) );
|
||||
if ( ! $goal ) {
|
||||
return new \WP_Error( 'sg_goal_not_found', __( 'Goal not found.', 'streamgoals-donate' ), array( 'status' => 404 ) );
|
||||
@@ -76,7 +85,7 @@ final class DonateController {
|
||||
return new \WP_Error( 'sg_db', __( 'Could not start the donation. Please try again.', 'streamgoals-donate' ), array( 'status' => 500 ) );
|
||||
}
|
||||
try {
|
||||
$orderId = Client::fromSettings()->createOrder( new Money( $v['total_minor'], $v['currency'] ), (string) $id, 'sg-order-' . $id );
|
||||
$orderId = Client::fromSettings()->createOrder( new Money( $v['total_minor'], $v['currency'] ), (string) $id, self::request_id( 'order', $id ) );
|
||||
} catch ( PayPalException $e ) {
|
||||
DonationStore::delete( $id );
|
||||
error_log( 'StreamGoals: create order failed: ' . $e->getMessage() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions
|
||||
@@ -104,7 +113,7 @@ final class DonateController {
|
||||
}
|
||||
|
||||
try {
|
||||
$cap = Client::fromSettings()->captureOrder( $orderId, 'sg-capture-' . $row->id );
|
||||
$cap = Client::fromSettings()->captureOrder( $orderId, self::request_id( 'capture', (int) $row->id, (string) $row->created_at ) );
|
||||
} catch ( PayPalException $e ) {
|
||||
if ( 'ORDER_ALREADY_CAPTURED' === $e->paypalName ) {
|
||||
$fresh = DonationStore::find( (int) $row->id );
|
||||
@@ -120,7 +129,8 @@ final class DonateController {
|
||||
(string) $cap['capture_id'],
|
||||
$cap['amount'],
|
||||
array( DonationStore::class, 'mark_completed' ),
|
||||
array( DonationStore::class, 'notify_applied' )
|
||||
array( DonationStore::class, 'notify_applied' ),
|
||||
array( DonationStore::class, 'record_capture_id' )
|
||||
);
|
||||
if ( CaptureFinalizer::PENDING === $res ) {
|
||||
// PENDING capture (e-check/review) or amount mismatch: stay pending; the COMPLETED webhook finalizes it.
|
||||
@@ -302,7 +312,25 @@ final class DonateController {
|
||||
);
|
||||
}
|
||||
|
||||
/** Idempotency key, stable per donation (created_at is stored), not derivable from the id alone. */
|
||||
private static function request_id( string $kind, int $id, ?string $createdAt = null ): string {
|
||||
if ( null === $createdAt ) {
|
||||
$row = DonationStore::find( $id );
|
||||
$createdAt = $row ? (string) $row->created_at : '';
|
||||
}
|
||||
return RequestId::make( $kind, $id, $createdAt, wp_salt( 'auth' ) );
|
||||
}
|
||||
|
||||
private static function limit( string $bucket, int $max, int $window ): ?\WP_Error {
|
||||
$ip = self::visitor_bucket();
|
||||
if ( ( new RateLimiter( new WpCache() ) )->allow( $bucket . '|' . $ip, $max, $window ) ) {
|
||||
return null;
|
||||
}
|
||||
return new \WP_Error( 'sg_rate_limited', __( 'Too many requests. Please wait a moment.', 'streamgoals-donate' ), array( 'status' => 429 ) );
|
||||
}
|
||||
|
||||
/** Rate-limit bucket for the current visitor (resolved IP, IPv6 collapsed to its /64). */
|
||||
public static function visitor_bucket(): string {
|
||||
$server = array();
|
||||
foreach ( array( 'REMOTE_ADDR', 'HTTP_CF_CONNECTING_IP', 'HTTP_X_REAL_IP', 'HTTP_X_FORWARDED_FOR' ) as $k ) {
|
||||
if ( isset( $_SERVER[ $k ] ) && is_string( $_SERVER[ $k ] ) ) {
|
||||
@@ -310,9 +338,6 @@ final class DonateController {
|
||||
}
|
||||
}
|
||||
$ip = \StreamGoals\Core\ClientIp::resolve( $server, (string) \StreamGoals\Core\Settings::get( 'visitor_ip_header', 'auto' ), (array) apply_filters( 'streamgoals_cloudflare_ranges', array() ) );
|
||||
if ( ( new RateLimiter( new WpCache() ) )->allow( $bucket . '|' . $ip, $max, $window ) ) {
|
||||
return null;
|
||||
}
|
||||
return new \WP_Error( 'sg_rate_limited', __( 'Too many requests. Please wait a moment.', 'streamgoals-donate' ), array( 'status' => 429 ) );
|
||||
return \StreamGoals\Core\ClientIp::bucketKey( $ip );
|
||||
}
|
||||
}
|
||||
@@ -30,18 +30,27 @@ final class OverlayController {
|
||||
) );
|
||||
}
|
||||
|
||||
/** Lazily generates the token (32 hex chars) on first use. */
|
||||
public static function token(): string {
|
||||
/** Read-only. The stored token, or '' when none is configured. Safe for public requests. */
|
||||
public static function current_token(): string {
|
||||
$token = (string) Settings::get( 'overlay_token', '' );
|
||||
if ( strlen( $token ) < 32 ) {
|
||||
$token = bin2hex( random_bytes( 16 ) );
|
||||
Settings::update( array( 'overlay_token' => $token ) );
|
||||
}
|
||||
return $token;
|
||||
return strlen( $token ) >= 32 ? $token : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Token for the admin screens; generates one (a DB write) if missing, but ONLY for a user who can manage options.
|
||||
* Public requests must never reach this; they use verify_token(), which never writes.
|
||||
*/
|
||||
public static function ensure_token(): string {
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
return '';
|
||||
}
|
||||
return Settings::ensure_overlay_token();
|
||||
}
|
||||
|
||||
/** Read-only compare; with no token configured nothing matches (callers answer 404). */
|
||||
public static function verify_token( string $given ): bool {
|
||||
return hash_equals( self::token(), $given );
|
||||
$token = self::current_token();
|
||||
return '' !== $token && hash_equals( $token, $given );
|
||||
}
|
||||
|
||||
public static function handle( \WP_REST_Request $req ): \WP_REST_Response|\WP_Error {
|
||||
|
||||
@@ -7,7 +7,9 @@ use StreamGoals\Core\Settings;
|
||||
use StreamGoals\PayPal\CaptureFinalizer;
|
||||
use StreamGoals\PayPal\Client;
|
||||
use StreamGoals\PayPal\DonationStore;
|
||||
use StreamGoals\PayPal\RateLimiter;
|
||||
use StreamGoals\PayPal\WebhookEvent;
|
||||
use StreamGoals\PayPal\WebhookGuard;
|
||||
use StreamGoals\PayPal\WpCache;
|
||||
|
||||
/** PayPal webhook receiver. Signature is verified through PayPal's API before anything is trusted. */
|
||||
@@ -34,7 +36,14 @@ final class WebhookController {
|
||||
if ( '' === $webhookId ) {
|
||||
return new \WP_Error( 'sg_webhook_unconfigured', 'Webhook is not configured.', array( 'status' => 503 ) );
|
||||
}
|
||||
if ( ! WebhookGuard::admit( new RateLimiter( new WpCache() ), DonateController::visitor_bucket() ) ) {
|
||||
return new \WP_Error( 'sg_rate_limited', 'Too many requests.', array( 'status' => 429 ) );
|
||||
}
|
||||
$raw = $req->get_body();
|
||||
$bad = WebhookGuard::check( $req->get_headers(), $raw, time() );
|
||||
if ( null !== $bad ) {
|
||||
return new \WP_Error( $bad['code'], 'Rejected.', array( 'status' => $bad['status'] ) );
|
||||
}
|
||||
if ( ! Client::fromSettings()->verifyWebhook( $req->get_headers(), $raw, $webhookId ) ) {
|
||||
return new \WP_Error( 'sg_webhook_signature', 'Invalid signature.', array( 'status' => 401 ) );
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
declare( strict_types=1 );
|
||||
|
||||
namespace StreamGoals\Update;
|
||||
|
||||
/**
|
||||
* Pure decisions for redirects of token-bearing update requests. The access token may only ever travel to the pinned
|
||||
* host over https; a redirect is followed (by Updater) only when it stays on that host, with a small hop limit.
|
||||
*/
|
||||
final class RedirectPolicy {
|
||||
|
||||
public const MAX_HOPS = 3;
|
||||
|
||||
/**
|
||||
* @param string $current URL that answered with a 3xx.
|
||||
* @param string $location Its Location header (absolute or relative).
|
||||
* @param string $pinnedHost Configured update host (lower case).
|
||||
* @param int $hop Redirects already followed (0 for the first 3xx).
|
||||
* @return string|null Absolute URL to follow with the token, or null to stop and NOT follow.
|
||||
*/
|
||||
public static function nextUrl( string $current, string $location, string $pinnedHost, int $hop ): ?string {
|
||||
if ( $hop >= self::MAX_HOPS || '' === trim( $location ) || preg_match( '/[\x00-\x1f\x7f]/', $location ) ) {
|
||||
return null;
|
||||
}
|
||||
$abs = self::resolve( $current, trim( $location ) );
|
||||
if ( null === $abs || ! ReleaseParser::isAllowedUrl( $abs, $pinnedHost ) ) {
|
||||
return null;
|
||||
}
|
||||
$port = parse_url( $abs, PHP_URL_PORT );
|
||||
return null === $port || 443 === (int) $port ? $abs : null;
|
||||
}
|
||||
|
||||
/** Whether the token may be attached to a request for $url. */
|
||||
public static function tokenAllowed( string $url, string $pinnedHost ): bool {
|
||||
if ( ! ReleaseParser::isAllowedUrl( $url, $pinnedHost ) ) {
|
||||
return false;
|
||||
}
|
||||
$port = parse_url( $url, PHP_URL_PORT );
|
||||
return null === $port || 443 === (int) $port;
|
||||
}
|
||||
|
||||
private static function resolve( string $base, string $loc ): ?string {
|
||||
if ( 1 === preg_match( '#^[a-z][a-z0-9+.\-]*://#i', $loc ) ) {
|
||||
return $loc;
|
||||
}
|
||||
$b = parse_url( $base );
|
||||
if ( ! is_array( $b ) || ! isset( $b['scheme'], $b['host'] ) ) {
|
||||
return null;
|
||||
}
|
||||
if ( str_starts_with( $loc, '//' ) ) {
|
||||
return $b['scheme'] . ':' . $loc;
|
||||
}
|
||||
$origin = $b['scheme'] . '://' . $b['host'] . ( isset( $b['port'] ) ? ':' . $b['port'] : '' );
|
||||
if ( str_starts_with( $loc, '/' ) ) {
|
||||
return $origin . $loc;
|
||||
}
|
||||
$dir = preg_replace( '#/[^/]*$#', '/', (string) ( $b['path'] ?? '/' ) );
|
||||
return $origin . $dir . $loc;
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,7 @@ final class Updater {
|
||||
add_filter( 'pre_set_site_transient_update_plugins', array( self::class, 'inject' ) );
|
||||
add_filter( 'plugins_api', array( self::class, 'pluginInfo' ), 10, 3 );
|
||||
add_filter( 'http_request_args', array( self::class, 'authorize' ), 10, 2 );
|
||||
add_filter( 'pre_http_request', array( self::class, 'followSafely' ), 10, 3 );
|
||||
add_action( 'upgrader_process_complete', array( self::class, 'afterUpgrade' ), 10, 2 );
|
||||
add_action( 'admin_post_' . self::ACTION_CHECK, array( self::class, 'handleCheck' ) );
|
||||
}
|
||||
@@ -170,7 +171,7 @@ final class Updater {
|
||||
}
|
||||
$token = trim( (string) Settings::get( 'update_token', '' ) );
|
||||
$host = self::repo()['host'];
|
||||
if ( '' === $token || ! ReleaseParser::isAllowedUrl( $url, $host ) ) {
|
||||
if ( '' === $token || ! RedirectPolicy::tokenAllowed( $url, $host ) ) {
|
||||
return $args;
|
||||
}
|
||||
$args['headers'] = is_array( $args['headers'] ?? null ) ? $args['headers'] : array();
|
||||
@@ -181,6 +182,50 @@ final class Updater {
|
||||
return $args;
|
||||
}
|
||||
|
||||
private static bool $following = false;
|
||||
|
||||
/**
|
||||
* Token-bearing requests never let HTTP redirect handling carry the Authorization header anywhere: we send the
|
||||
* request with redirection disabled and follow a 3xx ourselves ONLY while it stays on the pinned host (max 3 hops).
|
||||
* A redirect to any other host is not followed at all, so the token cannot leak and the update fails closed.
|
||||
*/
|
||||
public static function followSafely( mixed $pre, mixed $args, mixed $url ): mixed {
|
||||
if ( false !== $pre || self::$following || ! is_array( $args ) || ! is_string( $url ) ) {
|
||||
return $pre;
|
||||
}
|
||||
$headers = is_array( $args['headers'] ?? null ) ? $args['headers'] : array();
|
||||
$has = false;
|
||||
foreach ( array_keys( $headers ) as $k ) {
|
||||
$has = $has || 'authorization' === strtolower( (string) $k );
|
||||
}
|
||||
$host = self::repo()['host'];
|
||||
if ( ! $has || ! RedirectPolicy::tokenAllowed( $url, $host ) ) {
|
||||
return $pre;
|
||||
}
|
||||
self::$following = true;
|
||||
try {
|
||||
$current = $url;
|
||||
$resp = null;
|
||||
for ( $hop = 0; $hop <= RedirectPolicy::MAX_HOPS; $hop++ ) {
|
||||
$a = $args;
|
||||
$a['redirection'] = 0;
|
||||
$resp = wp_remote_request( $current, $a );
|
||||
$code = is_wp_error( $resp ) ? 0 : (int) wp_remote_retrieve_response_code( $resp );
|
||||
if ( $code < 300 || $code >= 400 || is_wp_error( $resp ) ) {
|
||||
return $resp;
|
||||
}
|
||||
$next = RedirectPolicy::nextUrl( $current, (string) wp_remote_retrieve_header( $resp, 'location' ), $host, $hop );
|
||||
if ( null === $next ) {
|
||||
return $resp; // not followed: the caller sees the 3xx and fails
|
||||
}
|
||||
$current = $next;
|
||||
}
|
||||
return $resp;
|
||||
} finally {
|
||||
self::$following = false;
|
||||
}
|
||||
}
|
||||
|
||||
public static function afterUpgrade( mixed $upgrader, mixed $options ): void {
|
||||
if ( is_array( $options ) && ( $options['type'] ?? '' ) === 'plugin' ) {
|
||||
self::clearCache();
|
||||
|
||||
@@ -79,5 +79,29 @@ final class ClientIpTest extends TestCase {
|
||||
$this->assertSame( '198.18.0.5', ClientIp::resolve( $srv, ClientIp::MODE_AUTO ) );
|
||||
$this->assertSame( self::CLIENT, ClientIp::resolve( $srv, ClientIp::MODE_AUTO, array( '198.18.0.0/15' ) ) );
|
||||
}
|
||||
}
|
||||
|
||||
public function test_bucket_key_ipv4_unchanged_and_unknown_stays(): void {
|
||||
$this->assertSame( '203.0.113.9', ClientIp::bucketKey( '203.0.113.9' ) );
|
||||
$this->assertSame( 'unknown', ClientIp::bucketKey( 'unknown' ) );
|
||||
}
|
||||
|
||||
public function test_bucket_key_ipv6_collapses_to_slash_64(): void {
|
||||
$a = ClientIp::bucketKey( '2001:db8:abcd:12::1' );
|
||||
$b = ClientIp::bucketKey( '2001:0db8:abcd:0012:ffff:eeee:dddd:cccc' );
|
||||
$this->assertSame( $a, $b, 'same /64 must share a bucket' );
|
||||
$this->assertSame( '20010db8abcd0012/64', $a );
|
||||
$this->assertNotSame( $a, ClientIp::bucketKey( '2001:db8:abcd:13::1' ), 'different /64 must differ' );
|
||||
$this->assertNotSame( $a, ClientIp::bucketKey( '203.0.113.9' ) );
|
||||
}
|
||||
|
||||
public function test_bucket_key_ipv4_mapped_ipv6_maps_to_the_ipv4(): void {
|
||||
$this->assertSame( '203.0.113.9', ClientIp::bucketKey( '::ffff:203.0.113.9' ) );
|
||||
$this->assertSame( '203.0.113.9', ClientIp::bucketKey( '::ffff:cb00:7109' ) );
|
||||
}
|
||||
|
||||
public function test_bucket_key_garbage_becomes_unknown(): void {
|
||||
foreach ( array( '', 'garbage', '999.1.1.1', '<script>', 'fe80::1%eth0' ) as $g ) {
|
||||
$this->assertSame( 'unknown', ClientIp::bucketKey( $g ), $g );
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
declare( strict_types=1 );
|
||||
|
||||
namespace {
|
||||
if ( ! function_exists( 'get_option' ) ) {
|
||||
function get_option( $k, $d = false ) { return array_key_exists( $k, $GLOBALS['sg_test_opts'] ) ? $GLOBALS['sg_test_opts'][ $k ] : $d; }
|
||||
}
|
||||
if ( ! function_exists( 'update_option' ) ) {
|
||||
function update_option( $k, $v, $a = null ) { $GLOBALS['sg_test_opts'][ $k ] = $v; $GLOBALS['sg_test_writes'] = ( $GLOBALS['sg_test_writes'] ?? 0 ) + 1; return true; }
|
||||
}
|
||||
if ( ! function_exists( 'current_user_can' ) ) {
|
||||
function current_user_can( $cap ) { return (bool) ( $GLOBALS['sg_test_can'] ?? false ); }
|
||||
}
|
||||
}
|
||||
|
||||
namespace StreamGoals\Tests\Unit {
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use StreamGoals\Core\Settings;
|
||||
use StreamGoals\Rest\OverlayController;
|
||||
|
||||
/** L5: the overlay token is never created by an unauthenticated request. */
|
||||
final class OverlayTokenTest extends TestCase {
|
||||
|
||||
protected function setUp(): void {
|
||||
$GLOBALS['sg_test_opts'] = array();
|
||||
$GLOBALS['sg_test_writes'] = 0;
|
||||
$GLOBALS['sg_test_can'] = false;
|
||||
}
|
||||
|
||||
public function test_public_verify_with_no_token_is_false_and_writes_nothing(): void {
|
||||
$this->assertFalse( OverlayController::verify_token( 'anything' ) );
|
||||
$this->assertFalse( OverlayController::verify_token( '' ) );
|
||||
$this->assertSame( 0, $GLOBALS['sg_test_writes'], 'unauthenticated request must not write' );
|
||||
$this->assertSame( '', OverlayController::current_token() );
|
||||
}
|
||||
|
||||
public function test_ensure_token_refuses_without_capability(): void {
|
||||
$this->assertSame( '', OverlayController::ensure_token() );
|
||||
$this->assertSame( 0, $GLOBALS['sg_test_writes'] );
|
||||
}
|
||||
|
||||
public function test_admin_generates_once_and_public_compare_then_works(): void {
|
||||
$GLOBALS['sg_test_can'] = true;
|
||||
$t = OverlayController::ensure_token();
|
||||
$this->assertSame( 32, strlen( $t ) );
|
||||
$this->assertSame( $t, OverlayController::ensure_token(), 'stable' );
|
||||
$this->assertSame( 1, $GLOBALS['sg_test_writes'] );
|
||||
$GLOBALS['sg_test_can'] = false;
|
||||
$this->assertTrue( OverlayController::verify_token( $t ) );
|
||||
$this->assertFalse( OverlayController::verify_token( str_repeat( 'a', 32 ) ) );
|
||||
}
|
||||
|
||||
public function test_activation_helper_generates_when_missing_and_keeps_existing(): void {
|
||||
$t = Settings::ensure_overlay_token();
|
||||
$this->assertSame( 32, strlen( $t ) );
|
||||
$this->assertSame( $t, Settings::ensure_overlay_token() );
|
||||
$this->assertSame( 1, $GLOBALS['sg_test_writes'] );
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -131,7 +131,7 @@ final class PayPalClientTest extends TestCase {
|
||||
|
||||
public function test_verify_webhook_success_and_failure(): void {
|
||||
$headers = array(
|
||||
'paypal_transmission_id' => array( 'T1' ), 'PAYPAL-TRANSMISSION-TIME' => '2026-01-01T00:00:00Z',
|
||||
'paypal_transmission_id' => array( 'T1' ), 'PAYPAL-TRANSMISSION-TIME' => gmdate( 'Y-m-d\TH:i:s\Z' ),
|
||||
'paypal_transmission_sig' => 'SIG', 'paypal_cert_url' => 'https://api.paypal.com/cert', 'paypal_auth_algo' => 'SHA256withRSA',
|
||||
);
|
||||
$raw = '{"id":"WH-1","event_type":"PAYMENT.CAPTURE.COMPLETED"}';
|
||||
@@ -156,10 +156,49 @@ final class PayPalClientTest extends TestCase {
|
||||
|
||||
$c = $this->client();
|
||||
$this->http->queue = array( new TransportException( 'boom' ) );
|
||||
$headers['paypal_cert_url'] = 'x';
|
||||
$headers['paypal_cert_url'] = 'https://api.paypal.com/cert';
|
||||
$this->assertFalse( $c->verifyWebhook( $headers, $raw, 'WHID' ) );
|
||||
}
|
||||
|
||||
/** @return array<string,string> */
|
||||
private function goodWebhookHeaders(): array {
|
||||
return array(
|
||||
'paypal-transmission-id' => 'T1', 'paypal-transmission-time' => gmdate( 'Y-m-d\TH:i:s\Z' ),
|
||||
'paypal-transmission-sig' => 'SIG', 'paypal-cert-url' => 'https://api.paypal.com/v1/notifications/certs/CERT-1', 'paypal-auth-algo' => 'SHA256withRSA',
|
||||
);
|
||||
}
|
||||
|
||||
/** M2: every locally rejectable request must be refused WITHOUT any outbound call (not even the OAuth token). */
|
||||
public function test_verify_webhook_makes_no_outbound_call_for_locally_rejectable_requests(): void {
|
||||
$raw = '{"id":"WH-1","event_type":"PAYMENT.CAPTURE.COMPLETED"}';
|
||||
$cases = array();
|
||||
foreach ( array_keys( $this->goodWebhookHeaders() ) as $missing ) {
|
||||
$h = $this->goodWebhookHeaders();
|
||||
unset( $h[ $missing ] );
|
||||
$cases[ 'missing ' . $missing ] = array( $h, $raw );
|
||||
}
|
||||
foreach ( array( 'http://api.paypal.com/c', 'https://evil.example/c', 'https://paypal.com.evil.example/c', 'https://evilpaypal.com/c', 'https://api.paypal.com@evil.example/c', 'https://user@api.paypal.com/c', 'https://api.paypal.com:8443/c', 'ftp://api.paypal.com/c', 'not a url' ) as $u ) {
|
||||
$cases[ 'cert ' . $u ] = array( array( 'paypal-cert-url' => $u ) + $this->goodWebhookHeaders(), $raw );
|
||||
}
|
||||
foreach ( array( gmdate( 'Y-m-d\TH:i:s\Z', time() - 7200 ), gmdate( 'Y-m-d\TH:i:s\Z', time() + 7200 ), 'yesterday', '', '0' ) as $t ) {
|
||||
$cases[ 'time ' . $t ] = array( array( 'paypal-transmission-time' => $t ) + $this->goodWebhookHeaders(), $raw );
|
||||
}
|
||||
$cases['body too large'] = array( $this->goodWebhookHeaders(), '{"x":"' . str_repeat( 'a', 70000 ) . '"}' );
|
||||
|
||||
foreach ( $cases as $name => [ $headers, $body ] ) {
|
||||
$c = $this->client();
|
||||
$this->assertFalse( $c->verifyWebhook( $headers, $body, 'WHID' ), $name );
|
||||
$this->assertCount( 0, $this->http->calls, "outbound call made for: $name" );
|
||||
}
|
||||
}
|
||||
|
||||
public function test_verify_webhook_still_calls_paypal_for_a_well_formed_request(): void {
|
||||
$c = $this->client();
|
||||
$this->http->queue = array( $this->token(), FakeTransport::json( 200, array( 'verification_status' => 'SUCCESS' ) ) );
|
||||
$this->assertTrue( $c->verifyWebhook( $this->goodWebhookHeaders(), '{"id":"WH-1"}', 'WHID' ) );
|
||||
$this->assertCount( 2, $this->http->calls );
|
||||
}
|
||||
|
||||
public function test_create_webhook_registers_only_completed_event(): void {
|
||||
$c = $this->client();
|
||||
$this->http->queue = array( $this->token(), FakeTransport::json( 201, array( 'id' => 'WH77' ) ) );
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
declare( strict_types=1 );
|
||||
|
||||
namespace StreamGoals\Tests\Unit;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use StreamGoals\Core\Maintenance;
|
||||
use StreamGoals\Core\Money;
|
||||
use StreamGoals\PayPal\CaptureFinalizer;
|
||||
use StreamGoals\PayPal\WebhookEvent;
|
||||
|
||||
/** M5: a PENDING PayPal capture must keep its capture id, survive the purge, and be finalized exactly once by the webhook. */
|
||||
final class PendingCaptureFlowTest extends TestCase {
|
||||
|
||||
/** @var array<int,array<string,mixed>> */
|
||||
private array $rows = array();
|
||||
private int $notified = 0;
|
||||
|
||||
protected function setUp(): void {
|
||||
foreach ( array( 'HOUR_IN_SECONDS' => 3600, 'DAY_IN_SECONDS' => 86400 ) as $k => $v ) {
|
||||
if ( ! defined( $k ) ) {
|
||||
define( $k, $v );
|
||||
}
|
||||
}
|
||||
$this->notified = 0;
|
||||
$this->rows = array(
|
||||
1 => array( 'id' => 1, 'goal_id' => 3, 'status' => 'pending', 'total_minor' => 1099, 'currency' => 'USD', 'paypal_order_id' => 'ORD1', 'paypal_capture_id' => null, 'created_at' => gmdate( 'Y-m-d H:i:s', time() - 30 * 86400 ) ),
|
||||
);
|
||||
}
|
||||
|
||||
private function record(): callable {
|
||||
return function ( int $id, string $cap ): bool {
|
||||
if ( 'pending' === $this->rows[ $id ]['status'] && empty( $this->rows[ $id ]['paypal_capture_id'] ) ) {
|
||||
$this->rows[ $id ]['paypal_capture_id'] = $cap;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
}
|
||||
|
||||
private function mark(): callable {
|
||||
return function ( int $id, string $cap ): bool {
|
||||
if ( 'pending' !== $this->rows[ $id ]['status'] ) {
|
||||
return false;
|
||||
}
|
||||
$this->rows[ $id ]['status'] = 'completed';
|
||||
$this->rows[ $id ]['paypal_capture_id'] = $cap;
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
private function notify(): callable {
|
||||
return function ( int $id ): void { ++$this->notified; };
|
||||
}
|
||||
|
||||
private function purge(): void {
|
||||
foreach ( $this->rows as $id => $r ) {
|
||||
if ( Maintenance::isPurgeable( $r, time() ) ) {
|
||||
unset( $this->rows[ $id ] );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function webhook(): string {
|
||||
$ev = WebhookEvent::fromArray( array(
|
||||
'id' => 'WH-1', 'event_type' => 'PAYMENT.CAPTURE.COMPLETED',
|
||||
'resource' => array( 'id' => 'CAP1', 'custom_id' => '1', 'amount' => array( 'value' => '10.99', 'currency_code' => 'USD' ), 'supplementary_data' => array( 'related_ids' => array( 'order_id' => 'ORD1' ) ) ),
|
||||
) );
|
||||
$row = $this->rows[ (int) $ev->customId ] ?? null;
|
||||
if ( ! $row ) {
|
||||
foreach ( $this->rows as $r ) {
|
||||
if ( $r['paypal_capture_id'] === $ev->captureId || $r['paypal_order_id'] === $ev->orderId ) {
|
||||
$row = $r;
|
||||
}
|
||||
}
|
||||
}
|
||||
return $row ? CaptureFinalizer::finalize( $row, 'COMPLETED', $ev->captureId, $ev->amount, $this->mark(), $this->notify() ) : 'ignored';
|
||||
}
|
||||
|
||||
public function test_pending_capture_is_stored_survives_purge_and_webhook_finalizes_once(): void {
|
||||
$res = CaptureFinalizer::finalize( $this->rows[1], 'PENDING', 'CAP1', new Money( 1099, 'USD' ), $this->mark(), $this->notify(), $this->record() );
|
||||
$this->assertSame( CaptureFinalizer::PENDING, $res );
|
||||
$this->assertSame( 'CAP1', $this->rows[1]['paypal_capture_id'], 'PENDING capture id must be persisted' );
|
||||
$this->assertSame( 'pending', $this->rows[1]['status'] );
|
||||
|
||||
$this->purge();
|
||||
$this->assertArrayHasKey( 1, $this->rows, 'row with capture evidence must survive the purge even when old' );
|
||||
|
||||
$this->assertSame( CaptureFinalizer::COMPLETED, $this->webhook() );
|
||||
$this->assertSame( 'completed', $this->rows[1]['status'] );
|
||||
$this->assertSame( CaptureFinalizer::DUPLICATE, $this->webhook(), 'replayed webhook is a no-op' );
|
||||
$this->assertSame( 1, $this->notified, 'side effects exactly once' );
|
||||
}
|
||||
|
||||
public function test_without_recording_the_old_behaviour_would_lose_the_row(): void {
|
||||
CaptureFinalizer::finalize( $this->rows[1], 'PENDING', 'CAP1', new Money( 1099, 'USD' ), $this->mark(), $this->notify() );
|
||||
$this->assertNull( $this->rows[1]['paypal_capture_id'] );
|
||||
$this->purge();
|
||||
$this->assertArrayNotHasKey( 1, $this->rows );
|
||||
}
|
||||
|
||||
public function test_amount_mismatch_also_records_capture_id(): void {
|
||||
CaptureFinalizer::finalize( $this->rows[1], 'COMPLETED', 'CAP1', new Money( 5, 'USD' ), $this->mark(), $this->notify(), $this->record() );
|
||||
$this->assertSame( 'CAP1', $this->rows[1]['paypal_capture_id'] );
|
||||
$this->assertSame( 'pending', $this->rows[1]['status'] );
|
||||
}
|
||||
|
||||
public function test_no_capture_id_means_nothing_recorded(): void {
|
||||
$called = false;
|
||||
CaptureFinalizer::finalize( $this->rows[1], 'PENDING', '', null, $this->mark(), $this->notify(), function () use ( &$called ) { $called = true; return true; } );
|
||||
$this->assertFalse( $called );
|
||||
}
|
||||
|
||||
public function test_is_purgeable_rules(): void {
|
||||
$old = gmdate( 'Y-m-d H:i:s', time() - 8 * 86400 );
|
||||
$new = gmdate( 'Y-m-d H:i:s', time() - 6 * 86400 );
|
||||
$b = array( 'status' => 'pending', 'goal_id' => 1, 'paypal_capture_id' => null, 'created_at' => $old );
|
||||
$this->assertTrue( Maintenance::isPurgeable( $b, time() ) );
|
||||
$this->assertTrue( Maintenance::isPurgeable( array( 'paypal_capture_id' => '' ) + $b, time() ) );
|
||||
$this->assertFalse( Maintenance::isPurgeable( array( 'created_at' => $new ) + $b, time() ) );
|
||||
$this->assertFalse( Maintenance::isPurgeable( array( 'paypal_capture_id' => 'X' ) + $b, time() ) );
|
||||
$this->assertFalse( Maintenance::isPurgeable( array( 'status' => 'completed' ) + $b, time() ) );
|
||||
$this->assertFalse( Maintenance::isPurgeable( array( 'goal_id' => 0 ) + $b, time() ) );
|
||||
$this->assertFalse( Maintenance::isPurgeable( array( 'created_at' => 'garbage' ) + $b, time() ) );
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
declare( strict_types=1 );
|
||||
|
||||
namespace StreamGoals\Tests\Unit;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use StreamGoals\PayPal\RequestId;
|
||||
|
||||
final class RequestIdTest extends TestCase {
|
||||
|
||||
public function test_stable_per_donation_and_within_paypal_limit(): void {
|
||||
$a = RequestId::make( 'order', 42, '2026-01-01 10:00:00', 'salt' );
|
||||
$this->assertSame( $a, RequestId::make( 'order', 42, '2026-01-01 10:00:00', 'salt' ) );
|
||||
$this->assertLessThanOrEqual( 108, strlen( $a ) );
|
||||
$this->assertMatchesRegularExpression( '/^sg-order-[0-9a-f]{64}$/', $a );
|
||||
}
|
||||
|
||||
public function test_reused_auto_increment_id_gets_a_different_key(): void {
|
||||
$this->assertNotSame( RequestId::make( 'order', 42, '2026-01-01 10:00:00', 's' ), RequestId::make( 'order', 42, '2026-01-02 10:00:00', 's' ) );
|
||||
}
|
||||
|
||||
public function test_not_derivable_without_the_salt_and_kinds_differ(): void {
|
||||
$this->assertNotSame( RequestId::make( 'order', 42, 't', 'salt1' ), RequestId::make( 'order', 42, 't', 'salt2' ) );
|
||||
$this->assertNotSame( RequestId::make( 'order', 42, 't', 's' ), RequestId::make( 'capture', 42, 't', 's' ) );
|
||||
$this->assertStringNotContainsString( 'sg-order-42', RequestId::make( 'order', 42, 't', 's' ) );
|
||||
}
|
||||
}
|
||||
@@ -58,12 +58,19 @@ final class UninstallerTest extends TestCase {
|
||||
$dyn = array_merge( $dyn, $b[1] );
|
||||
preg_match_all( "/'(sg_rl_)'\s*\./", $src, $c );
|
||||
$dyn = array_merge( $dyn, $c[1] );
|
||||
// Cache-seam keys (WpCache -> transients): $key = 'sg_xxx_' . md5(...)
|
||||
preg_match_all( "/'(sg_\w+?_)'\s*\.\s*md5/", $src, $d );
|
||||
$dyn = array_merge( $dyn, $d[1] );
|
||||
}
|
||||
$this->assertContains( 'sg_update_release', $fixed, 'scan sanity check' );
|
||||
$this->assertSame( array(), array_values( array_diff( array_unique( $fixed ), Uninstaller::site_transients() ) ) );
|
||||
$this->assertSame( array(), array_values( array_diff( array_unique( $dyn ), Uninstaller::transient_prefixes() ) ) );
|
||||
$this->assertContains( 'sg_rl_', Uninstaller::transient_prefixes() );
|
||||
$this->assertContains( 'sg_notice_', Uninstaller::transient_prefixes() );
|
||||
$this->assertContains( 'sg_pp_token_', Uninstaller::transient_prefixes(), 'cached PayPal access token' );
|
||||
$this->assertContains( 'sg_wh_', Uninstaller::transient_prefixes(), 'webhook de-dupe markers' );
|
||||
$this->assertContains( 'sg_pp_token_', $dyn, 'scan sanity check' );
|
||||
$this->assertContains( 'sg_wh_', $dyn, 'scan sanity check' );
|
||||
$this->assertSame( Maintenance::HOOK, Uninstaller::CRON_HOOK );
|
||||
}
|
||||
|
||||
@@ -104,10 +111,10 @@ final class UninstallerTest extends TestCase {
|
||||
public function test_dynamic_transient_cleanup_only_targets_plugin_prefixes(): void {
|
||||
$db = $this->fakeDb();
|
||||
Uninstaller::delete_dynamic_transients( $db );
|
||||
$this->assertCount( 4, $db->queries );
|
||||
$this->assertCount( 2 * count( Uninstaller::transient_prefixes() ), $db->queries );
|
||||
foreach ( $db->queries as $q ) {
|
||||
$this->assertStringStartsWith( 'DELETE FROM wp7_options WHERE option_name LIKE', $q );
|
||||
$this->assertMatchesRegularExpression( '/_transient_(timeout_)?sg_(rl|notice)_%/', str_replace( '\\', '', $q ) );
|
||||
$this->assertMatchesRegularExpression( '/_transient_(timeout_)?sg_(rl|notice|pp_token|wh)_%/', str_replace( '\\', '', $q ) );
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
declare( strict_types=1 );
|
||||
|
||||
namespace StreamGoals\Tests\Unit;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use StreamGoals\Update\RedirectPolicy;
|
||||
|
||||
final class UpdateRedirectPolicyTest extends TestCase {
|
||||
|
||||
private const H = 'repo.example.net';
|
||||
|
||||
public function test_same_host_redirects_are_followed_relative_and_absolute(): void {
|
||||
$cur = 'https://repo.example.net/a/b/c.zip';
|
||||
$this->assertSame( 'https://repo.example.net/x/y.zip', RedirectPolicy::nextUrl( $cur, 'https://repo.example.net/x/y.zip', self::H, 0 ) );
|
||||
$this->assertSame( 'https://repo.example.net/x/y.zip', RedirectPolicy::nextUrl( $cur, '/x/y.zip', self::H, 0 ) );
|
||||
$this->assertSame( 'https://repo.example.net/a/b/d.zip', RedirectPolicy::nextUrl( $cur, 'd.zip', self::H, 1 ) );
|
||||
$this->assertSame( 'https://repo.example.net/q', RedirectPolicy::nextUrl( $cur, '//repo.example.net/q', self::H, 0 ) );
|
||||
}
|
||||
|
||||
public function test_other_hosts_and_downgrades_are_never_followed(): void {
|
||||
$cur = 'https://repo.example.net/a.zip';
|
||||
foreach ( array( 'https://evil.example/a.zip', 'http://repo.example.net/a.zip', '//evil.example/a', 'https://repo.example.net.evil.example/a', 'https://repo.example.net@evil.example/a', 'https://user@repo.example.net/a', 'https://repo.example.net:8443/a', 'ftp://repo.example.net/a', '', "/ok\r\nX: y" ) as $loc ) {
|
||||
$this->assertNull( RedirectPolicy::nextUrl( $cur, $loc, self::H, 0 ), $loc );
|
||||
}
|
||||
}
|
||||
|
||||
public function test_hop_limit(): void {
|
||||
$this->assertNotNull( RedirectPolicy::nextUrl( 'https://repo.example.net/a', '/b', self::H, 2 ) );
|
||||
$this->assertNull( RedirectPolicy::nextUrl( 'https://repo.example.net/a', '/b', self::H, 3 ) );
|
||||
}
|
||||
|
||||
public function test_token_allowed_only_for_https_pinned_host_default_port(): void {
|
||||
$this->assertTrue( RedirectPolicy::tokenAllowed( 'https://repo.example.net/x', self::H ) );
|
||||
$this->assertTrue( RedirectPolicy::tokenAllowed( 'https://REPO.example.net:443/x', self::H ) );
|
||||
$this->assertFalse( RedirectPolicy::tokenAllowed( 'https://repo.example.net:8443/x', self::H ) );
|
||||
$this->assertFalse( RedirectPolicy::tokenAllowed( 'http://repo.example.net/x', self::H ) );
|
||||
$this->assertFalse( RedirectPolicy::tokenAllowed( 'https://cdn.example.net/x', self::H ) );
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
declare( strict_types=1 );
|
||||
|
||||
namespace StreamGoals\Tests\Unit;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use StreamGoals\PayPal\ArrayCache;
|
||||
use StreamGoals\PayPal\RateLimiter;
|
||||
use StreamGoals\PayPal\WebhookGuard;
|
||||
|
||||
final class WebhookGuardTest extends TestCase {
|
||||
|
||||
public function test_per_ip_limit_then_429_and_other_ips_unaffected(): void {
|
||||
$l = new RateLimiter( new ArrayCache() );
|
||||
for ( $i = 0; $i < WebhookGuard::IP_LIMIT; $i++ ) {
|
||||
$this->assertTrue( WebhookGuard::admit( $l, '203.0.113.1' ) );
|
||||
}
|
||||
$this->assertFalse( WebhookGuard::admit( $l, '203.0.113.1' ), 'over the per-IP limit' );
|
||||
$this->assertTrue( WebhookGuard::admit( $l, '203.0.113.2' ), 'PayPal\'s other addresses keep working' );
|
||||
}
|
||||
|
||||
public function test_global_cap(): void {
|
||||
$l = new RateLimiter( new ArrayCache() );
|
||||
$ok = 0;
|
||||
for ( $i = 0; $i < WebhookGuard::GLOBAL_LIMIT + 50; $i++ ) {
|
||||
$ok += WebhookGuard::admit( $l, '10.0.' . intdiv( $i, 100 ) . '.' . ( $i % 100 ) ) ? 1 : 0; // every request from a fresh bucket
|
||||
}
|
||||
$this->assertSame( WebhookGuard::GLOBAL_LIMIT, $ok );
|
||||
}
|
||||
|
||||
public function test_rejections_have_the_expected_status(): void {
|
||||
$good = array( 'paypal-transmission-id' => 'T', 'paypal-transmission-time' => gmdate( 'Y-m-d\TH:i:s\Z' ), 'paypal-transmission-sig' => 'S', 'paypal-cert-url' => 'https://api.sandbox.paypal.com/c', 'paypal-auth-algo' => 'A' );
|
||||
$this->assertNull( WebhookGuard::check( $good, '{}', time() ) );
|
||||
$this->assertSame( 413, WebhookGuard::check( $good, str_repeat( 'a', 65537 ), time() )['status'] );
|
||||
$this->assertNull( WebhookGuard::check( $good, str_repeat( 'a', 65536 ), time() ) );
|
||||
$this->assertSame( 401, WebhookGuard::check( array_diff_key( $good, array( 'paypal-auth-algo' => 1 ) ), '{}', time() )['status'] );
|
||||
$this->assertSame( 401, WebhookGuard::check( array( 'paypal-cert-url' => 'https://evil.test/' ) + $good, '{}', time() )['status'] );
|
||||
$this->assertSame( 401, WebhookGuard::check( array( 'paypal-transmission-time' => '2020-01-01T00:00:00Z' ) + $good, '{}', time() )['status'] );
|
||||
$this->assertSame( 401, WebhookGuard::check( array( 'paypal-transmission-time' => 'garbage' ) + $good, '{}', time() )['status'] );
|
||||
}
|
||||
|
||||
public function test_time_window_boundaries_and_offsets(): void {
|
||||
$now = 1800000000;
|
||||
$this->assertTrue( WebhookGuard::timeWithinWindow( gmdate( 'Y-m-d\TH:i:s\Z', $now - 3600 ), $now ) );
|
||||
$this->assertFalse( WebhookGuard::timeWithinWindow( gmdate( 'Y-m-d\TH:i:s\Z', $now - 3601 ), $now ) );
|
||||
$this->assertFalse( WebhookGuard::timeWithinWindow( gmdate( 'Y-m-d\TH:i:s\Z', $now + 3601 ), $now ) );
|
||||
$this->assertTrue( WebhookGuard::timeWithinWindow( gmdate( 'Y-m-d\TH:i:s', $now + 3600 ) . '+01:00', $now ) );
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user