| @@ -12,9 +12,10 @@ | ||
| 12 | 12 | * - Resolve the active page size for a given admin list, preferring an |
| 13 | 13 | * explicit submission on that list's own request key, then the user's |
| 14 | 14 | * persisted preference (stored in user_meta), and finally the |
| 15 | 15 | * page-specific default. A submitted valid value is persisted back to |
| 16 | - * user_meta so it survives across page loads and sessions. | |
| 16 | + * user_meta so it survives across page loads and sessions — gated on a | |
| 17 | + * per-page nonce so a crafted link cannot change the stored preference. | |
| 17 | 18 | * - Render an inline `<select>` with the allowed options, wired to refresh |
| 18 | 19 | * the current page with the new page size. |
| 19 | 20 | * |
| 20 | 21 | * Meta key scheme: `metasync_per_page_<page_key>` (one preference per page per |
| @@ -120,9 +121,9 @@ | ||
| 120 | 121 | * @return array<string,string> |
| 121 | 122 | */ |
| 122 | 123 | public static function request_state($page_key, $request = null) |
| 123 | 124 | { |
| 124 | - $request = is_array($request) ? $request : $_REQUEST; | |
| 125 | + $request = is_array($request) ? $request : $_REQUEST; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only, allow-listed list state for links | |
| 125 | 126 | $keys = array_merge(self::state_params($page_key), [self::request_key($page_key)]); |
| 126 | 127 | $state = []; |
| 127 | 128 | |
| 128 | 129 | foreach ($keys as $key) { |
| @@ -168,14 +169,57 @@ | ||
| 168 | 169 | return $state; |
| 169 | 170 | } |
| 170 | 171 | |
| 171 | 172 | /** |
| 173 | + * Nonce action protecting persistence of a page-size preference. | |
| 174 | + * | |
| 175 | + * The selector's navigation is a plain GET (no form submit), so the nonce | |
| 176 | + * travels as a `_wpnonce` query parameter added by metasync-per-page.js | |
| 177 | + * and is verified in resolve() before anything is written to user_meta. | |
| 178 | + * | |
| 179 | + * @param string $page_key Unique list identifier. | |
| 180 | + * @return string | |
| 181 | + */ | |
| 182 | + public static function nonce_action($page_key) | |
| 183 | + { | |
| 184 | + return 'metasync_per_page_' . $page_key; | |
| 185 | + } | |
| 186 | + | |
| 187 | + /** | |
| 188 | + * Page keys that have a results-per-page selector. Kept beside the | |
| 189 | + * resolution logic so the localized nonce map (page_nonces()) and the | |
| 190 | + * persistence gate in resolve() cannot drift apart. | |
| 191 | + * | |
| 192 | + * @return string[] | |
| 193 | + */ | |
| 194 | + public static function page_keys() | |
| 195 | + { | |
| 196 | + return ['redirections', '404_monitor', 'media_library', 'sync_log']; | |
| 197 | + } | |
| 198 | + | |
| 199 | + /** | |
| 200 | + * Nonce for each selector page key, for wp_localize_script(). | |
| 201 | + * | |
| 202 | + * @return array<string,string> | |
| 203 | + */ | |
| 204 | + public static function page_nonces() | |
| 205 | + { | |
| 206 | + $nonces = []; | |
| 207 | + foreach (self::page_keys() as $page_key) { | |
| 208 | + $nonces[$page_key] = wp_create_nonce(self::nonce_action($page_key)); | |
| 209 | + } | |
| 210 | + return $nonces; | |
| 211 | + } | |
| 212 | + | |
| 213 | + /** | |
| 172 | 214 | * Resolve the active per-page value for a given admin list. |
| 173 | 215 | * |
| 174 | 216 | * Resolution order: |
| 175 | 217 | * 1. `$_REQUEST[$request_key]` when present AND a valid allow-list |
| 176 | - * integer — also persisted to user_meta so the choice survives | |
| 177 | - * future sessions. | |
| 218 | + * integer — applied to this request, and persisted to user_meta | |
| 219 | + * when the request also carries a valid `metasync_per_page_<page_key>` | |
| 220 | + * nonce (added by the selector's own navigation) so only the | |
| 221 | + * selector itself can change the stored preference. | |
| 178 | 222 | * 2. The user's persisted preference (`metasync_per_page_<page_key>`), |
| 179 | 223 | * when it is a valid allow-list integer. |
| 180 | 224 | * 3. The supplied `$default`. |
| 181 | 225 | * |
| @@ -194,13 +238,23 @@ | ||
| 194 | 238 | $request_key = $request_key === null ? self::request_key($page_key) : $request_key; |
| 195 | 239 | $user_id = function_exists('get_current_user_id') ? get_current_user_id() : 0; |
| 196 | 240 | $meta_key = 'metasync_per_page_' . $page_key; |
| 197 | 241 | |
| 198 | - // 1) A freshly submitted value wins and is persisted. | |
| 242 | + // 1) A freshly submitted value wins and is persisted — but only when | |
| 243 | + // the request carries the per-page nonce. A nonce-less URL (shared, | |
| 244 | + // bookmarked, forged) still applies the value for this one request | |
| 245 | + // without writing the user's stored preference. | |
| 199 | 246 | if (isset($_REQUEST[$request_key])) { |
| 200 | 247 | $submitted = (int) $_REQUEST[$request_key]; |
| 201 | 248 | if (in_array($submitted, self::ALLOWED_VALUES, true)) { |
| 202 | - if ($user_id > 0 && function_exists('update_user_meta')) { | |
| 249 | + $nonce_ok = false; | |
| 250 | + if (isset($_REQUEST['_wpnonce']) && is_scalar($_REQUEST['_wpnonce'])) { | |
| 251 | + $nonce = sanitize_key(wp_unslash($_REQUEST['_wpnonce'])); | |
| 252 | + if ($nonce !== '' && function_exists('wp_verify_nonce')) { | |
| 253 | + $nonce_ok = (bool) wp_verify_nonce($nonce, self::nonce_action($page_key)); | |
| 254 | + } | |
| 255 | + } | |
| 256 | + if ($nonce_ok && $user_id > 0 && function_exists('update_user_meta')) { | |
| 203 | 257 | update_user_meta($user_id, $meta_key, $submitted); |
| 204 | 258 | } |
| 205 | 259 | return $submitted; |
| 206 | 260 | } |