diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ab3015..9746644 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,25 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.4.9] - 2026-02-26 + +### Security + +- Fixed XSS vulnerability: replaced all jQuery `.html()` injections with safe `.text()` DOM construction in admin.js +- Fixed insecure token generation: replaced `Math.random()` with `crypto.getRandomValues()` (Web Crypto API) +- Fixed XSS via string interpolation in `updateValueRows()`: replaced HTML string building with jQuery DOM construction +- Added 1 MB import size limit to prevent DoS via large JSON payloads in CustomMetricBuilder +- Removed `site_url` from metric export data to prevent information disclosure +- Added import mode validation (allowlist check) in CustomMetricBuilder + +### Changed + +- Extracted shared authentication logic (`wp_prometheus_authenticate_request()`) to eliminate code duplication between MetricsEndpoint and isolated mode handler +- Extracted `showNotice()` helper in admin.js to DRY up 10+ duplicated AJAX response handling patterns +- Extracted `is_hpos_enabled()` helper method in Collector to DRY up WooCommerce HPOS checks +- Optimized WooCommerce product type counting: uses `paginate: true` COUNT query instead of loading all product IDs into memory +- Added missing options to `Installer::uninstall()` cleanup (isolated_mode, storage adapter, Redis/APCu config) + ## [0.4.8] - 2026-02-07 ### Fixed diff --git a/assets/js/admin.js b/assets/js/admin.js index 8f98aff..ecc0fad 100644 --- a/assets/js/admin.js +++ b/assets/js/admin.js @@ -235,31 +235,19 @@ $spinner.removeClass('is-active'); if (response.success) { - $message - .removeClass('notice-error') - .addClass('notice notice-success') - .html('

' + response.data.message + '

') - .show(); + showNotice($message, response.data.message, 'success'); // Reload page after successful validation/activation. setTimeout(function() { location.reload(); }, 1500); } else { - $message - .removeClass('notice-success') - .addClass('notice notice-error') - .html('

' + (response.data.message || 'An error occurred.') + '

') - .show(); + showNotice($message, response.data.message || 'An error occurred.', 'error'); } }, error: function() { $spinner.removeClass('is-active'); - $message - .removeClass('notice-success') - .addClass('notice notice-error') - .html('

Connection error. Please try again.

') - .show(); + showNotice($message, 'Connection error. Please try again.', 'error'); } }); } @@ -287,30 +275,18 @@ $spinner.removeClass('is-active'); if (response.success) { - $message - .removeClass('notice-error') - .addClass('notice notice-success') - .html('

' + response.data.message + '

') - .show(); + showNotice($message, response.data.message, 'success'); setTimeout(function() { window.location.href = window.location.pathname + '?page=wp-prometheus&tab=custom'; }, 1000); } else { - $message - .removeClass('notice-success') - .addClass('notice notice-error') - .html('

' + (response.data.message || 'An error occurred.') + '

') - .show(); + showNotice($message, response.data.message || 'An error occurred.', 'error'); } }, error: function() { $spinner.removeClass('is-active'); - $message - .removeClass('notice-success') - .addClass('notice notice-error') - .html('

Connection error. Please try again.

') - .show(); + showNotice($message, 'Connection error. Please try again.', 'error'); } }); } @@ -398,11 +374,7 @@ $spinner.removeClass('is-active'); if (response.success) { - $message - .removeClass('notice-error') - .addClass('notice notice-success') - .html('

' + response.data.message + '

') - .show(); + showNotice($message, response.data.message, 'success'); $('#import-options').slideUp(); $('#import-metrics-file').val(''); @@ -412,20 +384,12 @@ location.reload(); }, 1500); } else { - $message - .removeClass('notice-success') - .addClass('notice notice-error') - .html('

' + (response.data.message || 'An error occurred.') + '

') - .show(); + showNotice($message, response.data.message || 'An error occurred.', 'error'); } }, error: function() { $spinner.removeClass('is-active'); - $message - .removeClass('notice-success') - .addClass('notice notice-error') - .html('

Connection error. Please try again.

') - .show(); + showNotice($message, 'Connection error. Please try again.', 'error'); } }); } @@ -478,26 +442,14 @@ $spinner.removeClass('is-active'); if (response.success) { - $message - .removeClass('notice-error') - .addClass('notice notice-success') - .html('

' + response.data.message + '

') - .show(); + showNotice($message, response.data.message, 'success'); } else { - $message - .removeClass('notice-success') - .addClass('notice notice-error') - .html('

' + (response.data.message || 'An error occurred.') + '

') - .show(); + showNotice($message, response.data.message || 'An error occurred.', 'error'); } }, error: function() { $spinner.removeClass('is-active'); - $message - .removeClass('notice-success') - .addClass('notice notice-error') - .html('

Connection error. Please try again.

') - .show(); + showNotice($message, 'Connection error. Please try again.', 'error'); } }); } @@ -546,15 +498,30 @@ // Remove all inputs except the value and button. $row.find('input').remove(); - // Re-add label inputs. + // Re-add label inputs using safe DOM construction. for (var i = 0; i < labelCount; i++) { var val = currentValues[i] || ''; - $row.prepend(''); + var $input = $('', { + type: 'text', + name: 'label_values[' + rowIndex + '][]', + 'class': 'small-text', + placeholder: labels[i] || 'value', + value: val + }); + $row.prepend($input); } - // Re-add value input. + // Re-add value input using safe DOM construction. var metricVal = currentValues[currentValues.length - 1] || ''; - $row.find('.remove-value-row').before(''); + var $valueInput = $('', { + type: 'number', + name: 'label_values[' + rowIndex + '][]', + 'class': 'small-text', + step: 'any', + placeholder: 'Value', + value: metricVal + }); + $row.find('.remove-value-row').before($valueInput); }); } @@ -584,7 +551,7 @@ } /** - * Generate a random token. + * Generate a cryptographically secure random token. * * @param {number} length Token length. * @return {string} Generated token. @@ -592,12 +559,31 @@ function generateToken(length) { var charset = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; var token = ''; + var randomValues = new Uint32Array(length); + window.crypto.getRandomValues(randomValues); for (var i = 0; i < length; i++) { - token += charset.charAt(Math.floor(Math.random() * charset.length)); + token += charset.charAt(randomValues[i] % charset.length); } return token; } + /** + * Show a notice message safely (XSS-safe). + * + * @param {jQuery} $element The message container element. + * @param {string} message The message text. + * @param {string} type Notice type: 'success', 'error', or 'warning'. + */ + function showNotice($element, message, type) { + var removeClasses = 'notice-error notice-success notice-warning'; + $element + .removeClass(removeClasses) + .addClass('notice notice-' + type) + .empty() + .append($('

').text(message)) + .show(); + } + /** * Download a file. * @@ -666,12 +652,8 @@ $spinner.removeClass('is-active'); if (response.success) { - var noticeClass = response.data.warning ? 'notice-warning' : 'notice-success'; - $message - .removeClass('notice-error notice-success notice-warning') - .addClass('notice ' + noticeClass) - .html('

' + response.data.message + '

') - .show(); + var type = response.data.warning ? 'warning' : 'success'; + showNotice($message, response.data.message, type); if (!response.data.warning) { setTimeout(function() { @@ -679,20 +661,12 @@ }, 1500); } } else { - $message - .removeClass('notice-success notice-warning') - .addClass('notice notice-error') - .html('

' + (response.data.message || 'An error occurred.') + '

') - .show(); + showNotice($message, response.data.message || 'An error occurred.', 'error'); } }, error: function() { $spinner.removeClass('is-active'); - $message - .removeClass('notice-success notice-warning') - .addClass('notice notice-error') - .html('

Connection error. Please try again.

') - .show(); + showNotice($message, 'Connection error. Please try again.', 'error'); } }); } @@ -720,26 +694,14 @@ $spinner.removeClass('is-active'); if (response.success) { - $message - .removeClass('notice-error notice-warning') - .addClass('notice notice-success') - .html('

' + response.data.message + '

') - .show(); + showNotice($message, response.data.message, 'success'); } else { - $message - .removeClass('notice-success notice-warning') - .addClass('notice notice-error') - .html('

' + (response.data.message || 'Connection test failed.') + '

') - .show(); + showNotice($message, response.data.message || 'Connection test failed.', 'error'); } }, error: function() { $spinner.removeClass('is-active'); - $message - .removeClass('notice-success notice-warning') - .addClass('notice notice-error') - .html('

Connection error. Please try again.

') - .show(); + showNotice($message, 'Connection error. Please try again.', 'error'); } }); } diff --git a/src/Endpoint/MetricsEndpoint.php b/src/Endpoint/MetricsEndpoint.php index 9cbce84..0e3711a 100644 --- a/src/Endpoint/MetricsEndpoint.php +++ b/src/Endpoint/MetricsEndpoint.php @@ -102,52 +102,12 @@ class MetricsEndpoint { /** * Authenticate the metrics request. * + * Uses the shared authentication helper to avoid code duplication + * with the isolated mode handler in wp-prometheus.php. + * * @return bool */ private function authenticate(): bool { - $auth_token = get_option( 'wp_prometheus_auth_token', '' ); - - // If no token is set, deny access. - if ( empty( $auth_token ) ) { - return false; - } - - // Check for Bearer token in Authorization header. - $auth_header = $this->get_authorization_header(); - if ( ! empty( $auth_header ) && preg_match( '/Bearer\s+(.*)$/i', $auth_header, $matches ) ) { - return hash_equals( $auth_token, $matches[1] ); - } - - // Check for token in query parameter (less secure but useful for testing). - // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Auth token check. - if ( isset( $_GET['token'] ) && hash_equals( $auth_token, sanitize_text_field( wp_unslash( $_GET['token'] ) ) ) ) { - return true; - } - - return false; - } - - /** - * Get the Authorization header from the request. - * - * @return string - */ - private function get_authorization_header(): string { - if ( isset( $_SERVER['HTTP_AUTHORIZATION'] ) ) { - return sanitize_text_field( wp_unslash( $_SERVER['HTTP_AUTHORIZATION'] ) ); - } - - if ( isset( $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ) ) { - return sanitize_text_field( wp_unslash( $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ) ); - } - - if ( function_exists( 'apache_request_headers' ) ) { - $headers = apache_request_headers(); - if ( isset( $headers['Authorization'] ) ) { - return sanitize_text_field( $headers['Authorization'] ); - } - } - - return ''; + return wp_prometheus_authenticate_request(); } } diff --git a/src/Installer.php b/src/Installer.php index eb1eff0..54e35c8 100644 --- a/src/Installer.php +++ b/src/Installer.php @@ -65,6 +65,14 @@ final class Installer { 'wp_prometheus_enabled_metrics', 'wp_prometheus_runtime_metrics', 'wp_prometheus_custom_metrics', + 'wp_prometheus_isolated_mode', + 'wp_prometheus_storage_adapter', + 'wp_prometheus_redis_host', + 'wp_prometheus_redis_port', + 'wp_prometheus_redis_password', + 'wp_prometheus_redis_database', + 'wp_prometheus_redis_prefix', + 'wp_prometheus_apcu_prefix', ); foreach ( $options as $option ) { diff --git a/src/Metrics/Collector.php b/src/Metrics/Collector.php index 8de12fd..64f2851 100644 --- a/src/Metrics/Collector.php +++ b/src/Metrics/Collector.php @@ -447,6 +447,16 @@ class Collector { return class_exists( 'WooCommerce' ); } + /** + * Check if WooCommerce HPOS (High-Performance Order Storage) is enabled. + * + * @return bool + */ + private function is_hpos_enabled(): bool { + return class_exists( '\Automattic\WooCommerce\Utilities\OrderUtil' ) + && \Automattic\WooCommerce\Utilities\OrderUtil::custom_orders_table_usage_is_enabled(); + } + /** * Collect WooCommerce metrics. * @@ -498,16 +508,17 @@ class Collector { } } - // Count by product type (for published products only). + // Count by product type (for published products only) using count query. foreach ( array_keys( $product_types ) as $type ) { $args = array( 'status' => 'publish', 'type' => $type, - 'limit' => -1, + 'limit' => 1, 'return' => 'ids', + 'paginate' => true, ); - $products = wc_get_products( $args ); - $gauge->set( count( $products ), array( 'publish', $type ) ); + $result = wc_get_products( $args ); + $gauge->set( $result->total, array( 'publish', $type ) ); } } @@ -553,8 +564,7 @@ class Collector { $currency = get_woocommerce_currency(); // Check if HPOS (High-Performance Order Storage) is enabled. - $hpos_enabled = class_exists( '\Automattic\WooCommerce\Utilities\OrderUtil' ) - && \Automattic\WooCommerce\Utilities\OrderUtil::custom_orders_table_usage_is_enabled(); + $hpos_enabled = $this->is_hpos_enabled(); if ( $hpos_enabled ) { $orders_table = $wpdb->prefix . 'wc_orders'; diff --git a/src/Metrics/CustomMetricBuilder.php b/src/Metrics/CustomMetricBuilder.php index 0632d45..6129ed7 100644 --- a/src/Metrics/CustomMetricBuilder.php +++ b/src/Metrics/CustomMetricBuilder.php @@ -47,6 +47,13 @@ class CustomMetricBuilder { */ const MAX_LABEL_VALUES = 50; + /** + * Maximum import JSON size in bytes (1 MB). + * + * @var int + */ + const MAX_IMPORT_SIZE = 1048576; + /** * Get all custom metrics. * @@ -332,7 +339,6 @@ class CustomMetricBuilder { 'version' => self::EXPORT_VERSION, 'plugin_version' => WP_PROMETHEUS_VERSION, 'exported_at' => gmdate( 'c' ), - 'site_url' => home_url(), 'metrics' => array_values( $metrics ), ); @@ -348,6 +354,17 @@ class CustomMetricBuilder { * @throws \InvalidArgumentException If JSON is invalid. */ public function import( string $json, string $mode = 'skip' ): array { + // Prevent DoS via excessively large imports. + if ( strlen( $json ) > self::MAX_IMPORT_SIZE ) { + throw new \InvalidArgumentException( + sprintf( + /* translators: %s: Maximum size */ + __( 'Import data exceeds maximum size of %s.', 'wp-prometheus' ), + size_format( self::MAX_IMPORT_SIZE ) + ) + ); + } + $data = json_decode( $json, true ); if ( json_last_error() !== JSON_ERROR_NONE ) { @@ -358,6 +375,12 @@ class CustomMetricBuilder { throw new \InvalidArgumentException( __( 'No metrics found in import file.', 'wp-prometheus' ) ); } + // Validate import mode. + $valid_modes = array( 'skip', 'overwrite', 'rename' ); + if ( ! in_array( $mode, $valid_modes, true ) ) { + $mode = 'skip'; + } + $result = array( 'imported' => 0, 'skipped' => 0, diff --git a/wp-prometheus.php b/wp-prometheus.php index d906ebb..d5af869 100644 --- a/wp-prometheus.php +++ b/wp-prometheus.php @@ -107,36 +107,8 @@ function wp_prometheus_isolated_metrics_handler(): void { return; // Let normal flow handle unlicensed state. } - // Authenticate. - $auth_token = get_option( 'wp_prometheus_auth_token', '' ); - if ( empty( $auth_token ) ) { - status_header( 401 ); - header( 'WWW-Authenticate: Bearer realm="WP Prometheus Metrics"' ); - header( 'Content-Type: text/plain; charset=utf-8' ); - echo 'Unauthorized'; - exit; - } - - // Check Bearer token. - $auth_header = ''; - if ( isset( $_SERVER['HTTP_AUTHORIZATION'] ) ) { - $auth_header = sanitize_text_field( wp_unslash( $_SERVER['HTTP_AUTHORIZATION'] ) ); - } elseif ( isset( $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ) ) { - $auth_header = sanitize_text_field( wp_unslash( $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ) ); - } - - $authenticated = false; - if ( ! empty( $auth_header ) && preg_match( '/Bearer\s+(.*)$/i', $auth_header, $matches ) ) { - $authenticated = hash_equals( $auth_token, $matches[1] ); - } - - // Check query parameter fallback. - // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Auth token check. - if ( ! $authenticated && isset( $_GET['token'] ) ) { - $authenticated = hash_equals( $auth_token, sanitize_text_field( wp_unslash( $_GET['token'] ) ) ); - } - - if ( ! $authenticated ) { + // Authenticate using shared helper. + if ( ! wp_prometheus_authenticate_request() ) { status_header( 401 ); header( 'WWW-Authenticate: Bearer realm="WP Prometheus Metrics"' ); header( 'Content-Type: text/plain; charset=utf-8' ); @@ -161,6 +133,64 @@ function wp_prometheus_isolated_metrics_handler(): void { exit; } +/** + * Authenticate a metrics request using Bearer token or query parameter. + * + * Shared authentication logic used by both the MetricsEndpoint class + * and the isolated mode handler to avoid code duplication. + * + * @return bool True if authenticated, false otherwise. + */ +function wp_prometheus_authenticate_request(): bool { + $auth_token = get_option( 'wp_prometheus_auth_token', '' ); + + // If no token is set, deny access. + if ( empty( $auth_token ) ) { + return false; + } + + // Check for Bearer token in Authorization header. + $auth_header = wp_prometheus_get_authorization_header(); + if ( ! empty( $auth_header ) && preg_match( '/Bearer\s+(.*)$/i', $auth_header, $matches ) ) { + return hash_equals( $auth_token, $matches[1] ); + } + + // Check for token in query parameter (less secure but useful for testing). + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Auth token check. + if ( isset( $_GET['token'] ) && hash_equals( $auth_token, sanitize_text_field( wp_unslash( $_GET['token'] ) ) ) ) { + return true; + } + + return false; +} + +/** + * Get the Authorization header from the request. + * + * Checks multiple sources for the Authorization header to support + * different server configurations (Apache, nginx, CGI, etc.). + * + * @return string The Authorization header value, or empty string if not found. + */ +function wp_prometheus_get_authorization_header(): string { + if ( isset( $_SERVER['HTTP_AUTHORIZATION'] ) ) { + return sanitize_text_field( wp_unslash( $_SERVER['HTTP_AUTHORIZATION'] ) ); + } + + if ( isset( $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ) ) { + return sanitize_text_field( wp_unslash( $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ) ); + } + + if ( function_exists( 'apache_request_headers' ) ) { + $headers = apache_request_headers(); + if ( isset( $headers['Authorization'] ) ) { + return sanitize_text_field( $headers['Authorization'] ); + } + } + + return ''; +} + // Try early metrics handling before full plugin initialization. wp_prometheus_early_metrics_check();