Skip to content

Commit b085bd3

Browse files
authored
Merge commit from fork
CVE 2025-8678
2 parents e81f66c + c329a91 commit b085bd3

9 files changed

Lines changed: 123 additions & 19 deletions

File tree

SECURITY.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,4 @@
44

55
[You can report security bugs through the official WP Crontrol Vulnerability Disclosure Program on Patchstack](https://patchstack.com/database/vdp/wp-crontrol). The Patchstack team helps validate, triage, and handle any security vulnerabilities.
66

7-
Do not report security issues on GitHub or the WordPress.org support forums. Thank you.
7+
Do not report security issues on GitHub, on the WordPress.org support forums, or via email. Thank you.

phpstan.neon.dist

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ parameters:
1414
- tests/phpstan/stubs.php
1515
dynamicConstantNames:
1616
- CRONTROL_DISALLOW_PHP_EVENTS
17+
WPCompat:
18+
pluginFile: wp-crontrol.php
1719
ignoreErrors:
1820
-
1921
identifier: requireOnce.fileNotFound

src/bootstrap.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1765,7 +1765,7 @@ function show_cron_form( $editing ) {
17651765
);
17661766
}
17671767
?>
1768-
<input type="url" class="regular-text code" id="crontrol_url" name="crontrol_url" value="<?php echo esc_url( $is_editing_url ? $existing['args'][0]['url'] : '' ); ?>" />
1768+
<input type="url" class="regular-text code" id="crontrol_url" name="crontrol_url" value="<?php echo esc_attr( $is_editing_url ? $existing['args'][0]['url'] : '' ); ?>" />
17691769
<?php do_action( 'crontrol/manage/url', $existing ); ?>
17701770
</td>
17711771
</tr>
@@ -2720,7 +2720,7 @@ function action_url_cron_event( array $args ): void {
27202720
home_url( '/' )
27212721
),
27222722
);
2723-
$response = wp_remote_request( $url, $request_args );
2723+
$response = wp_safe_remote_request( $url, $request_args );
27242724

27252725
if ( is_wp_error( $response ) ) {
27262726
throw new Exception(

src/event-list-table.php

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -354,11 +354,7 @@ protected function extra_tablenav( $which ) {
354354
public function single_row( $event ) {
355355
$classes = array();
356356

357-
if ( ( 'crontrol_cron_job' === $event->hook ) && isset( $event->args[0]['syntax_error_message'] ) ) {
358-
$classes[] = 'crontrol-error';
359-
}
360-
361-
if ( integrity_failed( $event ) ) {
357+
if ( self::row_has_error( $event ) ) {
362358
$classes[] = 'crontrol-error';
363359
}
364360

@@ -437,6 +433,7 @@ protected function handle_row_actions( $event, $column_name, $primary ) {
437433

438434
// PHP cron events can be edited as long as they are enabled and the user has permission.
439435
$can_edit = ( 'crontrol_cron_job' !== $event->hook ) || ( self::$can_manage_php_crons && self::$php_crons_enabled );
436+
$has_error = self::row_has_error( $event );
440437

441438
if ( $can_edit ) {
442439
$link = array(
@@ -462,9 +459,9 @@ protected function handle_row_actions( $event, $column_name, $primary ) {
462459
}
463460

464461
// PHP cron events can be run as long as they are enabled.
465-
$can_run = ( 'crontrol_cron_job' !== $event->hook ) || self::$php_crons_enabled;
462+
$can_run = ( ( 'crontrol_cron_job' !== $event->hook ) || self::$php_crons_enabled ) && ! $has_error;
466463

467-
if ( ! is_paused( $event ) && ! integrity_failed( $event ) && $can_run ) {
464+
if ( ! is_paused( $event ) && $can_run ) {
468465
$link = array(
469466
'page' => 'wp-crontrol',
470467
'crontrol_action' => 'run-cron',
@@ -551,6 +548,21 @@ protected function handle_row_actions( $event, $column_name, $primary ) {
551548
return $this->row_actions( $links );
552549
}
553550

551+
/**
552+
* @param stdClass $event The cron event for the current row.
553+
*/
554+
private static function row_has_error( $event ): bool {
555+
if ( 'crontrol_cron_job' === $event->hook && isset( $event->args[0]['syntax_error_message'] ) ) {
556+
return true;
557+
}
558+
559+
if ( 'crontrol_url_cron_job' === $event->hook && isset( $event->args[0]['url_error_message'] ) ) {
560+
return true;
561+
}
562+
563+
return integrity_failed( $event );
564+
}
565+
554566
/**
555567
* Outputs the checkbox cell of a table row.
556568
*
@@ -656,6 +668,12 @@ protected function column_crontrol_hook( $event ) {
656668
$output = esc_html__( 'URL cron event', 'wp-crontrol' );
657669
}
658670

671+
if ( isset( $event->args[0]['url_error_message'] ) ) {
672+
$output .= '<br><span class="status-crontrol-error"><span class="dashicons dashicons-warning" aria-hidden="true"></span> ';
673+
$output .= esc_html( $event->args[0]['url_error_message'] );
674+
$output .= '</span>';
675+
}
676+
659677
return $output;
660678
}
661679

src/event.php

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,7 @@ function add( $next_run_local, $schedule, $hook, array $args ) {
144144
}
145145

146146
$next_run_utc = (int) get_gmt_from_date( gmdate( 'Y-m-d H:i:s', $next_run_local ), 'U' );
147+
$error = null;
147148

148149
if ( 'crontrol_cron_job' === $hook && ! empty( $args[0]['code'] ) ) {
149150
try {
@@ -163,6 +164,16 @@ function add( $next_run_local, $schedule, $hook, array $args ) {
163164
} catch ( \ParseError $e ) {
164165
$args[0]['syntax_error_message'] = $e->getMessage();
165166
$args[0]['syntax_error_line'] = $e->getLine();
167+
$error = $e;
168+
}
169+
}
170+
171+
if ( 'crontrol_url_cron_job' === $hook && ! empty( $args[0]['url'] ) ) {
172+
try {
173+
validate_url( $args[0]['url'] );
174+
} catch ( \InvalidArgumentException $e ) {
175+
$args[0]['url_error_message'] = $e->getMessage();
176+
$error = $e;
166177
}
167178
}
168179

@@ -176,7 +187,14 @@ function add( $next_run_local, $schedule, $hook, array $args ) {
176187
return $result;
177188
}
178189

179-
return true;
190+
return ( $error instanceof \Throwable ) ? new WP_Error(
191+
'has_error',
192+
sprintf(
193+
/* translators: %s: The error message. */
194+
__( 'The cron event was saved but contains an error: %s.', 'wp-crontrol' ),
195+
$error->getMessage(),
196+
),
197+
) : true;
180198
}
181199

182200
/**
@@ -557,3 +575,42 @@ function get_core_cron_array() {
557575

558576
return $crons;
559577
}
578+
579+
/**
580+
* Validates a URL for a cron event.
581+
*
582+
* @see https://github.com/WordPress/wordpress-develop/blob/197f0a71ad27d0688b6380c869aeaf92addd1451/src/wp-includes/class-wp-http.php#L283-L299
583+
*
584+
* @throws \InvalidArgumentException If the URL is not valid or contains an invalid protocol.
585+
*
586+
* @param string $url The URL to validate.
587+
*/
588+
function validate_url( string $url ): void {
589+
$valid = wp_http_validate_url( $url );
590+
591+
if ( $valid === false ) {
592+
throw new \InvalidArgumentException(
593+
esc_html(
594+
sprintf(
595+
/* translators: %s: The URL that failed validation. */
596+
__( 'The URL "%s" is not allowed', 'wp-crontrol' ),
597+
$url,
598+
)
599+
)
600+
);
601+
}
602+
603+
$filtered = wp_kses_bad_protocol( $url, array( 'http', 'https', 'ssl' ) );
604+
605+
if ( $filtered === '' ) {
606+
throw new \InvalidArgumentException(
607+
esc_html(
608+
sprintf(
609+
/* translators: %s: The URL that failed validation. */
610+
__( 'The URL "%s" contains an invalid protocol', 'wp-crontrol' ),
611+
$url,
612+
)
613+
)
614+
);
615+
}
616+
}

tests/_support/AcceptanceTester.php

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ public function amOnCronScheduleListingPage() {
9999
* @param string $args The event arguments encoded as JSON.
100100
* @return string
101101
*/
102-
public function amWorkingWithACronEvent( string $hook_name, string $args = '' ) {
102+
public function amWorkingWithANewCronEvent( string $hook_name, string $args = '' ) {
103103
$this->amOnCronEventListingPage();
104104
$this->click( 'Add New Cron Event', '#wpbody' );
105105
$this->fillField( 'Hook Name', $hook_name );
@@ -109,4 +109,16 @@ public function amWorkingWithACronEvent( string $hook_name, string $args = '' )
109109

110110
return Locator::contains( '.crontrol-events tr', $hook_name );
111111
}
112+
113+
/**
114+
* Work with an existing cron event.
115+
*
116+
* @param string $hook_name The event hook name.
117+
* @return string
118+
*/
119+
public function amWorkingWithAnExistingCronEvent( string $hook_name ) {
120+
$this->amOnCronEventListingPage();
121+
122+
return Locator::contains( '.crontrol-events tr', $hook_name );
123+
}
112124
}

tests/acceptance/AddEventCest.php

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,21 @@ public function AddingANewURLEvent( AcceptanceTester $I ) {
4848
$I->see( 'https://example.org/' );
4949
}
5050

51+
public function AddingANewURLEventWithDisallowedURLShowsError( AcceptanceTester $I ) {
52+
$I->amOnCronEventListingPage();
53+
$I->click( 'Add New Cron Event', '#wpbody' );
54+
$I->selectOption( 'input[name="crontrol_action"]', 'URL cron event' );
55+
$I->fillField( '#crontrol_url', 'http://localhost:22' );
56+
$I->click( 'Add Event' );
57+
$I->see( 'Cron Events', 'h1' );
58+
$I->seeAdminErrorNotice( 'The cron event was saved but contains an error: The URL "http://localhost:22" is not allowed' );
59+
60+
$row = $I->amWorkingWithAnExistingCronEvent( 'http://localhost:22' );
61+
$I->see( 'Edit', $row );
62+
$I->see( 'Delete', $row );
63+
$I->dontSee( 'Run now', $row );
64+
}
65+
5166
public function AddingANewPHPEvent( AcceptanceTester $I ) {
5267
$I->amOnCronEventListingPage();
5368
$I->click( 'Add New Cron Event', '#wpbody' );

tests/acceptance/DeleteAllWithHookCest.php

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,9 @@ public function _before( AcceptanceTester $I ) {
1212
}
1313

1414
public function DeletingAHook( AcceptanceTester $I ) {
15-
$I->amWorkingWithACronEvent( 'example_hook', '[1]' );
16-
$I->amWorkingWithACronEvent( 'example_hook', '[2]' );
17-
$row = $I->amWorkingWithACronEvent( 'example_hook', '[3]' );
15+
$I->amWorkingWithANewCronEvent( 'example_hook', '[1]' );
16+
$I->amWorkingWithANewCronEvent( 'example_hook', '[2]' );
17+
$row = $I->amWorkingWithANewCronEvent( 'example_hook', '[3]' );
1818

1919
$I->click( 'Delete all events with this hook (3)', $row );
2020
$I->acceptPopup();
@@ -24,9 +24,9 @@ public function DeletingAHook( AcceptanceTester $I ) {
2424
}
2525

2626
public function DeletingAPersistentWordPressCoreHook( AcceptanceTester $I ) {
27-
$I->amWorkingWithACronEvent( 'wp_scheduled_delete', '[1]' );
28-
$I->amWorkingWithACronEvent( 'wp_scheduled_delete', '[2]' );
29-
$row = $I->amWorkingWithACronEvent( 'wp_scheduled_delete', '[3]' );
27+
$I->amWorkingWithANewCronEvent( 'wp_scheduled_delete', '[1]' );
28+
$I->amWorkingWithANewCronEvent( 'wp_scheduled_delete', '[2]' );
29+
$row = $I->amWorkingWithANewCronEvent( 'wp_scheduled_delete', '[3]' );
3030

3131
$I->click( 'Delete all events with this hook (4)', $row );
3232
$I->acceptPopup();

tests/acceptance/PauseEventCest.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ public function _before( AcceptanceTester $I ) {
1212
}
1313

1414
public function PausingAnEvent( AcceptanceTester $I ) {
15-
$row = $I->amWorkingWithACronEvent( 'pause_me_soon' );
15+
$row = $I->amWorkingWithANewCronEvent( 'pause_me_soon' );
1616

1717
$I->click( 'Pause', $row );
1818
$I->seeAdminSuccessNotice( 'Paused the pause_me_soon hook.' );

0 commit comments

Comments
 (0)