security: apply 11 audit findings (2 critical, 4 high, 5 medium/low)

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 <noreply@anthropic.com>
This commit is contained in:
peptiderepo
2026-04-10 20:46:46 +07:00
parent 229fb577bf
commit 866a8dc1ea
6 changed files with 80 additions and 20 deletions

View File

@@ -30,6 +30,15 @@ class PSA_AI_Generator {
* @return array|WP_Error { is_valid: bool, canonical_name: string, reason: string } * @return array|WP_Error { is_valid: bool, canonical_name: string, reason: string }
*/ */
public static function validate_peptide_name( $name ) { 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(); $options = self::get_settings();
$api_key = $options['api_key']; $api_key = $options['api_key'];
$model = $options['validation_model']; $model = $options['validation_model'];
@@ -89,14 +98,14 @@ class PSA_AI_Generator {
public static function background_generate( $post_id, $peptide_name ) { public static function background_generate( $post_id, $peptide_name ) {
$post = get_post( $post_id ); $post = get_post( $post_id );
if ( ! $post || 'draft' !== $post->post_status || 'peptide' !== $post->post_type ) { 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; return;
} }
// Only process posts that are still in 'pending' state. // Only process posts that are still in 'pending' state.
$source = get_post_meta( $post_id, 'psa_source', true ); $source = get_post_meta( $post_id, 'psa_source', true );
if ( 'pending' !== $source ) { 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; return;
} }
@@ -124,7 +133,7 @@ class PSA_AI_Generator {
// Safeguard: if post_content is still empty after generation, log a warning. // Safeguard: if post_content is still empty after generation, log a warning.
if ( empty( trim( $post_content ) ) ) { 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 ) ) ); update_post_meta( $post_id, 'psa_generation_error', 'AI returned empty overview/description content. Available fields: ' . implode( ', ', array_keys( $result ) ) );
return; return;
} }
@@ -161,11 +170,11 @@ class PSA_AI_Generator {
return; 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 ); self::save_peptide_meta( $post_id, $result, $pubchem_data );
// Create a matching Knowledge Base article. // 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. // Clean up generation tracking.
delete_post_meta( $post_id, 'psa_generation_started' ); 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. * Build the lightweight validation prompt.
* *
@@ -393,9 +424,14 @@ PROMPT;
return $result; return $result;
} }
// Rate limited — retry with exponential backoff. // Rate limited — retry with exponential backoff.
if ( $attempt < $max_retries ) { if ( $attempt < $max_retries ) {
$delay = $base_delay * pow( 2, $attempt - 1 ); // 5s, 10s, 20s $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...' ); error_log( 'PSA: Rate limited on attempt ' . $attempt . '/' . $max_retries . '. Retrying in ' . $delay . 's...' );
sleep( $delay ); sleep( $delay );
} }
@@ -515,10 +551,11 @@ PROMPT;
* @param int $peptide_post_id The peptide post ID. * @param int $peptide_post_id The peptide post ID.
* @param array $ai_data The parsed AI data. * @param array $ai_data The parsed AI data.
* @param string $peptide_name The peptide name. * @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' ) ) { 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; return;
} }
@@ -607,7 +644,7 @@ PROMPT;
'post_title' => $title, 'post_title' => $title,
'post_content' => wp_kses_post( $c ), 'post_content' => wp_kses_post( $c ),
'post_type' => 'epkb_post_type_1', 'post_type' => 'epkb_post_type_1',
'post_status' => 'publish', 'post_status' => $post_status,
), ),
true true
); );

View File

@@ -20,6 +20,9 @@ class PSA_Config {
const REST_RATE_LIMIT = 20; const REST_RATE_LIMIT = 20;
const AJAX_RATE_LIMIT = 10; const AJAX_RATE_LIMIT = 10;
// Daily generation cap
const DAILY_GENERATION_CAP = 50;
// Generation timing // Generation timing
const PENDING_TIMEOUT = 300; // seconds (5 minutes) const PENDING_TIMEOUT = 300; // seconds (5 minutes)
const MAX_GENERATION_RETRIES = 3; const MAX_GENERATION_RETRIES = 3;

View File

@@ -4,6 +4,7 @@
* Provides a unified interface for sending error responses with consistent structure. * 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' ) ) { if ( ! defined( 'ABSPATH' ) ) {

View File

@@ -39,8 +39,8 @@ class PSA_Search {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery // phpcs:ignore WordPress.DB.DirectDatabaseQuery
$wpdb->query( $wpdb->query(
"DELETE FROM {$wpdb->options} "DELETE FROM {$wpdb->options}
WHERE option_name LIKE 'transient_psa_search_%' WHERE option_name LIKE '_transient_psa_search_%'
OR option_name LIKE 'transient_timeout_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.', 'message' => 'We were unable to generate content for this peptide. Please try again later.',
) )
); );
return;
} }
// Retry generation. // Retry generation.
@@ -273,6 +274,19 @@ class PSA_Search {
* @return void Exits with wp_send_json_* responses. * @return void Exits with wp_send_json_* responses.
*/ */
private static function handle_new_generation( $query ) { 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. // Validate peptide name via AI.
$validation = PSA_AI_Generator::validate_peptide_name( $query ); $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.' ); 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. // Schedule async background generation.
error_log( 'PSA: Scheduling async generation for "' . $canonical_name . '" (post ' . $placeholder_id . ')' ); error_log( 'PSA: Scheduling async generation for "' . $canonical_name . '" (post ' . $placeholder_id . ')' );
self::schedule_background_generation( $placeholder_id, $canonical_name ); self::schedule_background_generation( $placeholder_id, $canonical_name );

View File

@@ -48,7 +48,9 @@ add_action( 'admin_init', 'psa_admin_init' );
* Uses is_page() which is reliable even in block templates. * Uses is_page() which is reliable even in block templates.
*/ */
function psa_is_kb_page() { 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() { function psa_get_client_ip() {
$headers = array( $headers = array(
'HTTP_CF_CONNECTING_IP', 'HTTP_CF_CONNECTING_IP',
'HTTP_X_FORWARDED_FOR',
'HTTP_X_REAL_IP',
'REMOTE_ADDR', 'REMOTE_ADDR',
); );
foreach ( $headers as $header ) { foreach ( $headers as $header ) {