Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
12 changes: 11 additions & 1 deletion includes/AdminSettings.php
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ final class AdminSettings {
'show_remember_me' => true,
'input_border_radius' => 8,
'footer_text' => '',
'footer_link_url' => '',
'footer_link' => '',
];

/**
Expand Down Expand Up @@ -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 );
}

Expand Down Expand Up @@ -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 ] ?? '' );
Expand Down
25 changes: 14 additions & 11 deletions includes/CookieHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
22 changes: 16 additions & 6 deletions includes/Protection.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 );
}

/**
Expand All @@ -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';
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
2 changes: 1 addition & 1 deletion readme.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
44 changes: 44 additions & 0 deletions tests/ProtectionTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}
37 changes: 37 additions & 0 deletions tests/bootstrap.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
4 changes: 2 additions & 2 deletions vendor/composer/installed.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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(),
Expand Down