Skip to content

Commit a7f0e65

Browse files
fix(wordpress): bind report control proof to request chronology
1 parent a4b1a87 commit a7f0e65

3 files changed

Lines changed: 112 additions & 36 deletions

File tree

‎integrations/wordpress/ct-scan.php‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3647,7 +3647,9 @@ function ct_scan_validate_report_job( $row, $token = '', $require_capability = f
36473647
|| ! in_array( $row->control_method ?? null, array( 'dns_txt', 'https_file', 'manual_review' ), true )
36483648
|| ! ct_scan_valid_datetime( (string) ( $row->control_valid_until ?? '' ) )
36493649
|| $row->control_valid_until !== $row->authorization_expires_at
3650-
|| strtotime( $row->control_verified_at . ' UTC' ) > strtotime( $row->control_valid_until . ' UTC' ) ) {
3650+
|| $row->control_verified_at < $row->created_at || $row->control_verified_at < $row->terms_accepted_at
3651+
|| $row->control_verified_at < $row->confirmed_at || $row->control_verified_at > ct_scan_utc_now()
3652+
|| $row->control_verified_at > $row->control_valid_until ) {
36513653
return new WP_Error( 'report_target_unverified', 'Control of the exact target was not verified for the authorized scan window.' );
36523654
}
36533655
}

‎integrations/wordpress/standalone-tests/report-access.php‎

Lines changed: 50 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -530,6 +530,35 @@ function confirmation_case_changes( $name, $row ) {
530530
check( 200 === ct_scan_report_access_response( 'POST', $job_id, $key[1] )['status'], 'valid confirmation remains a redeemable positive control: ' . $case );
531531
}
532532

533+
// Exact-target proof must be server-observed after the saved request and confirmation, never in the future.
534+
foreach ( array( 'before_acceptance', 'before_creation', 'before_confirmation', 'future', 'after_authorization' ) as $case ) {
535+
list( $job_id ) = fixture_reset();
536+
$good = clone $GLOBALS['wpdb']->row;
537+
if ( 'before_acceptance' === $case ) { $bad_at = gmdate( 'Y-m-d H:i:s', strtotime( $good->terms_accepted_at . ' UTC' ) - 1 ); }
538+
if ( 'before_creation' === $case ) { $GLOBALS['wpdb']->row->created_at = gmdate( 'Y-m-d H:i:s', strtotime( $good->terms_accepted_at . ' UTC' ) + 60 ); $GLOBALS['wpdb']->row->confirmed_at = gmdate( 'Y-m-d H:i:s', strtotime( $good->terms_accepted_at . ' UTC' ) + 120 ); $bad_at = gmdate( 'Y-m-d H:i:s', strtotime( $good->terms_accepted_at . ' UTC' ) + 30 ); }
539+
if ( 'before_confirmation' === $case ) { $GLOBALS['wpdb']->row->confirmed_at = gmdate( 'Y-m-d H:i:s', time() - 300 ); $bad_at = gmdate( 'Y-m-d H:i:s', time() - 600 ); }
540+
if ( 'future' === $case ) { $bad_at = gmdate( 'Y-m-d H:i:s', time() + 60 ); }
541+
if ( 'after_authorization' === $case ) { $bad_at = gmdate( 'Y-m-d H:i:s', strtotime( $good->authorization_expires_at . ' UTC' ) + 1 ); }
542+
$baseline = clone $GLOBALS['wpdb']->row;
543+
$GLOBALS['wpdb']->row->control_verified_at = $bad_at;
544+
$notification = ct_scan_admin_send_report( $job_id );
545+
check( is_wp_error( $notification ) && 'report_target_unverified' === $notification->get_error_code() && 0 === count( $GLOBALS['mail_sends'] ), 'invalid control chronology sends no notification: ' . $case );
546+
$GLOBALS['wpdb']->row = $baseline;
547+
$GLOBALS['wpdb']->row->control_verified_at = gmdate( 'Y-m-d H:i:s', time() - 60 );
548+
$GLOBALS['mail_result'] = true;
549+
check( true === ct_scan_admin_send_report( $job_id ), 'valid control chronology sends positive notification: ' . $case );
550+
$mail = end( $GLOBALS['mail_sends'] );
551+
preg_match( '#key=([a-f0-9]{64})#', $mail['body'], $key );
552+
$valid = clone $GLOBALS['wpdb']->row;
553+
$GLOBALS['wpdb']->row->control_verified_at = $bad_at;
554+
$preview = ct_scan_report_access_response( 'GET', $job_id, $key[1] );
555+
check( false === strpos( $preview['body'], '9f7e' ) && null === $GLOBALS['wpdb']->row->report_access_used_at && null === $GLOBALS['wpdb']->row->closure_source, 'invalid control chronology GET stays harmless: ' . $case );
556+
$response = ct_scan_report_access_response( 'POST', $job_id, $key[1] );
557+
check( 410 === $response['status'] && false === strpos( $response['body'], '9f7e' ) && null === $GLOBALS['wpdb']->row->report_access_used_at && null === $GLOBALS['wpdb']->row->closure_source, 'invalid control chronology serves no findings or closure: ' . $case );
558+
$GLOBALS['wpdb']->row = $valid;
559+
check( 200 === ct_scan_report_access_response( 'POST', $job_id, $key[1] )['status'], 'valid control chronology remains redeemable: ' . $case );
560+
}
561+
533562
// A proof changed between validation and the one-time write cannot be used to disclose findings.
534563
foreach ( array( 'confirmed_at', 'created_at', 'terms_accepted_at', 'authorization_expires_at' ) as $race_field ) {
535564
list( $job_id, $mail ) = send_fixture_link( true );
@@ -543,23 +572,27 @@ function confirmation_case_changes( $name, $row ) {
543572

544573
// Prepared report delivery may happen after target-traffic authorization expired, provided confirmation
545574
// happened within that original window and current report/recipient/control requirements still pass.
546-
list( $job_id, $request_id ) = fixture_reset();
547-
$accepted_epoch = time() - 3 * 86400;
548-
$accepted_sql = gmdate( 'Y-m-d H:i:s', $accepted_epoch );
549-
$created_sql = gmdate( 'Y-m-d H:i:s', $accepted_epoch + 60 );
550-
$confirmed_sql = gmdate( 'Y-m-d H:i:s', $accepted_epoch + 120 );
551-
$expired_sql = gmdate( 'Y-m-d H:i:s', time() - 86400 );
552-
$GLOBALS['wpdb']->row->terms_accepted_at = $accepted_sql;
553-
$GLOBALS['wpdb']->row->created_at = $created_sql;
554-
$GLOBALS['wpdb']->row->confirmed_at = $confirmed_sql;
555-
$GLOBALS['wpdb']->row->authorization_expires_at = $expired_sql;
556-
$GLOBALS['wpdb']->row->control_required = 0; // Original grandfathered legacy header-only fixture.
557-
$GLOBALS['meta'][$request_id]['_ct_terms_accepted_at'] = gmdate( 'Y-m-d\TH:i:s\Z', $accepted_epoch );
558-
$GLOBALS['meta'][$request_id]['_ct_scan_authorization_expires_at'] = $expired_sql;
559-
$GLOBALS['mail_result'] = true;
560-
check( true === ct_scan_admin_send_report( $job_id ), 'canonical confirmation permits a prepared report after traffic authorization expiry' );
561-
$expired_mail = end( $GLOBALS['mail_sends'] ); preg_match( '#key=([a-f0-9]{64})#', $expired_mail['body'], $expired_key );
562-
check( 200 === ct_scan_report_access_response( 'POST', $job_id, $expired_key[1] )['status'], 'prepared report remains retrievable with valid historical confirmation after traffic expiry' );
575+
foreach ( array( false, true ) as $historical_control ) {
576+
list( $job_id, $request_id ) = fixture_reset();
577+
$accepted_epoch = time() - 3 * 86400;
578+
$accepted_sql = gmdate( 'Y-m-d H:i:s', $accepted_epoch );
579+
$created_sql = gmdate( 'Y-m-d H:i:s', $accepted_epoch + 60 );
580+
$confirmed_sql = gmdate( 'Y-m-d H:i:s', $accepted_epoch + 120 );
581+
$expired_sql = gmdate( 'Y-m-d H:i:s', time() - 86400 );
582+
$GLOBALS['wpdb']->row->terms_accepted_at = $accepted_sql;
583+
$GLOBALS['wpdb']->row->created_at = $created_sql;
584+
$GLOBALS['wpdb']->row->confirmed_at = $confirmed_sql;
585+
$GLOBALS['wpdb']->row->authorization_expires_at = $expired_sql;
586+
$GLOBALS['wpdb']->row->control_required = $historical_control ? 1 : 0;
587+
$GLOBALS['wpdb']->row->control_verified_at = gmdate( 'Y-m-d H:i:s', $accepted_epoch + 180 );
588+
$GLOBALS['wpdb']->row->control_valid_until = $expired_sql;
589+
$GLOBALS['meta'][$request_id]['_ct_terms_accepted_at'] = gmdate( 'Y-m-d\TH:i:s\Z', $accepted_epoch );
590+
$GLOBALS['meta'][$request_id]['_ct_scan_authorization_expires_at'] = $expired_sql;
591+
$GLOBALS['mail_result'] = true;
592+
check( true === ct_scan_admin_send_report( $job_id ), 'canonical confirmation permits a prepared report after traffic authorization expiry' );
593+
$expired_mail = end( $GLOBALS['mail_sends'] ); preg_match( '#key=([a-f0-9]{64})#', $expired_mail['body'], $expired_key );
594+
check( 200 === ct_scan_report_access_response( 'POST', $job_id, $expired_key[1] )['status'], 'prepared report remains retrievable with valid historical confirmation after traffic expiry' );
595+
}
563596

564597
list( $job_id ) = fixture_reset();
565598
check( true === ct_scan_admin_save_manual_remainder( $job_id, '0' ) && '0' === $GLOBALS['wpdb']->row->manual_remainder_notes, 'the accepted zero-string manual notes value remains present' );

‎integrations/wordpress/tests/integration.php‎

Lines changed: 59 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -284,26 +284,67 @@ function ct_test_redeem_report( $job_id, $mail ) {
284284
ct_test_assert( false !== $wpdb->query( $wpdb->prepare( 'SET SESSION sql_mode = %s', $confirmation_sql_mode ) ), 'Could not restore the disposable session SQL mode.' );
285285
}
286286

287+
// Stored target-control proof must belong to this request and have already happened. These
288+
// canonical DATETIME values must survive MariaDB exactly, then fail before mail or retrieval.
289+
foreach ( array( 'before_acceptance', 'before_creation', 'before_confirmation', 'future', 'after_authorization' ) as $control_case ) {
290+
list( $control_id, $control_fields ) = ct_test_request( 'control-report-' . str_replace( '_', '-', $control_case ) . '.example.net' );
291+
$request_ids[] = $control_id;
292+
$accepted_epoch = time() - 3600;
293+
update_post_meta( $control_id, '_ct_terms_accepted_at', gmdate( 'Y-m-d\TH:i:s\Z', $accepted_epoch ) );
294+
$control_event = ct_scan_on_request_ready( $control_id, $control_fields );
295+
ct_test_assert( is_array( $control_event ), 'Control chronology fixture did not queue.' );
296+
$control_job = (int) $control_event['job_id'];
297+
ct_test_queue_job( $control_job );
298+
$control_row = ct_scan_row( $control_job );
299+
$control_good_at = gmdate( 'Y-m-d H:i:s', time() - 60 );
300+
ct_test_assert( 1 === $wpdb->update( $jobs, array( 'status' => 'failed', 'finished_at' => ct_scan_utc_now(), 'created_at' => gmdate( 'Y-m-d H:i:s', $accepted_epoch + 60 ), 'confirmed_at' => gmdate( 'Y-m-d H:i:s', $accepted_epoch + 120 ), 'control_required' => 1, 'control_method' => 'dns_txt', 'control_verified_at' => $control_good_at, 'control_valid_until' => $control_row->authorization_expires_at ), array( 'job_id' => $control_job ) ), 'Control chronology fixture preparation failed.' );
301+
ct_test_assert( true === ct_scan_admin_save_manual_remainder( $control_job, 'Synthetic target-control chronology review.' ), 'Control chronology manual review failed.' );
302+
switch ( $control_case ) {
303+
case 'before_acceptance': $control_bad_at = gmdate( 'Y-m-d H:i:s', $accepted_epoch - 1 ); break;
304+
case 'before_creation': $control_bad_at = gmdate( 'Y-m-d H:i:s', $accepted_epoch + 30 ); break;
305+
case 'before_confirmation': $control_bad_at = gmdate( 'Y-m-d H:i:s', $accepted_epoch + 90 ); break;
306+
case 'future': $control_bad_at = gmdate( 'Y-m-d H:i:s', time() + 60 ); break;
307+
case 'after_authorization': $control_bad_at = gmdate( 'Y-m-d H:i:s', strtotime( $control_row->authorization_expires_at . ' UTC' ) + 1 ); break;
308+
}
309+
ct_test_assert( 1 === $wpdb->update( $jobs, array( 'control_verified_at' => $control_bad_at ), array( 'job_id' => $control_job ) ) && $control_bad_at === ct_scan_row( $control_job )->control_verified_at, 'Invalid control chronology was not stored exactly: ' . $control_case );
310+
$control_mail_count = count( $mail_calls );
311+
$control_refused = ct_scan_admin_send_report( $control_job );
312+
ct_test_assert( is_wp_error( $control_refused ) && 'report_target_unverified' === $control_refused->get_error_code() && count( $mail_calls ) === $control_mail_count && 'not_sent' === ct_scan_row( $control_job )->report_notification_status, 'Invalid control chronology sent notification: ' . $control_case );
313+
ct_test_assert( 1 === $wpdb->update( $jobs, array( 'control_verified_at' => $control_good_at ), array( 'job_id' => $control_job ) ) && true === ct_scan_admin_send_report( $control_job ), 'Valid control notification control failed: ' . $control_case );
314+
$control_mail = end( $mail_calls );
315+
ct_test_assert( 1 === preg_match( '/[?&]key=([a-f0-9]{64})/', $control_mail['message'], $control_key ), 'Control report capability missing.' );
316+
ct_test_assert( 1 === $wpdb->update( $jobs, array( 'control_verified_at' => $control_bad_at ), array( 'job_id' => $control_job ) ), 'Invalid retrieval control update failed.' );
317+
$control_response = ct_scan_report_access_response( 'POST', $control_job, $control_key[1] );
318+
$control_after = ct_scan_row( $control_job );
319+
ct_test_assert( 410 === $control_response['status'] && false === strpos( $control_response['body'], 'Synthetic target-control chronology review.' ) && null === $control_after->report_access_used_at && null === $control_after->closure_source, 'Invalid control chronology exposed report or closure: ' . $control_case );
320+
ct_test_assert( 1 === $wpdb->update( $jobs, array( 'control_verified_at' => $control_good_at ), array( 'job_id' => $control_job ) ) && 200 === ct_scan_report_access_response( 'POST', $control_job, $control_key[1] )['status'], 'Valid control retrieval control failed: ' . $control_case );
321+
}
322+
287323
// Traffic authorization expiry does not retroactively erase valid mailbox confirmation. This is
288324
// an original legacy no-control fixture, with confirmation inside its frozen historical window.
289-
list( $historical_id, $historical_fields ) = ct_test_request( 'historical-confirmation-fixture.example.net' );
290-
$request_ids[] = $historical_id;
291-
$historical_accepted_epoch = time() - 3 * 86400;
292-
update_post_meta( $historical_id, '_ct_terms_accepted_at', gmdate( 'Y-m-d\TH:i:s\Z', $historical_accepted_epoch ) );
293-
$historical_event = ct_scan_on_request_ready( $historical_id, $historical_fields );
294-
ct_test_assert( is_array( $historical_event ), 'Historical confirmation fixture did not queue.' );
295-
$historical_expiry = gmdate( 'Y-m-d H:i:s', time() - 86400 );
296-
ct_test_assert( 1 === $wpdb->update( $jobs, array(
297-
'status' => 'failed', 'finished_at' => ct_scan_utc_now(), 'created_at' => gmdate( 'Y-m-d H:i:s', $historical_accepted_epoch + 60 ),
298-
'confirmed_at' => gmdate( 'Y-m-d H:i:s', $historical_accepted_epoch + 120 ), 'authorization_expires_at' => $historical_expiry,
299-
'confirmation_token_hash' => null, 'confirmation_expires_at' => null,
300-
), array( 'job_id' => (int) $historical_event['job_id'] ) ), 'Historical confirmation fixture update failed.' );
301-
update_post_meta( $historical_id, '_ct_scan_authorization_expires_at', $historical_expiry );
302-
$historical_row = ct_scan_row( $historical_event['job_id'] );
303-
ct_test_assert( 0 === (int) $historical_row->control_required && ct_scan_confirmation_metadata_trusted( $historical_row ) && ! ct_scan_authorization_is_live( $historical_row ), 'Historical positive control did not isolate valid confirmation from expired traffic authorization.' );
304-
ct_test_assert( true === ct_scan_admin_save_manual_remainder( $historical_event['job_id'], 'Synthetic report prepared after traffic authorization expired.' ), 'Historical report manual review failed.' );
305-
ct_test_assert( true === ct_scan_admin_send_report( $historical_event['job_id'] ), 'Valid historical confirmation was refused a prepared report notification.' );
306-
ct_test_redeem_report( $historical_event['job_id'], end( $mail_calls ) );
325+
foreach ( array( false, true ) as $historical_control ) {
326+
list( $historical_id, $historical_fields ) = ct_test_request( 'historical-confirmation-' . (int) $historical_control . '-fixture.example.net' );
327+
$request_ids[] = $historical_id;
328+
$historical_accepted_epoch = time() - 3 * 86400;
329+
update_post_meta( $historical_id, '_ct_terms_accepted_at', gmdate( 'Y-m-d\TH:i:s\Z', $historical_accepted_epoch ) );
330+
$historical_event = ct_scan_on_request_ready( $historical_id, $historical_fields );
331+
ct_test_assert( is_array( $historical_event ), 'Historical confirmation fixture did not queue.' );
332+
$historical_expiry = gmdate( 'Y-m-d H:i:s', time() - 86400 );
333+
ct_test_assert( 1 === $wpdb->update( $jobs, array(
334+
'status' => 'failed', 'finished_at' => ct_scan_utc_now(), 'created_at' => gmdate( 'Y-m-d H:i:s', $historical_accepted_epoch + 60 ),
335+
'confirmed_at' => gmdate( 'Y-m-d H:i:s', $historical_accepted_epoch + 120 ), 'authorization_expires_at' => $historical_expiry,
336+
'confirmation_token_hash' => null, 'confirmation_expires_at' => null,
337+
'control_required' => $historical_control ? 1 : 0, 'control_method' => 'dns_txt',
338+
'control_verified_at' => gmdate( 'Y-m-d H:i:s', $historical_accepted_epoch + 180 ), 'control_valid_until' => $historical_expiry,
339+
), array( 'job_id' => (int) $historical_event['job_id'] ) ), 'Historical confirmation fixture update failed.' );
340+
update_post_meta( $historical_id, '_ct_scan_authorization_expires_at', $historical_expiry );
341+
$historical_row = ct_scan_row( $historical_event['job_id'] );
342+
ct_test_assert( (int) $historical_control === (int) $historical_row->control_required && ct_scan_confirmation_metadata_trusted( $historical_row ) && ! ct_scan_authorization_is_live( $historical_row ), 'Historical positive control did not isolate valid confirmation from expired traffic authorization.' );
343+
ct_test_assert( true === ct_scan_admin_save_manual_remainder( $historical_event['job_id'], 'Synthetic report prepared after traffic authorization expired.' ), 'Historical report manual review failed.' );
344+
ct_test_assert( true === ct_scan_admin_send_report( $historical_event['job_id'] ), 'Valid historical confirmation was refused a prepared report notification.' );
345+
ct_test_redeem_report( $historical_event['job_id'], end( $mail_calls ) );
346+
347+
}
307348

308349
// Unknown mail outcomes remain one-shot; explicit recipient redemption still records actual report access.
309350
list( $unknown_id, $unknown_fields ) = ct_test_request( 'unknown-mail-fixture.example.net' );

0 commit comments

Comments
 (0)