Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions docs/WORDPRESS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
118 changes: 113 additions & 5 deletions integrations/wordpress/ct-scan.php
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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,
Expand All @@ -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} (
Expand All @@ -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 ) {
Expand Down Expand Up @@ -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 );
Expand Down Expand Up @@ -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 ) );
Expand All @@ -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 ) {
Expand Down Expand Up @@ -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 '<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Stop website screening</title><body><main><h1>Stop your website screening</h1><p>Stop the screening of this website:</p><p><strong>' . esc_html( $target ) . '</strong></p><p>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.</p><form method="post" action="' . $action . '"><input type="hidden" name="action" value="ct_scan_stop"><input type="hidden" name="job_id" value="' . esc_attr( (string) $job_id ) . '"><input type="hidden" name="stop" value="' . esc_attr( $token ) . '"><input type="hidden" name="_wpnonce" value="' . esc_attr( $nonce ) . '"><button type="submit">Stop screening</button></form></main></body></html>';
}

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 );
}
Expand Down
31 changes: 31 additions & 0 deletions integrations/wordpress/tests/integration.php
Original file line number Diff line number Diff line change
Expand Up @@ -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() );
Expand Down
Loading