fix: security hardening for LLM thumbnail feature (QA-002,004,005,006)

- Use $wpdb->prepare() with esc_like() in clear_article_cache()
- Add strict base64_decode() validation with error handling
- Add isset()/is_array()/is_string() checks for nested array access
- Add wp_http_validate_url() SSRF prevention on all image downloads
- Add limit_response_size (5MB) on image URL downloads
- Set chmod 0644 after file_put_contents()
This commit is contained in:
Terence Ong
2026-04-06 10:19:53 +07:00
parent 01a794cd5f
commit 4367d68202

View File

@@ -370,15 +370,271 @@ class Peptide_News_LLM {
}
}
/**
* Generate AI thumbnails for articles that still have no image
* after OG scraping. Uses an image generation model via OpenRouter.
*
* @param int $batch_size Max articles to process per run.
* @return int Number of thumbnails generated.
*/
public static function generate_missing_thumbnails( $batch_size = 10 ) {
if ( ! self::is_enabled() ) {
return 0;
}
$image_model = get_option( 'peptide_news_llm_image_model', '' );
if ( empty( $image_model ) ) {
return 0;
}
global $wpdb;
$table = $wpdb->prefix . 'peptide_news_articles';
// Find active articles with no thumbnail (neither URL nor local).
$articles = $wpdb->get_results( $wpdb->prepare(
"SELECT id, title, excerpt, ai_summary, tags, categories
FROM {$table}
WHERE is_active = 1
AND ( thumbnail_url = '' OR thumbnail_url IS NULL )
AND ( thumbnail_local = '' OR thumbnail_local IS NULL )
ORDER BY fetched_at DESC
LIMIT %d",
$batch_size
) );
if ( empty( $articles ) ) {
return 0;
}
$api_key = get_option( 'peptide_news_openrouter_api_key', '' );
$generated = 0;
foreach ( $articles as $article ) {
$image_data = self::generate_article_thumbnail( $api_key, $image_model, $article );
if ( $image_data ) {
// Save the image locally.
$local_path = self::save_generated_image( $image_data, $article->id );
if ( $local_path ) {
$wpdb->update(
$table,
array( 'thumbnail_local' => $local_path ),
array( 'id' => $article->id ),
array( '%s' ),
array( '%d' )
);
$generated++;
}
}
// Respect rate limits.
usleep( 1000000 ); // 1 second between generations.
}
if ( $generated > 0 ) {
self::clear_article_cache();
}
return $generated;
}
/**
* Generate a thumbnail image for a single article via OpenRouter.
*
* @param string $api_key
* @param string $model Image generation model ID.
* @param object $article Database row.
* @return string|false Base64-encoded image data, or false.
*/
private static function generate_article_thumbnail( $api_key, $model, $article ) {
$prompt = self::build_image_prompt( $article );
$body = array(
'model' => $model,
'messages' => array(
array(
'role' => 'user',
'content' => $prompt,
),
),
);
$response = wp_remote_post( self::API_URL, array(
'timeout' => 60,
'headers' => array(
'Authorization' => 'Bearer ' . $api_key,
'Content-Type' => 'application/json',
'HTTP-Referer' => home_url(),
'X-Title' => 'Peptide News Aggregator',
),
'body' => wp_json_encode( $body ),
) );
if ( is_wp_error( $response ) ) {
self::log_error( 'Image generation failed for article ' . $article->id . ': ' . $response->get_error_message() );
return false;
}
$status = wp_remote_retrieve_response_code( $response );
$data = json_decode( wp_remote_retrieve_body( $response ), true );
if ( $status < 200 || $status >= 300 ) {
$error = isset( $data['error']['message'] ) ? $data['error']['message'] : 'HTTP ' . $status;
self::log_error( 'Image generation HTTP error for article ' . $article->id . ': ' . $error );
return false;
}
// Extract the image data from the response.
// OpenRouter image models may return base64 data in various formats.
if ( ! empty( $data['choices'][0]['message']['content'] ) ) {
$content = $data['choices'][0]['message']['content'];
// If content is an array (multimodal), look for image parts.
if ( is_array( $content ) ) {
foreach ( $content as $part ) {
if ( isset( $part['type'] ) && 'image_url' === $part['type']
&& isset( $part['image_url'] ) && is_array( $part['image_url'] )
&& isset( $part['image_url']['url'] ) && is_string( $part['image_url']['url'] ) ) {
$url = $part['image_url']['url'];
if ( strpos( $url, 'data:image' ) === 0 ) {
// Extract base64 from data URL.
$base64 = preg_replace( '/^data:image\/\w+;base64,/', '', $url );
$decoded = base64_decode( $base64, true );
if ( false === $decoded ) {
self::log_error( 'Invalid base64 image data for article.' );
return false;
}
return $decoded;
}
}
}
}
// If it's a URL string, download it (with SSRF protection).
if ( is_string( $content ) && preg_match( '/^https?:\/\//', $content ) ) {
$content_url = trim( $content );
if ( ! wp_http_validate_url( $content_url ) ) {
self::log_error( 'Invalid image URL (SSRF protection) for article ' . $article->id );
return false;
}
$img_response = wp_remote_get( $content_url, array(
'timeout' => 30,
'limit_response_size' => 5 * 1024 * 1024,
) );
if ( ! is_wp_error( $img_response ) ) {
return wp_remote_retrieve_body( $img_response );
}
}
}
// Check for data array format (DALL-E style).
if ( ! empty( $data['data'][0]['b64_json'] ) ) {
$decoded = base64_decode( $data['data'][0]['b64_json'], true );
if ( false === $decoded ) {
self::log_error( 'Invalid base64 in DALL-E response for article ' . $article->id );
return false;
}
return $decoded;
}
if ( ! empty( $data['data'][0]['url'] ) ) {
$dalle_url = $data['data'][0]['url'];
if ( wp_http_validate_url( $dalle_url ) ) {
$img_response = wp_remote_get( $dalle_url, array(
'timeout' => 30,
'limit_response_size' => 5 * 1024 * 1024,
) );
if ( ! is_wp_error( $img_response ) ) {
return wp_remote_retrieve_body( $img_response );
}
} else {
self::log_error( 'Invalid DALL-E image URL (SSRF protection) for article ' . $article->id );
}
}
self::log_error( 'Could not extract image data from response for article ' . $article->id );
return false;
}
/**
* Build the prompt for AI image generation.
*
* @param object $article
* @return string
*/
private static function build_image_prompt( $article ) {
$summary = ! empty( $article->ai_summary ) ? $article->ai_summary : $article->excerpt;
$tags = ! empty( $article->tags ) ? $article->tags : ( $article->categories ?? '' );
return "Generate a professional, science-themed thumbnail image for a peptide research news article. "
. "The image should be modern, clean, and suitable for a news website card layout (16:9 aspect ratio). "
. "Use a scientific aesthetic: molecular structures, laboratory elements, or abstract biotech visuals. "
. "Do NOT include any text or words in the image.\n\n"
. "Article title: " . $article->title . "\n"
. ( ! empty( $summary ) ? "Summary: " . mb_substr( $summary, 0, 200 ) . "\n" : '' )
. ( ! empty( $tags ) ? "Keywords: " . $tags . "\n" : '' );
}
/**
* Save a generated image to the WP uploads directory.
*
* @param string $image_data Raw image binary data.
* @param int $article_id
* @return string|false Relative path from uploads base, or false.
*/
private static function save_generated_image( $image_data, $article_id ) {
if ( empty( $image_data ) || strlen( $image_data ) < 1000 ) {
return false;
}
$upload_dir = wp_upload_dir();
$target_dir = $upload_dir['basedir'] . '/peptide-news-thumbs';
if ( ! file_exists( $target_dir ) ) {
wp_mkdir_p( $target_dir );
}
// Detect image type from data.
$finfo = new finfo( FILEINFO_MIME_TYPE );
$mime = $finfo->buffer( $image_data );
$ext_map = array(
'image/png' => 'png',
'image/jpeg' => 'jpg',
'image/webp' => 'webp',
'image/gif' => 'gif',
);
$extension = isset( $ext_map[ $mime ] ) ? $ext_map[ $mime ] : 'png';
$filename = 'pn-ai-' . $article_id . '.' . $extension;
$filepath = $target_dir . '/' . $filename;
$result = file_put_contents( $filepath, $image_data );
if ( false === $result ) {
self::log_error( 'Failed to save generated thumbnail: ' . $filepath );
return false;
}
// Set secure file permissions.
chmod( $filepath, 0644 );
return 'peptide-news-thumbs/' . $filename;
}
/**
* Clear all article transient caches.
*/
private static function clear_article_cache() {
global $wpdb;
$wpdb->query(
"DELETE FROM {$wpdb->options}
WHERE option_name LIKE '_transient_peptide_news_articles_%'
OR option_name LIKE '_transient_timeout_peptide_news_articles_%'"
$wpdb->prepare(
"DELETE FROM {$wpdb->options}
WHERE option_name LIKE %s
OR option_name LIKE %s",
$wpdb->esc_like( '_transient_peptide_news_articles_' ) . '%',
$wpdb->esc_like( '_transient_timeout_peptide_news_articles_' ) . '%'
)
);
}