diff --git a/plugin/waypoints/plan-your-day.php b/plugin/waypoints/plan-your-day.php index d82de2c..71c5c32 100644 --- a/plugin/waypoints/plan-your-day.php +++ b/plugin/waypoints/plan-your-day.php @@ -7,7 +7,6 @@ * Requires PHP: 8.2 * Author: acodebeard * Text Domain: waypoints - * Domain Path: /languages * License: GPL-2.0-or-later * License URI: https://www.gnu.org/licenses/gpl-2.0.html */ diff --git a/plugin/waypoints/src/Admin/SettingsPage.php b/plugin/waypoints/src/Admin/SettingsPage.php index 165f5be..e75166a 100644 --- a/plugin/waypoints/src/Admin/SettingsPage.php +++ b/plugin/waypoints/src/Admin/SettingsPage.php @@ -823,6 +823,7 @@ private function build_google_test_query( string $default_address, array $catego } private function render_google_test_notice(): void { + // phpcs:disable WordPress.Security.NonceVerification.Recommended -- Read-only admin notice flag from a nonce-protected Google API test redirect. if ( ! isset( $_GET['plan_your_day_google_tested'] ) || is_array( $_GET['plan_your_day_google_tested'] ) ) { return; } @@ -847,6 +848,7 @@ private function render_google_test_notice(): void { ) ) ); + // phpcs:enable WordPress.Security.NonceVerification.Recommended } private function render_google_test_results_panel(): void { @@ -1083,9 +1085,11 @@ private function is_settings_screen( string $hook_suffix ): bool { return true; } + // phpcs:disable WordPress.Security.NonceVerification.Recommended -- Read-only admin page routing check; no state is changed. $page = isset( $_GET['page'] ) && ! is_array( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : ''; + // phpcs:enable WordPress.Security.NonceVerification.Recommended return Settings::PAGE_SLUG === $page; } @@ -1249,6 +1253,7 @@ private function render_select( string $name, string $id, string $value, array $ } private function render_cache_notice(): void { + // phpcs:disable WordPress.Security.NonceVerification.Recommended -- Read-only admin notice flag from nonce-protected cache-clear redirects. if ( ! isset( $_GET['plan_your_day_cache_cleared'] ) ) { $this->render_scope_cache_notice(); $this->render_place_cache_notice(); @@ -1274,9 +1279,11 @@ private function render_cache_notice(): void { $this->render_scope_cache_notice(); $this->render_place_cache_notice(); + // phpcs:enable WordPress.Security.NonceVerification.Recommended } private function render_scope_cache_notice(): void { + // phpcs:disable WordPress.Security.NonceVerification.Recommended -- Read-only admin notice flag from a nonce-protected cache-clear redirect. if ( ! isset( $_GET['plan_your_day_cache_scope_cleared'] ) || is_array( $_GET['plan_your_day_cache_scope_cleared'] ) ) { return; } @@ -1307,9 +1314,11 @@ private function render_scope_cache_notice(): void { ) ) ); + // phpcs:enable WordPress.Security.NonceVerification.Recommended } private function render_place_cache_notice(): void { + // phpcs:disable WordPress.Security.NonceVerification.Recommended -- Read-only admin notice flag from a nonce-protected cache-clear redirect. if ( ! isset( $_GET['plan_your_day_cache_place_cleared'] ) || is_array( $_GET['plan_your_day_cache_place_cleared'] ) ) { return; } @@ -1340,6 +1349,7 @@ private function render_place_cache_notice(): void { ) ) ); + // phpcs:enable WordPress.Security.NonceVerification.Recommended } private function google_cache_scope_choices(): array { diff --git a/plugin/waypoints/src/Frontend/PlannerBlock.php b/plugin/waypoints/src/Frontend/PlannerBlock.php index 500afab..70a6a12 100644 --- a/plugin/waypoints/src/Frontend/PlannerBlock.php +++ b/plugin/waypoints/src/Frontend/PlannerBlock.php @@ -52,6 +52,7 @@ public function render( array $attributes = [], string $content = '', mixed $blo $this->assets->enqueue(); + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public dynamic block reads URL planner state; values are sanitized by RequestStateParser before use. return $this->renderer->render( $_GET, $action_url ); } } diff --git a/plugin/waypoints/src/Frontend/PlannerShortcode.php b/plugin/waypoints/src/Frontend/PlannerShortcode.php index b291d98..e026645 100644 --- a/plugin/waypoints/src/Frontend/PlannerShortcode.php +++ b/plugin/waypoints/src/Frontend/PlannerShortcode.php @@ -35,6 +35,7 @@ public function render( array|string $attributes = [], ?string $content = null, $this->assets->enqueue(); + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public shortcode reads URL planner state; values are sanitized by RequestStateParser before use. return $this->renderer->render( $_GET, $action_url ); } } diff --git a/plugin/waypoints/src/Plugin.php b/plugin/waypoints/src/Plugin.php index b956088..59adaf2 100644 --- a/plugin/waypoints/src/Plugin.php +++ b/plugin/waypoints/src/Plugin.php @@ -105,7 +105,6 @@ private function __construct() { } public function init(): void { - add_action( 'init', [ $this, 'load_textdomain' ], 0 ); add_action( 'init', [ $this->settings, 'maybe_upgrade' ], 1 ); add_action( 'init', [ $this->frontend_assets, 'register' ], 2 ); add_action( 'init', [ $this->planner_shortcode, 'register' ] ); @@ -121,14 +120,6 @@ public function init(): void { add_action( 'admin_post_plan_your_day_test_google_api', [ $this->settings_page, 'handle_test_google_api' ] ); } - public function load_textdomain(): void { - load_plugin_textdomain( - PLAN_YOUR_DAY_TEXT_DOMAIN, - false, - dirname( plugin_basename( PLAN_YOUR_DAY_PLUGIN_FILE ) ) . '/languages' - ); - } - public function settings(): Settings { return $this->settings; } diff --git a/plugin/waypoints/src/Rest/PlannerRoutes.php b/plugin/waypoints/src/Rest/PlannerRoutes.php index 2a433a6..a666d64 100644 --- a/plugin/waypoints/src/Rest/PlannerRoutes.php +++ b/plugin/waypoints/src/Rest/PlannerRoutes.php @@ -239,16 +239,18 @@ private function guard_request( WP_REST_Request $request, string $scope, array $ 'status' => 403, ] ); + $server_snapshot = $this->debug_server_snapshot(); + DebugLogger::log( 'rest.guard.invalid_origin', [ 'scope' => $scope, - 'host' => $_SERVER['HTTP_HOST'] ?? '', - 'origin' => $_SERVER['HTTP_ORIGIN'] ?? '', - 'referer' => $_SERVER['HTTP_REFERER'] ?? '', - 'sec_fetch_site' => $_SERVER['HTTP_SEC_FETCH_SITE'] ?? '', - 'sec_fetch_mode' => $_SERVER['HTTP_SEC_FETCH_MODE'] ?? '', - 'sec_fetch_dest' => $_SERVER['HTTP_SEC_FETCH_DEST'] ?? '', + 'host' => $server_snapshot['host'], + 'origin' => $server_snapshot['origin'], + 'referer' => $server_snapshot['referer'], + 'sec_fetch_site' => $server_snapshot['sec_fetch_site'], + 'sec_fetch_mode' => $server_snapshot['sec_fetch_mode'], + 'sec_fetch_dest' => $server_snapshot['sec_fetch_dest'], ] ); @@ -271,8 +273,8 @@ private function guard_request( WP_REST_Request $request, string $scope, array $ 'scope' => $scope, 'token_present' => '' !== $endpoint_token, 'cookie_present' => isset( $_COOKIE['plan_your_day_visitor'] ), - 'origin' => $_SERVER['HTTP_ORIGIN'] ?? '', - 'referer' => $_SERVER['HTTP_REFERER'] ?? '', + 'origin' => $this->server_value( 'HTTP_ORIGIN' ), + 'referer' => $this->server_value( 'HTTP_REFERER' ), ] ); @@ -688,7 +690,32 @@ private function debug_request_params( ?WP_REST_Request $request ): array { 'move_waypoint' => $request->get_param( 'move_waypoint' ), ], 'has_token' => '' !== (string) $request->get_param( 'endpoint_token' ), - 'content_type' => $_SERVER['CONTENT_TYPE'] ?? '', + 'content_type' => $this->server_value( 'CONTENT_TYPE' ), + ]; + } + + /** + * @return array{host:string, origin:string, referer:string, sec_fetch_site:string, sec_fetch_mode:string, sec_fetch_dest:string} + */ + private function debug_server_snapshot(): array { + return [ + 'host' => $this->server_value( 'HTTP_HOST' ), + 'origin' => $this->server_value( 'HTTP_ORIGIN' ), + 'referer' => $this->server_value( 'HTTP_REFERER' ), + 'sec_fetch_site' => $this->server_value( 'HTTP_SEC_FETCH_SITE' ), + 'sec_fetch_mode' => $this->server_value( 'HTTP_SEC_FETCH_MODE' ), + 'sec_fetch_dest' => $this->server_value( 'HTTP_SEC_FETCH_DEST' ), ]; } + + private function server_value( string $key ): string { + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Dynamic server header key is scalar-checked, unslashed, and sanitized before return. + $value = $_SERVER[ $key ] ?? ''; + + if ( ! is_scalar( $value ) ) { + return ''; + } + + return sanitize_text_field( wp_unslash( (string) $value ) ); + } } diff --git a/plugin/waypoints/src/Security/RateLimiter.php b/plugin/waypoints/src/Security/RateLimiter.php index ad83691..43ef2e0 100644 --- a/plugin/waypoints/src/Security/RateLimiter.php +++ b/plugin/waypoints/src/Security/RateLimiter.php @@ -223,6 +223,7 @@ private function can_use_database_advisory_locks(): bool { private function get_database_advisory_lock_result( string $key ): mixed { global $wpdb; + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- MySQL advisory locks are connection-level primitives and cannot be represented through WordPress object caching. return $wpdb->get_var( $wpdb->prepare( 'SELECT GET_LOCK(%s, %d)', @@ -235,6 +236,7 @@ private function get_database_advisory_lock_result( string $key ): mixed { private function release_database_advisory_lock( string $key ): void { global $wpdb; + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Releases the matching MySQL advisory lock; there is no cacheable WordPress API equivalent. $wpdb->get_var( $wpdb->prepare( 'SELECT RELEASE_LOCK(%s)', diff --git a/plugin/waypoints/src/Security/VisitorTokenManager.php b/plugin/waypoints/src/Security/VisitorTokenManager.php index 4968a4a..ddaca1e 100644 --- a/plugin/waypoints/src/Security/VisitorTokenManager.php +++ b/plugin/waypoints/src/Security/VisitorTokenManager.php @@ -84,12 +84,14 @@ private function get_or_create_visitor_token(): string { } private function get_cookie_token(): string { + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Raw cookie is scalar-checked, unslashed, sanitized, and regex-validated before use. $cookie_value = $_COOKIE[ self::COOKIE_NAME ] ?? ''; if ( ! is_scalar( $cookie_value ) ) { return ''; } + $cookie_value = wp_unslash( $cookie_value ); $cookie_value = strtolower( trim( sanitize_text_field( (string) $cookie_value ) ) ); return 1 === preg_match( '/\A[a-f0-9]{48}\z/', $cookie_value ) ? $cookie_value : ''; diff --git a/plugin/waypoints/src/Support/DebugLogger.php b/plugin/waypoints/src/Support/DebugLogger.php index 0ce7e7c..8221778 100644 --- a/plugin/waypoints/src/Support/DebugLogger.php +++ b/plugin/waypoints/src/Support/DebugLogger.php @@ -31,6 +31,7 @@ public static function log( string $event, array $context = [] ): void { $encoded_context = '{"encoding":"failed"}'; } + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Debug logging is opt-in through WP_DEBUG or an explicit filter. error_log( sprintf( '[plan-your-day] %s %s', $event, $encoded_context ) ); } diff --git a/plugin/waypoints/tests/PluginDisplayNameTest.php b/plugin/waypoints/tests/PluginDisplayNameTest.php index a98d0cc..5d3f86e 100644 --- a/plugin/waypoints/tests/PluginDisplayNameTest.php +++ b/plugin/waypoints/tests/PluginDisplayNameTest.php @@ -9,6 +9,7 @@ final class PluginDisplayNameTest extends TestCase { public function test_front_facing_metadata_uses_waypoints_display_name(): void { self::assertStringContainsString( 'Plugin Name: Waypoints', $this->fixture( 'plan-your-day.php' ) ); self::assertStringContainsString( 'Text Domain: waypoints', $this->fixture( 'plan-your-day.php' ) ); + self::assertStringNotContainsString( 'Domain Path:', $this->fixture( 'plan-your-day.php' ) ); self::assertStringContainsString( "define( 'PLAN_YOUR_DAY_TEXT_DOMAIN', 'waypoints' );", $this->fixture( 'plan-your-day.php' ) ); $release = json_decode( $this->fixture( 'release.json' ), true ); @@ -51,6 +52,13 @@ public function test_shortcode_exposes_waypoints_tag_with_legacy_alias(): void { self::assertStringContainsString( 'add_shortcode( self::LEGACY_TAG', $source ); } + public function test_wordpress_org_translation_loading_is_left_to_core(): void { + $source = $this->fixture( 'src/Plugin.php' ); + + self::assertStringNotContainsString( 'load_plugin_textdomain', $source ); + self::assertStringNotContainsString( 'load_textdomain', $source ); + } + private function fixture( string $path ): string { $content = file_get_contents( dirname( __DIR__ ) . '/' . $path ); diff --git a/plugin/waypoints/tests/SubmissionReadinessToolTest.php b/plugin/waypoints/tests/SubmissionReadinessToolTest.php index a7d41d2..5b0547a 100644 --- a/plugin/waypoints/tests/SubmissionReadinessToolTest.php +++ b/plugin/waypoints/tests/SubmissionReadinessToolTest.php @@ -77,13 +77,13 @@ public function test_rejects_not_production_ready_readme_copy(): void { self::assertStringContainsString( 'readme.txt must not describe the plugin as not production ready.', $result['output'] ); } - public function test_rejects_composer_metadata_in_submission_artifact(): void { + public function test_rejects_missing_composer_json_when_vendor_ships_in_submission_artifact(): void { $workspace = $this->make_workspace(); $plugin_dir = $workspace . '/waypoints'; $artifact = $workspace . '/waypoints-1.0.zip'; $this->write_plugin_fixture( $plugin_dir, 'waypoints', '1.0' ); - file_put_contents( $plugin_dir . '/composer.json', "{}\n" ); + unlink( $plugin_dir . '/composer.json' ); $this->zip_fixture( $workspace, 'waypoints', $artifact ); $result = $this->run_tool( @@ -96,7 +96,7 @@ public function test_rejects_composer_metadata_in_submission_artifact(): void { ); self::assertSame( 1, $result['status'], $result['output'] ); - self::assertStringContainsString( 'Artifact must not contain waypoints/composer.json.', $result['output'] ); + self::assertStringContainsString( 'Artifact must contain waypoints/composer.json.', $result['output'] ); } /** @@ -207,6 +207,7 @@ private function write_plugin_fixture( string $plugin_dir, string $slug, string ) ); + file_put_contents( $plugin_dir . '/composer.json', "{}\n" ); file_put_contents( $plugin_dir . '/vendor/autoload.php', "options ) && method_exists( $wpdb, 'esc_like' ) && method_exists( $wpdb, 'prepare' ) ) { + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Uninstall cleanup removes plugin-scoped legacy rate-limit rows and does not need caching. $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s",