diff --git a/CHANGELOG.md b/CHANGELOG.md index 24f6ff1..850911d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,16 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [1.2.2] - 2025-12-15 + +### Security & Fixed + +- Hardened redirect handling: all posted redirect URLs are now validated and external/hostile URLs are rejected, always falling back to the site home if unsafe +- Session cookie fix: session cookies now omit the expires option for correct browser behavior +- Footer link customize key is now consistent and backward compatible +- Added PHPUnit test to ensure external redirect URLs are always rejected + + ## [1.2.1] - 2024-12-15 diff --git a/composer.json b/composer.json index c5712e3..19c2487 100644 --- a/composer.json +++ b/composer.json @@ -3,6 +3,7 @@ "description": "Password protects all pages and posts except the front page", "type": "wordpress-plugin", "license": "GPL-2.0+", + "version": "1.2.2", "require": { "php": ">=8.3", "yahnis-elsts/plugin-update-checker": "^5.6" diff --git a/includes/AdminSettings.php b/includes/AdminSettings.php index 0d7a659..1e3340b 100644 --- a/includes/AdminSettings.php +++ b/includes/AdminSettings.php @@ -56,7 +56,7 @@ final class AdminSettings { 'show_remember_me' => true, 'input_border_radius' => 8, 'footer_text' => '', - 'footer_link_url' => '', + 'footer_link' => '', ]; /** @@ -186,6 +186,12 @@ private function get_default_settings(): array { public static function get_customize_settings(): array { $settings = get_option( self::OPTION_NAME, [] ); $customize = $settings[ 'customize' ] ?? []; + + // Backward-compat: prior versions used footer_link_url. + if ( empty( $customize[ 'footer_link' ] ) && ! empty( $customize[ 'footer_link_url' ] ) ) { + $customize[ 'footer_link' ] = $customize[ 'footer_link_url' ]; + } + return array_merge( self::CUSTOMIZE_DEFAULTS, $customize ); } @@ -1005,6 +1011,10 @@ private function sanitize_customize_settings( array $input ): array { } // URLs - sanitize as URLs. + if ( empty( $input[ 'footer_link' ] ) && ! empty( $input[ 'footer_link_url' ] ) ) { + $input[ 'footer_link' ] = $input[ 'footer_link_url' ]; + } + $url_fields = [ 'bg_image', 'logo', 'footer_link' ]; foreach ( $url_fields as $field ) { $sanitized[ $field ] = esc_url_raw( $input[ $field ] ?? '' ); diff --git a/includes/CookieHandler.php b/includes/CookieHandler.php index 8c97b33..130ca06 100644 --- a/includes/CookieHandler.php +++ b/includes/CookieHandler.php @@ -52,25 +52,28 @@ public function set_cookie( string $password_hash, int $expiry_days = 30 ): void $cookie_name = $this->get_cookie_name(); $cookie_value = $this->generate_cookie_value( $password_hash ); - // Calculate expiry time. - $expire = $expiry_days === 0 ? 0 : time() + ( $expiry_days * DAY_IN_SECONDS ); - // Get cookie path and domain from WordPress constants. $cookie_path = defined( 'COOKIEPATH' ) ? COOKIEPATH : '/'; $cookie_domain = defined( 'COOKIE_DOMAIN' ) ? COOKIE_DOMAIN : ''; + $cookie_options = [ + 'path' => $cookie_path, + 'domain' => $cookie_domain, + 'secure' => is_ssl(), + 'httponly' => true, + 'samesite' => 'Lax', + ]; + + // Calculate expiry time. For session cookies, omit the expires option. + if ( $expiry_days !== 0 ) { + $cookie_options[ 'expires' ] = time() + ( $expiry_days * DAY_IN_SECONDS ); + } + // Set the cookie. setcookie( name: $cookie_name, value: $cookie_value, - expires_or_options: [ - 'expires' => $expire, - 'path' => $cookie_path, - 'domain' => $cookie_domain, - 'secure' => is_ssl(), - 'httponly' => true, - 'samesite' => 'Lax', - ] + expires_or_options: $cookie_options ); // Also set in $_COOKIE for immediate use. diff --git a/includes/Protection.php b/includes/Protection.php index b0252c2..10bcd2d 100644 --- a/includes/Protection.php +++ b/includes/Protection.php @@ -137,11 +137,16 @@ private function show_password_form(): never { * Get the current URL. */ private function get_current_url(): string { - $protocol = is_ssl() ? 'https://' : 'http://'; - $host = sanitize_text_field( wp_unslash( $_SERVER[ 'HTTP_HOST' ] ?? '' ) ); - $uri = sanitize_text_field( wp_unslash( $_SERVER[ 'REQUEST_URI' ] ?? '' ) ); + $request_uri = isset( $_SERVER[ 'REQUEST_URI' ] ) ? wp_unslash( $_SERVER[ 'REQUEST_URI' ] ) : ''; + if ( ! is_string( $request_uri ) ) { + $request_uri = ''; + } + + // Prevent header injection and ensure this stays a local path. + $request_uri = preg_replace( "/[\r\n].*/", '', $request_uri ); + $request_uri = '/' . ltrim( $request_uri, '/' ); - return $protocol . $host . $uri; + return home_url( $request_uri ); } /** @@ -168,9 +173,14 @@ public function handle_form_submission(): never { : ''; // Get redirect URL. - $redirect_url = isset( $_POST[ 'passwp_redirect' ] ) + $redirect_url_raw = isset( $_POST[ 'passwp_redirect' ] ) ? esc_url_raw( wp_unslash( $_POST[ 'passwp_redirect' ] ) ) - : home_url(); + : ''; + $default_redirect = home_url( '/' ); + $redirect_url = $redirect_url_raw !== '' ? $redirect_url_raw : $default_redirect; + if ( function_exists( '\\wp_validate_redirect' ) ) { + $redirect_url = wp_validate_redirect( $redirect_url, $default_redirect ); + } // Get remember me checkbox. $remember = ( $_POST[ 'passwp_remember' ] ?? '' ) === '1'; diff --git a/package.json b/package.json index 77919d0..dfb61c1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "passwp-posts", - "version": "1.2.1", + "version": "1.2.2", "description": "Password protects all pages and posts except the front page", "type": "module", "scripts": { diff --git a/readme.txt b/readme.txt index 1f1ac5d..c3396ce 100644 --- a/readme.txt +++ b/readme.txt @@ -4,7 +4,7 @@ Tags: password, protection, privacy, security, access control Requires at least: 6.8 Tested up to: 6.9 Requires PHP: 8.3 -Stable tag: 1.2.1 +Stable tag: 1.2.2 License: GPLv2 or later License URI: https://www.gnu.org/licenses/gpl-2.0.html diff --git a/tests/ProtectionTest.php b/tests/ProtectionTest.php index a9aa05f..d373602 100644 --- a/tests/ProtectionTest.php +++ b/tests/ProtectionTest.php @@ -254,4 +254,48 @@ function ( $url ) use ( &$redirect_url ) { $_POST = array(); } + + /** + * Test form submission rejects external redirect URLs. + */ + public function test_form_submission_rejects_external_redirect(): void { + $_POST = array( + 'passwp_posts_nonce' => 'valid_nonce', + 'passwp_password' => 'correct_password', + 'passwp_redirect' => 'https://evil.example/phish', + 'passwp_remember' => '1', + ); + + Functions\when( 'wp_verify_nonce' )->justReturn( true ); + Functions\when( 'get_option' )->justReturn( + array( + 'enabled' => true, + 'password_hash' => '$2y$10$validhashhere', + 'cookie_expiry_days' => 30, + ) + ); + Functions\when( 'wp_check_password' )->justReturn( true ); + Functions\when( 'wp_salt' )->justReturn( 'test_salt' ); + + $redirect_url = ''; + Functions\when( 'wp_safe_redirect' )->alias( + function ( $url ) use ( &$redirect_url ) { + $redirect_url = $url; + throw new \Exception( 'redirect_called' ); + } + ); + + try { + $protection = new Protection(); + $protection->handle_form_submission(); + } catch (\Exception $e) { + if ( 'redirect_called' !== $e->getMessage() ) { + throw $e; + } + } + + $this->assertEquals( 'https://example.com/', $redirect_url ); + + $_POST = array(); + } } diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 6866c32..690dfb0 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -51,6 +51,43 @@ define( 'PASSWP_POSTS_URL', 'https://example.com/wp-content/plugins/passwp-posts/' ); } +/** + * Minimal WordPress redirect validator for tests. + * + * Brain\Monkey does not define this function by default, but the plugin conditionally + * calls it when present. Defining a minimal compatible implementation here allows + * us to test redirect hardening logic. + */ +if ( ! function_exists( 'wp_validate_redirect' ) ) { + function wp_validate_redirect( $location, $default = '' ) { + $location = is_string( $location ) ? trim( $location ) : ''; + $default = is_string( $default ) ? $default : ''; + + if ( $location === '' ) { + return $default; + } + + $allowed_host = parse_url( home_url( '/' ), PHP_URL_HOST ); + $host = parse_url( $location, PHP_URL_HOST ); + $scheme = parse_url( $location, PHP_URL_SCHEME ); + + // Allow relative URLs. + if ( $host === null ) { + return $location; + } + + // Only allow http(s) to the site's host. + if ( $host !== $allowed_host ) { + return $default; + } + if ( $scheme !== null && ! in_array( strtolower( (string) $scheme ), array( 'http', 'https' ), true ) ) { + return $default; + } + + return $location; + } +} + /** * Base test case class with Brain\Monkey setup. */ diff --git a/vendor/composer/installed.php b/vendor/composer/installed.php index 91a61f0..4e949a6 100644 --- a/vendor/composer/installed.php +++ b/vendor/composer/installed.php @@ -3,7 +3,7 @@ 'name' => 'soderlind/passwp-posts', 'pretty_version' => 'dev-main', 'version' => 'dev-main', - 'reference' => '8dbc6445966a732fd3c022dcf079a6cb5d0a7017', + 'reference' => 'acd9d3ab66e315ac38c8f84da750aef4bdf65524', 'type' => 'wordpress-plugin', 'install_path' => __DIR__ . '/../../', 'aliases' => array(), @@ -292,7 +292,7 @@ 'soderlind/passwp-posts' => array( 'pretty_version' => 'dev-main', 'version' => 'dev-main', - 'reference' => '8dbc6445966a732fd3c022dcf079a6cb5d0a7017', + 'reference' => 'acd9d3ab66e315ac38c8f84da750aef4bdf65524', 'type' => 'wordpress-plugin', 'install_path' => __DIR__ . '/../../', 'aliases' => array(),