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
Test and Release / test (push) Successful in 1m23s
Test and Release / release (push) Successful in 5s

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
shadowdaoandClaude Sonnet 5.5 committed 2026-11-15 14:07:44 -08:00
1 parent 68bd917660
commit 473fe192cd
28 files changed
+747 -34

No files matched your search

+13
View File
@@ -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:
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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>';
}
}
+1 -1
View File
@@ -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' => '?' );
+23
View File
@@ -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;
+1
View File
@@ -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. */
+19
View File
@@ -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;
+10
View File
@@ -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' ) );
}
+2 -2
View File
@@ -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. */
+13 -3
View File
@@ -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;
}
}
+4 -3
View File
@@ -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 ) {
+14
View File
@@ -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 );
+20
View File
@@ -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 );
}
}
+81
View File
@@ -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;
}
}
+32 -7
View File
@@ -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 );
}
}
+17 -8
View File
@@ -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 {
+9
View File
@@ -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 ) );
}
+60
View File
@@ -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;
}
}
+46 -1
View File
@@ -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();
+25 -1
View File
@@ -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 );
}
}
}
+61
View File
@@ -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'] );
}
}
}
+41 -2
View File
@@ -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' ) ) );
+126
View File
@@ -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() ) );
}
}
+27
View File
@@ -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' ) );
}
}
+9 -2
View File
@@ -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 ) );
}
}
}
+40
View File
@@ -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 ) );
}
}
+49
View File
@@ -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 ) );
}
}