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
3 changes: 3 additions & 0 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ jobs:
- name: Hook registration regression test
run: php tests/hook-registration-test.php

- name: OnePress waiver dispatch test
run: php tests/onepress-dispatch-test.php

build:
name: Package Release Project
runs-on: ubuntu-latest
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,6 @@ jobs:

- name: Hook registration regression test
run: php tests/hook-registration-test.php

- name: OnePress waiver dispatch test
run: php tests/onepress-dispatch-test.php
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ This plugin implements server-level Basic Authentication for your WordPress site
- Sites that need an additional layer of security before the WordPress login

Key features:
- Forces Basic Authentication for all requests (except AJAX, CRON, and CLI)
- Forces Basic Authentication for all requests, except: WordPress AJAX, cron and WP-CLI; XML-RPC; the Jetpack, `wp/v2` and `wp/v3` REST namespaces requested under `/wp-json/`; a MyPressable WP Admin one-click login carrying a valid, unexpired Pressable OnePress Login token while that plugin is active; and any request from a user already logged in to WordPress
- Integrates with WordPress user database for authentication
- Handles logout functionality properly
- Prevents caching of authentication requests
Expand Down
106 changes: 105 additions & 1 deletion pressable-basic-authentication.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
/*
Plugin Name: Hosting Basic Authentication
Description: Forces all users to authenticate using Basic Authentication before accessing any page.
Version: 1.0.5
Version: 1.0.6
License: GPL2
Text Domain: hosting-basic-authentication
*/
Expand Down Expand Up @@ -269,9 +269,113 @@ private function should_skip_auth() {
return true;
}

if ($this->is_valid_onepress_login_request()) {
return true;
}

return false;
}

/**
* Whether this is a MyPressable WP Admin one-click login carrying a valid token.
*
* MyPressable sends the user to `wp-login.php?mpcp_token=…`, and the Pressable
* OnePress Login plugin validates that token on `plugins_loaded` priority 10. This
* plugin challenges on priority 1 and exits, so without this waiver the token is
* never seen and the one-click login can only ever end in a credential prompt.
*
* The waiver repeats OnePress's own checks -- stored token present, unexpired,
* same user agent, matching secret -- and is deliberately narrower than OnePress
* (the token must arrive in the query string, in exactly four parts), so it admits
* only requests OnePress will then log in. It is read-only: OnePress deletes the
* stored token as it consumes it, which keeps the token single-use, so a replayed
* URL fails here and is challenged as usual.
*
* OnePress registers on, and reads, `$_REQUEST`, which at `plugins_loaded` is still
* PHP's own, built from `request_order`. The query-string token must also be the
* one `$_REQUEST` holds. Otherwise a `request_order` without G would let wp-login.php
* through with OnePress never handling it, and a body value overriding the query one
* would have OnePress consume a token the waiver never checked.
*
* OnePress must also be ready to handle the request, or nothing would consume the
* token and the waiver would only expose the password form: loaded, and not stood
* down by its own readiness guard, which is repeated here in full. skip_request()
* does not make it redundant: it reads the DOING_AJAX and DOING_CRON constants,
* while OnePress reads wp_doing_ajax() and wp_doing_cron(), which are filterable.
* Every active plugin file is included before `plugins_loaded` fires, so the class
* check does not depend on load order.
*
* `$_GET`, `$_REQUEST` and `$_SERVER` are read raw on purpose: wp_magic_quotes()
* only runs after `plugins_loaded`, so nothing has been slashed yet and
* wp_unslash() would corrupt a value rather than restore it. That is also how
* OnePress reads them.
*
* @return bool
*/
private function is_valid_onepress_login_request() {
if ( ! class_exists( 'Pressable_OnePress_Login_Plugin', false ) ) {
return false;
}

if ( wp_installing() || wp_doing_cron() || wp_doing_ajax() || ( defined( 'WP_CLI' ) && WP_CLI ) ) {
return false;
}

if ( 'wp-login.php' !== ( $GLOBALS['pagenow'] ?? '' ) ) {
return false;
}

$encoded = $_GET['mpcp_token'] ?? null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The waiver validates $_GET['mpcp_token'], but OnePress decides whether to handle the request from $_REQUEST (non-blocking). In pressable-onepress-login, is_ready_to_handle_mpcp_login_request() registers the handler only when isset( $_REQUEST['mpcp_token'] ) (class-pressable-onepress-login-plugin.php:172), and handle_server_login_request() reads $_REQUEST['mpcp_token'] (line 25). At plugins_loaded, $_REQUEST is still PHP's own, built from request_order. WordPress only rebuilds it as $_GET + $_POST in wp_magic_quotes(), after plugins_loaded.

  • With the stock request_order = "GP" I see no hole. A valid query token puts the value in $_REQUEST, so OnePress registers. Every one of its branches, success and all four failures, ends in wp_safe_redirect + exit, so nothing else on wp-login.php runs. A POST mpcp_token overriding the query one only makes OnePress validate the wrong value and burn the sender's own token.
  • With a request_order that leaves out G, OnePress never registers. A request like POST /wp-login.php?mpcp_token=<valid> with action=lostpassword then passes the waiver and runs WordPress's normal login actions with no Basic Auth challenge. It still needs a valid, unexpired token and a matching UA, so this is not a bypass for an outsider. But it breaks the property the docblock above states: "nothing else reaches wp-login.php unchallenged."

A cheap way to make the two agree whatever the PHP config:

$encoded = $_GET['mpcp_token'] ?? null;
// OnePress reads (and registers on) $_REQUEST; only waive when it will see this same token.
if ( ! isset( $_REQUEST['mpcp_token'] ) || $_REQUEST['mpcp_token'] !== $encoded ) {
    return false;
}

The test stubs OnePress with an empty class and never sets $_REQUEST, so a case with $_GET set and $_REQUEST unset (or different) would pin it. Is this worth adding, or do you know WP Cloud pins request_order to GP? If so, reply with that and I'll take it as answered.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — added your guard. I don't know what request_order WP Cloud's PHP uses, so I didn't want to depend on it.

  • d731272: tests first, one with the token absent from $_REQUEST and one with a different $_REQUEST token. Both failed against the previous head.
  • ac519a7: pressable-basic-authentication.php:335 now returns false unless ($_REQUEST['mpcp_token'] ?? null) === $encoded. The docblock explains why: OnePress registers on and reads $_REQUEST, which at plugins_loaded is still PHP's own, built from request_order.

Both suites are green on PHP 8.1 and 8.4. Deleting the new check fails both new cases.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified at 3955e10: line 335 rejects unless $_REQUEST holds the same string as the validated query token. Because the is_string check on line 331 runs first, a missing, array-valued or different $_REQUEST value can no longer get the waiver, whatever request_order is. Both new cases pin it. Resolved.


// A genuine token encodes about 120 bytes to about 160 characters. The cap bounds
// the decoding and splitting below, which run before any authentication.
if ( ! is_string( $encoded ) || '' === $encoded || strlen( $encoded ) > 256 ) {
return false;
}

if ( ( $_REQUEST['mpcp_token'] ?? null ) !== $encoded ) {
return false;
}

// MyPressable encodes URL-safe and unpadded, so map it to the standard alphabet.
// Strict mode returns false on any character outside that alphabet instead of
// silently discarding it; it still accepts padding and skips whitespace.
$decoded = base64_decode( strtr( $encoded, '-_', '+/' ), true );

if ( false === $decoded ) {
return false;
}

// "<wp_user_id>-<secret>-<site_id>-<md5(user agent)>"; none of the parts can
// contain a hyphen, so any other count is not a token MyPressable minted.
$parts = explode( '-', $decoded );

if ( 4 !== count( $parts ) || 1 !== preg_match( '/\A[0-9]+\z/', $parts[0] ) ) {
return false;
}

list( $user_id, $secret, , $user_agent_hash ) = $parts;

$stored = get_user_meta( (int) $user_id, 'mpcp_auth_token', true );

if ( ! is_array( $stored ) || ! isset( $stored['value'], $stored['exp'] )
|| ! is_string( $stored['value'] ) || ! is_numeric( $stored['exp'] ) ) {
return false;
}

if ( (int) $stored['exp'] < time() ) {
return false;
}

$user_agent = $_SERVER['HTTP_USER_AGENT'] ?? null;

if ( ! is_string( $user_agent ) || ! hash_equals( md5( $user_agent ), $user_agent_hash ) ) {
return false;
}

return hash_equals( $stored['value'], md5( $secret ) );
}

/**
* Whether a path prefix names a script the server would execute.
*
Expand Down
12 changes: 11 additions & 1 deletion readme.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Tags: pressable, basic auth, authentication, security
Requires at least: 6.7
Tested up to: 7.1
Requires PHP: 8.1
Stable tag: 1.0.5
Stable tag: 1.0.6
License: GPLv2 or later
License URI: http://www.gnu.org/licenses/gpl-2.0.html

Expand All @@ -17,6 +17,7 @@ The Pressable Basic Authentication plugin enforces HTTP Basic Authentication acr
* Enforces HTTP Basic Authentication on all front-end and back-end pages.
* Installed on sites to restrict public access.
* Allows super administrators to bypass authentication for seamless management.
* Lets the MyPressable WP Admin button sign in: a request carrying a valid, unexpired Pressable OnePress Login token passes, while that plugin is active.
* Integrates with WordPress's authentication system for user verification.
* Provides a mechanism to log out of Basic Authentication sessions.​

Expand Down Expand Up @@ -44,6 +45,15 @@ No manual installation is necessary.​

== Changelog ==

= 1.0.6 =
* Fixed: the WP Admin button in MyPressable (Pressable OnePress Login) ended in a
Basic Authentication prompt instead of signing the user in. A one-click login
request now passes Basic Authentication only when it carries a token the
OnePress plugin will accept -- present, unexpired, presented with the same
User-Agent it was issued to, and matching the stored secret -- and only while
that plugin is active. Any other request to wp-login.php is still challenged
unless an existing exemption applies, such as an existing WordPress session.

= 1.0.5 =
* Fixed: Basic Authentication could be bypassed entirely on any URL, with no
credentials, by making a request to a gated page resemble one of the endpoints
Expand Down
108 changes: 108 additions & 0 deletions tests/fixtures/onepress-dispatch-router.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
<?php
/**
* Router for tests/onepress-dispatch-test.php, served by PHP's built-in web server.
*
* Loads the plugin, stands in for the few WordPress functions its request path calls
* and for the OnePress Login plugin, then fires `plugins_loaded` the way WordPress
* does. The response says which happened: Basic Auth challenged (401), or the
* request reached OnePress's handler.
*
* It runs under the cli-server SAPI on purpose: the plugin waives every CLI request,
* so only a non-CLI request exercises the real dispatch.
*
* @package HostingBasicAuthentication
*/

// Only ever a fixture for the dispatch test, never something a site should serve.
// `/tests export-ignore` keeps it out of the release zip; this is the second layer.
if ( 'cli-server' !== php_sapi_name() ) {
exit( 1 );
}

define( 'ABSPATH', __DIR__ );

// Mirrors the values tests/onepress-dispatch-test.php mints its token from.
const DISPATCH_SECRET = '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08';
const DISPATCH_USER_ID = 7;

$GLOBALS['wp_filter'] = array();
$GLOBALS['pagenow'] = basename( strtok( $_SERVER['REQUEST_URI'] ?? '/', '?' ) );

function add_action( $hook, $callback, $priority = 10, $accepted_args = 1 ) {
$GLOBALS['wp_filter'][ $hook ][ $priority ][] = $callback;
}

function add_filter( $hook, $callback, $priority = 10, $accepted_args = 1 ) {
add_action( $hook, $callback, $priority, $accepted_args );
}

function do_action( $hook ) {
$by_priority = $GLOBALS['wp_filter'][ $hook ] ?? array();
ksort( $by_priority );

foreach ( $by_priority as $callbacks ) {
foreach ( $callbacks as $callback ) {
call_user_func( $callback );
}
}
}

function is_multisite() {
return false;
}

function is_super_admin() {
return false;
}

function is_user_logged_in() {
return false;
}

function wp_installing() {
return false;
}

function wp_doing_ajax() {
return false;
}

function wp_doing_cron() {
return false;
}

function esc_html__( $text, $domain = 'default' ) {
return htmlspecialchars( $text, ENT_QUOTES );
}

function get_user_meta( $user_id, $key = '', $single = false ) {
if ( DISPATCH_USER_ID !== $user_id || 'mpcp_auth_token' !== $key ) {
return '';
}

return array( 'value' => md5( DISPATCH_SECRET ), 'exp' => time() + 30 );
}

/**
* Stand-in for Pressable OnePress Login: registers on the same hook and priority, under
* the same condition, and reports that it was reached instead of logging anyone in.
*/
final class Pressable_OnePress_Login_Plugin {
public function __construct() {
if ( 'wp-login.php' === $GLOBALS['pagenow'] && isset( $_REQUEST['mpcp_token'] ) ) {
add_action( 'plugins_loaded', array( $this, 'handle_server_login_request' ) );
}
}

public function handle_server_login_request() {
echo 'ONEPRESS-HANDLED';
exit;
}
}

require __DIR__ . '/../../pressable-basic-authentication.php';
new Pressable_OnePress_Login_Plugin();

do_action( 'plugins_loaded' );

echo 'NOT-CHALLENGED-NOR-HANDLED';
Loading
Loading