*/ private const HELD_ABILITIES = [ 'albert/update-user', 'albert/create-user', 'albert/delete-user', ]; /** * Option names whose value being written is high-risk. * * @since 1.5.0 * @var list */ private const HIGH_RISK_OPTIONS = [ 'siteurl', 'home', 'admin_email', 'default_role', 'users_can_register', 'template', 'stylesheet', // Switches plugins on or off wholesale, so it can disable Albert, a // security plugin, or everything at once. 'active_plugins', // Silently deindexes the site. Nothing on any screen shouts about it and // the damage surfaces weeks later as lost traffic. 'blog_public', // Changes every URL on the site at once, breaking inbound links. 'permalink_structure', ]; /** * Input keys an option-writing ability tends to name its target option with. * * Deliberately excludes bare `name` and `key`. They are ordinary English * words, so `albert/create-term { name: "home" }` (a category called home) * matched a high-risk option name and got held for approval. Creating a * category is not an option write, and a gate that stops it teaches people * to stop reading the queue. The cost is a third-party ability that names * its parameter `name`, which is a fair trade against holding ordinary * content work. * * @since 1.5.0 * @var list */ private const OPTION_KEYS = [ 'option', 'option_name', 'setting' ]; /** * Compares option names as the database does. * * @since 1.5.0 * @var OptionNames */ private OptionNames $names; /** * Wire the policy. * * @param OptionNames|null $names Name matcher; defaults to a fresh one. */ public function __construct( ?OptionNames $names = null ) { $this->names = $names ?? new OptionNames(); } /** * Whether this call must be held regardless of its annotation. * * @param WP_Ability $ability The ability about to execute. * @param array $input Its resolved input. * * @return bool * @since 1.5.0 */ public function must_hold( WP_Ability $ability, array $input ): bool { return $this->is_held_ability( $ability->get_name() ) || $this->grants_admin_role( $input ) || $this->writes_high_risk_option( $input ); } /** * The protected option this call's input names, if any. * * Those are refused rather than held ({@see ConnectionGuard}), so a call * naming one is never put in front of a person to approve. Matched as the * database matches names, and an ability declaring itself `readonly` is * left alone: reading a protected option changes nothing. * * @param WP_Ability $ability The ability about to execute. * @param array $input Its input. * * @return string|null * @since 1.5.0 */ public function protected_option( WP_Ability $ability, array $input ): ?string { $annotations = $ability->get_meta_item( 'annotations', [] ); if ( is_array( $annotations ) && ( $annotations['readonly'] ?? false ) === true ) { return null; } return $this->named_option( $input, ConnectionGuard::protected_names() ); } /** * Whether an ability is on the high-risk name list. * * @param string $ability_name The ability id. * * @return bool * @since 1.5.0 */ private function is_held_ability( string $ability_name ): bool { /** * Filters the abilities safe mode always holds, regardless of annotation. * * Add-ons append the ids of their own high-risk abilities. * * @since 1.5.0 * * @param list $abilities Ability ids. */ $held = apply_filters( 'albert/safe_mode/high_risk_abilities', self::HELD_ABILITIES ); return is_array( $held ) && in_array( $ability_name, $held, true ); } /** * Whether the input assigns a role that can manage the site. * * Reads the conventional `role` / `roles` keys and resolves each slug to a * WordPress role, holding when any grants `manage_options`. That is the * privilege-escalation vector the annotation axis misses. * * @param array $input Resolved input. * * @return bool * @since 1.5.0 */ private function grants_admin_role( array $input ): bool { $candidates = []; if ( isset( $input['role'] ) && is_string( $input['role'] ) ) { $candidates[] = $input['role']; } if ( isset( $input['roles'] ) && is_array( $input['roles'] ) ) { foreach ( $input['roles'] as $role ) { if ( is_string( $role ) ) { $candidates[] = $role; } } } foreach ( $candidates as $slug ) { $role = get_role( $slug ); if ( $role !== null && $role->has_cap( 'manage_options' ) ) { return true; } } return false; } /** * Whether the input names a high-risk option to write. * * Checks the values of the option-naming keys an option-writing ability * typically uses, so a third-party `foo/update-option` targeting `siteurl` is * held without Albert knowing that ability exists. * * @param array $input Resolved input. * * @return bool * @since 1.5.0 */ private function writes_high_risk_option( array $input ): bool { /** * Filters the option names safe mode treats as high-risk to write. * * @since 1.5.0 * * @param list $options Option names. */ $high_risk = apply_filters( 'albert/safe_mode/high_risk_options', self::HIGH_RISK_OPTIONS ); if ( ! is_array( $high_risk ) ) { return false; } return $this->named_option( $input, $high_risk ) !== null; } /** * The listed option one of the option-naming keys reaches, if any. * * Compared by the database, so `SİTEURL` or `sïteurl` is `siteurl`. When the * database cannot answer, the value itself is returned: holding or refusing * a call is the safe side. * * @param array $input Resolved input. * @param array $options Option names. * * @return string|null * @since 1.5.0 */ private function named_option( array $input, array $options ): ?string { $options = array_values( array_filter( $options, 'is_string' ) ); foreach ( self::OPTION_KEYS as $key ) { if ( ! isset( $input[ $key ] ) || ! is_string( $input[ $key ] ) ) { continue; } // WordPress trims an option name before it reaches the database. $name = trim( $input[ $key ] ); try { $match = $this->names->find( $name, $options ); } catch ( \RuntimeException $e ) { return $name; } if ( $match !== null ) { return $match; } } return null; } }