|null */ public function list_ours(): ?array; /** * Blocks one address. * * @param string $ip Address. * @param string $note Rule notes, starting with the ownership mark. * @return array{ok:bool,error:string,id:string} */ public function block( string $ip, string $note ): array; /** * Removes one of our rules by its id. * * @param string $rule_id Provider rule id. * @return array{ok:bool,error:string,id:string} */ public function unblock( string $rule_id ): array; /** * Proves the credentials can read and write the rules. * * @return array{ok:bool,error:string,id:string} */ public function test(): array; /** Why the last list_ours() returned null, in the provider's words. */ public function last_error(): string; } /** * Cloudflare IP Access Rules on the site's zone. * * Uses the zone ID and API token the site already saved for the cache purge (Images tab), * the token through CSDT_Secrets. The token needs Zone > Firewall Services > Edit. */ final class CSDT_Edge_Cloudflare implements CSDT_Edge_Provider { /** Cloudflare API base. */ const API = 'https://api.cloudflare.com/client/v4'; /** Rules read per page, and pages read at most. */ const PER_PAGE = 500; const MAX_PAGES = 4; /** * The ownership mark for this site, "csdt:". * * @var string */ private $mark; /** * Why the last read failed. * * @var string */ private $error = ''; /** * Provider for one site's mark. * * @param string $mark The ownership mark, CSDT_Edge_Waf::mark(). */ public function __construct( string $mark ) { $this->mark = $mark; } /** {@inheritdoc} */ public function id(): string { return 'cloudflare'; } /** {@inheritdoc} */ public function label(): string { return 'Cloudflare'; } /** {@inheritdoc} */ public function configured(): bool { $c = CSDT_CF_Comment_Rule::credentials(); return '' !== $c['token'] && self::is_id( $c['zone'] ); } /** {@inheritdoc} */ public function last_error(): string { return $this->error; } /** Whether a value is a Cloudflare identifier (32 hex characters). */ private static function is_id( $id ): bool { return is_string( $id ) && 1 === preg_match( '/^[a-f0-9]{32}$/i', $id ); } /** * One API call under the zone's IP Access Rules. No retry. * * @param string $method HTTP method. * @param string $suffix '' for the collection, '/' for one rule, '?query' for a list. * @param array|null $body JSON body, or null for none. * @return array A CSDT_Http::json() result. */ private function call( string $method, string $suffix, ?array $body = null ): array { $c = CSDT_CF_Comment_Rule::credentials(); $args = array( 'method' => $method, 'user-agent' => CSDT_DevTools::USER_AGENT, 'timeout' => 15, 'headers' => array( 'Authorization' => 'Bearer ' . $c['token'], 'Content-Type' => 'application/json', ), ); if ( null !== $body ) { $args['body'] = wp_json_encode( $body ); } // GET and POST /zones/{zone}/firewall/access_rules/rules, DELETE .../rules/{id}. // wporg-disclosed: /firewall/access_rules/rules return CSDT_Http::json( wp_remote_request( self::API . '/zones/' . $c['zone'] . '/firewall/access_rules/rules' . $suffix, $args ) ); } /** Whether a reply is a 2xx with success true. */ private static function succeeded( array $r ): bool { return $r['code'] >= 200 && $r['code'] < 300 && is_array( $r['data'] ) && ! empty( $r['data']['success'] ); } /** {@inheritdoc} */ public function list_ours(): ?array { $this->error = ''; if ( ! $this->configured() ) { $this->error = __( 'Save a Cloudflare zone ID and API token first (Images tab, Cloudflare Cache Purge).', 'cloudscale-cyber-devtools' ); return null; } $ours = array(); for ( $page = 1; $page <= self::MAX_PAGES; $page++ ) { $r = $this->call( 'GET', '?' . http_build_query( array( 'mode' => 'block', 'notes' => $this->mark, 'per_page' => self::PER_PAGE, 'page' => $page ) ) ); if ( ! self::succeeded( $r ) || ! is_array( $r['data']['result'] ?? null ) ) { $this->error = CSDT_CF_Comment_Rule::error_text( $r ); return null; } foreach ( $r['data']['result'] as $rule ) { $own = self::own_rule( $rule, $this->mark ); if ( null !== $own ) { $ours[] = $own; } } $pages = (int) ( $r['data']['result_info']['total_pages'] ?? 1 ); if ( $page >= $pages ) { break; } } return $ours; } /** * A rule from Cloudflare as ours, or null when it is not ours. * * Ours means: zone scope, notes starting with this site's mark followed by a space or the * end, a block of one address, and a usable id. Cloudflare's notes filter is a substring * match, so the prefix is checked here rather than trusted. * * @param mixed $rule One rule. * @param string $mark This site's mark. * @return array{id:string,ip:string,notes:string}|null */ public static function own_rule( $rule, string $mark ): ?array { if ( ! is_array( $rule ) || ! self::is_id( $rule['id'] ?? null ) ) { return null; } $notes = (string) ( $rule['notes'] ?? '' ); if ( 0 !== strpos( $notes, $mark ) || ( strlen( $notes ) > strlen( $mark ) && ' ' !== $notes[ strlen( $mark ) ] ) ) { return null; } if ( isset( $rule['scope']['type'] ) && 'zone' !== $rule['scope']['type'] ) { return null; } $target = (string) ( $rule['configuration']['target'] ?? '' ); $ip = CSDT_Edge_Waf::canonical( (string) ( $rule['configuration']['value'] ?? '' ) ); if ( ! in_array( $target, array( 'ip', 'ip6' ), true ) || '' === $ip ) { return null; } return array( 'id' => (string) $rule['id'], 'ip' => $ip, 'notes' => $notes, ); } /** {@inheritdoc} */ public function block( string $ip, string $note ): array { $ip = CSDT_Edge_Waf::canonical( $ip ); if ( '' === $ip || 0 !== strpos( $note, $this->mark ) ) { return array( 'ok' => false, 'error' => __( 'Refused: not a single address, or a note without our mark.', 'cloudscale-cyber-devtools' ), 'id' => '' ); } $r = $this->call( 'POST', '', array( 'mode' => 'block', 'configuration' => array( 'target' => false !== strpos( $ip, ':' ) ? 'ip6' : 'ip', 'value' => $ip, ), 'notes' => $note, ) ); if ( self::succeeded( $r ) && self::is_id( $r['data']['result']['id'] ?? null ) ) { return array( 'ok' => true, 'error' => '', 'id' => (string) $r['data']['result']['id'] ); } return array( 'ok' => false, 'error' => CSDT_CF_Comment_Rule::error_text( $r ), 'id' => '' ); } /** {@inheritdoc} */ public function unblock( string $rule_id ): array { if ( ! self::is_id( $rule_id ) ) { return array( 'ok' => false, 'error' => __( 'Refused: not a Cloudflare rule id.', 'cloudscale-cyber-devtools' ), 'id' => '' ); } $r = $this->call( 'DELETE', '/' . $rule_id ); if ( self::succeeded( $r ) ) { return array( 'ok' => true, 'error' => '', 'id' => $rule_id ); } return array( 'ok' => false, 'error' => CSDT_CF_Comment_Rule::error_text( $r ), 'id' => '' ); } /** * Read, then write: lists the rules, then adds and at once removes one rule for * 192.0.2.1 (TEST-NET-1, documentation only, never a real visitor), marked as ours so a * failed removal is cleaned up by the next Push run. * * @return array{ok:bool,error:string,id:string} */ public function test(): array { if ( null === $this->list_ours() ) { return array( 'ok' => false, 'error' => $this->error, 'id' => '' ); } $add = $this->block( '192.0.2.1', $this->mark . ' connection test, removed at once' ); if ( ! $add['ok'] ) { /* translators: %s: Cloudflare's error */ return array( 'ok' => false, 'error' => sprintf( __( 'The token can read the rules but not add one (it needs Zone > Firewall Services > Edit): %s', 'cloudscale-cyber-devtools' ), $add['error'] ), 'id' => '' ); } $del = $this->unblock( $add['id'] ); if ( ! $del['ok'] ) { /* translators: %s: Cloudflare's error */ return array( 'ok' => false, 'error' => sprintf( __( 'The token added a test rule for 192.0.2.1 but could not remove it: %s', 'cloudscale-cyber-devtools' ), $del['error'] ), 'id' => $add['id'] ); } return array( 'ok' => true, 'error' => '', 'id' => '' ); } }