From b0985abd26c81b90469b57ace316410be8787e0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Jakes=CC=8C?= Date: Fri, 9 Oct 2026 16:15:20 +0200 Subject: [PATCH 1/2] Add location checks and redesign SQLite settings Warn in Site Health and SQLite settings when the database is under a known public root, including randomized and custom paths. Account for symlinks and keep unknown locations distinct from paths outside known public roots. These checks do not test web access or change database storage. Site Health skips the recommendation on local sites. Organize settings into Overview and Database protection tabs with database size, a hidden path that can be revealed and copied, and instructions for protecting new and existing databases. Site Health Info reports the same database size. Preserve installation and multisite permissions, and cover the behavior with focused tests. --- .../admin-page.css | 283 ++++++++++ .../admin-page.js | 24 + .../admin-page.php | 287 ++++++++++- .../health-check.php | 39 +- .../load.php | 1 + .../readme.txt | 6 + .../storage-health.php | 246 +++++++++ ...atabase_Integration_Authorization_Test.php | 2 +- .../phpunit/WP_SQLite_Storage_Health_Test.php | 487 ++++++++++++++++++ 9 files changed, 1344 insertions(+), 31 deletions(-) create mode 100644 packages/plugin-sqlite-database-integration/admin-page.css create mode 100644 packages/plugin-sqlite-database-integration/admin-page.js create mode 100644 packages/plugin-sqlite-database-integration/storage-health.php create mode 100644 tests/phpunit/WP_SQLite_Storage_Health_Test.php diff --git a/packages/plugin-sqlite-database-integration/admin-page.css b/packages/plugin-sqlite-database-integration/admin-page.css new file mode 100644 index 000000000..5d28fa50a --- /dev/null +++ b/packages/plugin-sqlite-database-integration/admin-page.css @@ -0,0 +1,283 @@ +.settings_page_sqlite-integration #wpcontent, +.settings_page_sqlite-integration.auto-fold #wpcontent { + padding: 0; +} + +.settings_page_sqlite-integration .notice { + margin: 25px 20px 15px; +} + +.sqlite-integration { + margin: 0; +} + +.sqlite-integration .sqlite-header { + padding: 8px 20px 0; + text-align: center; + background: #fff; + border-bottom: 1px solid #dcdcde; +} + +.sqlite-integration .sqlite-header h1 { + font-weight: 600; + margin: 0 0.8rem 1rem; + padding: 9px 0 4px; + font-size: 23px; + line-height: 1.3; +} + +.sqlite-integration .sqlite-intro { + margin: 0 0 1rem; + color: #50575e; +} + +.sqlite-integration .sqlite-tabs { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 0 2rem; +} + +.sqlite-integration .sqlite-tab { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + padding: 0.5rem 1rem 1rem; + color: inherit; + text-decoration: none; +} + +.sqlite-integration .sqlite-tab:focus { + color: #1d2327; + outline: 1px solid #787c82; + box-shadow: none; +} + +.sqlite-integration .sqlite-tab[aria-current="page"] { + box-shadow: inset 0 -3px var(--wp-admin-theme-color, #2271b1); + font-weight: 600; +} + +.sqlite-integration .sqlite-content { + max-width: 740px; + margin: 24px auto; + padding: 0 20px; +} + +.sqlite-integration .dashicons { + width: 18px; + height: 18px; + font-size: 18px; + flex-shrink: 0; +} + +.sqlite-integration .sqlite-warning { + color: #996800; +} + +.sqlite-integration .sqlite-success { + color: #008a20; +} + +.sqlite-integration .sqlite-box, +.sqlite-integration .sqlite-help { + background: #fff; + border: 1px solid #c3c4c7; + margin-bottom: 22px; +} + +.sqlite-integration .sqlite-box > h2 { + font-size: 16px; + line-height: 1.5; + margin: 0; + padding: 18px 20px; + border-bottom: 1px solid #c3c4c7; +} + +.sqlite-integration .sqlite-data { + margin: 0; +} + +.sqlite-integration .sqlite-help > div { + padding: 0 20px 12px; +} + +.sqlite-integration .sqlite-data > div { + display: grid; + grid-template-columns: 120px minmax(0, 1fr); + gap: 20px; + padding: 20px; +} + +.sqlite-integration .sqlite-data > div + div { + border-top: 1px solid #c3c4c7; +} + +.sqlite-integration .sqlite-data dt { + font-weight: 600; +} + +.sqlite-integration .sqlite-data dd { + min-width: 0; + margin: 0; +} + +.sqlite-integration .sqlite-data p { + margin: 0 0 10px; +} + +.sqlite-integration .sqlite-data p:last-child { + margin-bottom: 0; +} + +.sqlite-integration .sqlite-data .description { + margin-top: 10px; + font-size: 12px; + font-style: normal; +} + +.sqlite-integration .sqlite-status { + display: flex; + align-items: flex-start; + gap: 8px; +} + +.sqlite-integration .sqlite-status-dot { + width: 8px; + height: 8px; + margin-top: 6px; + border-radius: 50%; + background: #008a20; + flex-shrink: 0; +} + +.sqlite-integration .sqlite-path summary { + cursor: pointer; + list-style: none; +} + +.sqlite-integration .sqlite-path summary::-webkit-details-marker { + display: none; +} + +.sqlite-integration .sqlite-path-hidden { + margin-inline-end: 12px; + color: #50575e; +} + +.sqlite-integration .sqlite-show-path, +.sqlite-integration .sqlite-hide-path { + color: #2271b1; + text-decoration: underline; +} + +.sqlite-integration .sqlite-hide-path, +.sqlite-integration .sqlite-path[open] .sqlite-path-hidden, +.sqlite-integration .sqlite-path[open] .sqlite-show-path, +.sqlite-integration [hidden] { + display: none; +} + +.sqlite-integration .sqlite-path[open] .sqlite-hide-path { + display: inline; +} + +.sqlite-integration .sqlite-path pre { + margin: 12px 0; + padding: 10px; + background: #f0f0f1; + white-space: nowrap; + overflow-x: auto; + direction: ltr; + text-align: left; +} + +.sqlite-integration .sqlite-path code, +.sqlite-integration .sqlite-instructions pre code { + padding: 0; + margin: 0; + background: transparent; +} + +/* Keep the live region rendered so that screen readers announce new messages. */ +.sqlite-integration .sqlite-copy-status { + display: block; +} + +.sqlite-integration .sqlite-copy-status:not(:empty) { + margin-top: 8px; +} + +.sqlite-integration .sqlite-box-footer { + padding: 14px 20px; + margin: 0; + border-top: 1px solid #c3c4c7; + font-size: 12px; + color: #50575e; +} + +.sqlite-integration .sqlite-instructions { + padding: 8px 20px 20px; +} + +.sqlite-integration .sqlite-instructions h3 { + margin-top: 24px; +} + +.sqlite-integration .sqlite-instructions pre { + padding: 12px; + background: #f0f0f1; + white-space: pre; + overflow-x: auto; + direction: ltr; + text-align: left; +} + +.sqlite-integration .sqlite-instructions li + li { + margin-top: 8px; +} + +.sqlite-integration .sqlite-manage { + margin-top: 28px; +} + +.sqlite-integration .sqlite-help summary { + padding: 16px 20px; + font-weight: 600; + cursor: pointer; +} + +.sqlite-integration .sqlite-installation .notice { + margin-inline: 0; +} + +@media (pointer: coarse) { + + .sqlite-integration .sqlite-path summary { + display: flex; + align-items: center; + min-height: 44px; + } +} + +@media (max-width: 600px) { + + .sqlite-integration .sqlite-header { + padding-inline: 12px; + } + + .sqlite-integration .sqlite-tabs { + gap: 0; + } + + .sqlite-integration .sqlite-content { + margin: 16px auto; + padding: 0 12px; + } + + .sqlite-integration .sqlite-data > div { + grid-template-columns: minmax(0, 1fr); + gap: 10px; + } +} diff --git a/packages/plugin-sqlite-database-integration/admin-page.js b/packages/plugin-sqlite-database-integration/admin-page.js new file mode 100644 index 000000000..ea4ee4091 --- /dev/null +++ b/packages/plugin-sqlite-database-integration/admin-page.js @@ -0,0 +1,24 @@ +/* global ClipboardJS */ +( function() { + 'use strict'; + + const button = document.querySelector( '.sqlite-integration .sqlite-copy-path' ); + if ( ! button || typeof ClipboardJS === 'undefined' ) { + return; + } + + const status = document.querySelector( '.sqlite-integration .sqlite-copy-status' ); + const clipboard = new ClipboardJS( button ); + button.hidden = false; + + clipboard.on( 'success', function( event ) { + status.textContent = status.dataset.success; + event.clearSelection(); + } ); + clipboard.on( 'error', function() { + status.textContent = status.dataset.error; + } ); + button.closest( 'details' ).addEventListener( 'toggle', function() { + status.textContent = ''; + } ); +}() ); diff --git a/packages/plugin-sqlite-database-integration/admin-page.php b/packages/plugin-sqlite-database-integration/admin-page.php index 8f833f496..88f257b49 100644 --- a/packages/plugin-sqlite-database-integration/admin-page.php +++ b/packages/plugin-sqlite-database-integration/admin-page.php @@ -20,6 +20,21 @@ function sqlite_add_admin_menu() { } add_action( is_multisite() ? 'network_admin_menu' : 'admin_menu', 'sqlite_add_admin_menu' ); +/** + * Enqueue assets only on the SQLite settings screen. + * + * @access private + * @param string $hook Current admin screen hook. + */ +function sqlite_plugin_enqueue_admin_assets( $hook ) { + if ( 'settings_page_sqlite-integration' !== $hook ) { + return; + } + wp_enqueue_style( 'sqlite-integration-admin', plugins_url( 'admin-page.css', SQLITE_MAIN_FILE ), array(), SQLITE_DRIVER_VERSION ); + wp_enqueue_script( 'sqlite-integration-admin', plugins_url( 'admin-page.js', SQLITE_MAIN_FILE ), array( 'clipboard' ), SQLITE_DRIVER_VERSION, true ); +} +add_action( 'admin_enqueue_scripts', 'sqlite_plugin_enqueue_admin_assets' ); + /** * The admin page contents. */ @@ -31,6 +46,240 @@ function sqlite_integration_admin_screen() { ); } + // This read-only navigation does not change any settings. + $tab = isset( $_GET['tab'] ) && is_string( $_GET['tab'] ) && 'protection' === $_GET['tab'] ? 'protection' : 'overview'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended + $active = defined( 'SQLITE_DB_DROPIN_VERSION' ); + $status = sqlite_plugin_get_database_location_status(); + $url = sqlite_plugin_get_admin_page_url(); + ?> +
+
+

+

+ +
+
+
+ + + + + +
+

+
+
+ + + + + +
+ + + +
+

+

+

+
+
+
+ +
+

+

+
+

+

+ deactivate the plugin to remove the SQLite drop-in, or manually remove %2$s. Verify that your content loads correctly from MySQL.', 'sqlite-database-integration' ) ), + esc_url( self_admin_url( 'plugins.php' ) ), + '' . esc_html( basename( WP_CONTENT_DIR ) . '/db.php' ) . '' + ); + ?> +

+ +

+ +
+
+ + + + +
+
+ +
+

+
+

+ +
+ + + +
+ 'dashicons-warning sqlite-warning', + 'label' => __( 'Review database location', 'sqlite-database-integration' ), + 'summary' => __( 'The database location may be publicly accessible.', 'sqlite-database-integration' ), + 'description' => sqlite_plugin_get_database_location_warning(), + ); + } + if ( 'outside' === $status ) { + return array( + 'icon' => 'dashicons-yes-alt sqlite-success', + 'label' => __( 'Outside known public roots', 'sqlite-database-integration' ), + 'summary' => __( 'Public access has not been tested.', 'sqlite-database-integration' ), + 'description' => __( 'The database is outside the WordPress directory, content directory, and other public roots identified by this check. Other websites, aliases, or server rules may still expose this folder.', 'sqlite-database-integration' ), + ); + } + if ( 'memory' === $status ) { + return array( + 'icon' => 'dashicons-info-outline', + 'label' => __( 'In-memory database', 'sqlite-database-integration' ), + 'summary' => __( 'There is no database file to expose.', 'sqlite-database-integration' ), + 'description' => __( 'SQLite stores this database in memory, so there is no database file to expose.', 'sqlite-database-integration' ), + ); + } + return array( + 'icon' => 'dashicons-warning sqlite-warning', + 'label' => __( 'Database location unavailable', 'sqlite-database-integration' ), + 'summary' => __( 'The database location could not be checked.', 'sqlite-database-integration' ), + 'description' => __( 'We could not determine an absolute database path to check. Ask your hosting provider to review its location.', 'sqlite-database-integration' ), + ); +} + +/** + * Display the active database size and an initially hidden filesystem path. + * + * @access private + * @param string $path Active database path, or ":memory:". + */ +function sqlite_plugin_render_database_details( $path ) { + $size = sqlite_plugin_get_database_size( $path ); + ?> +
+ + +

+ +
+
+ + + +
+ +
+ + +
+ +
+ +
+

+
+

+

private folder outside the public web root, or ask your hosting provider to help you find one. Make sure the folder is not exposed through another website, alias, or symbolic link.', 'sqlite-database-integration' ) ); ?>

+

+ +

+

+ DB_PATH', + 'wp-config.php' + ); + ?> +

+
define( 'DB_PATH', '/path/to/private/database.sqlite' );
+ +

+

+ DB_PATH' + ); + ?> +

+
    +
  1. +
  2. +
  3. + DB_PATH', + 'wp-config.php' + ); + ?> +
  4. +
+ +

+

+
+
+ -
-

-
- -
- -
-

-
-

- deactivate the plugin. Alternatively, you can manually delete the %2$s file from your server.', 'sqlite-database-integration' ), - esc_url( self_admin_url( 'plugins.php' ) ), - '' . esc_html( basename( WP_CONTENT_DIR ) ) . '/db.php' - ); - ?> -

- -
+
+ +

-
+

' . esc_html( basename( WP_CONTENT_DIR ) ) . '/db.php' ); ?>

-
+

' . esc_html( basename( WP_CONTENT_DIR ) ) . '' ); ?>

-
-

+
+

@@ -120,7 +351,7 @@ function sqlite_integration_admin_screen() { -
+
true, ); + $database_size = sqlite_plugin_get_database_size( DB_PATH ); + $info['wp-database']['fields']['database_size'] = array( 'label' => __( 'Database size', 'sqlite-database-integration' ), - 'value' => ':memory:' === DB_PATH + 'value' => null === $database_size ? __( 'Not available', 'sqlite-database-integration' ) - : size_format( filesize( DB_PATH ) ), + : size_format( $database_size, 1 ), ); unset( $info['wp-database']['fields']['extension'] ); @@ -75,8 +77,41 @@ function sqlite_plugin_filter_site_status_tests( $tests ) { unset( $tests['direct']['utf8mb4_support'] ); unset( $tests['direct']['sql_server'] ); unset( $tests['direct']['persistent_object_cache'] ); // Throws an error because DB_NAME is not defined. + + // Local development sites are not served publicly. + if ( + 'local' !== wp_get_environment_type() + && current_user_can( sqlite_plugin_get_manage_capability() ) + && 'public' === sqlite_plugin_get_database_location_status() + ) { + $tests['direct']['sqlite_database_location'] = array( + 'label' => __( 'SQLite database location', 'sqlite-database-integration' ), + 'test' => 'sqlite_plugin_test_database_location', + 'skip_cron' => true, + ); + } } return $tests; } add_filter( 'site_status_tests', 'sqlite_plugin_filter_site_status_tests' ); + +/** + * Recommend private storage when the database is in a potentially public folder. + * + * @access private + * @return array Site Health result. + */ +function sqlite_plugin_test_database_location() { + return array( + 'label' => __( 'Review the SQLite database location', 'sqlite-database-integration' ), + 'status' => 'recommended', + 'badge' => array( + 'label' => __( 'Security', 'sqlite-database-integration' ), + 'color' => 'blue', + ), + 'description' => wp_kses_post( wpautop( sqlite_plugin_get_database_location_warning() ) ), + 'actions' => '', + 'test' => 'sqlite_database_location', + ); +} diff --git a/packages/plugin-sqlite-database-integration/load.php b/packages/plugin-sqlite-database-integration/load.php index ad5d08bd8..5a4b5e45f 100644 --- a/packages/plugin-sqlite-database-integration/load.php +++ b/packages/plugin-sqlite-database-integration/load.php @@ -22,6 +22,7 @@ define( 'SQLITE_MAIN_FILE', __FILE__ ); require_once __DIR__ . '/capabilities.php'; +require_once __DIR__ . '/storage-health.php'; require_once __DIR__ . '/admin-page.php'; require_once __DIR__ . '/activate.php'; require_once __DIR__ . '/deactivate.php'; diff --git a/packages/plugin-sqlite-database-integration/readme.txt b/packages/plugin-sqlite-database-integration/readme.txt index bd71661a2..74c0f42fa 100644 --- a/packages/plugin-sqlite-database-integration/readme.txt +++ b/packages/plugin-sqlite-database-integration/readme.txt @@ -62,6 +62,12 @@ The random path makes the database location difficult to guess when it is not ot Integrations should always read `DB_PATH` after WordPress loads instead of assuming a fixed database path. += Why do I see a database location warning? = + +Your SQLite database is in a folder that your website may expose, such as the WordPress folder, `wp-content`, or another known public web root. We recommend moving it to a private folder outside the public web root that your web server does not expose. + +For instructions, open **Settings → SQLite integration → Database protection**. On multisite, use Network Admin. + = How can I configure the database location? = To properly **secure the SQLite database**, define the `DB_PATH` constant to an **explicit path** that is protected from public web access. An explicit `DB_PATH` value is used as-is without randomization. Define it in `wp-config.php`: diff --git a/packages/plugin-sqlite-database-integration/storage-health.php b/packages/plugin-sqlite-database-integration/storage-health.php new file mode 100644 index 000000000..1bf75ff08 --- /dev/null +++ b/packages/plugin-sqlite-database-integration/storage-health.php @@ -0,0 +1,246 @@ +Learn how to protect your database', 'sqlite-database-integration' ), + esc_url( add_query_arg( 'tab', 'protection', sqlite_plugin_get_admin_page_url() ) . '#sqlite-database-protection' ) + ); +} + +/** + * Check the active or planned database location against known public directories. + * + * An "outside" result does not prove the location is private: server aliases and + * other public directories may be unknown to WordPress. No storage is initialized. + * + * @access private + * @return string Location status: public, outside, memory, or unknown. + */ +function sqlite_plugin_get_database_location_status() { + $path = sqlite_plugin_get_database_path(); + if ( ':memory:' === $path ) { + return 'memory'; + } + $paths = sqlite_plugin_get_storage_path_variants( $path ); + if ( empty( $paths ) ) { + return 'unknown'; + } + + $directories = array(); + foreach ( sqlite_plugin_get_public_roots() as $root ) { + // A root is a directory, so a bare UNC share is valid with a trailing slash. + if ( is_string( $root ) && '' !== $root ) { + $root = rtrim( $root, '/\\' ) . '/'; + } + $directories = array_merge( $directories, sqlite_plugin_get_storage_path_variants( $root ) ); + } + $directory_ids = array(); + foreach ( $directories as $directory ) { + foreach ( $paths as $candidate ) { + if ( $candidate === $directory || 0 === strpos( $candidate, rtrim( $directory, '/' ) . '/' ) ) { + return 'public'; + } + } + $stat = @stat( $directory ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Do not disclose filesystem paths. + // Some platforms do not provide usable inode numbers. + if ( false !== $stat && ! empty( $stat['ino'] ) ) { + $directory_ids[] = $stat['dev'] . ':' . $stat['ino']; + } + } + + // realpath() may preserve letter case on case-insensitive filesystems. + foreach ( $paths as $candidate ) { + for ( $ancestor = $candidate; ; $ancestor = dirname( $ancestor ) ) { + $stat = @stat( $ancestor ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Do not disclose the database path. + if ( false !== $stat && in_array( $stat['dev'] . ':' . $stat['ino'], $directory_ids, true ) ) { + return 'public'; + } + if ( dirname( $ancestor ) === $ancestor ) { + break; + } + } + } + return 'outside'; +} + +/** + * Read the active path, or the planned legacy/default location before setup. + * + * Never initialize storage or load migration metadata to display this page. + * + * @access private + * @return mixed Configured path, which may be invalid. + */ +function sqlite_plugin_get_database_path() { + if ( defined( 'DB_PATH' ) ) { + return DB_PATH; + } + if ( defined( 'FQDB' ) ) { + return FQDB; + } + + // Match FQDBDIR and FQDB as defined in constants.php. + if ( defined( 'FQDBDIR' ) ) { + $path = FQDBDIR; + } elseif ( defined( 'DB_DIR' ) ) { + $path = is_string( DB_DIR ) ? rtrim( DB_DIR, '/\\' ) . '/' : DB_DIR; + } else { + $path = WP_CONTENT_DIR . '/database/'; + } + // The legacy FQDB appends DB_FILE without a separator. + if ( is_string( $path ) && defined( 'DB_FILE' ) && is_string( DB_FILE ) ) { + $path .= DB_FILE; + } + return $path; +} + +/** + * List the directories that WordPress or the web server may serve publicly. + * + * @access private + * @return mixed[] Directory paths, which may be invalid. + */ +function sqlite_plugin_get_public_roots() { + $uploads = wp_upload_dir( null, false ); + $roots = array( + WP_CONTENT_DIR, + ABSPATH, + sqlite_plugin_get_home_directory(), + WP_PLUGIN_DIR, + WPMU_PLUGIN_DIR, + $uploads['basedir'], + ); + // Apache sets CONTEXT_DOCUMENT_ROOT for aliases and user directories. + foreach ( array( 'DOCUMENT_ROOT', 'CONTEXT_DOCUMENT_ROOT' ) as $name ) { + if ( isset( $_SERVER[ $name ] ) ) { + $roots[] = wp_unslash( $_SERVER[ $name ] ); + } + } + return $roots; +} + +/** + * Find the site root when WordPress is installed in its own subdirectory. + * + * Unlike get_home_path(), match the subdirectory against ABSPATH rather than the + * running script, and never fall back to the filesystem root. + * + * @access private + * @return string|null Site root, or null when WordPress is not in its subdirectory. + */ +function sqlite_plugin_get_home_directory() { + $home = preg_replace( '~^\w+://~', '', untrailingslashit( (string) get_option( 'home' ) ) ); + $site = preg_replace( '~^\w+://~', '', untrailingslashit( (string) get_option( 'siteurl' ) ) ); + if ( 0 !== stripos( $site, $home . '/' ) ) { + return null; + } + + // Remove the WordPress subdirectory, such as "/wp/", from the end of ABSPATH. + $subdirectory = rawurldecode( substr( $site, strlen( $home ) ) ) . '/'; + $abspath = str_replace( '\\', '/', ABSPATH ); + $length = strlen( $abspath ) - strlen( $subdirectory ); + if ( $length < 1 || 0 !== substr_compare( $abspath, $subdirectory, $length, strlen( $subdirectory ), true ) ) { + return null; + } + return substr( $abspath, 0, $length ); +} + +/** + * Measure the database and its SQLite sidecars without opening the database. + * + * @access private + * @param string $path Database file path, or ":memory:". + * @return int|null Total bytes, or null when unavailable. + */ +function sqlite_plugin_get_database_size( $path ) { + // Filesystem warnings must not disclose the randomized database path. + $size = ':memory:' === $path ? false : @filesize( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged + if ( false === $size ) { + return null; + } + foreach ( array( '-wal', '-shm', '-journal' ) as $suffix ) { + $size += (int) @filesize( $path . $suffix ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged + } + return $size; +} + +/** + * Keep both the configured path and its filesystem target for location checks. + * + * The configured path may be public even when a symlink points outside the root. + * Resolve the nearest existing ancestor to also check locations before setup. + * + * @access private + * @param mixed $path Filesystem path. + * @return string[] Normalized absolute paths, or an empty array for other values. + */ +function sqlite_plugin_get_storage_path_variants( $path ) { + if ( ! is_string( $path ) || '' === $path || false !== strpos( $path, "\0" ) ) { + return array(); + } + // Accept the same absolute paths as WP_SQLite_Storage::is_absolute_path(). + if ( '\\' === DIRECTORY_SEPARATOR ) { + $path = str_replace( '\\', '/', $path ); + if ( ! preg_match( '~^(?:[a-zA-Z]:/|//[^/]+/[^/]+/)~', $path ) ) { + return array(); + } + } elseif ( '/' !== $path[0] ) { + return array(); + } + + $paths = array( sqlite_plugin_normalize_storage_path( $path ) ); + $suffix = ''; + for ( $ancestor = $path; ; $ancestor = dirname( $ancestor ) ) { + // Filesystem warnings can disclose the randomized database path. + $resolved = @realpath( $ancestor ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged + if ( false !== $resolved ) { + $paths[] = sqlite_plugin_normalize_storage_path( $resolved . $suffix ); + break; + } + if ( dirname( $ancestor ) === $ancestor ) { + break; + } + $suffix = '/' . basename( $ancestor ) . $suffix; + } + + return array_unique( $paths ); +} + +/** + * Normalize separators and dot segments for directory comparisons. + * + * @access private + * @param string $path Absolute filesystem path. + * @return string Normalized path. + */ +function sqlite_plugin_normalize_storage_path( $path ) { + $prefix = '/'; + if ( '\\' === DIRECTORY_SEPARATOR ) { + $path = strtolower( str_replace( '\\', '/', $path ) ); + preg_match( '~^(?:[a-z]:|//[^/]+/[^/]+)(?:/|$)~', $path, $matches ); + $prefix = rtrim( $matches[0], '/' ) . '/'; + $path = substr( $path, strlen( $matches[0] ) ); + } + $parts = array(); + foreach ( explode( '/', $path ) as $part ) { + if ( '' === $part || '.' === $part ) { + continue; + } + if ( '..' === $part ) { + array_pop( $parts ); + } else { + $parts[] = $part; + } + } + return $prefix . implode( '/', $parts ); +} diff --git a/tests/phpunit/WP_SQLite_Database_Integration_Authorization_Test.php b/tests/phpunit/WP_SQLite_Database_Integration_Authorization_Test.php index 8dc833568..3cbe14512 100644 --- a/tests/phpunit/WP_SQLite_Database_Integration_Authorization_Test.php +++ b/tests/phpunit/WP_SQLite_Database_Integration_Authorization_Test.php @@ -770,7 +770,7 @@ private function assert_admin_screen_is_accessible() { sqlite_integration_admin_screen(); $output = ob_get_clean(); - $this->assertStringContainsString( 'SQLite is enabled.', $output ); + $this->assertStringContainsString( 'SQLite is active', $output ); $this->assertStringContainsString( esc_url( self_admin_url( 'plugins.php' ) ), $output ); } diff --git a/tests/phpunit/WP_SQLite_Storage_Health_Test.php b/tests/phpunit/WP_SQLite_Storage_Health_Test.php new file mode 100644 index 000000000..26905343d --- /dev/null +++ b/tests/phpunit/WP_SQLite_Storage_Health_Test.php @@ -0,0 +1,487 @@ +directory = sys_get_temp_dir() . '/sqlite-storage-health-' . bin2hex( random_bytes( 8 ) ); + $this->document_root = $_SERVER['DOCUMENT_ROOT'] ?? null; + $this->assertTrue( mkdir( $this->directory, 0700 ) ); + } + + public function tear_down() { + wp_dequeue_style( 'sqlite-integration-admin' ); + wp_dequeue_script( 'sqlite-integration-admin' ); + if ( null === $this->document_root ) { + unset( $_SERVER['DOCUMENT_ROOT'] ); + } else { + $_SERVER['DOCUMENT_ROOT'] = $this->document_root; + } + $this->remove_directory( $this->directory ); + parent::tear_down(); + } + + /** + * @dataProvider database_locations + */ + public function test_checks_locations_without_initializing_storage( $settings, $document_root, $expected, $environment = array() ) { + $this->assert_location_status( $settings, $document_root, $expected, $environment ); + $this->assertSame( array( '.', '..' ), scandir( $this->directory ) ); + } + + public function database_locations() { + $filesystem_root = sys_get_temp_dir(); + while ( dirname( $filesystem_root ) !== $filesystem_root ) { + $filesystem_root = dirname( $filesystem_root ); + } + + return array( + 'default storage' => array( array(), null, 'public' ), + 'randomized path' => array( array( 'DB_PATH' => '{root}/content/database/.ht.secret/.ht.sqlite' ), null, 'public' ), + 'WordPress directory' => array( array( 'DB_PATH' => '{root}/wordpress/site.db' ), null, 'public' ), + 'document root only' => array( array( 'DB_PATH' => '{root}/public/site.db' ), '{root}/public', 'public' ), + 'private explicit path' => array( array( 'DB_PATH' => '{root}/private/site.db' ), '{root}/public', 'outside' ), + 'private path wins' => array( + array( + 'DB_PATH' => '{root}/private/site.db', + 'FQDB' => '{root}/content/site.db', + ), + null, + 'outside', + ), + 'legacy private file path' => array( array( 'FQDB' => '{root}/private/legacy.db' ), null, 'outside' ), + 'legacy private directory' => array( array( 'DB_DIR' => '{root}/private' ), null, 'outside' ), + 'legacy root wins' => array( + array( + 'FQDBDIR' => '{root}/private', + 'DB_DIR' => '{root}/content', + ), + null, + 'outside', + ), + 'legacy filename with parents' => array( + array( + 'DB_DIR' => '{root}/private', + 'DB_FILE' => '../content/site.db', + ), + null, + 'public', + ), + 'legacy file without slash' => array( + array( + 'FQDBDIR' => '{root}/content', + 'DB_FILE' => '.db', + ), + null, + 'outside', + ), + 'in-memory database' => array( + array( + 'DB_PATH' => ':memory:', + 'FQDB' => '{root}/content/site.db', + ), + null, + 'memory', + ), + 'legacy in-memory database' => array( array( 'FQDB' => ':memory:' ), null, 'memory' ), + 'invalid explicit path' => array( + array( + 'DB_PATH' => false, + 'FQDB' => '{root}/content/site.db', + ), + null, + 'unknown', + ), + 'empty explicit path' => array( array( 'DB_PATH' => '' ), null, 'unknown' ), + 'relative explicit path' => array( array( 'DB_PATH' => 'content/site.db' ), null, 'unknown' ), + 'stream path' => array( array( 'DB_PATH' => 'file://{root}/content/site.db' ), null, 'unknown' ), + 'nul byte' => array( array( 'DB_PATH' => "{root}/content/site.db\0" ), null, 'unknown' ), + 'sibling prefix' => array( array( 'DB_PATH' => '{root}/content-private/site.db' ), null, 'outside' ), + 'dot segments inside' => array( array( 'DB_PATH' => '{root}/private/../content/./site.db' ), null, 'public' ), + 'dot segments outside' => array( array( 'DB_PATH' => '{root}/content/../private/site.db' ), null, 'outside' ), + 'repeated separators' => array( array( 'DB_PATH' => '{root}//content//site.db' ), null, 'public' ), + 'root directory itself' => array( array( 'DB_DIR' => '{root}/content/' ), null, 'public' ), + 'empty document root' => array( array( 'DB_PATH' => '{root}/private/site.db' ), '', 'outside' ), + 'relative document root' => array( array( 'DB_PATH' => '{root}/private/site.db' ), 'private', 'outside' ), + 'invalid document root' => array( array( 'DB_PATH' => '{root}/private/site.db' ), array(), 'outside' ), + 'filesystem document root' => array( array( 'DB_PATH' => '{root}/private/site.db' ), $filesystem_root, 'public' ), + 'slashed document root' => array( array( 'DB_PATH' => "{root}/o'brien/site.db" ), addslashes( "{root}/o'brien" ), 'public' ), + 'context document root' => array( + array( 'DB_PATH' => '{root}/public/site.db' ), + null, + 'public', + array( 'CONTEXT_DOCUMENT_ROOT' => '{root}/public' ), + ), + 'plugin directory' => array( + array( + 'WP_PLUGIN_DIR' => '{root}/plugins', + 'DB_PATH' => '{root}/plugins/site.db', + ), + null, + 'public', + ), + 'must-use plugin directory' => array( + array( + 'WPMU_PLUGIN_DIR' => '{root}/mu-plugins', + 'DB_PATH' => '{root}/mu-plugins/site.db', + ), + null, + 'public', + ), + 'uploads directory' => array( + array( 'DB_PATH' => '{root}/media/site.db' ), + null, + 'public', + array( 'upload_dir' => '{root}/media' ), + ), + 'site above WordPress' => array( + array( + 'ABSPATH' => '{root}/site/wp/', + 'DB_PATH' => '{root}/site/site.db', + ), + null, + 'public', + array( 'siteurl' => 'https://example.com/wp' ), + ), + 'encoded WordPress directory' => array( + array( + 'ABSPATH' => '{root}/site/my wp/', + 'DB_PATH' => '{root}/site/site.db', + ), + null, + 'public', + array( + 'home' => 'http://example.com/', + 'siteurl' => 'https://EXAMPLE.com/My%20WP', + ), + ), + 'mismatched WordPress path' => array( + array( 'DB_PATH' => '{root}/site.db' ), + null, + 'outside', + array( 'siteurl' => 'https://example.com/wp' ), + ), + 'WordPress on another host' => array( + array( + 'ABSPATH' => '{root}/site/wp/', + 'DB_PATH' => '{root}/site/site.db', + ), + null, + 'outside', + array( 'siteurl' => 'https://wp.example.com/wp' ), + ), + ); + } + + public function test_checks_symlinks_in_both_directions_and_before_creation() { + $this->assertTrue( mkdir( $this->directory . '/content' ) ); + $this->assertTrue( mkdir( $this->directory . '/private' ) ); + $this->assertTrue( symlink( $this->directory . '/private', $this->directory . '/content/link' ) ); + $this->assertTrue( symlink( $this->directory . '/content', $this->directory . '/private/link' ) ); + + $this->assert_location_status( array( 'DB_PATH' => '{root}/content/link/new/site.db' ), null, 'public' ); + $this->assert_location_status( array( 'DB_PATH' => '{root}/private/link/new/site.db' ), null, 'public' ); + $this->assertFileDoesNotExist( $this->directory . '/private/new' ); + $this->assertFileDoesNotExist( $this->directory . '/content/new' ); + } + + public function test_respects_filesystem_case_sensitivity() { + $this->assertTrue( mkdir( $this->directory . '/content' ) ); + $same_directory = is_dir( $this->directory . '/CONTENT' ); + if ( ! $same_directory ) { + $this->assertTrue( mkdir( $this->directory . '/CONTENT' ) ); + } + file_put_contents( $this->directory . '/CONTENT/site.db', 'Existing database contents.' ); + $status = $same_directory ? 'public' : 'outside'; + + $this->assert_location_status( array( 'DB_PATH' => '{root}/CONTENT/site.db' ), null, $status ); + $this->assert_location_status( array( 'DB_PATH' => '{root}/CONTENT/new/site.db' ), null, $status ); + $this->assert_location_status( + array( + 'WP_CONTENT_DIR' => '{root}/CONTENT', + 'DB_PATH' => '{root}/content/new/site.db', + ), + null, + $status + ); + $this->assertFileDoesNotExist( $this->directory . '/CONTENT/new' ); + $this->assertSame( 'Existing database contents.', file_get_contents( $this->directory . '/CONTENT/site.db' ) ); + } + + public function test_checks_the_target_of_a_symlinked_content_directory() { + $this->assertTrue( mkdir( $this->directory . '/shared-content' ) ); + $this->assertTrue( symlink( $this->directory . '/shared-content', $this->directory . '/content' ) ); + + $this->assert_location_status( array( 'DB_PATH' => '{root}/shared-content/site.db' ), null, 'public' ); + } + + public function test_keeps_the_warning_for_a_broken_symlink_in_a_public_directory() { + $this->assertTrue( mkdir( $this->directory . '/content' ) ); + $this->assertTrue( symlink( $this->directory . '/missing', $this->directory . '/content/link' ) ); + + $this->assert_location_status( array( 'DB_PATH' => '{root}/content/link/site.db' ), null, 'public' ); + } + + public function test_site_health_recommends_review_without_disclosing_the_path() { + $this->set_management_user(); + $_SERVER['DOCUMENT_ROOT'] = dirname( DB_PATH ); + + $tests = sqlite_plugin_filter_site_status_tests( array( 'direct' => array() ) ); + $test = $tests['direct']['sqlite_database_location']; + $result = call_user_func( $test['test'] ); + + $this->assertTrue( $test['skip_cron'] ); + $this->assertSame( 'recommended', $result['status'] ); + $this->assertSame( 'Security', $result['badge']['label'] ); + $this->assertStringContainsString( 'The SQLite database location may be publicly accessible.', $result['description'] ); + $settings_url = is_multisite() + ? network_admin_url( 'settings.php?page=sqlite-integration' ) + : admin_url( 'options-general.php?page=sqlite-integration' ); + $this->assertStringContainsString( + wp_kses_post( 'Learn how to protect your database' ), + $result['description'] + ); + $this->assertStringNotContainsString( DB_PATH, wp_json_encode( $result ) ); + $this->assertStringNotContainsString( basename( dirname( DB_PATH ) ), $result['description'] ); + } + + public function test_site_health_omits_the_check_for_users_who_cannot_manage_storage() { + wp_set_current_user( self::factory()->user->create( array( 'role' => is_multisite() ? 'administrator' : 'subscriber' ) ) ); + $_SERVER['DOCUMENT_ROOT'] = dirname( DB_PATH ); + + $tests = sqlite_plugin_filter_site_status_tests( array( 'direct' => array() ) ); + + $this->assertArrayNotHasKey( 'sqlite_database_location', $tests['direct'] ); + } + + public function test_site_health_omits_the_check_on_local_sites() { + $this->set_management_user(); + $_SERVER['DOCUMENT_ROOT'] = dirname( DB_PATH ); + + $environment = getenv( 'WP_ENVIRONMENT_TYPE' ); + $environment_type = wp_get_environment_type(); + putenv( 'WP_ENVIRONMENT_TYPE=local' ); + try { + $tests = sqlite_plugin_filter_site_status_tests( array( 'direct' => array() ) ); + } finally { + // wp_get_environment_type() keeps the last value it read, so let it read the previous type. + putenv( 'WP_ENVIRONMENT_TYPE=' . $environment_type ); + wp_get_environment_type(); + putenv( false === $environment ? 'WP_ENVIRONMENT_TYPE' : 'WP_ENVIRONMENT_TYPE=' . $environment ); + } + + $this->assertArrayNotHasKey( 'sqlite_database_location', $tests['direct'] ); + $this->assertSame( $environment_type, wp_get_environment_type() ); + } + + public function test_settings_screen_shows_the_same_warning() { + $this->set_management_user(); + $_SERVER['DOCUMENT_ROOT'] = dirname( DB_PATH ); + $_GET['tab'] = 'protection'; + + ob_start(); + sqlite_integration_admin_screen(); + $html = ob_get_clean(); + + $this->assertStringContainsString( 'dashicons-warning sqlite-warning', $html ); + $this->assertStringContainsString( wp_kses_post( wpautop( sqlite_plugin_get_database_location_warning() ) ), $html ); + $this->assertStringContainsString( 'id="sqlite-database-protection"', $html ); + $this->assertStringContainsString( 'Public access has not been tested.', $html ); + $this->assertStringContainsString( 'Changing DB_PATH does not move an existing database.', $html ); + $this->assertStringNotContainsString( DB_PATH, $html ); + } + + public function test_overview_shows_database_details_and_links_to_protection() { + $this->set_management_user(); + unset( $_GET['tab'] ); + $_SERVER['DOCUMENT_ROOT'] = dirname( DB_PATH ); + ob_start(); + sqlite_integration_admin_screen(); + $html = ob_get_clean(); + + $this->assertStringContainsString( 'SQLite is active', $html ); + $this->assertStringContainsString( 'Database size', $html ); + $this->assertStringContainsString( 'Includes the database and its supporting files.', $html ); + $this->assertStringContainsString( '
', $html ); + $this->assertStringNotContainsString( '
assertStringContainsString( esc_html( DB_PATH ), $html ); + $this->assertStringContainsString( 'Copy path', $html ); + $this->assertStringContainsString( 'View protection status', $html ); + $this->assertStringNotContainsString( 'id="sqlite-database-protection"', $html ); + } + + public function test_outside_location_has_a_limited_positive_result() { + ob_start(); + sqlite_plugin_render_protection_status( 'outside' ); + $html = ob_get_clean(); + + $this->assertStringContainsString( 'Outside known public roots', $html ); + $this->assertStringContainsString( 'dashicons-yes-alt sqlite-success', $html ); + $this->assertStringContainsString( 'Other websites, aliases, or server rules may still expose this folder.', $html ); + $this->assertStringContainsString( 'Public access has not been tested.', $html ); + $this->assertStringNotContainsString( 'sqlite-warning', $html ); + } + + /** + * @dataProvider invalid_tabs + */ + public function test_invalid_tabs_fall_back_to_overview( $tab ) { + $this->set_management_user(); + $_GET['tab'] = $tab; + ob_start(); + sqlite_integration_admin_screen(); + $html = ob_get_clean(); + $this->assertStringContainsString( 'id="sqlite-status-heading"', $html ); + $this->assertStringNotContainsString( '' ), array( 'other' ) ); + } + + public function test_size_includes_only_the_database_and_sqlite_sidecars() { + $path = $this->directory . '/database.sqlite'; + foreach ( array( + '' => 1024, + '-wal' => 256, + '-shm' => 128, + '-journal' => 64, + '.backup' => 999, + ) as $suffix => $size ) { + file_put_contents( $path . $suffix, str_repeat( 'x', $size ) ); + } + $this->assertSame( 1472, sqlite_plugin_get_database_size( $path ) ); + file_put_contents( $path . '-wal', 'x' ); + $this->assertSame( 1217, sqlite_plugin_get_database_size( $path ) ); + unlink( $path . '-wal' ); + $this->assertSame( 1216, sqlite_plugin_get_database_size( $path ) ); + } + + public function test_size_handles_empty_missing_and_in_memory_databases() { + $path = $this->directory . '/empty.sqlite'; + file_put_contents( $path, '' ); + $this->assertSame( 0, sqlite_plugin_get_database_size( $path ) ); + $this->assertNull( sqlite_plugin_get_database_size( $path . '.missing' ) ); + $this->assertNull( sqlite_plugin_get_database_size( ':memory:' ) ); + } + + public function test_site_health_info_reports_the_same_size_as_the_overview() { + $info = sqlite_plugin_filter_debug_data( array() ); + ob_start(); + sqlite_plugin_render_database_details( DB_PATH ); + $html = ob_get_clean(); + $this->assertStringContainsString( esc_html( $info['wp-database']['fields']['database_size']['value'] ), $html ); + } + + public function test_database_path_is_escaped_and_never_a_download_link() { + $path = $this->directory . '/.sqlite'; + ob_start(); + sqlite_plugin_render_database_details( $path ); + $html = ob_get_clean(); + $this->assertStringContainsString( esc_html( $path ), $html ); + $this->assertStringNotContainsString( '