Stop your website screening
Stop the screening of this website:
' . esc_html( $target ) . '
No new screening will start after you press Stop. A check that has already started may finish its current requests. Opening this page alone changes nothing.
diff --git a/docs/WORDPRESS.md b/docs/WORDPRESS.md index 83771f8..291ac6d 100644 --- a/docs/WORDPRESS.md +++ b/docs/WORDPRESS.md @@ -30,6 +30,13 @@ flowchart LR - Confirmation tokens are hashed at rest, short lived, and consumed by an explicit POST. GET requests, including email-security prefetches, cannot confirm. Mailbox control is not independent proof of domain ownership. +- The confirmation email also carries a separate Stop link, also hashed at + rest. GET only shows a Stop form; a nonce-protected POST cancels the job + (`customer_stopped`) under the queue lock. Claims and starts then refuse it + and late results are rejected. A check the worker already started may finish + its current requests: there is not yet a per-request admission guard. Stopping + a finished job changes nothing and does not block its report. Schema 2 adds + `stop_token_hash`; the upgrade runs automatically on `init`. - Jobs have atomic claims. A claim that was never started can be recovered; a started job with an uncertain outcome is not automatically scanned again. A lost result acknowledgement retries the stored receipt, not the scan. diff --git a/integrations/wordpress/ct-scan.php b/integrations/wordpress/ct-scan.php index 3fbc110..9c31e4f 100644 --- a/integrations/wordpress/ct-scan.php +++ b/integrations/wordpress/ct-scan.php @@ -17,7 +17,7 @@ } if ( ! defined( 'CT_SCAN_SCHEMA_VERSION' ) ) { - define( 'CT_SCAN_SCHEMA_VERSION', '1' ); + define( 'CT_SCAN_SCHEMA_VERSION', '2' ); } /** Load the secret-bearing settings without ever rendering their values. */ @@ -107,6 +107,7 @@ function ct_scan_maybe_install_schema() { status varchar(32) NOT NULL, created_at datetime NOT NULL, confirmation_token_hash char(64) NULL, + stop_token_hash char(64) NULL, confirmation_expires_at datetime NULL, confirmation_email_status varchar(16) NOT NULL DEFAULT 'pending', confirmed_at datetime NULL, @@ -132,7 +133,8 @@ function ct_scan_maybe_install_schema() { KEY status_created (status,created_at), KEY scope_created (scope_hash,created_at), KEY authorization_expires_at (authorization_expires_at), - KEY confirmation_token_hash (confirmation_token_hash) + KEY confirmation_token_hash (confirmation_token_hash), + KEY stop_token_hash (stop_token_hash) ) {$charset};"; dbDelta( $sql ); $sql = "CREATE TABLE {$nonces} ( @@ -143,7 +145,7 @@ function ct_scan_maybe_install_schema() { ) {$charset};"; dbDelta( $sql ); $required = array( - $jobs => array( 'job_id', 'request_id', 'target_origin', 'scope_hash', 'terms_version', 'terms_hash', 'terms_accepted_at', 'authorization_expires_at', 'status', 'lease_hash', 'result_json', 'receipt_hash', 'report_delivered_at' ), + $jobs => array( 'job_id', 'request_id', 'target_origin', 'scope_hash', 'terms_version', 'terms_hash', 'terms_accepted_at', 'authorization_expires_at', 'status', 'lease_hash', 'result_json', 'receipt_hash', 'report_delivered_at', 'stop_token_hash' ), $nonces => array( 'nonce_hash', 'expires_at' ), ); foreach ( $required as $table => $columns ) { @@ -382,6 +384,8 @@ function ct_scan_on_request_ready( $request_id, $fields ) { } $config = ct_scan_config(); $token = bin2hex( random_bytes( 32 ) ); + // Separate long-lived token for the customer's Stop link; only its hash is stored. + $stop_token = bin2hex( random_bytes( 32 ) ); $now = time(); $ttl = max( 1, min( 168, absint( $config['confirmation_ttl_hours'] ) ) ); $created = gmdate( 'Y-m-d H:i:s', $now ); @@ -434,8 +438,9 @@ function ct_scan_on_request_ready( $request_id, $fields ) { 'created_at' => $created, 'confirmation_token_hash' => hash( 'sha256', $token ), 'confirmation_expires_at' => $confirm_expires, + 'stop_token_hash' => hash( 'sha256', $stop_token ), ), - array( '%d', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s' ) + array( '%d', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s' ) ); if ( 1 !== $inserted ) { $existing = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$jobs} WHERE request_id = %d LIMIT 1", $request_id ) ); @@ -461,10 +466,16 @@ function ct_scan_on_request_ready( $request_id, $fields ) { array( 'action' => 'ct_scan_confirm', 'token' => $token ), admin_url( 'admin-post.php' ) ); + $stop_url = add_query_arg( + array( 'action' => 'ct_scan_stop', 'stop' => $stop_token ), + admin_url( 'admin-post.php' ) + ); $subject = 'Confirm your authorized PHP2Shell screening'; $message = "A request was saved to screen the website you authorized.\n\n" . "To confirm and allow one passive check of " . wp_parse_url( $accepted['target'], PHP_URL_HOST ) . ", open this link and press Confirm. Opening the link alone does not authorize a scan:\n\n" - . $url . "\n\nThis confirmation expires in {$ttl} hours. If you did not submit this request, ignore this message. Mailbox confirmation shows control of this email address; it does not independently prove domain ownership.\n"; + . $url . "\n\nThis confirmation expires in {$ttl} hours. If you did not submit this request, ignore this message. Mailbox confirmation shows control of this email address; it does not independently prove domain ownership.\n\n" + . "To stop this screening at any time, open this link and press Stop. Opening the link alone changes nothing:\n\n" + . $stop_url . "\n"; $mail_status = 'unknown'; $pending = $wpdb->update( $jobs, array( 'confirmation_email_status' => 'unknown' ), array( 'job_id' => $job_id, 'confirmation_email_status' => 'pending' ), array( '%s' ), array( '%d', '%s' ) ); if ( 1 !== $pending ) { @@ -685,6 +696,103 @@ function ct_scan_row_by_confirmation_hash( $hash ) { add_action( 'admin_post_nopriv_ct_scan_confirm', 'ct_scan_confirmation_handler' ); add_action( 'admin_post_ct_scan_confirm', 'ct_scan_confirmation_handler' ); +function ct_scan_stop_token_from_request( $source ) { + if ( ! isset( $source['stop'] ) || ! is_string( $source['stop'] ) ) { + return ''; + } + $token = wp_unslash( $source['stop'] ); + return preg_match( '/\A[a-f0-9]{64}\z/i', $token ) ? strtolower( $token ) : ''; +} + +function ct_scan_row_by_stop_hash( $hash ) { + global $wpdb; + $table = ct_scan_jobs_table(); + return $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$table} WHERE stop_token_hash = %s LIMIT 1", $hash ) ); +} + +/** + * Customer stop: request-bound and idempotent, serialized with claims, starts and results + * by the queue lock. A cancelled job is rejected by the start and result endpoints. A check + * the worker already started may finish its current requests; no new screening starts. + * Returns 'stopped', 'already_stopped', 'already_finished', or a WP_Error. + */ +function ct_scan_customer_stop( $job_id, $token ) { + if ( ! ct_scan_lock_acquire() ) { + return new WP_Error( 'queue_busy', 'The request is temporarily busy. Please try again.' ); + } + global $wpdb; + $table = ct_scan_jobs_table(); + try { + $row = ct_scan_row( $job_id ); + if ( ! $row || empty( $row->stop_token_hash ) || '' === $token || ! hash_equals( $row->stop_token_hash, hash( 'sha256', $token ) ) ) { + return new WP_Error( 'stop_link_invalid', 'This stop link is invalid.' ); + } + if ( 'cancelled' === $row->status ) { + return 'already_stopped'; + } + if ( ct_scan_terminal_status( $row->status ) ) { + return 'already_finished'; + } + $now = ct_scan_utc_now(); + $updated = $wpdb->update( + $table, + array( 'status' => 'cancelled', 'error_code' => 'customer_stopped', 'finished_at' => $now, 'confirmation_token_hash' => null, 'confirmation_expires_at' => null, 'lease_hash' => null, 'lease_until' => null ), + array( 'job_id' => (int) $row->job_id, 'status' => $row->status ), + null, + array( '%d', '%s' ) + ); + if ( 1 !== $updated ) { + return new WP_Error( 'stop_write_failed', 'The stop could not be recorded. Please try again or contact support@complete.tech.' ); + } + ct_scan_mark_request_status( (int) $row->request_id, 'cancelled', 'customer_stopped' ); + } finally { + ct_scan_lock_release(); + } + return 'stopped'; +} + +function ct_scan_stop_markup( $job_id, $token, $nonce, $target ) { + $action = esc_url( admin_url( 'admin-post.php' ) ); + return '
Stop the screening of this website:
' . esc_html( $target ) . '
No new screening will start after you press Stop. A check that has already started may finish its current requests. Opening this page alone changes nothing.