| @@ -1,11 +1,16 @@ | ||
| 1 | 1 | <?php |
| 2 | +namespace WPDeveloper\BetterDocs\Core; | |
| 2 | 3 | |
| 3 | -namespace WPDeveloper\BetterDocs\Core; | |
| 4 | +if ( ! defined( 'ABSPATH' ) ) { | |
| 5 | + exit; | |
| 6 | +} | |
| 4 | 7 | |
| 8 | + | |
| 5 | 9 | use WPDeveloper\BetterDocs\Utils\Base; |
| 6 | 10 | use WPDeveloper\BetterDocs\Core\Settings; |
| 7 | 11 | use WPDeveloper\BetterDocs\Utils\AIHelper; |
| 12 | +use WPDeveloper\BetterDocs\Utils\AIUsage; | |
| 8 | 13 | |
| 9 | 14 | class ArticleSummary extends Base { |
| 10 | 15 | |
| 11 | 16 | public $settings; |
| @@ -51,32 +56,50 @@ | ||
| 51 | 56 | wp_die(); |
| 52 | 57 | } |
| 53 | 58 | |
| 54 | 59 | // Verify the nonce |
| 55 | - if ( ! isset( $_POST['nonce'] ) || ! wp_verify_nonce( $_POST['nonce'], 'betterdocs_article_summary_nonce' ) ) { //phpcs:ignore | |
| 60 | + $nonce = isset( $_POST['nonce'] ) ? sanitize_text_field( wp_unslash( $_POST['nonce'] ) ) : ''; | |
| 61 | + if ( ! wp_verify_nonce( $nonce, 'betterdocs_article_summary_nonce' ) ) { | |
| 56 | 62 | wp_send_json_error( 'Invalid nonce' ); |
| 57 | 63 | wp_die(); |
| 58 | 64 | } |
| 59 | 65 | |
| 60 | - $post_id = intval( $_POST['post_id'] ); //phpcs:ignore | |
| 61 | - $post_title = sanitize_text_field( $_POST['post_title'] ); //phpcs:ignore | |
| 62 | - $post_content = wp_kses_post( $_POST['post_content'] ); //phpcs:ignore | |
| 66 | + $post_id = isset( $_POST['post_id'] ) ? intval( wp_unslash( $_POST['post_id'] ) ) : 0; //phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce verified above. | |
| 63 | 67 | |
| 64 | - // Validate that this is a docs post type | |
| 65 | - if ( $post_id > 0 ) { | |
| 66 | - $post_type = get_post_type( $post_id ); | |
| 67 | - if ( $post_type !== 'docs' ) { | |
| 68 | - wp_send_json_error( 'Doc Summarizer is only available for documentation posts.' ); | |
| 69 | - wp_die(); | |
| 70 | - } | |
| 68 | + // A valid documentation post is required. The summary is always derived from | |
| 69 | + // the canonical post record below, never from anything in the request body. | |
| 70 | + if ( $post_id <= 0 ) { | |
| 71 | + wp_send_json_error( 'Invalid document.' ); | |
| 72 | + wp_die(); | |
| 73 | + } | |
| 71 | 74 | |
| 72 | - // Check if post is password protected and user hasn't provided correct password | |
| 73 | - if ( post_password_required( $post_id ) ) { | |
| 74 | - wp_send_json_error( 'This document is password protected. Please enter the correct password to access the summary.' ); | |
| 75 | - wp_die(); | |
| 76 | - } | |
| 75 | + $post = get_post( $post_id ); | |
| 76 | + | |
| 77 | + // Validate that this is a docs post type. | |
| 78 | + if ( ! $post || $post->post_type !== 'docs' ) { | |
| 79 | + wp_send_json_error( 'Doc Summarizer is only available for documentation posts.' ); | |
| 80 | + wp_die(); | |
| 77 | 81 | } |
| 78 | 82 | |
| 83 | + // Only summarize content the requester is actually allowed to view, so an | |
| 84 | + // unauthenticated caller can't trigger summaries of drafts/private docs. | |
| 85 | + if ( 'publish' !== $post->post_status && ! current_user_can( 'read_post', $post_id ) ) { | |
| 86 | + wp_send_json_error( 'Doc Summarizer is only available for published documentation posts.' ); | |
| 87 | + wp_die(); | |
| 88 | + } | |
| 89 | + | |
| 90 | + // Check if post is password protected and user hasn't provided correct password. | |
| 91 | + if ( post_password_required( $post_id ) ) { | |
| 92 | + wp_send_json_error( 'This document is password protected. Please enter the correct password to access the summary.' ); | |
| 93 | + wp_die(); | |
| 94 | + } | |
| 95 | + | |
| 96 | + // SECURITY (fbs-82814): never trust client-supplied title/content. Load the | |
| 97 | + // canonical values server-side so prompt-injection payloads in the request | |
| 98 | + // body cannot reach the AI model or influence the cache key. | |
| 99 | + $post_title = $post->post_title; | |
| 100 | + $post_content = wp_strip_all_tags( $post->post_content ); | |
| 101 | + | |
| 79 | 102 | if ( empty( $post_content ) ) { |
| 80 | 103 | wp_send_json_error( 'No content provided for summary generation.' ); |
| 81 | 104 | wp_die(); |
| 82 | 105 | } |
| @@ -88,10 +111,12 @@ | ||
| 88 | 111 | $stored_hash = get_post_meta( $post_id, '_betterdocs_article_summary_hash', true ); |
| 89 | 112 | |
| 90 | 113 | // Return existing summary if content hasn't changed |
| 91 | 114 | if ( ! empty( $existing_summary ) && $content_hash === $stored_hash ) { |
| 92 | - // Clean existing summary in case it has old formatting | |
| 93 | - $cleaned_existing = $this->clean_summary_content( $existing_summary ); | |
| 115 | + // Clean existing summary in case it has old formatting, and run it | |
| 116 | + // through wp_kses_post() so any previously-stored poisoned markup is | |
| 117 | + // neutralized before it is returned to the browser. (fbs-82814) | |
| 118 | + $cleaned_existing = wp_kses_post( $this->clean_summary_content( $existing_summary ) ); | |
| 94 | 119 | wp_send_json_success( $cleaned_existing ); |
| 95 | 120 | wp_die(); |
| 96 | 121 | } |
| 97 | 122 | } |
| @@ -103,11 +128,17 @@ | ||
| 103 | 128 | wp_send_json_error( $summary->get_error_message() ); |
| 104 | 129 | wp_die(); |
| 105 | 130 | } |
| 106 | 131 | |
| 107 | - // Clean up the summary content | |
| 108 | - $cleaned_summary = $this->clean_summary_content( $summary ); | |
| 132 | + // Count only fresh generations (cache hits returned earlier). | |
| 133 | + AIUsage::record( 'article_summary', $post_id ); | |
| 109 | 134 | |
| 135 | + // Clean up the summary content and sanitize the AI output with wp_kses_post() | |
| 136 | + // before it is stored or returned. This strips dangerous attributes/tags | |
| 137 | + // (onerror, <script>, etc.) even if a prompt injection ever succeeds, while | |
| 138 | + // preserving safe formatting like <p>/<strong>/<ul>. (fbs-82814) | |
| 139 | + $cleaned_summary = wp_kses_post( $this->clean_summary_content( $summary ) ); | |
| 140 | + | |
| 110 | 141 | // Store summary in post meta if post ID is provided |
| 111 | 142 | if ( $post_id > 0 && ! empty( $cleaned_summary ) ) { |
| 112 | 143 | update_post_meta( $post_id, '_betterdocs_article_summary', $cleaned_summary ); |
| 113 | 144 | update_post_meta( $post_id, '_betterdocs_article_summary_hash', md5( $post_content ) ); |
| @@ -131,9 +162,9 @@ | ||
| 131 | 162 | } |
| 132 | 163 | |
| 133 | 164 | try { |
| 134 | 165 | if ( ! $this->ai_helper->has_api_key() ) { |
| 135 | - return new \WP_Error( 'no_api_key', 'OpenAI API key is not configured. Please add your API key in BetterDocs settings.' ); | |
| 166 | + return new \WP_Error( 'no_api_key', 'AI API key is not configured. Please add your API key in BetterDocs settings.' ); | |
| 136 | 167 | } |
| 137 | 168 | |
| 138 | 169 | // Prepare content for AI processing |
| 139 | 170 | $prepared_content = $this->ai_helper->prepare_content_for_ai( $content, 4000 ); |