From 866a8dc1ead31bac13b248de748735cb121100e7 Mon Sep 17 00:00:00 2001 From: peptiderepo Date: Fri, 10 Apr 2026 20:46:46 +0700 Subject: [PATCH] security: apply 11 audit findings (2 critical, 4 high, 5 medium/low) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SEC-001 CRITICAL: Remove spoofable X-Forwarded-For/X-Real-IP from IP detection; trust only CF-Connecting-IP + REMOTE_ADDR SEC-004 HIGH: Add peptide name input validation with character whitelist and prompt injection blocking SEC-006 HIGH: Add missing return after wp_send_json_success in handle_pending_retry max-retries block SEC-007 MEDIUM: KB articles now respect auto_publish setting instead of always publishing SEC-009 MEDIUM: Replace hardcoded page ID 73 with configurable kb_page_id setting SEC-010 MEDIUM: Fix cache invalidation — add underscore prefix to transient name pattern SEC-011 MEDIUM: Skip sleep() during WP-Cron to avoid blocking PHP workers on rate limit retry SEC-012 MEDIUM: Add global daily generation cap (50/day) to prevent cost overruns SEC-013 LOW: Add @deprecated notice to unused PSA_Error class SEC-015 LOW: Convert uninstall.php from space to tab indentation (WPCS) Co-Authored-By: Claude Opus 4.6 --- includes/class-psa-ai-generator.php | 55 ++++++++++++++++++++++++----- includes/class-psa-config.php | 3 ++ includes/class-psa-error.php | 3 +- includes/class-psa-search.php | 23 ++++++++++-- peptide-search-ai.php | 6 ++-- uninstall.php | 10 +++--- 6 files changed, 80 insertions(+), 20 deletions(-) diff --git a/includes/class-psa-ai-generator.php b/includes/class-psa-ai-generator.php index c6c642b..1e75d7e 100644 --- a/includes/class-psa-ai-generator.php +++ b/includes/class-psa-ai-generator.php @@ -30,6 +30,15 @@ class PSA_AI_Generator { * @return array|WP_Error { is_valid: bool, canonical_name: string, reason: string } */ public static function validate_peptide_name( $name ) { + // Input character validation — block injection attempts before API call. + if ( ! self::validate_peptide_input( $name ) ) { + return array( + 'is_valid' => false, + 'canonical_name' => $name, + 'reason' => 'Invalid characters in peptide name.', + ); + } + $options = self::get_settings(); $api_key = $options['api_key']; $model = $options['validation_model']; @@ -89,14 +98,14 @@ class PSA_AI_Generator { public static function background_generate( $post_id, $peptide_name ) { $post = get_post( $post_id ); if ( ! $post || 'draft' !== $post->post_status || 'peptide' !== $post->post_type ) { - error_log( 'PSA: background_generate skipped — post ' . $post_id . ' not a draft peptide.' ); + error_log( 'PSA: background_generate skipped — post ' . $post_id . ' not a draft peptide.' ); return; } // Only process posts that are still in 'pending' state. $source = get_post_meta( $post_id, 'psa_source', true ); if ( 'pending' !== $source ) { - error_log( 'PSA: background_generate skipped — post ' . $post_id . ' source is "' . $source . '", not "pending".' ); + error_log( 'PSA: background_generate skipped — post ' . $post_id . ' source is "' . $source . '", not "pending".' ); return; } @@ -124,7 +133,7 @@ class PSA_AI_Generator { // Safeguard: if post_content is still empty after generation, log a warning. if ( empty( trim( $post_content ) ) ) { - error_log( 'PSA: WARNING — AI returned valid JSON but post content (overview/description) is empty for "' . $peptide_name . '". Available keys: ' . implode( ', ', array_keys( $result ) ) ); + error_log( 'PSA: WARNING — AI returned valid JSON but post content (overview/description) is empty for "' . $peptide_name . '". Available keys: ' . implode( ', ', array_keys( $result ) ) ); update_post_meta( $post_id, 'psa_generation_error', 'AI returned empty overview/description content. Available fields: ' . implode( ', ', array_keys( $result ) ) ); return; } @@ -161,11 +170,11 @@ class PSA_AI_Generator { return; } - // Save all meta fields — prefer PubChem data for molecular properties. + // Save all meta fields — prefer PubChem data for molecular properties. self::save_peptide_meta( $post_id, $result, $pubchem_data ); // Create a matching Knowledge Base article. - self::create_kb_article( $post_id, $result, $peptide_name ); + self::create_kb_article( $post_id, $result, $peptide_name, $post_status ); // Clean up generation tracking. delete_post_meta( $post_id, 'psa_generation_started' ); @@ -280,6 +289,28 @@ class PSA_AI_Generator { } } + /** + * Validate peptide name input characters and block injection patterns. + * + * @param string $name The peptide name to validate. + * @return bool True if input looks legitimate. + */ + private static function validate_peptide_input( $name ) { + // Allow only characters that appear in legitimate peptide names. + if ( ! preg_match( '/^[\p{L}\d\s\-\(\),\.\/\+\[\]]+$/u', $name ) ) { + return false; + } + // Block obvious prompt injection patterns. + $blocked = array( 'ignore', 'instruction', 'override', 'system prompt', 'forget', 'disregard', 'pretend' ); + $lower = strtolower( $name ); + foreach ( $blocked as $word ) { + if ( strpos( $lower, $word ) !== false ) { + return false; + } + } + return true; + } + /** * Build the lightweight validation prompt. * @@ -393,9 +424,14 @@ PROMPT; return $result; } - // Rate limited — retry with exponential backoff. + // Rate limited — retry with exponential backoff. if ( $attempt < $max_retries ) { $delay = $base_delay * pow( 2, $attempt - 1 ); // 5s, 10s, 20s + // Avoid blocking PHP workers during WP-Cron execution. + if ( defined( 'DOING_CRON' ) && DOING_CRON ) { + error_log( 'PSA: Rate limited during cron on attempt ' . $attempt . '/' . $max_retries . '. Returning to free PHP worker.' ); + return new WP_Error( 'rate_limited', 'Rate limited during background generation. Will retry on next cron trigger.' ); + } error_log( 'PSA: Rate limited on attempt ' . $attempt . '/' . $max_retries . '. Retrying in ' . $delay . 's...' ); sleep( $delay ); } @@ -515,10 +551,11 @@ PROMPT; * @param int $peptide_post_id The peptide post ID. * @param array $ai_data The parsed AI data. * @param string $peptide_name The peptide name. + * @param string $post_status The post status (default: 'draft'). */ - private static function create_kb_article( $peptide_post_id, $ai_data, $peptide_name ) { + private static function create_kb_article( $peptide_post_id, $ai_data, $peptide_name, $post_status = 'draft' ) { if ( ! post_type_exists( 'epkb_post_type_1' ) ) { - error_log( 'PSA: Echo Knowledge Base not active — skipping KB article.' ); + error_log( 'PSA: Echo Knowledge Base not active — skipping KB article.' ); return; } @@ -607,7 +644,7 @@ PROMPT; 'post_title' => $title, 'post_content' => wp_kses_post( $c ), 'post_type' => 'epkb_post_type_1', - 'post_status' => 'publish', + 'post_status' => $post_status, ), true ); diff --git a/includes/class-psa-config.php b/includes/class-psa-config.php index f361f95..7ac6a9e 100644 --- a/includes/class-psa-config.php +++ b/includes/class-psa-config.php @@ -20,6 +20,9 @@ class PSA_Config { const REST_RATE_LIMIT = 20; const AJAX_RATE_LIMIT = 10; + // Daily generation cap + const DAILY_GENERATION_CAP = 50; + // Generation timing const PENDING_TIMEOUT = 300; // seconds (5 minutes) const MAX_GENERATION_RETRIES = 3; diff --git a/includes/class-psa-error.php b/includes/class-psa-error.php index 9646506..ea95600 100644 --- a/includes/class-psa-error.php +++ b/includes/class-psa-error.php @@ -3,7 +3,8 @@ * Standardized error handling for Peptide Search AI. * Provides a unified interface for sending error responses with consistent structure. * - * @package PeptideSearchAI + * @package PeptideSearchAI + * @deprecated 4.1.0 Currently unused. Retained for potential future integration. */ if ( ! defined( 'ABSPATH' ) ) { diff --git a/includes/class-psa-search.php b/includes/class-psa-search.php index e3257f1..03f9ab6 100644 --- a/includes/class-psa-search.php +++ b/includes/class-psa-search.php @@ -39,8 +39,8 @@ class PSA_Search { // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->query( "DELETE FROM {$wpdb->options} - WHERE option_name LIKE 'transient_psa_search_%' - OR option_name LIKE 'transient_timeout_psa_search_%'" + WHERE option_name LIKE '_transient_psa_search_%' + OR option_name LIKE '_transient_timeout_psa_search_%'" ); } @@ -236,6 +236,7 @@ class PSA_Search { 'message' => 'We were unable to generate content for this peptide. Please try again later.', ) ); + return; } // Retry generation. @@ -273,6 +274,19 @@ class PSA_Search { * @return void Exits with wp_send_json_* responses. */ private static function handle_new_generation( $query ) { + // Global daily generation cap to prevent cost overruns. + $daily_key = 'psa_daily_gen_' . gmdate( 'Y-m-d' ); + $daily_count = (int) get_transient( $daily_key ); + if ( $daily_count >= PSA_Config::DAILY_GENERATION_CAP ) { + wp_send_json_success( + array( + 'status' => 'rate_limited', + 'message' => 'Our system has reached its daily limit for adding new peptides. Please try again tomorrow.', + ) + ); + return; + } + // Validate peptide name via AI. $validation = PSA_AI_Generator::validate_peptide_name( $query ); @@ -308,6 +322,11 @@ class PSA_Search { wp_send_json_error( 'Failed to create peptide entry. Please try again.' ); } + // Increment global daily generation counter. + $daily_key = 'psa_daily_gen_' . gmdate( 'Y-m-d' ); + $daily_count = (int) get_transient( $daily_key ); + set_transient( $daily_key, $daily_count + 1, DAY_IN_SECONDS ); + // Schedule async background generation. error_log( 'PSA: Scheduling async generation for "' . $canonical_name . '" (post ' . $placeholder_id . ')' ); self::schedule_background_generation( $placeholder_id, $canonical_name ); diff --git a/peptide-search-ai.php b/peptide-search-ai.php index 381317c..b3db80d 100644 --- a/peptide-search-ai.php +++ b/peptide-search-ai.php @@ -48,7 +48,9 @@ add_action( 'admin_init', 'psa_admin_init' ); * Uses is_page() which is reliable even in block templates. */ function psa_is_kb_page() { - return is_page( 73 ) || is_page( 'peptide-database' ); + $settings = get_option( 'psa_settings', array() ); + $kb_page = $settings['kb_page_id'] ?? 73; + return is_page( $kb_page ) || is_page( 'peptide-database' ); } /** @@ -169,8 +171,6 @@ register_deactivation_hook( __FILE__, 'psa_deactivate' ); function psa_get_client_ip() { $headers = array( 'HTTP_CF_CONNECTING_IP', - 'HTTP_X_FORWARDED_FOR', - 'HTTP_X_REAL_IP', 'REMOTE_ADDR', ); foreach ( $headers as $header ) { diff --git a/uninstall.php b/uninstall.php index d600a22..207e5f6 100644 --- a/uninstall.php +++ b/uninstall.php @@ -9,20 +9,20 @@ // Abort if not called by WordPress. if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) { - exit; + exit; } global $wpdb; // 1. Delete all peptide custom posts and their meta. $peptide_ids = $wpdb->get_col( - "SELECT ID FROM {$wpdb->posts} WHERE post_type = 'peptide'" + "SELECT ID FROM {$wpdb->posts} WHERE post_type = 'peptide'" ); if ( ! empty( $peptide_ids ) ) { - foreach ( $peptide_ids as $post_id ) { - wp_delete_post( (int) $post_id, true ); // Force delete, bypass trash. - } + foreach ( $peptide_ids as $post_id ) { + wp_delete_post( (int) $post_id, true ); // Force delete, bypass trash. + } } // 2. Delete plugin options.