Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"url": "https://develop.svn.wordpress.org/trunk"
},
"gutenberg": {
"sha": "9b8144036fa5faf75de43d4502ff9809fcf689ad",
"sha": "74a4f254a45f7a303bd27b8f8e104786380e8103",
"ghcrRepo": "WordPress/gutenberg/gutenberg-wp-develop-build"
},
"engines": {
Expand Down
167 changes: 108 additions & 59 deletions src/wp-includes/connectors.php
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,50 @@ function _wp_connectors_mask_api_key( string $key ): string {
return str_repeat( "\u{2022}", min( strlen( $key ) - 4, 16 ) ) . substr( $key, -4 );
}

/**
* Determines the source of an API key for a given provider.
*
* Checks in order: environment variable, PHP constant, database.
* Uses the same naming convention as the WP AI Client ProviderRegistry.
*
* @since 7.0.0
* @access private
*
* @param string $provider_id The provider ID (e.g., 'openai', 'anthropic', 'google').
* @param string $setting_name The option name for the API key (e.g., 'connectors_ai_openai_api_key').
* @return string The key source: 'env', 'constant', 'database', or 'none'.
*/
function _wp_connectors_get_api_key_source( string $provider_id, string $setting_name ): string {
// Convert provider ID to CONSTANT_CASE for env var name.
// e.g., 'openai' -> 'OPENAI', 'anthropic' -> 'ANTHROPIC'.
$constant_case_id = strtoupper(
preg_replace( '/([a-z])([A-Z])/', '$1_$2', str_replace( '-', '_', $provider_id ) )
);
$env_var_name = "{$constant_case_id}_API_KEY";

// Check environment variable first.
$env_value = getenv( $env_var_name );
if ( false !== $env_value && '' !== $env_value ) {
return 'env';
}

// Check PHP constant.
if ( defined( $env_var_name ) ) {
$const_value = constant( $env_var_name );
if ( is_string( $const_value ) && '' !== $const_value ) {
return 'constant';
}
}

// Check database.
$db_value = get_option( $setting_name, '' );
if ( '' !== $db_value ) {
return 'database';
}

return 'none';
}

/**
* Checks whether an API key is valid for a given provider.
*
Expand Down Expand Up @@ -378,89 +422,69 @@ function _wp_connectors_is_ai_api_key_valid( string $key, string $provider_id ):
}

/**
* Retrieves the real (unmasked) value of a connector API key.
* Masks and validates connector API keys in REST responses.
*
* Temporarily removes the masking filter, reads the option, then re-adds it.
*
* @since 7.0.0
* @access private
*
* @param string $option_name The option name for the API key.
* @param callable $mask_callback The mask filter function.
* @return string The real API key value.
*/
function _wp_connectors_get_real_api_key( string $option_name, callable $mask_callback ): string {
remove_filter( "option_{$option_name}", $mask_callback );
$value = get_option( $option_name, '' );
add_filter( "option_{$option_name}", $mask_callback );
return (string) $value;
}

/**
* Validates connector API keys in the REST response when explicitly requested.
* On every `/wp/v2/settings` response, masks connector API key values so raw
* keys are never exposed via the REST API.
*
* Runs on `rest_post_dispatch` for `/wp/v2/settings` requests that include connector
* fields via `_fields`. For each requested connector field, it validates the unmasked
* key against the provider and replaces the response value with `invalid_key` if
* validation fails.
* On POST or PUT requests, validates each updated key against the provider
* before masking. If validation fails, the key is reverted to an empty string.
*
* @since 7.0.0
* @access private
*
* @param WP_REST_Response $response The response object.
* @param WP_REST_Server $server The server instance.
* @param WP_REST_Request $request The request object.
* @return WP_REST_Response The potentially modified response.
* @return WP_REST_Response The modified response with masked/validated keys.
*/
function _wp_connectors_validate_keys_in_rest( WP_REST_Response $response, WP_REST_Server $server, WP_REST_Request $request ): WP_REST_Response {
function _wp_connectors_rest_settings_dispatch( WP_REST_Response $response, WP_REST_Server $server, WP_REST_Request $request ): WP_REST_Response {
if ( '/wp/v2/settings' !== $request->get_route() ) {
return $response;
}

$fields = $request->get_param( '_fields' );
if ( ! $fields ) {
return $response;
}

if ( is_array( $fields ) ) {
$requested = $fields;
} else {
$requested = array_map( 'trim', explode( ',', $fields ) );
}

$data = $response->get_data();
if ( ! is_array( $data ) ) {
return $response;
}

$is_update = 'POST' === $request->get_method() || 'PUT' === $request->get_method();

foreach ( wp_get_connectors() as $connector_id => $connector_data ) {
$auth = $connector_data['authentication'];
if ( 'ai_provider' !== $connector_data['type'] || 'api_key' !== $auth['method'] || empty( $auth['setting_name'] ) ) {
continue;
}

$setting_name = $auth['setting_name'];
if ( ! in_array( $setting_name, $requested, true ) ) {
if ( ! array_key_exists( $setting_name, $data ) ) {
continue;
}

$real_key = _wp_connectors_get_real_api_key( $setting_name, '_wp_connectors_mask_api_key' );
if ( '' === $real_key ) {
continue;
$value = $data[ $setting_name ];

// On update, validate the key before masking.
if ( $is_update && is_string( $value ) && '' !== $value ) {
if ( true !== _wp_connectors_is_ai_api_key_valid( $value, $connector_id ) ) {
update_option( $setting_name, '' );
$data[ $setting_name ] = '';
continue;
}
}

if ( true !== _wp_connectors_is_ai_api_key_valid( $real_key, $connector_id ) ) {
$data[ $setting_name ] = 'invalid_key';
// Mask the key in the response.
if ( is_string( $value ) && '' !== $value ) {
$data[ $setting_name ] = _wp_connectors_mask_api_key( $value );
}
}

$response->set_data( $data );
return $response;
}
add_filter( 'rest_post_dispatch', '_wp_connectors_validate_keys_in_rest', 10, 3 );
add_filter( 'rest_post_dispatch', '_wp_connectors_rest_settings_dispatch', 10, 3 );

/**
* Registers default connector settings and mask/sanitize filters.
* Registers default connector settings.
*
* @since 7.0.0
* @access private
Expand All @@ -479,10 +503,9 @@ function _wp_register_default_connector_settings(): void {
continue;
}

$setting_name = $auth['setting_name'];
register_setting(
'connectors',
$setting_name,
$auth['setting_name'],
array(
'type' => 'string',
'label' => sprintf(
Expand All @@ -497,18 +520,9 @@ function _wp_register_default_connector_settings(): void {
),
'default' => '',
'show_in_rest' => true,
'sanitize_callback' => static function ( string $value ) use ( $connector_id ): string {
$value = sanitize_text_field( $value );
if ( '' === $value ) {
return $value;
}

$valid = _wp_connectors_is_ai_api_key_valid( $value, $connector_id );
return true === $valid ? $value : '';
},
'sanitize_callback' => 'sanitize_text_field',
)
);
add_filter( "option_{$setting_name}", '_wp_connectors_mask_api_key' );
}
}
add_action( 'init', '_wp_register_default_connector_settings', 20 );
Expand Down Expand Up @@ -536,7 +550,13 @@ function _wp_connectors_pass_default_keys_to_ai_client(): void {
continue;
}

$api_key = _wp_connectors_get_real_api_key( $auth['setting_name'], '_wp_connectors_mask_api_key' );
// Skip if the key is already provided via env var or constant.
$key_source = _wp_connectors_get_api_key_source( $connector_id, $auth['setting_name'] );
if ( 'env' === $key_source || 'constant' === $key_source ) {
continue;
}

$api_key = get_option( $auth['setting_name'], '' );
if ( '' === $api_key ) {
continue;
}
Expand All @@ -562,6 +582,18 @@ function _wp_connectors_pass_default_keys_to_ai_client(): void {
* @return array<string, mixed> Script module data with connectors added.
*/
function _wp_connectors_get_connector_script_module_data( array $data ): array {
$registry = AiClient::defaultRegistry();

// Build a slug-to-file map for plugin installation status.
if ( ! function_exists( 'get_plugins' ) ) {
require_once ABSPATH . 'wp-admin/includes/plugin.php';
}
$plugin_files_by_slug = array();
foreach ( array_keys( get_plugins() ) as $plugin_file ) {
$slug = str_contains( $plugin_file, '/' ) ? dirname( $plugin_file ) : str_replace( '.php', '', $plugin_file );
$plugin_files_by_slug[ $slug ] = $plugin_file;
}

$connectors = array();
foreach ( wp_get_connectors() as $connector_id => $connector_data ) {
$auth = $connector_data['authentication'];
Expand All @@ -570,17 +602,34 @@ function _wp_connectors_get_connector_script_module_data( array $data ): array {
if ( 'api_key' === $auth['method'] ) {
$auth_out['settingName'] = $auth['setting_name'] ?? '';
$auth_out['credentialsUrl'] = $auth['credentials_url'] ?? null;
$auth_out['keySource'] = _wp_connectors_get_api_key_source( $connector_id, $auth['setting_name'] ?? '' );
try {
$auth_out['isConnected'] = $registry->hasProvider( $connector_id ) && $registry->isProviderConfigured( $connector_id );
} catch ( Exception $e ) {
$auth_out['isConnected'] = false;
}
}

$connector_out = array(
'name' => $connector_data['name'],
'description' => $connector_data['description'],
'logoUrl' => ! empty( $connector_data['logo_url'] ) ? $connector_data['logo_url'] : null,
'type' => $connector_data['type'],
'authentication' => $auth_out,
);

if ( ! empty( $connector_data['plugin'] ) ) {
$connector_out['plugin'] = $connector_data['plugin'];
if ( ! empty( $connector_data['plugin']['slug'] ) ) {
$plugin_slug = $connector_data['plugin']['slug'];
$plugin_file = $plugin_files_by_slug[ $plugin_slug ] ?? null;

$is_installed = null !== $plugin_file;
$is_activated = $is_installed && is_plugin_active( $plugin_file );

$connector_out['plugin'] = array(
'slug' => $plugin_slug,
'isInstalled' => $is_installed,
'isActivated' => $is_activated,
);
}

$connectors[ $connector_id ] = $connector_out;
Expand Down
2 changes: 1 addition & 1 deletion src/wp-includes/default-filters.php
Original file line number Diff line number Diff line change
Expand Up @@ -540,7 +540,7 @@
add_action( 'wp_abilities_api_init', 'wp_register_core_abilities' );

// Connectors API.
add_action( 'init', '_wp_connectors_init' );
add_action( 'init', '_wp_connectors_init', 15 );

// Sitemaps actions.
add_action( 'init', 'wp_sitemaps_get_server' );
Expand Down
Loading