From 2b162bddb74240e6d3b23e47409daf9ad9897504 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg Date: Thu, 22 Oct 2026 02:21:07 -0400 Subject: [PATCH] Add a customer Stop link to cancel a screening The confirmation email now carries a separate, request-bound Stop link whose token is stored only as a hash (schema 2 adds stop_token_hash; the dbDelta upgrade adds it in place). GET shows a Stop form and changes nothing, so email prefetches are harmless. A nonce-protected POST cancels the job as customer_stopped under the queue lock, clearing any lease and confirmation token. The existing start and result guards then refuse it, and it is never claimed again. Repeat stops are idempotent. Stopping a finished job changes nothing, and customer stops do not set the operator revoked_at flag, so a confirmed customer's report path is unaffected. A check the worker already started may still finish its current requests; a per-request admission guard is the follow-up. Refs CompleteTech-LLC/complete.tech#17 (gap 3, part 1). Co-Authored-By: Claude Opus 5.5 --- docs/WORDPRESS.md | 7 ++ integrations/wordpress/ct-scan.php | 118 ++++++++++++++++++- integrations/wordpress/tests/integration.php | 31 +++++ 3 files changed, 151 insertions(+), 5 deletions(-) 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() );