PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 2.9.5
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v2.9.5
3.0.0 2.11.12 2.11.11 2.11.10 2.11.9 2.11.7 2.11.8 2.11.6 2.11.5 2.11.4 2.11.3 2.11.1 2.11.2 2.11.0 2.10.5 2.10.4 2.10.3 2.10.2 2.10.1 2.10.0 2.9.9 2.9.8 2.9.6 2.9.7 2.9.5 All 88 releases
vigilante / includes / class-database-prefix.php

class-database-prefix.php in Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… 2.9.5, at includes/class-database-prefix.php

467 lines 14.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Database Prefix Changer Class
4 *
5 * Safely changes the WordPress database table prefix
6 *
7 * @package Vigilante
8 */
9
10 // Prevent direct access
11 if ( ! defined( 'ABSPATH' ) ) {
12 exit;
13 }
14
15 // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange, PluginCheck.Security.DirectDB.UnescapedDBParameter
16
17 /**
18 * Class Vigilante_Database_Prefix
19 *
20 * Changes the WordPress database prefix safely
21 */
22 class Vigilante_Database_Prefix {
23
24 /**
25 * WordPress database instance
26 *
27 * @var wpdb
28 */
29 private $wpdb;
30
31 /**
32 * Current prefix
33 *
34 * @var string
35 */
36 private $old_prefix;
37
38 /**
39 * New prefix to apply
40 *
41 * @var string
42 */
43 private $new_prefix;
44
45 /**
46 * Path to wp-config.php
47 *
48 * @var string
49 */
50 private $wpconfig_path;
51
52 /**
53 * Constructor
54 */
55 public function __construct() {
56 global $wpdb;
57 $this->wpdb = $wpdb;
58 $this->old_prefix = $wpdb->prefix;
59 $this->wpconfig_path = $this->find_wpconfig_path();
60 }
61
62 /**
63 * Get the current database prefix
64 *
65 * @return string
66 */
67 public function get_current_prefix() {
68 return $this->old_prefix;
69 }
70
71 /**
72 * Check if current prefix is the insecure default
73 *
74 * @return bool
75 */
76 public function is_default_prefix() {
77 return 'wp_' === $this->old_prefix;
78 }
79
80 /**
81 * Generate a random secure prefix
82 *
83 * Format: 2-3 lowercase letters + 2-3 digits + underscore (e.g., vg72_ or kx391_)
84 *
85 * @return string
86 */
87 public function generate_prefix() {
88 $letters = 'abcdefghijklmnopqrstuvwxyz';
89 $prefix = '';
90
91 // 2-3 random letters
92 $letter_count = wp_rand( 2, 3 );
93 for ( $i = 0; $i < $letter_count; $i++ ) {
94 $prefix .= $letters[ wp_rand( 0, strlen( $letters ) - 1 ) ];
95 }
96
97 // 2-3 random digits
98 $digit_count = wp_rand( 2, 3 );
99 for ( $i = 0; $i < $digit_count; $i++ ) {
100 $prefix .= wp_rand( 0, 9 );
101 }
102
103 $prefix .= '_';
104
105 // Verify no tables exist with this prefix
106 if ( $this->prefix_tables_exist( $prefix ) ) {
107 return $this->generate_prefix(); // Regenerate if collision
108 }
109
110 return $prefix;
111 }
112
113 /**
114 * Validate a prefix string
115 *
116 * @param string $prefix Prefix to validate.
117 * @return true|WP_Error
118 */
119 public function validate_prefix( $prefix ) {
120 // Must end with underscore
121 if ( substr( $prefix, -1 ) !== '_' ) {
122 return new WP_Error( 'no_underscore', __( 'Prefix must end with an underscore.', 'vigilante' ) );
123 }
124
125 // Length check (including underscore): 3-16 characters
126 $len = strlen( $prefix );
127 if ( $len < 3 || $len > 16 ) {
128 return new WP_Error( 'invalid_length', __( 'Prefix must be between 3 and 16 characters (including underscore).', 'vigilante' ) );
129 }
130
131 // Only lowercase letters, digits, and underscore
132 if ( ! preg_match( '/^[a-z0-9_]+$/', $prefix ) ) {
133 return new WP_Error( 'invalid_chars', __( 'Prefix must contain only lowercase letters, digits, and underscores.', 'vigilante' ) );
134 }
135
136 // Must start with a letter
137 if ( ! preg_match( '/^[a-z]/', $prefix ) ) {
138 return new WP_Error( 'must_start_letter', __( 'Prefix must start with a letter.', 'vigilante' ) );
139 }
140
141 // Cannot be the same as current
142 if ( $prefix === $this->old_prefix ) {
143 return new WP_Error( 'same_prefix', __( 'New prefix is the same as the current one.', 'vigilante' ) );
144 }
145
146 // Check for existing tables with this prefix
147 if ( $this->prefix_tables_exist( $prefix ) ) {
148 return new WP_Error( 'prefix_exists', __( 'Tables with this prefix already exist in the database.', 'vigilante' ) );
149 }
150
151 return true;
152 }
153
154 /**
155 * Execute the full prefix change operation
156 *
157 * @param string $new_prefix New prefix to apply.
158 * @return true|WP_Error
159 */
160 public function change_prefix( $new_prefix ) {
161 $this->new_prefix = $new_prefix;
162
163 // Step 1: Validate
164 $valid = $this->validate_prefix( $new_prefix );
165 if ( is_wp_error( $valid ) ) {
166 return $valid;
167 }
168
169 // Step 2: Check wp-config.php is writable
170 if ( ! $this->wpconfig_path ) {
171 return new WP_Error( 'wpconfig_not_found', __( 'Cannot locate wp-config.php file.', 'vigilante' ) );
172 }
173
174 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable
175 if ( ! is_writable( $this->wpconfig_path ) ) {
176 return new WP_Error( 'wpconfig_not_writable', __( 'wp-config.php is not writable. Check file permissions.', 'vigilante' ) );
177 }
178
179 // Step 3: Get all tables with current prefix
180 $tables = $this->get_prefixed_tables();
181 if ( empty( $tables ) ) {
182 return new WP_Error( 'no_tables', __( 'No tables found with the current prefix.', 'vigilante' ) );
183 }
184
185 // Step 4: Rename all tables
186 $rename_result = $this->rename_tables( $tables );
187 if ( is_wp_error( $rename_result ) ) {
188 return $rename_result;
189 }
190
191 // Step 5: Update wp-config.php
192 $config_result = $this->update_wpconfig();
193 if ( is_wp_error( $config_result ) ) {
194 // Rollback table renames
195 $this->rollback_tables( $tables );
196 return $config_result;
197 }
198
199 // Step 6: Update options table references (user_roles, etc.)
200 $this->update_options_prefix();
201
202 // Step 7: Update usermeta prefix keys
203 $this->update_usermeta_prefix();
204
205 return true;
206 }
207
208 /**
209 * Get all tables with the current prefix
210 *
211 * @return array Table names.
212 */
213 private function get_prefixed_tables() {
214 return $this->wpdb->get_col(
215 $this->wpdb->prepare(
216 'SHOW TABLES LIKE %s',
217 $this->wpdb->esc_like( $this->old_prefix ) . '%'
218 )
219 );
220 }
221
222 /**
223 * Check if tables exist with a given prefix
224 *
225 * @param string $prefix Prefix to check.
226 * @return bool
227 */
228 private function prefix_tables_exist( $prefix ) {
229 $result = $this->wpdb->get_var(
230 $this->wpdb->prepare(
231 'SHOW TABLES LIKE %s',
232 $this->wpdb->esc_like( $prefix ) . '%'
233 )
234 );
235
236 return ! empty( $result );
237 }
238
239 /**
240 * Rename all tables from old prefix to new prefix
241 *
242 * @param array $tables List of table names.
243 * @return true|WP_Error
244 */
245 private function rename_tables( $tables ) {
246 $renamed = array();
247
248 foreach ( $tables as $old_name ) {
249 $new_name = $this->new_prefix . substr( $old_name, strlen( $this->old_prefix ) );
250
251 // Use RENAME TABLE (atomic operation, works within same database)
252 $result = $this->wpdb->query(
253 $this->wpdb->prepare(
254 'RENAME TABLE %i TO %i',
255 $old_name,
256 $new_name
257 )
258 );
259
260 if ( false === $result ) {
261 // Rollback already renamed tables
262 foreach ( $renamed as $rollback_new => $rollback_old ) {
263 $this->wpdb->query(
264 $this->wpdb->prepare(
265 'RENAME TABLE %i TO %i',
266 $rollback_new,
267 $rollback_old
268 )
269 );
270 }
271
272 return new WP_Error(
273 'rename_failed',
274 sprintf(
275 /* translators: %s: Table name */
276 __( 'Failed to rename table: %s. All changes have been rolled back.', 'vigilante' ),
277 $old_name
278 )
279 );
280 }
281
282 $renamed[ $new_name ] = $old_name;
283 }
284
285 return true;
286 }
287
288 /**
289 * Rollback table renames
290 *
291 * @param array $original_tables Original table names.
292 */
293 private function rollback_tables( $original_tables ) {
294 foreach ( $original_tables as $old_name ) {
295 $new_name = $this->new_prefix . substr( $old_name, strlen( $this->old_prefix ) );
296
297 // Check if new name exists (it was renamed)
298 $exists = $this->wpdb->get_var(
299 $this->wpdb->prepare( 'SHOW TABLES LIKE %s', $new_name )
300 );
301
302 if ( $exists ) {
303 $this->wpdb->query(
304 $this->wpdb->prepare(
305 'RENAME TABLE %i TO %i',
306 $new_name,
307 $old_name
308 )
309 );
310 }
311 }
312 }
313
314 /**
315 * Update $table_prefix in wp-config.php
316 *
317 * @return true|WP_Error
318 */
319 private function update_wpconfig() {
320 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
321 $content = file_get_contents( $this->wpconfig_path );
322
323 if ( false === $content ) {
324 return new WP_Error( 'read_error', __( 'Cannot read wp-config.php.', 'vigilante' ) );
325 }
326
327 // Back up the original file
328 $backup_path = $this->wpconfig_path . '.vigilante-backup-' . gmdate( 'YmdHis' );
329 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
330 if ( ! file_put_contents( $backup_path, $content ) ) {
331 return new WP_Error( 'backup_error', __( 'Cannot create wp-config.php backup.', 'vigilante' ) );
332 }
333
334 // Match the $table_prefix line (handles single and double quotes, with/without spaces)
335 $pattern = '/(\$table_prefix\s*=\s*)([\'"]).+?\\2(\s*;)/';
336 $replacement = '${1}\'' . $this->new_prefix . '\'${3}';
337
338 $new_content = preg_replace( $pattern, $replacement, $content, 1, $count );
339
340 if ( 0 === $count || null === $new_content ) {
341 // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink
342 unlink( $backup_path );
343 return new WP_Error( 'replace_error', __( 'Cannot find $table_prefix in wp-config.php.', 'vigilante' ) );
344 }
345
346 // Write updated content
347 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
348 $result = file_put_contents( $this->wpconfig_path, $new_content );
349
350 if ( false === $result ) {
351 // Restore backup
352 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
353 file_put_contents( $this->wpconfig_path, $content );
354 // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink
355 unlink( $backup_path );
356 return new WP_Error( 'write_error', __( 'Cannot write to wp-config.php.', 'vigilante' ) );
357 }
358
359 // Clean up backup after successful write
360 // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink
361 unlink( $backup_path );
362
363 return true;
364 }
365
366 /**
367 * Update option names that contain the old prefix
368 *
369 * WordPress stores some options with the prefix in their name:
370 * - {prefix}user_roles
371 */
372 private function update_options_prefix() {
373 $options_table = $this->new_prefix . 'options';
374
375 // Find and update options that start with old prefix
376 $old_like = $this->wpdb->esc_like( $this->old_prefix ) . '%';
377
378 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
379 $options = $this->wpdb->get_results(
380 $this->wpdb->prepare(
381 "SELECT option_id, option_name FROM `{$options_table}` WHERE option_name LIKE %s",
382 $old_like
383 )
384 );
385
386 if ( $options ) {
387 foreach ( $options as $option ) {
388 $new_option_name = $this->new_prefix . substr( $option->option_name, strlen( $this->old_prefix ) );
389
390 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
391 $this->wpdb->update(
392 $options_table,
393 array( 'option_name' => $new_option_name ),
394 array( 'option_id' => $option->option_id ),
395 array( '%s' ),
396 array( '%d' )
397 );
398 }
399 }
400 }
401
402 /**
403 * Update usermeta keys that contain the old prefix
404 *
405 * WordPress stores some usermeta with the prefix in the key:
406 * - {prefix}capabilities
407 * - {prefix}user_level
408 * - {prefix}dashboard_quick_press_last_post_id
409 * - {prefix}user-settings
410 * - {prefix}user-settings-time
411 */
412 private function update_usermeta_prefix() {
413 $usermeta_table = $this->new_prefix . 'usermeta';
414
415 $old_like = $this->wpdb->esc_like( $this->old_prefix ) . '%';
416
417 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
418 $metas = $this->wpdb->get_results(
419 $this->wpdb->prepare(
420 "SELECT umeta_id, meta_key FROM `{$usermeta_table}` WHERE meta_key LIKE %s",
421 $old_like
422 )
423 );
424
425 if ( $metas ) {
426 foreach ( $metas as $meta ) {
427 $new_meta_key = $this->new_prefix . substr( $meta->meta_key, strlen( $this->old_prefix ) );
428
429 // Prefix migration has to rewrite meta_key values by definition — the slow-query
430 // rule does not apply here. Disable around the whole statement so the sniff
431 // catches both the call and the 'meta_key' array literal inside it.
432 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.SlowDBQuery.slow_db_query_meta_key
433 $this->wpdb->update(
434 $usermeta_table,
435 array( 'meta_key' => $new_meta_key ),
436 array( 'umeta_id' => $meta->umeta_id ),
437 array( '%s' ),
438 array( '%d' )
439 );
440 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.SlowDBQuery.slow_db_query_meta_key
441 }
442 }
443 }
444
445 /**
446 * Find wp-config.php path
447 *
448 * Checks standard location and one level up (common setup)
449 *
450 * @return string|false Path or false if not found.
451 */
452 private function find_wpconfig_path() {
453 // Standard location
454 $path = ABSPATH . 'wp-config.php';
455 if ( file_exists( $path ) ) {
456 return $path;
457 }
458
459 // One directory up
460 $path = dirname( ABSPATH ) . '/wp-config.php';
461 if ( file_exists( $path ) && ! file_exists( dirname( ABSPATH ) . '/wp-settings.php' ) ) {
462 return $path;
463 }
464
465 return false;
466 }
467 }