From d35cd08469ef89960914e766700b8f2b68997ced Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Mon, 23 Sep 2024 12:39:45 -0400 Subject: [PATCH 1/5] Escape inline css --- wpgraphql-ide.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/wpgraphql-ide.php b/wpgraphql-ide.php index 5031c8b..df42196 100644 --- a/wpgraphql-ide.php +++ b/wpgraphql-ide.php @@ -386,7 +386,7 @@ function enqueue_graphql_ide_menu_icon_css(): void { } '; - wp_add_inline_style( 'admin-bar', $custom_css ); + wp_add_inline_style( 'admin-bar', esc_html( $custom_css ) ); } /** @@ -558,7 +558,7 @@ function graphql_admin_notices_render_notices( array $notices ): void { // phpcs:ignore WordPress.WP.EnqueuedResourceParameters.MissingVersion wp_register_style( 'wpgraphql-ide-admin-notices', false ); wp_enqueue_style( 'wpgraphql-ide-admin-notices' ); - wp_add_inline_style( 'wpgraphql-ide-admin-notices', $custom_css ); + wp_add_inline_style( 'wpgraphql-ide-admin-notices', esc_html( $custom_css ) ); } /** From e4f3180dff6027cfa49166ee71b409845c84c39d Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Mon, 23 Sep 2024 13:06:35 -0400 Subject: [PATCH 2/5] sanitize/escape app context --- wpgraphql-ide.php | 49 +++++++++++++++++++++++++++++++++++------------ 1 file changed, 37 insertions(+), 12 deletions(-) diff --git a/wpgraphql-ide.php b/wpgraphql-ide.php index df42196..0650025 100644 --- a/wpgraphql-ide.php +++ b/wpgraphql-ide.php @@ -240,7 +240,8 @@ function register_wpadminbar_menus(): void { global $wp_admin_bar; - $app_context = get_app_context(); + // get_app_context() returns escaped data. + $safe_app_context = get_app_context(); // Retrieve the settings array $graphql_ide_settings = get_option( 'graphql_ide_settings', [] ); @@ -253,7 +254,7 @@ function register_wpadminbar_menus(): void { $wp_admin_bar->add_node( [ 'id' => 'wpgraphql-ide', - 'title' => '
' . esc_html( $app_context['drawerButtonLabel'] ) . '
', + 'title' => '
' . esc_html( $safe_app_context['drawerButtonLabel'] ) . '
', 'href' => '#', ] ); @@ -262,7 +263,7 @@ function register_wpadminbar_menus(): void { $wp_admin_bar->add_node( [ 'id' => 'wpgraphql-ide', - 'title' => '' . esc_html( $app_context['drawerButtonLabel'] ), + 'title' => '' . esc_html( $safe_app_context['drawerButtonLabel'] ), 'href' => esc_url( admin_url( 'admin.php?page=graphql-ide' ) ), ] ); @@ -341,7 +342,7 @@ function reorder_graphql_submenu_items(): void { $ordered_submenu[] = $graphql_ide; } if ( 'on' === $show_legacy_editor && $graphiql_ide ) { - $graphiql_ide[0] = 'Legacy GraphQL IDE'; + $graphiql_ide[0] = esc_html__( 'Legacy GraphQL IDE', 'wpgraphql-ide' ); $ordered_submenu[] = $graphiql_ide; } if ( $extensions ) { @@ -418,7 +419,8 @@ function enqueue_react_app_with_styles(): void { $render_asset_file = include WPGRAPHQL_IDE_PLUGIN_DIR_PATH . 'build/wpgraphql-ide-render.asset.php'; $graphql_asset_file = include WPGRAPHQL_IDE_PLUGIN_DIR_PATH . 'build/graphql.asset.php'; - $app_context = get_app_context(); + // get_app_context() returns escaped data. + $safe_app_context = get_app_context(); wp_register_script( 'graphql', @@ -438,11 +440,11 @@ function enqueue_react_app_with_styles(): void { $localized_data = [ 'nonce' => wp_create_nonce( 'wp_rest' ), - 'graphqlEndpoint' => trailingslashit( site_url() ) . 'index.php?' . \WPGraphQL\Router::$route, - 'rootElementId' => WPGRAPHQL_IDE_ROOT_ELEMENT_ID, - 'context' => $app_context, + 'graphqlEndpoint' => esc_url( trailingslashit( site_url() ) . 'index.php?' . \WPGraphQL\Router::$route ), + 'rootElementId' => esc_attr( WPGRAPHQL_IDE_ROOT_ELEMENT_ID ), + 'context' => $safe_app_context, 'isDedicatedIdePage' => current_screen_is_dedicated_ide_page(), - 'dedicatedIdeBaseUrl' => get_dedicated_ide_base_url(), + 'dedicatedIdeBaseUrl' => esc_url( get_dedicated_ide_base_url() ), ]; wp_localize_script( @@ -505,6 +507,27 @@ function get_plugin_header( string $key = '' ): ?string { return is_string( $plugin_header ) ? $plugin_header : null; } +/** + * Retrieves and sanitizes external fragments. + * + * @return array The sanitized array of external fragments. + */ +function get_external_fragments(): array { + // Retrieve external fragments using the filter. + $external_fragments = apply_filters( 'wpgraphql_ide_external_fragments', [] ); + + // Loop through each fragment, sanitize, and ensure it's a valid GraphQL fragment. + $sanitized_fragments = array_filter( + array_map( 'sanitize_text_field', $external_fragments ), + function( $fragment ) { + // Check if the fragment starts with "fragment" and contains "on" (basic GraphQL fragment validation). + return preg_match( '/^fragment\s+\w+\s+on\s+\w+\s*{/', trim( $fragment ) ); + } + ); + + return $sanitized_fragments; +} + /** * Retrieves app context. * @@ -516,16 +539,18 @@ function get_app_context(): array { // Get the avatar URL for the current user. Returns an empty string if no user is logged in. $avatar_url = $current_user->exists() ? get_avatar_url( $current_user->ID ) : ''; - return apply_filters( + $app_context = apply_filters( 'wpgraphql_ide_context', [ 'pluginVersion' => get_plugin_header( 'Version' ), 'pluginName' => get_plugin_header( 'Name' ), - 'externalFragments' => apply_filters( 'wpgraphql_ide_external_fragments', [] ), + 'externalFragments' => get_external_fragments(), 'avatarUrl' => $avatar_url, - 'drawerButtonLabel' => __( 'GraphQL IDE', 'wpgraphql-ide' ), + 'drawerButtonLabel' => esc_html__( 'GraphQL IDE', 'wpgraphql-ide' ), ] ); + + return $app_context; } /** From c47e3ea5883338cb52d993b5caef2a53cd76bf05 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Mon, 23 Sep 2024 13:21:07 -0400 Subject: [PATCH 3/5] Resolve linting issues --- wpgraphql-ide.php | 22 +++++++++------------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/wpgraphql-ide.php b/wpgraphql-ide.php index 0650025..34fbb40 100644 --- a/wpgraphql-ide.php +++ b/wpgraphql-ide.php @@ -387,7 +387,7 @@ function enqueue_graphql_ide_menu_icon_css(): void { } '; - wp_add_inline_style( 'admin-bar', esc_html( $custom_css ) ); + wp_add_inline_style( 'admin-bar', wp_kses_post( $custom_css ) ); } /** @@ -455,7 +455,7 @@ function enqueue_react_app_with_styles(): void { // Extensions looking to extend GraphiQL can hook in here, // after the window object is established, but before the App renders - do_action( 'wpgraphql_ide_enqueue_script', $app_context ); + do_action( 'wpgraphql_ide_enqueue_script', $safe_app_context ); wp_enqueue_script( 'wpgraphql-ide-render', @@ -517,15 +517,13 @@ function get_external_fragments(): array { $external_fragments = apply_filters( 'wpgraphql_ide_external_fragments', [] ); // Loop through each fragment, sanitize, and ensure it's a valid GraphQL fragment. - $sanitized_fragments = array_filter( + return array_filter( array_map( 'sanitize_text_field', $external_fragments ), - function( $fragment ) { + static function ( string $fragment ): bool { // Check if the fragment starts with "fragment" and contains "on" (basic GraphQL fragment validation). - return preg_match( '/^fragment\s+\w+\s+on\s+\w+\s*{/', trim( $fragment ) ); + return preg_match( '/^fragment\s+\w+\s+on\s+\w+\s*{/', trim( $fragment ) ) === 1; } ); - - return $sanitized_fragments; } /** @@ -537,20 +535,18 @@ function get_app_context(): array { $current_user = wp_get_current_user(); // Get the avatar URL for the current user. Returns an empty string if no user is logged in. - $avatar_url = $current_user->exists() ? get_avatar_url( $current_user->ID ) : ''; + $avatar_url = $current_user->exists() ? ( get_avatar_url( $current_user->ID ) ?: '' ) : ''; - $app_context = apply_filters( + return apply_filters( 'wpgraphql_ide_context', [ 'pluginVersion' => get_plugin_header( 'Version' ), 'pluginName' => get_plugin_header( 'Name' ), 'externalFragments' => get_external_fragments(), - 'avatarUrl' => $avatar_url, + 'avatarUrl' => esc_url( $avatar_url ), 'drawerButtonLabel' => esc_html__( 'GraphQL IDE', 'wpgraphql-ide' ), ] ); - - return $app_context; } /** @@ -583,7 +579,7 @@ function graphql_admin_notices_render_notices( array $notices ): void { // phpcs:ignore WordPress.WP.EnqueuedResourceParameters.MissingVersion wp_register_style( 'wpgraphql-ide-admin-notices', false ); wp_enqueue_style( 'wpgraphql-ide-admin-notices' ); - wp_add_inline_style( 'wpgraphql-ide-admin-notices', esc_html( $custom_css ) ); + wp_add_inline_style( 'wpgraphql-ide-admin-notices', wp_kses_post( $custom_css ) ); } /** From 352b6a36249af60d6a3bba720a9a10c53d09361f Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Mon, 23 Sep 2024 13:47:53 -0400 Subject: [PATCH 4/5] Prevent double escapes --- wpgraphql-ide.php | 64 +++++++++++++++++++++++++++++++---------------- 1 file changed, 42 insertions(+), 22 deletions(-) diff --git a/wpgraphql-ide.php b/wpgraphql-ide.php index 34fbb40..9153060 100644 --- a/wpgraphql-ide.php +++ b/wpgraphql-ide.php @@ -240,8 +240,7 @@ function register_wpadminbar_menus(): void { global $wp_admin_bar; - // get_app_context() returns escaped data. - $safe_app_context = get_app_context(); + $app_context = get_app_context(); // Retrieve the settings array $graphql_ide_settings = get_option( 'graphql_ide_settings', [] ); @@ -254,7 +253,7 @@ function register_wpadminbar_menus(): void { $wp_admin_bar->add_node( [ 'id' => 'wpgraphql-ide', - 'title' => '
' . esc_html( $safe_app_context['drawerButtonLabel'] ) . '
', + 'title' => '
' . esc_html( $app_context['drawerButtonLabel'] ) . '
', 'href' => '#', ] ); @@ -263,7 +262,7 @@ function register_wpadminbar_menus(): void { $wp_admin_bar->add_node( [ 'id' => 'wpgraphql-ide', - 'title' => '' . esc_html( $safe_app_context['drawerButtonLabel'] ), + 'title' => '' . esc_html( $app_context['drawerButtonLabel'] ), 'href' => esc_url( admin_url( 'admin.php?page=graphql-ide' ) ), ] ); @@ -419,8 +418,7 @@ function enqueue_react_app_with_styles(): void { $render_asset_file = include WPGRAPHQL_IDE_PLUGIN_DIR_PATH . 'build/wpgraphql-ide-render.asset.php'; $graphql_asset_file = include WPGRAPHQL_IDE_PLUGIN_DIR_PATH . 'build/graphql.asset.php'; - // get_app_context() returns escaped data. - $safe_app_context = get_app_context(); + $app_context = get_app_context(); wp_register_script( 'graphql', @@ -440,22 +438,24 @@ function enqueue_react_app_with_styles(): void { $localized_data = [ 'nonce' => wp_create_nonce( 'wp_rest' ), - 'graphqlEndpoint' => esc_url( trailingslashit( site_url() ) . 'index.php?' . \WPGraphQL\Router::$route ), - 'rootElementId' => esc_attr( WPGRAPHQL_IDE_ROOT_ELEMENT_ID ), - 'context' => $safe_app_context, + 'graphqlEndpoint' => trailingslashit( site_url() ) . 'index.php?' . \WPGraphQL\Router::$route, + 'rootElementId' => WPGRAPHQL_IDE_ROOT_ELEMENT_ID, + 'context' => $app_context, 'isDedicatedIdePage' => current_screen_is_dedicated_ide_page(), - 'dedicatedIdeBaseUrl' => esc_url( get_dedicated_ide_base_url() ), + 'dedicatedIdeBaseUrl' => get_dedicated_ide_base_url(), ]; + $escaped_data = wp_localize_escaped_data( $localized_data ); + wp_localize_script( 'wpgraphql-ide', 'WPGRAPHQL_IDE_DATA', - $localized_data + $escaped_data ); // Extensions looking to extend GraphiQL can hook in here, // after the window object is established, but before the App renders - do_action( 'wpgraphql_ide_enqueue_script', $safe_app_context ); + do_action( 'wpgraphql_ide_enqueue_script', $app_context ); wp_enqueue_script( 'wpgraphql-ide-render', @@ -526,6 +526,27 @@ static function ( string $fragment ): bool { ); } +/** + * Recursive function to escape an array or value for safe output, specifically for localizing data in WordPress. + * + * @param mixed $data The data to escape. + * @return mixed The escaped data. + */ +function wp_localize_escaped_data( $data ) { + if ( is_array( $data ) ) { + return array_map( __NAMESPACE__ . '\wp_localize_escaped_data', $data ); + } elseif ( is_string( $data ) ) { + // Use wp_kses_post to allow basic HTML for content and esc_url for URLs + return filter_var( $data, FILTER_VALIDATE_URL ) ? esc_url( $data ) : wp_kses_post( $data ); + } elseif ( is_int( $data ) ) { + return absint( $data ); + } elseif ( is_bool( $data ) ) { + return (bool) $data; + } + + return $data; // Return original value if it's not a string, int, or bool. +} + /** * Retrieves app context. * @@ -537,16 +558,15 @@ function get_app_context(): array { // Get the avatar URL for the current user. Returns an empty string if no user is logged in. $avatar_url = $current_user->exists() ? ( get_avatar_url( $current_user->ID ) ?: '' ) : ''; - return apply_filters( - 'wpgraphql_ide_context', - [ - 'pluginVersion' => get_plugin_header( 'Version' ), - 'pluginName' => get_plugin_header( 'Name' ), - 'externalFragments' => get_external_fragments(), - 'avatarUrl' => esc_url( $avatar_url ), - 'drawerButtonLabel' => esc_html__( 'GraphQL IDE', 'wpgraphql-ide' ), - ] - ); + $app_context = [ + 'pluginVersion' => get_plugin_header( 'Version' ), + 'pluginName' => get_plugin_header( 'Name' ), + 'externalFragments' => get_external_fragments(), + 'avatarUrl' => $avatar_url, + 'drawerButtonLabel' => __( 'GraphQL IDE', 'wpgraphql-ide' ), + ]; + + return apply_filters( 'wpgraphql_ide_context', $app_context ); } /** From 477a5553a14caf480254a2331276266a1780f723 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Mon, 23 Sep 2024 13:50:20 -0400 Subject: [PATCH 5/5] Add changeset --- .changeset/hip-flies-camp.md | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 .changeset/hip-flies-camp.md diff --git a/.changeset/hip-flies-camp.md b/.changeset/hip-flies-camp.md new file mode 100644 index 0000000..4c7e384 --- /dev/null +++ b/.changeset/hip-flies-camp.md @@ -0,0 +1,11 @@ +--- +"wpgraphql-ide": patch +--- + +### Added + +- Introduced `wp_localize_escaped_data()` function for recursively escaping data before localizing it in WordPress. This ensures safe output of strings, URLs, integers, and nested arrays when passing data to JavaScript, using native WordPress functions like `wp_kses_post()` and `esc_url()`. + +### Improved + +- Enhanced security by ensuring all localized data is properly sanitized before being passed to `wp_localize_script()`, preventing potential XSS vulnerabilities and ensuring safe use of dynamic data in JavaScript.