From 897362b1d4d0fe4977f3682f7a00a42b77a94061 Mon Sep 17 00:00:00 2001 From: John Blackbourn Date: Tue, 7 Jul 2026 22:53:10 +0100 Subject: [PATCH 01/10] Introduce a `wp_set_cookie()` function as a wrapper for `setcookie()` using the array signature for options. --- src/wp-activate.php | 13 +++- src/wp-admin/post.php | 13 +++- .../class-wp-recovery-mode-cookie-service.php | 26 ++++++- src/wp-includes/comment.php | 39 ++++++++++- src/wp-includes/functions.php | 57 ++++++++++++++++ src/wp-includes/option.php | 26 ++++++- src/wp-includes/pluggable.php | 52 ++++++++++++-- src/wp-login.php | 67 +++++++++++++++++-- 8 files changed, 275 insertions(+), 18 deletions(-) diff --git a/src/wp-activate.php b/src/wp-activate.php index f1eb579e61b19..fcc7a659972a4 100644 --- a/src/wp-activate.php +++ b/src/wp-activate.php @@ -38,7 +38,18 @@ $redirect_url = remove_query_arg( 'key' ); if ( remove_query_arg( false ) !== $redirect_url ) { - setcookie( $activate_cookie, $key, 0, $activate_path, COOKIE_DOMAIN, is_ssl(), true ); + wp_set_cookie( + $activate_cookie, + $key, + array( + 'expires' => 0, + 'path' => $activate_path, + 'domain' => COOKIE_DOMAIN, + 'secure' => is_ssl(), + 'httponly' => true, + 'samesite' => 'Lax', + ) + ); wp_safe_redirect( $redirect_url ); exit; } else { diff --git a/src/wp-admin/post.php b/src/wp-admin/post.php index dd7bad1bb3830..3ab958baa9b0b 100644 --- a/src/wp-admin/post.php +++ b/src/wp-admin/post.php @@ -228,7 +228,18 @@ // Session cookie flag that the post was saved. if ( isset( $_COOKIE['wp-saving-post'] ) && $_COOKIE['wp-saving-post'] === $post_id . '-check' ) { - setcookie( 'wp-saving-post', $post_id . '-saved', time() + DAY_IN_SECONDS, ADMIN_COOKIE_PATH, COOKIE_DOMAIN, is_ssl() ); + wp_set_cookie( + 'wp-saving-post', + $post_id . '-saved', + array( + 'expires' => time() + DAY_IN_SECONDS, + 'path' => ADMIN_COOKIE_PATH, + 'domain' => COOKIE_DOMAIN, + 'secure' => is_ssl(), + 'httponly' => false, + 'samesite' => 'Lax', + ) + ); } redirect_post( $post_id ); // Send user on their way while we keep working. diff --git a/src/wp-includes/class-wp-recovery-mode-cookie-service.php b/src/wp-includes/class-wp-recovery-mode-cookie-service.php index a2ee34a723aa8..a03f4730e12a7 100644 --- a/src/wp-includes/class-wp-recovery-mode-cookie-service.php +++ b/src/wp-includes/class-wp-recovery-mode-cookie-service.php @@ -47,10 +47,32 @@ public function set_cookie() { $expire = time() + $length; - setcookie( RECOVERY_MODE_COOKIE, $value, $expire, COOKIEPATH, COOKIE_DOMAIN, is_ssl(), true ); + wp_set_cookie( + RECOVERY_MODE_COOKIE, + $value, + array( + 'expires' => $expire, + 'path' => COOKIEPATH, + 'domain' => COOKIE_DOMAIN, + 'secure' => is_ssl(), + 'httponly' => true, + 'samesite' => 'Lax', + ) + ); if ( COOKIEPATH !== SITECOOKIEPATH ) { - setcookie( RECOVERY_MODE_COOKIE, $value, $expire, SITECOOKIEPATH, COOKIE_DOMAIN, is_ssl(), true ); + wp_set_cookie( + RECOVERY_MODE_COOKIE, + $value, + array( + 'expires' => $expire, + 'path' => SITECOOKIEPATH, + 'domain' => COOKIE_DOMAIN, + 'secure' => is_ssl(), + 'httponly' => true, + 'samesite' => 'Lax', + ) + ); } } diff --git a/src/wp-includes/comment.php b/src/wp-includes/comment.php index b93908adc0519..69b7e73b9ac23 100644 --- a/src/wp-includes/comment.php +++ b/src/wp-includes/comment.php @@ -606,9 +606,42 @@ function wp_set_comment_cookies( $comment, $user, $cookies_consent = true ) { $secure = ( 'https' === parse_url( home_url(), PHP_URL_SCHEME ) ); - setcookie( 'comment_author_' . COOKIEHASH, $comment->comment_author, $comment_cookie_lifetime, COOKIEPATH, COOKIE_DOMAIN, $secure ); - setcookie( 'comment_author_email_' . COOKIEHASH, $comment->comment_author_email, $comment_cookie_lifetime, COOKIEPATH, COOKIE_DOMAIN, $secure ); - setcookie( 'comment_author_url_' . COOKIEHASH, esc_url( $comment->comment_author_url ), $comment_cookie_lifetime, COOKIEPATH, COOKIE_DOMAIN, $secure ); + wp_set_cookie( + 'comment_author_' . COOKIEHASH, + $comment->comment_author, + array( + 'expires' => $comment_cookie_lifetime, + 'path' => COOKIEPATH, + 'domain' => COOKIE_DOMAIN, + 'secure' => $secure, + 'httponly' => false, + 'samesite' => 'Lax', + ) + ); + wp_set_cookie( + 'comment_author_email_' . COOKIEHASH, + $comment->comment_author_email, + array( + 'expires' => $comment_cookie_lifetime, + 'path' => COOKIEPATH, + 'domain' => COOKIE_DOMAIN, + 'secure' => $secure, + 'httponly' => false, + 'samesite' => 'Lax', + ) + ); + wp_set_cookie( + 'comment_author_url_' . COOKIEHASH, + esc_url( $comment->comment_author_url ), + array( + 'expires' => $comment_cookie_lifetime, + 'path' => COOKIEPATH, + 'domain' => COOKIE_DOMAIN, + 'secure' => $secure, + 'httponly' => false, + 'samesite' => 'Lax', + ) + ); } /** diff --git a/src/wp-includes/functions.php b/src/wp-includes/functions.php index f4b60dfbd4e3a..eff2ba9fcba77 100644 --- a/src/wp-includes/functions.php +++ b/src/wp-includes/functions.php @@ -1556,6 +1556,63 @@ function nocache_headers() { } } +/** + * Defines a cookie to be sent along with the rest of the HTTP headers. + * + * Wrapper for PHP's native setcookie() that provides a filter to adjust the + * options for all cookies in one place, and a short-circuit filter to prevent + * a cookie from being sent. + * + * The options are passed to setcookie() unchanged, so its native defaults apply + * to any that are omitted. + * + * @since x.y.z + * + * @param string $name The name of the cookie. + * @param string $value The value of the cookie. + * @param array $options { + * Optional. Options to pass to setcookie(). Default empty array. + * + * @type int $expires The time the cookie expires, as a Unix timestamp. + * @type string $path The path on the server in which the cookie will be available on. + * @type string $domain The (sub)domain that the cookie is available to. + * @type bool $secure Indicates that the cookie should only be transmitted over a secure HTTPS connection from the client. + * @type bool $httponly When true the cookie will be made accessible only through the HTTP protocol. + * @type string $samesite Whether the cookie should be available for cross-site requests. Accepts 'Lax', 'Strict', or 'None'. + * } + * @return bool Whether the cookie was sent successfully. + */ +function wp_set_cookie( string $name, string $value, array $options = array() ) : bool { + /** + * Filters the options used when a cookie is sent to the browser. + * + * @since x.y.z + * + * @param array $options The options passed to setcookie(). See wp_set_cookie() for the full list. + * @param string $name The name of the cookie. + * @param string $value The value of the cookie. + */ + $options = apply_filters( 'wp_set_cookie_options', $options, $name, $value ); + + /** + * Filters whether a cookie should be sent to the browser. + * + * Returning false prevents the cookie from being sent. + * + * @since x.y.z + * + * @param bool $send Whether to send the cookie. Default true. + * @param string $name The name of the cookie. + * @param string $value The value of the cookie. + * @param array $options The options passed to setcookie(). See wp_set_cookie() for the full list. + */ + if ( ! apply_filters( 'send_cookie', true, $name, $value, $options ) ) { + return false; + } + + return setcookie( $name, $value, $options ); +} + /** * Sets the HTTP headers for caching for 10 days with JavaScript content type. * diff --git a/src/wp-includes/option.php b/src/wp-includes/option.php index 7979c119a986f..882c8143369c4 100644 --- a/src/wp-includes/option.php +++ b/src/wp-includes/option.php @@ -1735,8 +1735,30 @@ function wp_user_settings() { // The cookie is not set in the current browser or the saved value is newer. $secure = ( 'https' === parse_url( admin_url(), PHP_URL_SCHEME ) ); - setcookie( 'wp-settings-' . $user_id, $settings, time() + YEAR_IN_SECONDS, SITECOOKIEPATH, '', $secure ); - setcookie( 'wp-settings-time-' . $user_id, time(), time() + YEAR_IN_SECONDS, SITECOOKIEPATH, '', $secure ); + wp_set_cookie( + 'wp-settings-' . $user_id, + $settings, + array( + 'expires' => time() + YEAR_IN_SECONDS, + 'path' => SITECOOKIEPATH, + 'domain' => '', + 'secure' => $secure, + 'httponly' => false, + 'samesite' => 'Lax', + ) + ); + wp_set_cookie( + 'wp-settings-time-' . $user_id, + time(), + array( + 'expires' => time() + YEAR_IN_SECONDS, + 'path' => SITECOOKIEPATH, + 'domain' => '', + 'secure' => $secure, + 'httponly' => false, + 'samesite' => 'Lax', + ) + ); $_COOKIE[ 'wp-settings-' . $user_id ] = $settings; } diff --git a/src/wp-includes/pluggable.php b/src/wp-includes/pluggable.php index f659186b9a70c..eb21c19342587 100644 --- a/src/wp-includes/pluggable.php +++ b/src/wp-includes/pluggable.php @@ -1190,11 +1190,55 @@ function wp_set_auth_cookie( $user_id, $remember = false, $secure = '', $token = return; } - setcookie( $auth_cookie_name, $auth_cookie, $expire, PLUGINS_COOKIE_PATH, COOKIE_DOMAIN, $secure, true ); - setcookie( $auth_cookie_name, $auth_cookie, $expire, ADMIN_COOKIE_PATH, COOKIE_DOMAIN, $secure, true ); - setcookie( LOGGED_IN_COOKIE, $logged_in_cookie, $expire, COOKIEPATH, COOKIE_DOMAIN, $secure_logged_in_cookie, true ); + wp_set_cookie( + $auth_cookie_name, + $auth_cookie, + array( + 'expires' => $expire, + 'path' => PLUGINS_COOKIE_PATH, + 'domain' => COOKIE_DOMAIN, + 'secure' => $secure, + 'httponly' => true, + 'samesite' => 'Lax', + ) + ); + wp_set_cookie( + $auth_cookie_name, + $auth_cookie, + array( + 'expires' => $expire, + 'path' => ADMIN_COOKIE_PATH, + 'domain' => COOKIE_DOMAIN, + 'secure' => $secure, + 'httponly' => true, + 'samesite' => 'Lax', + ) + ); + wp_set_cookie( + LOGGED_IN_COOKIE, + $logged_in_cookie, + array( + 'expires' => $expire, + 'path' => COOKIEPATH, + 'domain' => COOKIE_DOMAIN, + 'secure' => $secure_logged_in_cookie, + 'httponly' => true, + 'samesite' => 'Lax', + ) + ); if ( COOKIEPATH !== SITECOOKIEPATH ) { - setcookie( LOGGED_IN_COOKIE, $logged_in_cookie, $expire, SITECOOKIEPATH, COOKIE_DOMAIN, $secure_logged_in_cookie, true ); + wp_set_cookie( + LOGGED_IN_COOKIE, + $logged_in_cookie, + array( + 'expires' => $expire, + 'path' => SITECOOKIEPATH, + 'domain' => COOKIE_DOMAIN, + 'secure' => $secure_logged_in_cookie, + 'httponly' => true, + 'samesite' => 'Lax', + ) + ); } } endif; diff --git a/src/wp-login.php b/src/wp-login.php index abedea82c3589..458d3f36c168b 100644 --- a/src/wp-login.php +++ b/src/wp-login.php @@ -528,14 +528,47 @@ function wp_login_viewport_meta() { // Set a cookie now to see if they are supported by the browser. $secure = ( 'https' === parse_url( wp_login_url(), PHP_URL_SCHEME ) ); -setcookie( TEST_COOKIE, 'WP Cookie check', 0, COOKIEPATH, COOKIE_DOMAIN, $secure, true ); +wp_set_cookie( + TEST_COOKIE, + 'WP Cookie check', + array( + 'expires' => 0, + 'path' => COOKIEPATH, + 'domain' => COOKIE_DOMAIN, + 'secure' => $secure, + 'httponly' => true, + 'samesite' => 'Lax', + ) +); if ( SITECOOKIEPATH !== COOKIEPATH ) { - setcookie( TEST_COOKIE, 'WP Cookie check', 0, SITECOOKIEPATH, COOKIE_DOMAIN, $secure, true ); + wp_set_cookie( + TEST_COOKIE, + 'WP Cookie check', + array( + 'expires' => 0, + 'path' => SITECOOKIEPATH, + 'domain' => COOKIE_DOMAIN, + 'secure' => $secure, + 'httponly' => true, + 'samesite' => 'Lax', + ) + ); } if ( isset( $_GET['wp_lang'] ) ) { - setcookie( 'wp_lang', sanitize_text_field( $_GET['wp_lang'] ), 0, COOKIEPATH, COOKIE_DOMAIN, $secure, true ); + wp_set_cookie( + 'wp_lang', + sanitize_text_field( $_GET['wp_lang'] ), + array( + 'expires' => 0, + 'path' => COOKIEPATH, + 'domain' => COOKIE_DOMAIN, + 'secure' => $secure, + 'httponly' => true, + 'samesite' => 'Lax', + ) + ); } /** @@ -792,7 +825,20 @@ function wp_login_viewport_meta() { $secure = false; } - setcookie( 'wp-postpass_' . COOKIEHASH, $hasher->HashPassword( wp_unslash( $_POST['post_password'] ) ), $expire, COOKIEPATH, COOKIE_DOMAIN, $secure ); + $value = $hasher->HashPassword( wp_unslash( $_POST['post_password'] ) ); + + wp_set_cookie( + 'wp-postpass_' . COOKIEHASH, + $value, + array( + 'expires' => $expire, + 'path' => COOKIEPATH, + 'domain' => COOKIE_DOMAIN, + 'secure' => $secure, + 'httponly' => true, + 'samesite' => 'Lax', + ) + ); wp_safe_redirect( $redirect_to ); exit; @@ -942,7 +988,18 @@ function wp_login_viewport_meta() { if ( isset( $_GET['key'] ) && isset( $_GET['login'] ) ) { $value = sprintf( '%s:%s', wp_unslash( $_GET['login'] ), wp_unslash( $_GET['key'] ) ); - setcookie( $rp_cookie, $value, 0, $rp_path, COOKIE_DOMAIN, is_ssl(), true ); + wp_set_cookie( + $rp_cookie, + $value, + array( + 'expires' => 0, + 'path' => $rp_path, + 'domain' => COOKIE_DOMAIN, + 'secure' => is_ssl(), + 'httponly' => true, + 'samesite' => 'Lax', + ) + ); wp_safe_redirect( remove_query_arg( array( 'key', 'login' ) ) ); exit; From 38af3a1b386b1bb751e8ef232e4af6bb454577e2 Mon Sep 17 00:00:00 2001 From: John Blackbourn Date: Tue, 7 Jul 2026 23:02:36 +0100 Subject: [PATCH 02/10] Introduce a `wp_unset_cookie()` function as a wrapper for unsetting a cookie. --- src/wp-activate.php | 8 +- .../class-wp-recovery-mode-cookie-service.php | 16 ++- src/wp-includes/comment.php | 25 +++- src/wp-includes/functions.php | 20 +++ src/wp-includes/option.php | 7 +- src/wp-includes/pluggable.php | 134 +++++++++++++++--- src/wp-login.php | 16 ++- 7 files changed, 199 insertions(+), 27 deletions(-) diff --git a/src/wp-activate.php b/src/wp-activate.php index fcc7a659972a4..5d7eced309c38 100644 --- a/src/wp-activate.php +++ b/src/wp-activate.php @@ -60,7 +60,13 @@ if ( null === $result && isset( $_COOKIE[ $activate_cookie ] ) ) { $key = $_COOKIE[ $activate_cookie ]; $result = wpmu_activate_signup( $key ); - setcookie( $activate_cookie, ' ', time() - YEAR_IN_SECONDS, $activate_path, COOKIE_DOMAIN, is_ssl(), true ); + wp_unset_cookie( + $activate_cookie, + array( + 'path' => $activate_path, + 'domain' => COOKIE_DOMAIN, + ) + ); } if ( null === $result || ( is_wp_error( $result ) && 'invalid_key' === $result->get_error_code() ) ) { diff --git a/src/wp-includes/class-wp-recovery-mode-cookie-service.php b/src/wp-includes/class-wp-recovery-mode-cookie-service.php index a03f4730e12a7..ebe3193b74544 100644 --- a/src/wp-includes/class-wp-recovery-mode-cookie-service.php +++ b/src/wp-includes/class-wp-recovery-mode-cookie-service.php @@ -82,8 +82,20 @@ public function set_cookie() { * @since 5.2.0 */ public function clear_cookie() { - setcookie( RECOVERY_MODE_COOKIE, ' ', time() - YEAR_IN_SECONDS, COOKIEPATH, COOKIE_DOMAIN ); - setcookie( RECOVERY_MODE_COOKIE, ' ', time() - YEAR_IN_SECONDS, SITECOOKIEPATH, COOKIE_DOMAIN ); + wp_unset_cookie( + RECOVERY_MODE_COOKIE, + array( + 'path' => COOKIEPATH, + 'domain' => COOKIE_DOMAIN, + ) + ); + wp_unset_cookie( + RECOVERY_MODE_COOKIE, + array( + 'path' => SITECOOKIEPATH, + 'domain' => COOKIE_DOMAIN, + ) + ); } /** diff --git a/src/wp-includes/comment.php b/src/wp-includes/comment.php index 69b7e73b9ac23..dd968da5eb47a 100644 --- a/src/wp-includes/comment.php +++ b/src/wp-includes/comment.php @@ -586,10 +586,27 @@ function wp_set_comment_cookies( $comment, $user, $cookies_consent = true ) { if ( false === $cookies_consent ) { // Remove any existing cookies. - $past = time() - YEAR_IN_SECONDS; - setcookie( 'comment_author_' . COOKIEHASH, ' ', $past, COOKIEPATH, COOKIE_DOMAIN ); - setcookie( 'comment_author_email_' . COOKIEHASH, ' ', $past, COOKIEPATH, COOKIE_DOMAIN ); - setcookie( 'comment_author_url_' . COOKIEHASH, ' ', $past, COOKIEPATH, COOKIE_DOMAIN ); + wp_unset_cookie( + 'comment_author_' . COOKIEHASH, + array( + 'path' => COOKIEPATH, + 'domain' => COOKIE_DOMAIN, + ) + ); + wp_unset_cookie( + 'comment_author_email_' . COOKIEHASH, + array( + 'path' => COOKIEPATH, + 'domain' => COOKIE_DOMAIN, + ) + ); + wp_unset_cookie( + 'comment_author_url_' . COOKIEHASH, + array( + 'path' => COOKIEPATH, + 'domain' => COOKIE_DOMAIN, + ) + ); return; } diff --git a/src/wp-includes/functions.php b/src/wp-includes/functions.php index eff2ba9fcba77..3d06fc5b563d5 100644 --- a/src/wp-includes/functions.php +++ b/src/wp-includes/functions.php @@ -1613,6 +1613,26 @@ function wp_set_cookie( string $name, string $value, array $options = array() ) return setcookie( $name, $value, $options ); } +/** + * Removes a cookie from the browser. + * + * Sends a cookie with an empty value and an expiry time in the past, which + * instructs the browser to delete it. The path and domain must match those + * used when the cookie was originally set for the removal to take effect. + * + * @since x.y.z + * + * @param string $name The name of the cookie. + * @param array $options Optional. Options to pass to setcookie(). See wp_set_cookie() for the full list. + * Default empty array. + * @return bool True if the cookie was removed successfully, false otherwise. + */ +function wp_unset_cookie( string $name, array $options = array() ) : bool { + $options['expires'] = time() - YEAR_IN_SECONDS; + + return wp_set_cookie( $name, ' ', $options ); +} + /** * Sets the HTTP headers for caching for 10 days with JavaScript content type. * diff --git a/src/wp-includes/option.php b/src/wp-includes/option.php index 882c8143369c4..6322bce580991 100644 --- a/src/wp-includes/option.php +++ b/src/wp-includes/option.php @@ -1934,7 +1934,12 @@ function delete_all_user_settings() { } update_user_option( $user_id, 'user-settings', '', false ); - setcookie( 'wp-settings-' . $user_id, ' ', time() - YEAR_IN_SECONDS, SITECOOKIEPATH ); + wp_unset_cookie( + 'wp-settings-' . $user_id, + array( + 'path' => SITECOOKIEPATH, + ) + ); } /** diff --git a/src/wp-includes/pluggable.php b/src/wp-includes/pluggable.php index eb21c19342587..a4f924e400f2c 100644 --- a/src/wp-includes/pluggable.php +++ b/src/wp-includes/pluggable.php @@ -1263,31 +1263,131 @@ function wp_clear_auth_cookie() { } // Auth cookies. - setcookie( AUTH_COOKIE, ' ', time() - YEAR_IN_SECONDS, ADMIN_COOKIE_PATH, COOKIE_DOMAIN ); - setcookie( SECURE_AUTH_COOKIE, ' ', time() - YEAR_IN_SECONDS, ADMIN_COOKIE_PATH, COOKIE_DOMAIN ); - setcookie( AUTH_COOKIE, ' ', time() - YEAR_IN_SECONDS, PLUGINS_COOKIE_PATH, COOKIE_DOMAIN ); - setcookie( SECURE_AUTH_COOKIE, ' ', time() - YEAR_IN_SECONDS, PLUGINS_COOKIE_PATH, COOKIE_DOMAIN ); - setcookie( LOGGED_IN_COOKIE, ' ', time() - YEAR_IN_SECONDS, COOKIEPATH, COOKIE_DOMAIN ); - setcookie( LOGGED_IN_COOKIE, ' ', time() - YEAR_IN_SECONDS, SITECOOKIEPATH, COOKIE_DOMAIN ); + wp_unset_cookie( + AUTH_COOKIE, + array( + 'path' => ADMIN_COOKIE_PATH, + 'domain' => COOKIE_DOMAIN, + ) + ); + wp_unset_cookie( + SECURE_AUTH_COOKIE, + array( + 'path' => ADMIN_COOKIE_PATH, + 'domain' => COOKIE_DOMAIN, + ) + ); + wp_unset_cookie( + AUTH_COOKIE, + array( + 'path' => PLUGINS_COOKIE_PATH, + 'domain' => COOKIE_DOMAIN, + ) + ); + wp_unset_cookie( + SECURE_AUTH_COOKIE, + array( + 'path' => PLUGINS_COOKIE_PATH, + 'domain' => COOKIE_DOMAIN, + ) + ); + wp_unset_cookie( + LOGGED_IN_COOKIE, + array( + 'path' => COOKIEPATH, + 'domain' => COOKIE_DOMAIN, + ) + ); + wp_unset_cookie( + LOGGED_IN_COOKIE, + array( + 'path' => SITECOOKIEPATH, + 'domain' => COOKIE_DOMAIN, + ) + ); // Settings cookies. - setcookie( 'wp-settings-' . get_current_user_id(), ' ', time() - YEAR_IN_SECONDS, SITECOOKIEPATH ); - setcookie( 'wp-settings-time-' . get_current_user_id(), ' ', time() - YEAR_IN_SECONDS, SITECOOKIEPATH ); + wp_unset_cookie( + 'wp-settings-' . get_current_user_id(), + array( + 'path' => SITECOOKIEPATH, + ) + ); + wp_unset_cookie( + 'wp-settings-time-' . get_current_user_id(), + array( + 'path' => SITECOOKIEPATH, + ) + ); // Old cookies. - setcookie( AUTH_COOKIE, ' ', time() - YEAR_IN_SECONDS, COOKIEPATH, COOKIE_DOMAIN ); - setcookie( AUTH_COOKIE, ' ', time() - YEAR_IN_SECONDS, SITECOOKIEPATH, COOKIE_DOMAIN ); - setcookie( SECURE_AUTH_COOKIE, ' ', time() - YEAR_IN_SECONDS, COOKIEPATH, COOKIE_DOMAIN ); - setcookie( SECURE_AUTH_COOKIE, ' ', time() - YEAR_IN_SECONDS, SITECOOKIEPATH, COOKIE_DOMAIN ); + wp_unset_cookie( + AUTH_COOKIE, + array( + 'path' => COOKIEPATH, + 'domain' => COOKIE_DOMAIN, + ) + ); + wp_unset_cookie( + AUTH_COOKIE, + array( + 'path' => SITECOOKIEPATH, + 'domain' => COOKIE_DOMAIN, + ) + ); + wp_unset_cookie( + SECURE_AUTH_COOKIE, + array( + 'path' => COOKIEPATH, + 'domain' => COOKIE_DOMAIN, + ) + ); + wp_unset_cookie( + SECURE_AUTH_COOKIE, + array( + 'path' => SITECOOKIEPATH, + 'domain' => COOKIE_DOMAIN, + ) + ); // Even older cookies. - setcookie( USER_COOKIE, ' ', time() - YEAR_IN_SECONDS, COOKIEPATH, COOKIE_DOMAIN ); - setcookie( PASS_COOKIE, ' ', time() - YEAR_IN_SECONDS, COOKIEPATH, COOKIE_DOMAIN ); - setcookie( USER_COOKIE, ' ', time() - YEAR_IN_SECONDS, SITECOOKIEPATH, COOKIE_DOMAIN ); - setcookie( PASS_COOKIE, ' ', time() - YEAR_IN_SECONDS, SITECOOKIEPATH, COOKIE_DOMAIN ); + wp_unset_cookie( + USER_COOKIE, + array( + 'path' => COOKIEPATH, + 'domain' => COOKIE_DOMAIN, + ) + ); + wp_unset_cookie( + PASS_COOKIE, + array( + 'path' => COOKIEPATH, + 'domain' => COOKIE_DOMAIN, + ) + ); + wp_unset_cookie( + USER_COOKIE, + array( + 'path' => SITECOOKIEPATH, + 'domain' => COOKIE_DOMAIN, + ) + ); + wp_unset_cookie( + PASS_COOKIE, + array( + 'path' => SITECOOKIEPATH, + 'domain' => COOKIE_DOMAIN, + ) + ); // Post password cookie. - setcookie( 'wp-postpass_' . COOKIEHASH, ' ', time() - YEAR_IN_SECONDS, COOKIEPATH, COOKIE_DOMAIN ); + wp_unset_cookie( + 'wp-postpass_' . COOKIEHASH, + array( + 'path' => COOKIEPATH, + 'domain' => COOKIE_DOMAIN, + ) + ); } endif; diff --git a/src/wp-login.php b/src/wp-login.php index 458d3f36c168b..c4066162f28ac 100644 --- a/src/wp-login.php +++ b/src/wp-login.php @@ -1018,7 +1018,13 @@ function wp_login_viewport_meta() { } if ( ! $user || is_wp_error( $user ) ) { - setcookie( $rp_cookie, ' ', time() - YEAR_IN_SECONDS, $rp_path, COOKIE_DOMAIN, is_ssl(), true ); + wp_unset_cookie( + $rp_cookie, + array( + 'path' => $rp_path, + 'domain' => COOKIE_DOMAIN, + ) + ); if ( $user && $user->get_error_code() === 'expired_key' ) { wp_redirect( site_url( 'wp-login.php?action=lostpassword&error=expiredkey' ) ); @@ -1548,7 +1554,13 @@ function wp_login_viewport_meta() { if ( isset( $_COOKIE[ $rp_cookie ] ) && is_string( $_COOKIE[ $rp_cookie ] ) ) { $user_login = sanitize_user( strtok( wp_unslash( $_COOKIE[ $rp_cookie ] ), ':' ) ); list( $rp_path ) = explode( '?', wp_unslash( $_SERVER['REQUEST_URI'] ) ); - setcookie( $rp_cookie, ' ', time() - YEAR_IN_SECONDS, $rp_path, COOKIE_DOMAIN, is_ssl(), true ); + wp_unset_cookie( + $rp_cookie, + array( + 'path' => $rp_path, + 'domain' => COOKIE_DOMAIN, + ) + ); } login_header( __( 'Log In' ), '', $errors ); From af642d3202c14d97a6d5c9ea188e0f761dd2655e Mon Sep 17 00:00:00 2001 From: John Blackbourn Date: Tue, 7 Jul 2026 23:12:59 +0100 Subject: [PATCH 03/10] Docs and tests. --- src/wp-includes/functions.php | 4 +- src/wp-includes/pluggable.php | 2 + tests/phpunit/includes/functions.php | 4 +- tests/phpunit/tests/option/wpUserSettings.php | 56 +++++++++++++++---- 4 files changed, 52 insertions(+), 14 deletions(-) diff --git a/src/wp-includes/functions.php b/src/wp-includes/functions.php index 3d06fc5b563d5..af40a7f841b1d 100644 --- a/src/wp-includes/functions.php +++ b/src/wp-includes/functions.php @@ -1588,7 +1588,7 @@ function wp_set_cookie( string $name, string $value, array $options = array() ) * * @since x.y.z * - * @param array $options The options passed to setcookie(). See wp_set_cookie() for the full list. + * @param array $options The options to pass to setcookie(). * @param string $name The name of the cookie. * @param string $value The value of the cookie. */ @@ -1604,7 +1604,7 @@ function wp_set_cookie( string $name, string $value, array $options = array() ) * @param bool $send Whether to send the cookie. Default true. * @param string $name The name of the cookie. * @param string $value The value of the cookie. - * @param array $options The options passed to setcookie(). See wp_set_cookie() for the full list. + * @param array $options The options to pass to setcookie(). */ if ( ! apply_filters( 'send_cookie', true, $name, $value, $options ) ) { return false; diff --git a/src/wp-includes/pluggable.php b/src/wp-includes/pluggable.php index a4f924e400f2c..6ecbc09745b20 100644 --- a/src/wp-includes/pluggable.php +++ b/src/wp-includes/pluggable.php @@ -1173,6 +1173,8 @@ function wp_set_auth_cookie( $user_id, $remember = false, $secure = '', $token = /** * Allows preventing auth cookies from actually being sent to the client. * + * See also the {@see 'send_cookie'} filter. + * * @since 4.7.4 * @since 6.2.0 The `$expire`, `$expiration`, `$user_id`, `$scheme`, and `$token` parameters were added. * diff --git a/tests/phpunit/includes/functions.php b/tests/phpunit/includes/functions.php index d6b6218278ae3..34b2d60015da6 100644 --- a/tests/phpunit/includes/functions.php +++ b/tests/phpunit/includes/functions.php @@ -338,9 +338,9 @@ function _wp_rest_server_class_filter() { return 'Spy_REST_Server'; } -// Skip `setcookie` calls in auth_cookie functions due to warning: +// Skip `setcookie` calls in all functions due to warning: // Cannot modify header information - headers already sent by... -tests_add_filter( 'send_auth_cookies', '__return_false' ); +tests_add_filter( 'send_cookie', '__return_false' ); /** * After the init action has been run once, trying to re-register block types can cause diff --git a/tests/phpunit/tests/option/wpUserSettings.php b/tests/phpunit/tests/option/wpUserSettings.php index 44cffc730ec49..0f913098db875 100644 --- a/tests/phpunit/tests/option/wpUserSettings.php +++ b/tests/phpunit/tests/option/wpUserSettings.php @@ -9,28 +9,64 @@ class Tests_Option_wpUserSettings extends WP_UnitTestCase { /** - * Tests that PHP 8.1 "passing null to non-nullable" deprecation notice - * is not thrown for the `$domain` parameter of setcookie() calls in the function. + * Cookies captured via the `send_cookie` filter, keyed by cookie name. * - * The notice that we should not see: - * `Deprecated: setcookie(): Passing null to parameter #5 ($domain) of type string is deprecated`. + * @var array + */ + private $sent_cookies = array(); + + public function set_up() { + parent::set_up(); + + $this->sent_cookies = array(); + } + + /** + * Records a cookie sent via wp_set_cookie() and prevents it from actually + * being sent, so the value and options can be asserted without setcookie() + * attempting to modify headers that have already been sent under PHPUnit. * - * Note: This does not test the actual functioning of wp_user_settings(). - * It just and only tests for/against the deprecation notice. + * @param bool $send Whether to send the cookie. + * @param string $name The name of the cookie. + * @param string $value The value of the cookie. + * @param array $options The options passed to setcookie(). + * @return false Always false, to prevent the cookie from being sent. + */ + public function filter_send_cookie( $send, $name, $value, $options ) { + $this->sent_cookies[ $name ] = array( + 'value' => $value, + 'options' => $options, + ); + + return false; + } + + /** + * Tests that the user settings cookies are sent with the stored settings value + * and the expected cookie options. * * @ticket 54914 */ - public function test_wp_user_settings_does_not_throw_deprecation_notice_for_setcookie() { + public function test_wp_user_settings_sends_the_settings_cookies() { set_current_screen( 'edit.php' ); - wp_set_current_user( self::factory()->user->create() ); + $user_id = self::factory()->user->create(); + wp_set_current_user( $user_id ); // Verify that the function's starting conditions are satisfied. $this->assertTrue( is_admin() ); $this->assertGreaterThan( 0, get_current_user_id() ); - // `Cannot modify header information - headers already sent by...` from setcookie(). - $this->expectWarning(); + update_user_option( $user_id, 'user-settings', 'foo=bar' ); + + add_filter( 'send_cookie', array( $this, 'filter_send_cookie' ), 10, 4 ); wp_user_settings(); + + // The settings cookie is sent with the stored settings value. + $this->assertArrayHasKey( 'wp-settings-' . $user_id, $this->sent_cookies ); + $this->assertSame( 'foo=bar', $this->sent_cookies[ 'wp-settings-' . $user_id ]['value'] ); + + // The companion timestamp cookie is also sent. + $this->assertArrayHasKey( 'wp-settings-time-' . $user_id, $this->sent_cookies ); } } From 30edc99ffa05918f526df60c0a198532a833b2d6 Mon Sep 17 00:00:00 2001 From: John Blackbourn Date: Wed, 8 Jul 2026 00:23:27 +0100 Subject: [PATCH 04/10] Add a test to cover the cookie option filtering. --- tests/phpunit/tests/functions/wpSetCookie.php | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 tests/phpunit/tests/functions/wpSetCookie.php diff --git a/tests/phpunit/tests/functions/wpSetCookie.php b/tests/phpunit/tests/functions/wpSetCookie.php new file mode 100644 index 0000000000000..9b063d2cd89e8 --- /dev/null +++ b/tests/phpunit/tests/functions/wpSetCookie.php @@ -0,0 +1,81 @@ + + */ + private $sent_cookies = array(); + + public function set_up() { + parent::set_up(); + + $this->sent_cookies = array(); + } + + /** + * Records a cookie sent via wp_set_cookie() and prevents it from actually + * being sent, so the value and options can be asserted. + * + * @param bool $send Whether to send the cookie. + * @param string $name The name of the cookie. + * @param string $value The value of the cookie. + * @param array $options The options passed to setcookie(). + * @return false Always false, to prevent the cookie from being sent. + */ + public function filter_capture_cookie( $send, $name, $value, $options ) { + $this->sent_cookies[] = array( + 'name' => $name, + 'value' => $value, + 'options' => $options, + ); + + return false; + } + + /** + * Forces the SameSite and Secure options, used to test the options filter. + * + * @param array $options The cookie options. + * @return array The modified options. + */ + public function filter_force_strict_samesite( $options ) { + $options['samesite'] = 'Strict'; + $options['secure'] = true; + + return $options; + } + + /** + * Tests that the `wp_set_cookie_options` filter can modify the options that + * are ultimately passed to setcookie(). + */ + public function test_wp_set_cookie_options_filter_can_modify_options() { + add_filter( 'wp_set_cookie_options', array( $this, 'filter_force_strict_samesite' ) ); + add_filter( 'send_cookie', array( $this, 'filter_capture_cookie' ), 10, 4 ); + + wp_set_cookie( + 'test_cookie', + 'test_value', + array( + 'path' => '/', + 'samesite' => 'Lax', + ) + ); + + $options = $this->sent_cookies[0]['options']; + + $this->assertSame( 'Strict', $options['samesite'] ); + $this->assertTrue( $options['secure'] ); + $this->assertSame( '/', $options['path'] ); + } +} From 2cb6e995ac0b24f8e8912e6f41d7e907d4a8be29 Mon Sep 17 00:00:00 2001 From: John Blackbourn Date: Wed, 8 Jul 2026 00:40:37 +0100 Subject: [PATCH 05/10] Coding standards. --- src/wp-includes/functions.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/wp-includes/functions.php b/src/wp-includes/functions.php index af40a7f841b1d..b3b5dfe084422 100644 --- a/src/wp-includes/functions.php +++ b/src/wp-includes/functions.php @@ -1582,7 +1582,7 @@ function nocache_headers() { * } * @return bool Whether the cookie was sent successfully. */ -function wp_set_cookie( string $name, string $value, array $options = array() ) : bool { +function wp_set_cookie( string $name, string $value, array $options = array() ): bool { /** * Filters the options used when a cookie is sent to the browser. * @@ -1627,7 +1627,7 @@ function wp_set_cookie( string $name, string $value, array $options = array() ) * Default empty array. * @return bool True if the cookie was removed successfully, false otherwise. */ -function wp_unset_cookie( string $name, array $options = array() ) : bool { +function wp_unset_cookie( string $name, array $options = array() ): bool { $options['expires'] = time() - YEAR_IN_SECONDS; return wp_set_cookie( $name, ' ', $options ); From ed455dcb731d8df4bd8628168faded8d17385c25 Mon Sep 17 00:00:00 2001 From: John Blackbourn Date: Mon, 13 Jul 2026 12:01:06 +0100 Subject: [PATCH 06/10] Update tests/phpunit/tests/functions/wpSetCookie.php Co-authored-by: Mukesh Panchal --- tests/phpunit/tests/functions/wpSetCookie.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/phpunit/tests/functions/wpSetCookie.php b/tests/phpunit/tests/functions/wpSetCookie.php index 9b063d2cd89e8..0c29a62ff98b2 100644 --- a/tests/phpunit/tests/functions/wpSetCookie.php +++ b/tests/phpunit/tests/functions/wpSetCookie.php @@ -58,6 +58,8 @@ public function filter_force_strict_samesite( $options ) { /** * Tests that the `wp_set_cookie_options` filter can modify the options that * are ultimately passed to setcookie(). + * + * @ticket 37000 */ public function test_wp_set_cookie_options_filter_can_modify_options() { add_filter( 'wp_set_cookie_options', array( $this, 'filter_force_strict_samesite' ) ); From 35b58f4bcd3e6dacfab292181fb8a7525b001626 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 15 Jul 2026 00:49:40 -0700 Subject: [PATCH 07/10] Add array shapes to phpdoc --- src/wp-includes/functions.php | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/wp-includes/functions.php b/src/wp-includes/functions.php index e68361f9665b2..3d716fb9e3047 100644 --- a/src/wp-includes/functions.php +++ b/src/wp-includes/functions.php @@ -1585,6 +1585,15 @@ function nocache_headers() { * @type string $samesite Whether the cookie should be available for cross-site requests. Accepts 'Lax', 'Strict', or 'None'. * } * @return bool Whether the cookie was sent successfully. + * @phpstan-param non-empty-string $name + * @phpstan-param array{ + * expires?: int, + * path?: non-empty-string, + * domain?: non-empty-string, + * secure?: bool, + * httponly?: bool, + * samesite?: 'Lax'|'Strict'|'None', + * } $options */ function wp_set_cookie( string $name, string $value, array $options = array() ): bool { /** @@ -1627,9 +1636,18 @@ function wp_set_cookie( string $name, string $value, array $options = array() ): * @since x.y.z * * @param string $name The name of the cookie. - * @param array $options Optional. Options to pass to setcookie(). See wp_set_cookie() for the full list. + * @param array $options Optional. Options to pass to setcookie(). See {@see wp_set_cookie()} for the full list. * Default empty array. * @return bool True if the cookie was removed successfully, false otherwise. + * @phpstan-param non-empty-string $name + * @phpstan-param array{ + * expires?: int, + * path?: non-empty-string, + * domain?: non-empty-string, + * secure?: bool, + * httponly?: bool, + * samesite?: 'Lax'|'Strict'|'None', + * } $options */ function wp_unset_cookie( string $name, array $options = array() ): bool { $options['expires'] = time() - YEAR_IN_SECONDS; From 09ec3d691f0c192841466959b5fd82c3227d1e96 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 15 Jul 2026 00:50:26 -0700 Subject: [PATCH 08/10] Provide versions --- src/wp-includes/functions.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/wp-includes/functions.php b/src/wp-includes/functions.php index 3d716fb9e3047..b2ab1b8af1c38 100644 --- a/src/wp-includes/functions.php +++ b/src/wp-includes/functions.php @@ -1570,7 +1570,7 @@ function nocache_headers() { * The options are passed to setcookie() unchanged, so its native defaults apply * to any that are omitted. * - * @since x.y.z + * @since 7.1.0 * * @param string $name The name of the cookie. * @param string $value The value of the cookie. @@ -1599,7 +1599,7 @@ function wp_set_cookie( string $name, string $value, array $options = array() ): /** * Filters the options used when a cookie is sent to the browser. * - * @since x.y.z + * @since 7.1.0 * * @param array $options The options to pass to setcookie(). * @param string $name The name of the cookie. @@ -1612,7 +1612,7 @@ function wp_set_cookie( string $name, string $value, array $options = array() ): * * Returning false prevents the cookie from being sent. * - * @since x.y.z + * @since 7.1.0 * * @param bool $send Whether to send the cookie. Default true. * @param string $name The name of the cookie. @@ -1633,7 +1633,7 @@ function wp_set_cookie( string $name, string $value, array $options = array() ): * instructs the browser to delete it. The path and domain must match those * used when the cookie was originally set for the removal to take effect. * - * @since x.y.z + * @since 7.1.0 * * @param string $name The name of the cookie. * @param array $options Optional. Options to pass to setcookie(). See {@see wp_set_cookie()} for the full list. From 33c0e226b60fe67da0e8506f7999fa6792994ed1 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 15 Jul 2026 00:55:20 -0700 Subject: [PATCH 09/10] Widen some types --- src/wp-includes/functions.php | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/wp-includes/functions.php b/src/wp-includes/functions.php index b2ab1b8af1c38..81f8b14830058 100644 --- a/src/wp-includes/functions.php +++ b/src/wp-includes/functions.php @@ -1585,11 +1585,10 @@ function nocache_headers() { * @type string $samesite Whether the cookie should be available for cross-site requests. Accepts 'Lax', 'Strict', or 'None'. * } * @return bool Whether the cookie was sent successfully. - * @phpstan-param non-empty-string $name * @phpstan-param array{ * expires?: int, - * path?: non-empty-string, - * domain?: non-empty-string, + * path?: string, + * domain?: string, * secure?: bool, * httponly?: bool, * samesite?: 'Lax'|'Strict'|'None', @@ -1639,11 +1638,10 @@ function wp_set_cookie( string $name, string $value, array $options = array() ): * @param array $options Optional. Options to pass to setcookie(). See {@see wp_set_cookie()} for the full list. * Default empty array. * @return bool True if the cookie was removed successfully, false otherwise. - * @phpstan-param non-empty-string $name * @phpstan-param array{ * expires?: int, - * path?: non-empty-string, - * domain?: non-empty-string, + * path?: string, + * domain?: string, * secure?: bool, * httponly?: bool, * samesite?: 'Lax'|'Strict'|'None', From 81d8cbf5f198614e03612858c8b886e47b42d3c7 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 15 Jul 2026 00:59:36 -0700 Subject: [PATCH 10/10] Fix types of values for supplied options --- src/wp-includes/option.php | 2 +- src/wp-includes/pluggable.php | 4 ++-- src/wp-login.php | 3 ++- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/wp-includes/option.php b/src/wp-includes/option.php index d6a30b8278cff..21de3053b59ea 100644 --- a/src/wp-includes/option.php +++ b/src/wp-includes/option.php @@ -1751,7 +1751,7 @@ function wp_user_settings() { ); wp_set_cookie( 'wp-settings-time-' . $user_id, - time(), + (string) time(), array( 'expires' => time() + YEAR_IN_SECONDS, 'path' => SITECOOKIEPATH, diff --git a/src/wp-includes/pluggable.php b/src/wp-includes/pluggable.php index f6746ee6dfa3d..b67954933b2fd 100644 --- a/src/wp-includes/pluggable.php +++ b/src/wp-includes/pluggable.php @@ -1107,7 +1107,7 @@ function wp_set_auth_cookie( $user_id, $remember = false, $secure = '', $token = * @param bool $secure Whether the cookie should only be sent over HTTPS. * @param int $user_id User ID. */ - $secure = apply_filters( 'secure_auth_cookie', $secure, $user_id ); + $secure = (bool) apply_filters( 'secure_auth_cookie', $secure, $user_id ); /** * Filters whether the logged in cookie should only be sent over HTTPS. @@ -1118,7 +1118,7 @@ function wp_set_auth_cookie( $user_id, $remember = false, $secure = '', $token = * @param int $user_id User ID. * @param bool $secure Whether the auth cookie should only be sent over HTTPS. */ - $secure_logged_in_cookie = apply_filters( 'secure_logged_in_cookie', $secure_logged_in_cookie, $user_id, $secure ); + $secure_logged_in_cookie = (bool) apply_filters( 'secure_logged_in_cookie', $secure_logged_in_cookie, $user_id, $secure ); if ( $secure ) { $auth_cookie_name = SECURE_AUTH_COOKIE; diff --git a/src/wp-login.php b/src/wp-login.php index c61a5286710e5..2431e92250dcb 100644 --- a/src/wp-login.php +++ b/src/wp-login.php @@ -819,13 +819,14 @@ function wp_login_viewport_meta() { $secure = false; } + /** @var string $value */ $value = $hasher->HashPassword( wp_unslash( $_POST['post_password'] ) ); wp_set_cookie( 'wp-postpass_' . COOKIEHASH, $value, array( - 'expires' => $expire, + 'expires' => (int) $expire, 'path' => COOKIEPATH, 'domain' => COOKIE_DOMAIN, 'secure' => $secure,