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 website screening

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.

'; +} + +function ct_scan_stop_handler() { + $method = isset( $_SERVER['REQUEST_METHOD'] ) && is_string( $_SERVER['REQUEST_METHOD'] ) ? strtoupper( $_SERVER['REQUEST_METHOD'] ) : ''; + if ( 'GET' === $method ) { + $token = ct_scan_stop_token_from_request( $_GET ); + $row = '' === $token ? null : ct_scan_row_by_stop_hash( hash( 'sha256', $token ) ); + if ( ! $row ) { + ct_scan_send_confirmation_page( 'This stop link is invalid.', 404 ); + } + if ( ct_scan_terminal_status( $row->status ) ) { + ct_scan_send_confirmation_page( 'cancelled' === $row->status ? 'This screening is already stopped.' : 'This screening has already finished. Nothing further will run.' ); + } + $nonce = wp_create_nonce( 'ct_scan_stop_' . (int) $row->job_id . '_' . $token ); + ct_scan_send_confirmation_page( '', 200, ct_scan_stop_markup( (int) $row->job_id, $token, $nonce, $row->target_origin ) ); + } + if ( 'POST' !== $method ) { + ct_scan_send_confirmation_page( 'Use the stop form to continue.', 405 ); + } + $token = ct_scan_stop_token_from_request( $_POST ); + $job_id = isset( $_POST['job_id'] ) && is_scalar( $_POST['job_id'] ) ? absint( wp_unslash( $_POST['job_id'] ) ) : 0; + if ( ! $job_id || '' === $token || ! isset( $_POST['_wpnonce'] ) || ! is_string( $_POST['_wpnonce'] ) + || ! wp_verify_nonce( wp_unslash( $_POST['_wpnonce'] ), 'ct_scan_stop_' . $job_id . '_' . $token ) ) { + ct_scan_send_confirmation_page( 'This stop form is invalid or expired. Open the stop link again.', 403 ); + } + $result = ct_scan_customer_stop( $job_id, $token ); + if ( is_wp_error( $result ) ) { + ct_scan_send_confirmation_page( $result->get_error_message(), 'queue_busy' === $result->get_error_code() ? 503 : 410 ); + } + $messages = array( + 'stopped' => 'Your screening is stopped. No new screening will start.', + 'already_stopped' => 'This screening is already stopped.', + 'already_finished' => 'This screening has already finished. Nothing further will run.', + ); + ct_scan_send_confirmation_page( $messages[ $result ] ); +} +add_action( 'admin_post_nopriv_ct_scan_stop', 'ct_scan_stop_handler' ); +add_action( 'admin_post_ct_scan_stop', 'ct_scan_stop_handler' ); + function ct_scan_rest_response( $status, $payload ) { return new WP_REST_Response( $payload, (int) $status ); } diff --git a/integrations/wordpress/tests/integration.php b/integrations/wordpress/tests/integration.php index 70e36a5..49230b7 100644 --- a/integrations/wordpress/tests/integration.php +++ b/integrations/wordpress/tests/integration.php @@ -127,12 +127,43 @@ function ct_test_queue_job( $job_id ) { ct_test_assert( hash( 'sha256', $fields['target'] ) === $row->scope_hash, 'Scope hash must cover the exact canonical target.' ); ct_test_assert( 'accepted' === $row->confirmation_email_status, 'Confirmation mail acceptance state was not recorded.' ); ct_test_assert( false !== strpos( $mail_calls[0]['message'], 'token=' ), 'Confirmation link missing from the captured fixture email.' ); +ct_test_assert( 1 === preg_match( '/[?&]stop=([a-f0-9]{64})/', $mail_calls[0]['message'], $stop_match ), 'Stop link missing from the captured fixture email.' ); +ct_test_assert( 1 === preg_match( '/token=([a-f0-9]{64})/', $mail_calls[0]['message'], $confirm_match ) && $confirm_match[1] !== $stop_match[1], 'Stop and confirmation tokens must differ.' ); +ct_test_assert( hash( 'sha256', $stop_match[1] ) === $row->stop_token_hash && false === strpos( wp_json_encode( $row ), $stop_match[1] ), 'Only the stop token hash may be stored.' ); // Duplicate hook delivery is database-idempotent even when the event meta is absent. delete_post_meta( $request_id, '_ct_scan_event_recorded' ); $duplicate = ct_scan_on_request_ready( $request_id, $fields ); ct_test_assert( ! is_wp_error( $duplicate ) && ! empty( $duplicate['duplicate'] ) && (int) $duplicate['job_id'] === (int) $event['job_id'], 'Duplicate event created a second job or changed state.' ); +// Customer stop: wrong tokens fail, a finished job is not reopened, and a stopped job can never be claimed or started. +list( $stop_id, $stop_fields ) = ct_test_request( 'stop-fixture.example.net' ); +$request_ids[] = $stop_id; +$mail_before = count( $mail_calls ); +$stop_event = ct_scan_on_request_ready( $stop_id, $stop_fields ); +preg_match( '/[?&]stop=([a-f0-9]{64})/', $mail_calls[ $mail_before ]['message'], $stop_match ); +$stop_token = $stop_match[1]; +$stop_row = ct_scan_row_by_stop_hash( hash( 'sha256', $stop_token ) ); +ct_test_assert( $stop_row && (int) $stop_row->job_id === (int) $stop_event['job_id'], 'Stop token must resolve only to its own job.' ); +ct_test_assert( is_wp_error( ct_scan_customer_stop( $stop_event['job_id'], str_repeat( 'a', 64 ) ) ), 'A wrong stop token was accepted.' ); +ct_test_assert( is_wp_error( ct_scan_customer_stop( $event['job_id'], $stop_token ) ), 'A stop token stopped a different job.' ); +ct_test_assert( 'awaiting_confirmation' === ct_scan_row( $stop_event['job_id'] )->status, 'A rejected stop changed job state.' ); +ct_test_queue_job( $stop_event['job_id'] ); +$stop_claim = ct_test_rest_call( 'ct_scan_claim', '/ct-scan/v1/claim', array() ); +$stop_claimed = $stop_claim->get_data()['job']; +ct_test_assert( (int) $stop_claimed['id'] === (int) $stop_event['job_id'], 'Fixture expected the stop job to be claimed.' ); +ct_test_assert( 'stopped' === ct_scan_customer_stop( $stop_event['job_id'], $stop_token ), 'A leased job was not stopped by its customer.' ); +$stopped = ct_scan_row( $stop_event['job_id'] ); +ct_test_assert( 'cancelled' === $stopped->status && 'customer_stopped' === $stopped->error_code && empty( $stopped->lease_hash ) && empty( $stopped->revoked_at ), 'Customer stop must cancel and clear the lease without operator revocation.' ); +ct_test_assert( 'cancelled' === get_post_meta( $stop_id, '_ct_scan_status', true ), 'Request status did not record the stop.' ); +$late_start = ct_test_rest_call( 'ct_scan_start', '/ct-scan/v1/start', array( 'id' => $stop_claimed['id'], 'lease' => $stop_claimed['lease'], 'scope_hash' => $stop_claimed['scope_hash'] ) ); +ct_test_assert( 410 === $late_start->get_status(), 'A stopped job was started by a stale lease.' ); +$late_result = ct_test_rest_call( 'ct_scan_result', '/ct-scan/v1/result', array( 'id' => $stop_claimed['id'], 'lease' => $stop_claimed['lease'], 'scope_hash' => $stop_claimed['scope_hash'], 'outcome' => 'completed', 'result' => ct_test_result( $stop_fields['target'] ), 'error' => '', 'checker_revision' => str_repeat( 'a', 40 ) ) ); +ct_test_assert( 410 === $late_result->get_status(), 'A stopped job accepted a late result.' ); +ct_test_assert( 'already_stopped' === ct_scan_customer_stop( $stop_event['job_id'], $stop_token ), 'Repeated stop must be idempotent.' ); +$no_claim = ct_test_rest_call( 'ct_scan_claim', '/ct-scan/v1/claim', array() ); +ct_test_assert( 200 === $no_claim->get_status() && empty( $no_claim->get_data()['job'] ), 'A stopped job must never be claimed again.' ); + // Confirmed queue: claim, start-before-work, validated result, and exact receipt retry. ct_test_queue_job( $event['job_id'] ); $claim = ct_test_rest_call( 'ct_scan_claim', '/ct-scan/v1/claim', array() );