Viewing File: /usr/local/cpanel/share/wpx/mu-plugins/cpanel-return.php
<?php
/**
* Plugin Name: cPanel Return
* Plugin URI:
* Description: Adds a "Back to cPanel" link in the WordPress Admin sidebar
* when the site is accessed via cPanel Single Sign-On.
* Version: 1.6.0
* Author: cPanel, L.L.C.
* Author URI: https://cpanel.net
*
* Auto-loaded as a must-use plugin from wp-content/mu-plugins/.
* Installed by Cpanel::UserTasks::WPX after a successful WordPress install.
*
* Flow:
* 1. WPX::get_credentials appends panel_return_url to the SSO token URL.
* 2. WordPress loads this plugin when wp-login.php processes the token.
* 3. on_login() fires on the wp_login action — the standard login signal.
* It calls capture_return_url() which validates the URL (HTTPS required,
* max 2048 chars, no userinfo; host already validated by Perl/cpsrvd) and
* stores it in per-user meta. Only users with manage_options may store.
* 4. register_sidebar_link() adds "← Back to cPanel" to the left sidebar
* for all subsequent admin requests while the meta value is set.
* 5. maybe_redirect_to_cpanel() fires on admin_init; when the menu item is
* clicked it redirects before any HTML is output, avoiding a white flash.
* Only users with manage_options may trigger the redirect.
*
* Security notes (DUCKS-5997):
* - H1: The return URL's host must be a member of the cpanel_trusted_host
* wp_options entry — a comma-separated list of the cPanel hosts this site
* has been reached by (WPX::get_login_url adds each validated access host),
* so a login through one host does not invalidate a link minted through
* another. The Perl UAPI layer (cpsrvd) also pins the host before embedding
* panel_return_url in the signed SSO token, but that layer is bypassable by
* hand-crafting a wp-login.php request, so this check is the real guard.
* When the option is absent there is nothing to pin against and validation
* fails closed: no capture, no sidebar link, no redirect. The backend
* writes the option before installing this file and seeds it on every SSO
* login, so an unpinned site is a transient state that self-heals on its
* next launch from cPanel.
* - H2: capture and redirect are gated on manage_options capability.
* - M1: Capture fires only on wp_login (real login), not set_auth_cookie
* (which also fires on cookie reissue/extension).
* - M2: URL length capped at 2048 chars before update_user_meta, matching
* the Perl-side cap in Cpanel::API::WPX::get_credentials.
*/
define( 'CPANEL_RETURN_URL_META_KEY', '_cpanel_return_url' );
define( 'CPANEL_RETURN_URL_MAX_LENGTH', 2048 );
class CPanel_Return_Plugin {
public static function init() {
add_action( 'wp_login', [ __CLASS__, 'on_login' ], 10, 2 );
add_action( 'admin_menu', [ __CLASS__, 'register_sidebar_link' ] );
add_action( 'admin_init', [ __CLASS__, 'maybe_redirect_to_cpanel' ] );
add_action( 'wp_logout', [ __CLASS__, 'on_logout' ] );
}
/**
* Capture panel_return_url on real login only.
*
* Fires on wp_login — the standard WordPress login action. Unlike
* set_auth_cookie (which fires on cookie reissue/extension too), this
* only fires on genuine authentication events, narrowing the window
* during which a crafted URL could be injected.
*/
public static function on_login( string $user_login, WP_User $user ): void {
self::capture_return_url( $user->ID );
}
/**
* Store panel_return_url in per-user meta if present, valid, and the
* user has the manage_options capability.
*
* Validation (all must pass):
* - User has manage_options capability (H2)
* - URL length <= 2048 chars (M2)
* - HTTPS scheme, non-empty host, no userinfo (H1 — structural checks)
* - Host is a member of cpanel_trusted_host (H1 — origin check)
*
* $_SERVER['HTTP_HOST'] is the WordPress site domain, not the cPanel
* server hostname, so the cpanel_trusted_host option is the only local
* record of which hosts are legitimate; without it nothing is captured.
*/
private static function capture_return_url( int $user_id ): void {
if ( ! isset( $_GET['panel_return_url'] ) ) {
return;
}
// H2: Only administrators should store a return URL.
if ( ! user_can( $user_id, 'manage_options' ) ) {
return;
}
$url = (string) $_GET['panel_return_url'];
// M2: Mirror the 2048-char cap from the Perl side.
if ( strlen( $url ) > CPANEL_RETURN_URL_MAX_LENGTH ) {
return;
}
if ( self::is_valid_return_url( $url ) ) {
update_user_meta( $user_id, CPANEL_RETURN_URL_META_KEY, $url );
}
}
/**
* Validate a return URL: HTTPS scheme, non-empty host, no userinfo, and a
* host that is a member of the cpanel_trusted_host option.
*
* That option (written by the WPX backend) is a comma-separated list
* because the same site can be reached through more than one valid cPanel
* host (server FQDN, IP, cpanel.<domain>, a vanity hostname); a legacy
* single-host value is simply a one-element list. Matching is
* case-insensitive.
*
* An absent or empty option means no host is trusted, so every URL is
* rejected. Accepting any HTTPS host in that case would turn the sidebar
* link into an open redirect aimed at site administrators.
*
* @param string $url The URL to validate.
* @return bool True if the URL is safe to store/redirect to.
*/
private static function is_valid_return_url( string $url ): bool {
$parts = wp_parse_url( $url );
if ( ! isset( $parts['scheme'], $parts['host'] ) ||
$parts['scheme'] !== 'https' ||
$parts['host'] === '' ||
isset( $parts['user'] ) ) {
return false;
}
$trusted = (string) get_option( 'cpanel_trusted_host', '' );
if ( $trusted === '' ) {
return false;
}
// wp_parse_url keeps the [] on an IPv6 literal host; the Perl layer
// stores it bracket-stripped, so strip here too before comparing.
$host = self::strip_ipv6_brackets( strtolower( $parts['host'] ) );
$hosts = array_filter( array_map(
static function ( $h ) {
return self::strip_ipv6_brackets( trim( $h ) );
},
explode( ',', strtolower( $trusted ) )
) );
return in_array( $host, $hosts, true );
}
/**
* Remove enclosing brackets from an IPv6 literal host ("[::1]" -> "::1"),
* so a bracketed return-URL host matches the bracket-stripped value the
* Perl layer stores. Non-bracketed hosts are returned unchanged.
*/
private static function strip_ipv6_brackets( string $host ): string {
if ( strlen( $host ) >= 2 && $host[0] === '[' && substr( $host, -1 ) === ']' ) {
return substr( $host, 1, -1 );
}
return $host;
}
private static function get_return_url(): string {
$user_id = get_current_user_id();
if ( ! $user_id ) {
return '';
}
return (string) ( get_user_meta( $user_id, CPANEL_RETURN_URL_META_KEY, true ) ?? '' );
}
/**
* Register the "← Back to cPanel" sidebar menu entry.
* Only added when a valid return URL is stored in user meta.
*/
public static function register_sidebar_link(): void {
$url = self::get_return_url();
if ( ! $url || ! self::is_valid_return_url( $url ) ) {
return;
}
add_menu_page(
'Back to cPanel', // page title (unused — redirect fires first)
'Back to cPanel', // menu label
'manage_options', // capability
'cpanel-return', // menu slug
'__return_false', // render callback (never reached)
'dashicons-arrow-left-alt2', // icon
'1.5' // string decimal avoids integer-position collisions
);
}
/**
* Intercept clicks on the sidebar item before any HTML is output.
*
* Gated on manage_options (H2) so non-admin users cannot trigger the
* redirect even if they somehow land on ?page=cpanel-return.
* Full URL validation (H1) is re-applied as a defence-in-depth guard
* against corrupted meta values and against a value captured while the
* site was still unpinned.
*/
public static function maybe_redirect_to_cpanel(): void {
if ( ! isset( $_GET['page'] ) || $_GET['page'] !== 'cpanel-return' ) {
return;
}
// H2: Only administrators may use the redirect.
if ( ! current_user_can( 'manage_options' ) ) {
wp_safe_redirect( admin_url() );
exit;
}
$url = self::get_return_url();
if ( ! $url || ! self::is_valid_return_url( $url ) ) {
wp_safe_redirect( admin_url() );
exit;
}
// Add the stored host to the safe-redirect allow-list so that
// wp_safe_redirect accepts a cross-origin cPanel URL.
$host = (string) wp_parse_url( $url, PHP_URL_HOST );
add_filter(
'allowed_redirect_hosts',
static function ( array $hosts ) use ( $host ): array {
$hosts[] = $host;
return $hosts;
}
);
wp_safe_redirect( $url, 302 );
exit;
}
/**
* On logout: remove the stored return URL from user meta.
* The $user_id parameter is provided by the wp_logout action (WP 5.5+).
*/
public static function on_logout( int $user_id ): void {
delete_user_meta( $user_id, CPANEL_RETURN_URL_META_KEY );
}
}
CPanel_Return_Plugin::init();
Back to Directory