folder = strtolower( (string) strtok( $plugin_basename, '/' ) ); } /** * The decision for an administrator-run agent. * * @param ActionDescriptor $raw The core's descriptor. * @param ActionDescriptor|null $typed An extension's typing of it, * or null when none typed it. * @param Evaluation $evaluation What was decided. */ public function apply( ActionDescriptor $raw, ?ActionDescriptor $typed, Evaluation $evaluation ): Evaluation { if ( $this->protects( $raw ) || ( null !== $typed && $this->protects( $typed ) ) ) { return new Evaluation( Decision::DENY, Evaluation::REASON_FIREWALL_PROTECTED, $evaluation->policy_id, $evaluation->rule_index, null, false, $evaluation->network, null, false, false, $evaluation->extension, true ); } $loose = Decision::ALLOW === $evaluation->decision || Decision::LOG_ONLY === $evaluation->decision || ( Decision::REQUIRE_APPROVAL === $evaluation->decision && $evaluation->ai_triage ); if ( ! $loose || ! $this->needs_a_person( $raw, $typed ) ) { return $evaluation; } return new Evaluation( Decision::REQUIRE_APPROVAL, null, $evaluation->policy_id, $evaluation->rule_index, null, false, $evaluation->network, null, $evaluation->unresolved, $evaluation->over_budget, $evaluation->extension, true ); } /** * Whether an action writes the firewall's own plugin or options: by the * slug a route names, or by any naming key in its input. * * @param ActionDescriptor $action The action. */ public function protects( ActionDescriptor $action ): bool { if ( 'read' === $action->action_cat ) { return false; } $names = null === $action->entity_slug ? array() : array( $action->entity_slug ); // A REST content write's own slug or title names content, not a // plugin; any other input's naming keys are read, values and keys. $content = 'rest' === $action->source && in_array( self::singular( $action->entity_type ), self::CONTENT_TYPES, true ); foreach ( $content ? array() : self::NAME_KEYS as $key ) { $named = $action->input[ $key ] ?? array(); foreach ( is_array( $named ) ? array_merge( array_keys( $named ), array_values( $named ) ) : array( $named ) as $value ) { if ( is_string( $value ) ) { $names[] = $value; } } } foreach ( $names as $name ) { $name = (string) preg_replace( '#^(\./|/)?(wp-content/)?(plugins/)?#', '', strtolower( trim( $name ) ) ); if ( ( '' !== $this->folder && strtok( $name, '/' ) === $this->folder ) || str_starts_with( $name, self::OPTION_PREFIX ) ) { return true; } } return false; } /** * Whether the rules may not decide this action alone. * * @param ActionDescriptor $raw The core's descriptor. * @param ActionDescriptor|null $typed An extension's typing, if any. */ private function needs_a_person( ActionDescriptor $raw, ?ActionDescriptor $typed ): bool { $action = $typed ?? $raw; if ( 'read' === $action->action_cat ) { return null === $typed && ! self::declared_read( $raw ); } // The core's own reading counts as much as the extension's: typing // waives only the doubt about an untyped name. foreach ( array( $raw, $action ) as $reading ) { if ( in_array( self::singular( $reading->entity_type ), RuleSchema::HIGH_RISK_ENTITIES, true ) || self::carries_markup( $reading->input ) ) { return true; } } return null === $typed && ( 'rest' !== $raw->source || ! in_array( self::singular( $raw->entity_type ), self::CONTENT_TYPES, true ) ); } /** * Whether any string in an input holds markup WordPress would filter * for a user without unfiltered_html - a script, an inline handler, a * javascript: URL however encoded, a frame - which an administrator's * unfiltered_html would store for the next person to open. A block's * comment attributes carry `<` as `\u003c`, so those are read as * brackets too. * * @param array $input Input. */ private static function carries_markup( array $input ): bool { $found = false; array_walk_recursive( $input, static function ( $value ) use ( &$found ): void { if ( $found || ! is_string( $value ) || ( ! str_contains( $value, '<' ) && false === stripos( $value, '\\u003c' ) ) ) { return; } $text = str_ireplace( array( '\\u003c', '\\u003e' ), array( '<', '>' ), $value ); // A lone `<` in prose comes back as `<`: a bracket, not a // filtered tag. $brackets = array( '<', '>' ); $found = str_replace( $brackets, array( '<', '>' ), wp_kses_post( $text ) ) !== str_replace( $brackets, array( '<', '>' ), wp_kses_normalize_entities( $text ) ); } ); return $found; } /** * Whether a read is the core's own statement rather than a guess from a * name: a REST read, or an ability that declares itself read-only. * * @param ActionDescriptor $raw The core's descriptor. */ private static function declared_read( ActionDescriptor $raw ): bool { if ( 'rest' === $raw->source ) { return true; } if ( 'ability' !== $raw->source || null === $raw->ability_name || ! wp_has_ability( $raw->ability_name ) ) { return false; } $annotations = wp_get_ability( $raw->ability_name )?->get_meta_item( 'annotations' ); return is_array( $annotations ) && true === ( $annotations['readonly'] ?? null ); } /** * An entity name in the singular, so `options` is an option. * * @param string $entity Entity type. */ private static function singular( string $entity ): string { $entity = strtolower( $entity ); if ( str_ends_with( $entity, 'ies' ) ) { return substr( $entity, 0, -3 ) . 'y'; } return str_ends_with( $entity, 's' ) && ! str_ends_with( $entity, 'ss' ) ? substr( $entity, 0, -1 ) : $entity; } }