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