From b9195e7a974139a7637ffd87fe72315ef74b8ba2 Mon Sep 17 00:00:00 2001 From: Mike van den Hoek Date: Tue, 25 Feb 2025 13:46:42 +0100 Subject: [PATCH 01/30] (refactor): implement security measures for usage of the REST API endpoint(s) --- assets/css/editor.css | 7 ++ languages/yard-deepl-nl_NL.mo | Bin 2957 -> 3106 bytes languages/yard-deepl-nl_NL.po | 12 ++- languages/yard-deepl.pot | 12 ++- src/Controllers/RestAPIController.php | 130 +++++++++++++++++++++++ src/Providers/AssetsServiceProvider.php | 25 ++++- src/Providers/MetaBoxServiceProvider.php | 79 ++++++++++---- src/Services/TranslationService.php | 15 ++- src/helpers.php | 38 ++++--- webpack.config.js | 1 + 10 files changed, 275 insertions(+), 44 deletions(-) create mode 100644 assets/css/editor.css diff --git a/assets/css/editor.css b/assets/css/editor.css new file mode 100644 index 0000000..c9c9af6 --- /dev/null +++ b/assets/css/editor.css @@ -0,0 +1,7 @@ +.ydpl-metabox-row:not( :first-child ) { + margin-top: 1rem; +} + +.ydpl-metabox-row > p { + font-weight: 500; +} diff --git a/languages/yard-deepl-nl_NL.mo b/languages/yard-deepl-nl_NL.mo index fd6f7bb0134a2c9952fa1e18534f510355d20fa2..e74c46f35e7fcdecc94897bdde8f9f60e51c5504 100644 GIT binary patch delta 1193 zcmYk*OK1~O6vpvW6JyhdnpADAud%IKZH;whEog;^V0Ga_1RoHm?Wl<%wUgLF5CU$r zh*&5}UHL$K;Hm_4Bf1cwpx{QjQIu}fMcoOm1pi+$qL-Zeo7}mNbMMStZ@%AB`Wz46 zG|En5E%BhnEQ#GADoStIEP}f+iu-T{4&X{mVH;k=7M#Y#ID;DZ92@Z^uEuw`AHQO& zS;@NStpNw9;&>Qin8I2d#U)roO?WwQ0{Ph_l@hy!b$A!m|0!yG88z-j(Eb|L?=8ky z-{!bjN5gki#|YIbY((v_12s_}YT_i4yd4VKk03ugPNhUnqxxk6eVpKb5l>*4o4TK( zin6|)QQxIX@HA8T<8R@q6R!fB`||Z=nX2752&4gLQOay_zl^d{Xo{VpIDE-k)K7GRQH=u z_ghi(wAXR|nz)k&y(B%TME0Tay2ufQ;-$sJaH>k3P(AISv+Omp{L~rC4qRZ?In60nW)oL5)32zVD z_`oV<2erw}g9-dH#4hGwk`!nCBN8e&rzEu#%pfYris=|qtQxT2_i+QRE0u8B4(kuur35E2!ddY?MK2JN0vw z**Jzg8cN(ZtHlHcFoms{!3d6F4bGzG<*^=@Fo7R&0{5^DzoX{gV6$1le$#1Uz+?0g zMz9W(s0F*7S>$KKGzw${AL9gS{yWtFbEy9 zQ43w67XFFEXusU}U(^P-s6c!!G%xIo;x5-ooX3(I_n4&lK~z8y470zb=(OM<^0RT8 zYn(;}(nlkQX<|5v3V0T^&?*v(ZK5*V#yB3~6qZmK|3S@fA`0!-j)gFtmvpr87%J0A z)Pgh4_ec^phpb@>7{nFiXB#xS{|R+}2er;2YTY90oj5@Sa*A62BEb18qi+o8rTT$d z;GfeYTrCj75VoT3r=1zp#;=?sSmOFM=J5u#(Ho+rYctLqD)FTtyXz>{8PG;M$OmBQ z;*b|BJfp|J|I$sb(Ov0#;r*=~cn4Nf6=H_^oT@zakhHCe((I?|8&J`=p||Y`RVk<_ zR7KN8ePOl?_a9}YL(@xbp!T@|J!O^bJvM+k=p?m=su~r^T3Li>mG3&oA NfAyn&xhjzM?f}&HKWzX2 diff --git a/languages/yard-deepl-nl_NL.po b/languages/yard-deepl-nl_NL.po index 1832649..09a366d 100644 --- a/languages/yard-deepl-nl_NL.po +++ b/languages/yard-deepl-nl_NL.po @@ -30,14 +30,22 @@ msgstr "Yard | Digital" msgid "https://www.yard.nl" msgstr "https://www.yard.nl" -#: src/Providers/MetaboxServiceProvider.php:51 +#: src/Providers/MetaboxServiceProvider.php:72 msgid "Disable translation cache when this object contains dynamic content." msgstr "Schakel de vertalingscache uit wanneer dit object dynamische inhoud bevat." -#: src/Providers/MetaboxServiceProvider.php:76 +#: src/Providers/MetaboxServiceProvider.php:75 msgid "Disable translation cache?" msgstr "Vertalingscache uitschakelen?" +#: src/Providers/MetaboxServiceProvider.php:78 +msgid "Clear cached translations on save." +msgstr "Verwijder vertaalcache bij opslaan." + +#: src/Providers/MetaboxServiceProvider.php:80 +msgid "Clear translation cache?" +msgstr "Vertaalcache wissen?" + #: src/Providers/SettingsServiceProvider.php:66 msgid "Settings" msgstr "Instellingen" diff --git a/languages/yard-deepl.pot b/languages/yard-deepl.pot index aacc228..3ed8026 100644 --- a/languages/yard-deepl.pot +++ b/languages/yard-deepl.pot @@ -33,14 +33,22 @@ msgstr "" msgid "https://www.yard.nl" msgstr "" -#: src/Providers/MetaboxServiceProvider.php:51 +#: src/Providers/MetaboxServiceProvider.php:72 msgid "Disable translation cache when this object contains dynamic content." msgstr "" -#: src/Providers/MetaboxServiceProvider.php:76 +#: src/Providers/MetaboxServiceProvider.php:75 msgid "Disable translation cache?" msgstr "" +#: src/Providers/MetaboxServiceProvider.php:78 +msgid "Clear cached translations on save." +msgstr "" + +#: src/Providers/MetaboxServiceProvider.php:80 +msgid "Clear translation cache?" +msgstr "" + #: src/Providers/SettingsServiceProvider.php:66 msgid "Settings" msgstr "" diff --git a/src/Controllers/RestAPIController.php b/src/Controllers/RestAPIController.php index 0e804c9..940c39d 100644 --- a/src/Controllers/RestAPIController.php +++ b/src/Controllers/RestAPIController.php @@ -10,6 +10,7 @@ } use Exception; +use WP_Post; use WP_REST_Request; use WP_REST_Response; use YDPL\Services\TranslationService; @@ -21,6 +22,9 @@ */ class RestAPIController { + protected const RATE_LIMIT = 3; + protected const RATE_LIMIT_TIME_WINDOW_IN_SECONDS = 60; + use ErrorLog; protected TranslationService $service; @@ -40,6 +44,12 @@ public function handle_translate_request( WP_REST_Request $request ): WP_REST_Re $text = $request->get_param( 'text' ); $target_lang = $request->get_param( 'target_lang' ); $object_id = $request->get_param( 'object_id' ); + $origin = $request->get_header( 'origin' ); + $referer = (string) $request->get_header( 'referer' ); + + if ( is_null( $origin ) || home_url() !== $origin ) { + return $this->set_failure_response( 403, 'Invalid origin. Origin does not match the site URL.' ); + } // Are required by Deepl. if ( empty( $text ) || empty( $target_lang ) ) { @@ -51,6 +61,13 @@ public function handle_translate_request( WP_REST_Request $request ): WP_REST_Re return $this->set_failure_response( 400, 'Invalid input parameters.' ); } + // Apply rate limit check if object ID is absent or translation is not cached when an object ID is present. + if ( empty( $object_id ) || ! $this->service->object_has_cached_translation( (int) $object_id, $target_lang ) ) { + if ( $this->is_rate_limit_exceeded( (int) $object_id, $referer ) ) { + return $this->set_failure_response( 429, 'Rate limit exceeded or could not be validated.' ); + } + } + try { $translation = $this->service->handle_translation( (int) $object_id, $text, $target_lang ); @@ -68,6 +85,119 @@ public function handle_translate_request( WP_REST_Request $request ): WP_REST_Re ); } + /** + * @since 1.1.1 + */ + protected function is_rate_limit_exceeded( int $object_id, string $referer ): bool + { + if ( empty( $object_id ) && ! $this->referer_without_object_id_exists_and_matches_host( $referer ) ) { + return true; + } + + if ( ! empty( $object_id ) && ! $this->referer_with_object_id_exists_and_matches_url_path( $object_id, $referer ) ) { + return true; + } + + $client_ip = $this->get_client_ip(); + + // Validate if client IP is valid. + if ( 1 > strlen( $client_ip ) ) { + return true; + } + + $transient_key = 'ydpl_rate_limit_' . hash_hmac( 'sha256', $referer . $client_ip, SECURE_AUTH_KEY ); + $request_count = (int) ( get_transient( $transient_key ) ?: 0 ); + + if ( self::RATE_LIMIT <= $request_count ) { + return true; + } + + set_transient( $transient_key, $request_count + 1, self::RATE_LIMIT_TIME_WINDOW_IN_SECONDS ); + + return false; + } + + /** + * @since 1.1.1 + */ + protected function referer_without_object_id_exists_and_matches_host( string $referer ): bool + { + if ( ! $this->hosts_match( $referer, home_url() ) ) { + return false; + } + + // Validate if requested page exists. + $result = wp_remote_head( $referer ); + + if ( is_wp_error( $result ) ) { + return false; + } + + $response_code = wp_remote_retrieve_response_code( $result ); + + if ( ! is_int( $response_code ) || ! in_array( $response_code, array( 200, 201 ) ) ) { + return false; + } + + return true; + } + + /** + * @since 1.1.1 + */ + protected function referer_with_object_id_exists_and_matches_url_path( int $object_id, string $referer ): bool + { + $post = get_post( $object_id ); + + if ( ! $post instanceof WP_Post ) { + return false; + } + + $permalink = get_permalink( $post ); + + if ( ! $permalink ) { + return false; + } + + if ( ! $this->hosts_match( $referer, $permalink ) ) { + return false; + } + + $referer_path = wp_parse_url( $referer, PHP_URL_PATH ); + $permalink_path = wp_parse_url( $permalink, PHP_URL_PATH ); + + return str_replace( '/', '', $referer_path ) === str_replace( '/', '', $permalink_path ); + } + + /** + * @since 1.1.1 + */ + protected function hosts_match( mixed $url_one, mixed $url_two ): bool + { + $host_one = wp_parse_url( $url_one, PHP_URL_HOST ); + $host_two = wp_parse_url( $url_two, PHP_URL_HOST ); + + if ( ! is_string( $host_one ) || ! is_string( $host_two ) ) { + return false; + } + + return $host_one === $host_two; + } + + /** + * @since 1.1.1 + */ + protected function get_client_ip(): string + { + $remote_address = $_SERVER['REMOTE_ADDR'] ?? ''; + + if ( filter_var( $remote_address, FILTER_VALIDATE_IP ) === false ) { + return ''; + } + + return $remote_address; + } + /** * @since 0.0.1 */ diff --git a/src/Providers/AssetsServiceProvider.php b/src/Providers/AssetsServiceProvider.php index 19f8459..0fd9e69 100644 --- a/src/Providers/AssetsServiceProvider.php +++ b/src/Providers/AssetsServiceProvider.php @@ -22,6 +22,7 @@ class AssetsServiceProvider implements ServiceProviderInterface public function register(): void { add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_assets' ) ); + add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_admin_assets' ) ); } /** @@ -29,18 +30,17 @@ public function register(): void */ public function enqueue_assets(): void { - $path = ydpl_asset_url( 'main.asset.php' ); + $path = ydpl_asset_path( 'main.asset.php' ); $script_asset = file_exists( $path ) ? require $path : array( 'dependencies' => array(), 'version' => round( microtime( true ) ), ); - - wp_enqueue_script( 'ydpl-main', ydpl_asset_url( 'main.js' ), array( 'jquery' ), $script_asset['version'], true ); + wp_enqueue_script( 'ydpl-main', ydpl_asset_url( 'main.js' ), $script_asset['dependencies'] ?? array(), $script_asset['version'], true ); wp_localize_script( 'ydpl-main', 'ydpl', array( - 'ydpl_translate_post_id' => get_the_ID() ?: 0, + 'ydpl_translate_post_id' => get_the_ID() ?: get_queried_object_id() ?: 0, 'ydpl_rest_translate_url' => esc_url_raw( rest_url( YDPL_API_NAMESPACE . '/translate' ) ), 'ydpl_supported_languages' => $this->format_selected_supported_languages(), 'ydpl_api_request_nonce' => wp_create_nonce( YDPL_NONCE_REST_NAME ), @@ -48,6 +48,9 @@ public function enqueue_assets(): void ); } + /** + * @since 0.0.1 + */ private function format_selected_supported_languages(): array { $supported_languages = ydpl_resolve_from_container( 'ydpl.supported_target.languages' ); @@ -62,4 +65,18 @@ function ( $supported_language ) use ( $configured_supported_languages ) { return array_values( $filtered ); } + + /** + * @since 1.1.1 + */ + public function enqueue_admin_assets(): void + { + $path = ydpl_asset_path( 'editor.asset.php' ); + $script_asset = file_exists( $path ) ? require $path : array( + 'dependencies' => array(), + 'version' => round( microtime( true ) ), + ); + + wp_enqueue_style( 'ydpl-main', ydpl_asset_url( 'editor.css' ), $script_asset['dependencies'] ?? array(), $script_asset['version'] ); + } } diff --git a/src/Providers/MetaBoxServiceProvider.php b/src/Providers/MetaBoxServiceProvider.php index e00b406..2022a7a 100644 --- a/src/Providers/MetaBoxServiceProvider.php +++ b/src/Providers/MetaBoxServiceProvider.php @@ -20,20 +20,20 @@ class MetaBoxServiceProvider implements ServiceProviderInterface { public function register(): void { - add_action( 'add_meta_boxes', array( $this, 'register_meta_box' ), 999 ); - add_action( 'save_post', array( $this, 'save_metabox_values' ), 999 ); + add_action( 'add_meta_boxes', array( $this, 'register_meta_boxes' ), 999 ); + add_action( 'save_post', array( $this, 'handle_saved_metabox_values' ), 999 ); } /** * @since 1.1.0 */ - public function register_meta_box(): void + public function register_meta_boxes(): void { add_meta_box( 'yard-deepl', __( 'Yard Deepl', 'yard-deepl' ), - array( $this, 'render_meta_box' ), - apply_filters( 'yard::deepl/disable_cache_metabox_post_types', array( 'page' ) ), + array( $this, 'render_meta_boxes' ), + apply_filters( 'yard::deepl/cache_metabox_post_types', array( 'page' ) ), 'side', 'high' ); @@ -42,16 +42,11 @@ public function register_meta_box(): void /** * @since 1.1.0 */ - public function render_meta_box( WP_Post $post ): void + public function render_meta_boxes( WP_Post $post ): void { $this->security_nonce_field(); - $html = sprintf( - '

%s

', - esc_html__( 'Disable translation cache when this object contains dynamic content.', 'yard-deepl' ) - ); - - $html = $this->disable_translation_cache_metabox( $html, $post ); + $html = $this->translation_cache_metaboxes( $post ); echo $html; } @@ -67,34 +62,74 @@ private function security_nonce_field(): void /** * @since 1.1.0 */ - private function disable_translation_cache_metabox( string $html, WP_Post $post ): string + private function translation_cache_metaboxes( WP_Post $post ): string { - $is_disabled = get_post_meta( $post->ID, 'ydpl_disable_deepl_translation_cache', true ); - - $html .= '