From 203616101d721825978bdd9e963b4d0ca60b8293 Mon Sep 17 00:00:00 2001 From: omikheev Date: Thu, 3 Sep 2026 14:33:52 -0400 Subject: [PATCH 1/3] security: REST PoW parity, unified failure message, accurate XML-RPC docs and tests --- includes/class-cardea-core.php | 122 +++++++++++++++++++++------ readme.txt | 7 +- tests/e2e/api-comments.spec.js | 75 +++++++++++++++- tests/e2e/pow-comment.spec.js | 12 +-- tests/e2e/trackback-comments.spec.js | 4 +- tests/e2e/xmlrpc-comments.spec.js | 26 ++++-- tests/phpunit/Cardea_Core_Test.php | 103 +++++++++++++++++++--- 7 files changed, 293 insertions(+), 56 deletions(-) diff --git a/includes/class-cardea-core.php b/includes/class-cardea-core.php index 52dbb63..a7bef3f 100644 --- a/includes/class-cardea-core.php +++ b/includes/class-cardea-core.php @@ -45,6 +45,19 @@ class Cardea_Core { const OPTION_DIFFICULTY = 'cardea_difficulty'; const OPTION_TIME_WINDOW = 'cardea_time_window'; + /** + * User-facing verification failure message. + * + * One generic message for every failure mode: it stays actionable for + * legitimate users (refresh and retry) without revealing which check + * failed, so it cannot be used as an attack oracle. + * + * @return string Localized message. + */ + public static function failure_message() { + return __( 'Your comment could not be verified. Please refresh the page and try again.', 'cardea' ); + } + /** * Get the difficulty level (number of leading zeros required). * @@ -141,14 +154,14 @@ public function verify_solution( $challenge, $solution ) { if ( empty( $challenge['nonce'] ) || empty( $solution ) ) { return new WP_Error( 'cardea_missing_fields', - __( 'Missing challenge or solution fields.', 'cardea' ) + self::failure_message() ); } if ( ! $this->verify_signature( $challenge ) ) { return new WP_Error( 'cardea_invalid_signature', - __( 'Challenge signature verification failed.', 'cardea' ) + self::failure_message() ); } @@ -158,7 +171,7 @@ public function verify_solution( $challenge, $solution ) { if ( time() - $timestamp > $time_window ) { return new WP_Error( 'cardea_expired', - __( 'Challenge has expired. Please refresh the page and try again.', 'cardea' ) + self::failure_message() ); } @@ -166,7 +179,7 @@ public function verify_solution( $challenge, $solution ) { if ( get_transient( $used_key ) ) { return new WP_Error( 'cardea_replay', - __( 'This challenge has already been used.', 'cardea' ) + self::failure_message() ); } @@ -176,7 +189,7 @@ public function verify_solution( $challenge, $solution ) { if ( ! $this->hash_meets_difficulty( $hash, $challenge['difficulty'] ) ) { return new WP_Error( 'cardea_invalid', - __( 'Proof-of-Work verification failed.', 'cardea' ) + self::failure_message() ); } @@ -239,6 +252,16 @@ public function rest_get_challenge( $request ) { * @param WP_REST_Request $request The request object. * @return array|WP_Error */ + /** + * Verify PoW on REST API comment submission. + * + * Anonymous submissions must provide the same challenge fields as the + * comment form; they are verified by the identical pipeline. + * + * @param array $prepared_comment Prepared comment data. + * @param WP_REST_Request $request The request object. + * @return array|WP_Error + */ public function verify_rest_comment( $prepared_comment, $request ) { if ( current_user_can( 'moderate_comments' ) ) { return $prepared_comment; @@ -253,11 +276,59 @@ public function verify_rest_comment( $prepared_comment, $request ) { return $prepared_comment; } - return new WP_Error( - 'cardea_missing_fields', - __( 'Missing challenge fields.', 'cardea' ), - array( 'status' => 403 ) + $nonce = $this->get_sanitized_rest_param( $request, 'cardea_nonce' ); + $timestamp = $this->get_sanitized_rest_param( $request, 'cardea_timestamp' ); + $salt = $this->get_sanitized_rest_param( $request, 'cardea_salt' ); + $solution = $this->get_sanitized_rest_param( $request, 'cardea_solution' ); + $signature = $this->get_sanitized_rest_param( $request, 'cardea_signature' ); + + if ( empty( $nonce ) || empty( $timestamp ) || empty( $salt ) || empty( $solution ) ) { + return new WP_Error( + 'cardea_missing_fields', + self::failure_message(), + array( 'status' => 403 ) + ); + } + + if ( ! wp_verify_nonce( $nonce, 'cardea_challenge' ) ) { + return new WP_Error( + 'cardea_security_check', + self::failure_message(), + array( 'status' => 403 ) + ); + } + + $challenge = array( + 'nonce' => $nonce, + 'timestamp' => $timestamp, + 'salt' => $salt, + 'signature' => $signature, + 'difficulty' => $this->get_difficulty(), ); + + $result = $this->verify_solution( $challenge, $solution ); + + if ( is_wp_error( $result ) ) { + return new WP_Error( + $result->get_error_code(), + $result->get_error_message(), + array( 'status' => 403 ) + ); + } + + return $prepared_comment; + } + + /** + * Read and sanitize a plugin parameter from a REST request. + * + * @param object $request The request object. + * @param string $param Parameter name. + * @return string + */ + private function get_sanitized_rest_param( $request, $param ) { + $value = $request->get_param( $param ); + return sanitize_text_field( wp_unslash( is_scalar( $value ) ? (string) $value : '' ) ); } /** @@ -287,19 +358,11 @@ public function verify_comment_pow( $commentdata ) { $signature = isset( $_POST['cardea_signature'] ) ? sanitize_text_field( wp_unslash( $_POST['cardea_signature'] ) ) : ''; if ( empty( $nonce ) || empty( $timestamp ) || empty( $salt ) || empty( $solution ) ) { - wp_die( - esc_html__( 'Missing challenge fields.', 'cardea' ), - esc_html__( 'PoW Verification Failed', 'cardea' ), - array( 'response' => 403 ) - ); + $this->die_on_verification_failure(); } if ( ! wp_verify_nonce( $nonce, 'cardea_challenge' ) ) { - wp_die( - esc_html__( 'Security check failed.', 'cardea' ), - esc_html__( 'PoW Verification Failed', 'cardea' ), - array( 'response' => 403 ) - ); + $this->die_on_verification_failure(); } $challenge = array( @@ -307,19 +370,28 @@ public function verify_comment_pow( $commentdata ) { 'timestamp' => $timestamp, 'salt' => $salt, 'signature' => $signature, - 'difficulty' => (int) get_option( self::OPTION_DIFFICULTY, CARDEA_DEFAULT_DIFFICULTY ), + 'difficulty' => $this->get_difficulty(), ); $result = $this->verify_solution( $challenge, $solution ); if ( is_wp_error( $result ) ) { - wp_die( - esc_html( $result->get_error_message() ), - esc_html__( 'PoW Verification Failed', 'cardea' ), - array( 'response' => 403 ) - ); + $this->die_on_verification_failure(); } return $commentdata; } + + /** + * Terminate the request with the generic verification failure message. + * + * @codeCoverageIgnore + */ + private function die_on_verification_failure() { + wp_die( + esc_html( self::failure_message() ), + esc_html__( 'PoW Verification Failed', 'cardea' ), + array( 'response' => 403 ) + ); + } } diff --git a/readme.txt b/readme.txt index 144c928..0ba090c 100644 --- a/readme.txt +++ b/readme.txt @@ -54,7 +54,7 @@ To view the source code, contribute, or report issues, visit the [Cardea GitHub * **Non-Intrusive** - Works transparently for legitimate users; spammers must complete the PoW challenge. * **WordPress Standards** - Follows WordPress coding standards and best practices. * **Privacy First (GDPR Friendly)** - No cookies, no user tracking, no CAPTCHA popups, and absolutely zero data sent to third-party cloud APIs. -* **Smart Pathway Protection** - Flawlessly protects frontend forms and blocks XML-RPC botnets, while seamlessly allowing native Trackbacks and authenticated REST API requests. +* **Smart Pathway Protection** - Protects the frontend comment form and anonymous REST API comment submissions with the same Proof-of-Work gate, while allowing native Trackbacks/Pingbacks and authenticated requests. * **Page Caching Compatible** - Uses dynamic REST API endpoint to fetch fresh challenges, ensuring compatibility with edge caching (Cloudflare, Varnish) and full-page caching plugins. * **Logged-In User Bypass** - Skips PoW challenge for authenticated users, eliminating unnecessary CPU usage on the frontend. @@ -72,6 +72,7 @@ Cardea is built with an enterprise-grade engineering stack focused on reliabilit * Localized replay protection using WordPress transients * Auto-cleaning expired tokens via WordPress cron * Single verification pass: signature check + PoW validation +* Single-use tokens: a challenge can be redeemed exactly once, which bounds any interception-style attack to a single comment (standard one-shot-token semantics) **Testing Stack:** * **PHPUnit** - Backend logic verification (HMAC generation, challenge validation, replay prevention) @@ -104,9 +105,9 @@ Yes, but the mining may take slightly longer on older or slower mobile devices. This plugin primarily protects against automated bots. For human-spammers, consider using additional measures like moderation queues or other anti-spam plugins. -= Will this affect SEO bots or REST API submissions? = + = Will this affect SEO bots or REST API submissions? = -This plugin only affects the native WordPress comment form. REST API comments, XML-RPC, and other methods are not affected. +Anonymous comment submissions are gated everywhere: the comment form and the REST API (`wp/v2/comments`) both require a solved PoW challenge. Trackbacks, pingbacks, XML-RPC calls, and requests from logged-in users or moderators are not affected. Note that WordPress core does not expose anonymous comment creation over XML-RPC at all, so no Cardea hook is needed there. = Does it track users? = diff --git a/tests/e2e/api-comments.spec.js b/tests/e2e/api-comments.spec.js index 3d43d0f..090b115 100644 --- a/tests/e2e/api-comments.spec.js +++ b/tests/e2e/api-comments.spec.js @@ -23,6 +23,29 @@ const { test, expect } = require('@playwright/test'); const { runCLI } = require('@wp-playground/cli'); +const crypto = require('crypto'); + +/** + * Solve the PoW challenge locally (mirrors the client, used to feed the REST parity tests). + * + * @param {Object} challenge Challenge object from the REST endpoint. + * @returns {string} The solution (counter). + */ +function solveChallenge(challenge) { + const challengeString = challenge.nonce + '|' + challenge.timestamp + '|' + challenge.salt; + const prefix = '0'.repeat(challenge.difficulty); + let counter = 0; + for (;;) { + const hash = crypto.createHash('sha256').update(challengeString + counter).digest('hex'); + if (hash.startsWith(prefix)) { + return counter.toString(); + } + counter++; + if (counter > 5000000) { + throw new Error('Did not find a solution in time'); + } + } +} let cli; @@ -89,6 +112,56 @@ test.describe('Cardea - REST API Comment Protection', () => { expect(status).toBeLessThan(300); }); + test('should block anonymous REST API comments without PoW fields', async ({ request }) => { + const response = await request.post(`${cli.serverUrl}/wp-json/wp/v2/comments`, { + data: { + post: 1, + author_name: 'Anonymous', + author_email: 'anon@example.com', + content: 'Anonymous comment without challenge fields.' + } + }); + + expect(response.status()).toBe(403); + const body = await response.text(); + expect(body.toLowerCase()).toContain('could not be verified'); + }); + + test('should accept anonymous REST API comments with a valid PoW solution', async ({ request }) => { + const challengeResponse = await request.get(`${cli.serverUrl}/wp-json/cardea/v1/challenge?post_id=1`); + expect(challengeResponse.status()).toBe(200); + const challenge = await challengeResponse.json(); + const solution = solveChallenge(challenge); + + const response = await request.post(`${cli.serverUrl}/wp-json/wp/v2/comments`, { + data: { + post: 1, + author_name: 'REST PoW User', + author_email: 'rest-pow@example.com', + content: 'Anonymous comment with a valid PoW solution.', + cardea_nonce: challenge.nonce, + cardea_timestamp: String(challenge.timestamp), + cardea_salt: challenge.salt, + cardea_solution: solution, + cardea_signature: challenge.signature + } + }); + + expect(response.status()).toBe(201); + const created = await response.json(); + expect(created.id).toBeGreaterThan(0); + + // The comment is pending moderation for anonymous authors; verify it was + // actually stored by reading the comments list with admin authentication. + const commentsResponse = await request.get(`${cli.serverUrl}/wp-json/wp/v2/comments?post=1&per_page=50`, { + headers: { + 'Authorization': 'Basic ' + Buffer.from('admin:password').toString('base64') + } + }); + const comments = await commentsResponse.json(); + expect(comments.some(c => c.content.rendered.includes('Anonymous comment with a valid PoW solution.'))).toBe(true); + }); + test('should allow pingbacks via REST API', async ({ request }) => { const response = await request.post(`${cli.serverUrl}/wp-json/wp/v2/comments`, { data: { @@ -101,6 +174,6 @@ test.describe('Cardea - REST API Comment Protection', () => { }); const responseText = await response.text(); - expect(responseText.toLowerCase()).not.toContain('missing challenge fields'); + expect(responseText.toLowerCase()).not.toContain('could not be verified'); }); }); diff --git a/tests/e2e/pow-comment.spec.js b/tests/e2e/pow-comment.spec.js index 867fe3d..f461dc9 100644 --- a/tests/e2e/pow-comment.spec.js +++ b/tests/e2e/pow-comment.spec.js @@ -131,7 +131,7 @@ test.describe('Cardea - Proof-of-Work Comment Spam Protection', () => { HTMLFormElement.prototype.submit.call(clone); }); - await expect(page.locator('.wp-die-message')).toContainText('Missing', { ignoreCase: true }); + await expect(page.locator('.wp-die-message')).toContainText('could not be verified', { ignoreCase: true }); }); test('should reject tampered signature', async ({ page }) => { @@ -157,7 +157,7 @@ test('should reject tampered signature', async ({ page }) => { HTMLFormElement.prototype.submit.call(clone); }); - await expect(page.locator('.wp-die-message')).toContainText('signature', { ignoreCase: true }); + await expect(page.locator('.wp-die-message')).toContainText('could not be verified', { ignoreCase: true }); }); test('should reject tampered timestamp', async ({ page }) => { @@ -183,7 +183,7 @@ test('should reject tampered signature', async ({ page }) => { HTMLFormElement.prototype.submit.call(clone); }); - await expect(page.locator('.wp-die-message')).toContainText(/signature|expired/i, { timeout: 10000 }); + await expect(page.locator('.wp-die-message')).toContainText('could not be verified', { ignoreCase: true, timeout: 10000 }); }); test('should reject replay attacks (same valid payload submitted twice)', async ({ page }) => { @@ -247,7 +247,7 @@ test('should reject tampered signature', async ({ page }) => { HTMLFormElement.prototype.submit.call(clone); }, payload); - await expect(page.locator('.wp-die-message')).toContainText('already been used', { ignoreCase: true }); + await expect(page.locator('.wp-die-message')).toContainText('could not be verified', { ignoreCase: true }); }); test('should work without Web Worker support', async ({ page }) => { @@ -365,7 +365,7 @@ test.describe('Cardea - Admin Dashboard Reply', () => { await page.waitForTimeout(3000); const pageText = await page.locator('body').textContent(); - expect(pageText.toLowerCase()).not.toContain('missing challenge fields'); + expect(pageText.toLowerCase()).not.toContain('could not be verified'); expect(pageText.toLowerCase()).not.toContain('pow verification failed'); } else { // Form didn't appear, skip @@ -374,7 +374,7 @@ test.describe('Cardea - Admin Dashboard Reply', () => { } else { // No comments to reply to - verify we can at least access wp-admin without PoW errors const pageText = await page.locator('body').textContent(); - expect(pageText.toLowerCase()).not.toContain('missing challenge fields'); + expect(pageText.toLowerCase()).not.toContain('could not be verified'); expect(pageText.toLowerCase()).not.toContain('pow verification failed'); } }); diff --git a/tests/e2e/trackback-comments.spec.js b/tests/e2e/trackback-comments.spec.js index 7b00593..b5729c3 100644 --- a/tests/e2e/trackback-comments.spec.js +++ b/tests/e2e/trackback-comments.spec.js @@ -87,7 +87,7 @@ test.describe('Cardea - Trackback/Pingback Protection', () => { }); const responseBody = await response.text(); - expect(responseBody).not.toContain('Missing challenge fields'); + expect(responseBody).not.toContain('could not be verified'); }); test('should allow pingbacks to bypass PoW', async ({ request }) => { @@ -104,6 +104,6 @@ test.describe('Cardea - Trackback/Pingback Protection', () => { }); const responseBody = await response.text(); - expect(responseBody).not.toContain('Missing challenge fields'); + expect(responseBody).not.toContain('could not be verified'); }); }); diff --git a/tests/e2e/xmlrpc-comments.spec.js b/tests/e2e/xmlrpc-comments.spec.js index 8be1a37..fb2018d 100644 --- a/tests/e2e/xmlrpc-comments.spec.js +++ b/tests/e2e/xmlrpc-comments.spec.js @@ -72,8 +72,18 @@ test.afterAll(async () => { } }); -test.describe('Cardea - XML-RPC Comment Protection', () => { - test('should block XML-RPC comment submissions', async ({ request }) => { +/** + * Cardea intentionally does not hook XML-RPC: WordPress core exposes no + * anonymous comment-creation method over XML-RPC, and pingbacks/trackbacks + * are allowed to bypass the PoW gate by design. The tests below pin those + * guarantees with structured assertions instead of accepting any failure as + * "blocked". + */ +test.describe('Cardea - XML-RPC Surface', () => { + test('unknown XML-RPC comment methods are rejected by WordPress core', async ({ request }) => { + // wp.newComment is not a WordPress XML-RPC method; core answers with its + // structured unknown-method fault. This documents that there is no + // anonymous XML-RPC comment path Cardea would have to gate. const xmlPayload = ` wp.newComment @@ -101,14 +111,11 @@ test.describe('Cardea - XML-RPC Comment Protection', () => { }); const responseBody = await response.text(); - // Either our error or WordPress's error means it's blocked - const isBlocked = responseBody.toLowerCase().includes('missing challenge fields') || - responseBody.toLowerCase().includes('accepts post requests only') || - response.status() !== 200; - expect(isBlocked).toBe(true); + expect(responseBody).toContain(' { + test('XML-RPC pingbacks bypass the PoW gate by design', async ({ request }) => { const xmlPayload = ` pingback.ping @@ -123,7 +130,8 @@ test.describe('Cardea - XML-RPC Comment Protection', () => { data: xmlPayload }); + // The pingback path must never be rejected by Cardea's verification. const responseBody = await response.text(); - expect(responseBody.toLowerCase()).not.toContain('missing challenge fields'); + expect(responseBody.toLowerCase()).not.toContain('could not be verified'); }); }); diff --git a/tests/phpunit/Cardea_Core_Test.php b/tests/phpunit/Cardea_Core_Test.php index ee72292..e5180d3 100644 --- a/tests/phpunit/Cardea_Core_Test.php +++ b/tests/phpunit/Cardea_Core_Test.php @@ -217,7 +217,7 @@ public function test_verify_comment_pow_missing_fields() { $_POST['cardea_signature'] = 'testsig'; $this->expectException( Exception::class ); - $this->expectExceptionMessage( 'Missing challenge fields.' ); + $this->expectExceptionMessage( Cardea_Core::failure_message() ); $this->core->verify_comment_pow( array() ); } @@ -233,7 +233,7 @@ public function test_verify_comment_pow_invalid_nonce() { $_POST['cardea_signature'] = 'testsig'; $this->expectException( Exception::class ); - $this->expectExceptionMessage( 'Security check failed.' ); + $this->expectExceptionMessage( Cardea_Core::failure_message() ); $this->core->verify_comment_pow( array() ); } @@ -251,7 +251,7 @@ public function test_verify_comment_pow_invalid_solution() { $_POST['cardea_signature'] = $challenge['signature']; $this->expectException( Exception::class ); - $this->expectExceptionMessage( 'Proof-of-Work verification failed.' ); + $this->expectExceptionMessage( Cardea_Core::failure_message() ); $this->core->verify_comment_pow( array() ); } @@ -378,7 +378,7 @@ public function test_verify_comment_pow_validates_for_logged_out_comment_type() ); $this->expectException( Exception::class ); - $this->expectExceptionMessage( 'Missing challenge fields.' ); + $this->expectExceptionMessage( Cardea_Core::failure_message() ); $this->core->verify_comment_pow( $commentdata ); @@ -443,7 +443,7 @@ public function test_verify_rest_comment_bypasses_for_logged_in_user() { } /** - * Test verify_rest_comment blocks unauthenticated users. + * Test verify_rest_comment blocks anonymous users without challenge fields. */ public function test_verify_rest_comment_blocks_unauthenticated() { global $current_user; @@ -456,21 +456,104 @@ public function test_verify_rest_comment_blocks_unauthenticated() { $this->assertEquals( 'cardea_missing_fields', $result->get_error_code() ); } + /** + * Test verify_rest_comment accepts anonymous submissions with a valid PoW solution. + */ + public function test_verify_rest_comment_accepts_valid_solution() { + global $current_user; + $current_user = null; + + $challenge = $this->core->generate_challenge( 1 ); + $solution = $this->find_solution( $this->core->build_challenge_string( $challenge ), $challenge['difficulty'] ); + + $request = $this->createMockWP_REST_Request( + 'comment', + 1, + array( + 'cardea_nonce' => $challenge['nonce'], + 'cardea_timestamp' => (string) $challenge['timestamp'], + 'cardea_salt' => $challenge['salt'], + 'cardea_solution' => $solution, + 'cardea_signature' => $challenge['signature'], + ) + ); + + $prepared = array( 'post_id' => 1 ); + $result = $this->core->verify_rest_comment( $prepared, $request ); + + $this->assertEquals( $prepared, $result ); + } + + /** + * Test verify_rest_comment rejects anonymous submissions with an invalid solution. + */ + public function test_verify_rest_comment_rejects_invalid_solution() { + global $current_user; + $current_user = null; + + $challenge = $this->core->generate_challenge( 1 ); + + $request = $this->createMockWP_REST_Request( + 'comment', + 1, + array( + 'cardea_nonce' => $challenge['nonce'], + 'cardea_timestamp' => (string) $challenge['timestamp'], + 'cardea_salt' => $challenge['salt'], + 'cardea_solution' => 'invalid', + 'cardea_signature' => $challenge['signature'], + ) + ); + + $result = $this->core->verify_rest_comment( array(), $request ); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertEquals( 'cardea_invalid', $result->get_error_code() ); + } + + /** + * Test verify_rest_comment rejects anonymous submissions with an invalid nonce. + */ + public function test_verify_rest_comment_rejects_invalid_nonce() { + global $current_user; + $current_user = null; + + $request = $this->createMockWP_REST_Request( + 'comment', + 1, + array( + 'cardea_nonce' => 'invalid_nonce', + 'cardea_timestamp' => (string) time(), + 'cardea_salt' => 'testsalt', + 'cardea_solution' => '12345', + 'cardea_signature' => 'testsig', + ) + ); + + $result = $this->core->verify_rest_comment( array(), $request ); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertEquals( 'cardea_security_check', $result->get_error_code() ); + } + /** * Create a mock WP_REST_Request object. * * @param string $comment_type Optional comment type to configure. * @param int $post_id Optional post ID to configure. + * @param array $params Optional map of additional request parameters. * @return object */ - private function createMockWP_REST_Request( $comment_type = null, $post_id = null ) { - return new class( $comment_type, $post_id ) { + private function createMockWP_REST_Request( $comment_type = null, $post_id = null, $params = array() ) { + return new class( $comment_type, $post_id, $params ) { private $comment_type; private $post_id; + private $params; - public function __construct( $comment_type = null, $post_id = null ) { + public function __construct( $comment_type = null, $post_id = null, $params = array() ) { $this->comment_type = $comment_type; - $this->post_id = $post_id; + $this->post_id = $post_id; + $this->params = $params; } public function get_param( $param ) { @@ -480,7 +563,7 @@ public function get_param( $param ) { if ( $param === 'post_id' ) { return $this->post_id; } - return null; + return isset( $this->params[ $param ] ) ? $this->params[ $param ] : null; } }; } From e5a3ea585e9d57ca6f10d3d7306c95d753f3c156 Mon Sep 17 00:00:00 2001 From: omikheev Date: Thu, 3 Sep 2026 15:16:19 -0400 Subject: [PATCH 2/3] security: verify core auth behavior in e2e, fix xmlrpc test for WASM transport quirks, accurate REST docs --- readme.txt | 4 ++-- tests/e2e/api-comments.spec.js | 30 +++++++++++++----------------- tests/e2e/xmlrpc-comments.spec.js | 24 ++++++++++++++++-------- 3 files changed, 31 insertions(+), 27 deletions(-) diff --git a/readme.txt b/readme.txt index 0ba090c..9ee3e8b 100644 --- a/readme.txt +++ b/readme.txt @@ -54,7 +54,7 @@ To view the source code, contribute, or report issues, visit the [Cardea GitHub * **Non-Intrusive** - Works transparently for legitimate users; spammers must complete the PoW challenge. * **WordPress Standards** - Follows WordPress coding standards and best practices. * **Privacy First (GDPR Friendly)** - No cookies, no user tracking, no CAPTCHA popups, and absolutely zero data sent to third-party cloud APIs. -* **Smart Pathway Protection** - Protects the frontend comment form and anonymous REST API comment submissions with the same Proof-of-Work gate, while allowing native Trackbacks/Pingbacks and authenticated requests. +* **Smart Pathway Protection** - Gates anonymous comment submission end to end: the comment form requires a solved Proof-of-Work challenge, and anonymous REST comment creation is already rejected by WordPress core (401) — Cardea additionally applies the same PoW check to any REST comment creation core permits, as a defense-in-depth layer. Native Trackbacks/Pingbacks and authenticated requests are allowed. * **Page Caching Compatible** - Uses dynamic REST API endpoint to fetch fresh challenges, ensuring compatibility with edge caching (Cloudflare, Varnish) and full-page caching plugins. * **Logged-In User Bypass** - Skips PoW challenge for authenticated users, eliminating unnecessary CPU usage on the frontend. @@ -107,7 +107,7 @@ This plugin primarily protects against automated bots. For human-spammers, consi = Will this affect SEO bots or REST API submissions? = -Anonymous comment submissions are gated everywhere: the comment form and the REST API (`wp/v2/comments`) both require a solved PoW challenge. Trackbacks, pingbacks, XML-RPC calls, and requests from logged-in users or moderators are not affected. Note that WordPress core does not expose anonymous comment creation over XML-RPC at all, so no Cardea hook is needed there. +Anonymous comment submissions are gated everywhere. In the comment form, a solved PoW challenge is required. For the REST API (`wp/v2/comments`), WordPress core already rejects anonymous comment creation with a 401 (`rest_comment_login_required`); Cardea additionally runs the same PoW verification on `rest_pre_insert_comment`, so if core ever permits anonymous REST comments, they will still need a solved challenge. Trackbacks, pingbacks, XML-RPC calls, and requests from logged-in users or moderators are not affected. WordPress core does not expose anonymous comment creation over XML-RPC either, so no Cardea hook is needed there. = Does it track users? = diff --git a/tests/e2e/api-comments.spec.js b/tests/e2e/api-comments.spec.js index 090b115..e5f06d3 100644 --- a/tests/e2e/api-comments.spec.js +++ b/tests/e2e/api-comments.spec.js @@ -112,7 +112,9 @@ test.describe('Cardea - REST API Comment Protection', () => { expect(status).toBeLessThan(300); }); - test('should block anonymous REST API comments without PoW fields', async ({ request }) => { + test('anonymous REST API comments are rejected by WordPress core', async ({ request }) => { + // WordPress core requires login for anonymous comment creation via the + // REST API (rest_comment_login_required), before any Cardea check runs. const response = await request.post(`${cli.serverUrl}/wp-json/wp/v2/comments`, { data: { post: 1, @@ -122,12 +124,16 @@ test.describe('Cardea - REST API Comment Protection', () => { } }); - expect(response.status()).toBe(403); + expect(response.status()).toBe(401); const body = await response.text(); - expect(body.toLowerCase()).toContain('could not be verified'); + expect(body).toContain('rest_comment_login_required'); }); - test('should accept anonymous REST API comments with a valid PoW solution', async ({ request }) => { + test('a valid PoW solution does not bypass the core login requirement', async ({ request }) => { + // Defense in depth: Cardea's own rest_pre_insert_comment gate applies the + // same PoW pipeline to any anonymous REST comment creation that core + // allows. Today core blocks it with 401 first; if core ever changes that + // policy, this test forces an explicit decision instead of a silent gap. const challengeResponse = await request.get(`${cli.serverUrl}/wp-json/cardea/v1/challenge?post_id=1`); expect(challengeResponse.status()).toBe(200); const challenge = await challengeResponse.json(); @@ -147,19 +153,9 @@ test.describe('Cardea - REST API Comment Protection', () => { } }); - expect(response.status()).toBe(201); - const created = await response.json(); - expect(created.id).toBeGreaterThan(0); - - // The comment is pending moderation for anonymous authors; verify it was - // actually stored by reading the comments list with admin authentication. - const commentsResponse = await request.get(`${cli.serverUrl}/wp-json/wp/v2/comments?post=1&per_page=50`, { - headers: { - 'Authorization': 'Basic ' + Buffer.from('admin:password').toString('base64') - } - }); - const comments = await commentsResponse.json(); - expect(comments.some(c => c.content.rendered.includes('Anonymous comment with a valid PoW solution.'))).toBe(true); + expect(response.status()).toBe(401); + const body = await response.text(); + expect(body).toContain('rest_comment_login_required'); }); test('should allow pingbacks via REST API', async ({ request }) => { diff --git a/tests/e2e/xmlrpc-comments.spec.js b/tests/e2e/xmlrpc-comments.spec.js index fb2018d..54cae42 100644 --- a/tests/e2e/xmlrpc-comments.spec.js +++ b/tests/e2e/xmlrpc-comments.spec.js @@ -80,7 +80,7 @@ test.afterAll(async () => { * "blocked". */ test.describe('Cardea - XML-RPC Surface', () => { - test('unknown XML-RPC comment methods are rejected by WordPress core', async ({ request }) => { + test('unknown XML-RPC comment methods are rejected by WordPress core', async () => { // wp.newComment is not a WordPress XML-RPC method; core answers with its // structured unknown-method fault. This documents that there is no // anonymous XML-RPC comment path Cardea would have to gate. @@ -105,17 +105,24 @@ test.describe('Cardea - XML-RPC Surface', () => { `; - const response = await request.post(`${cli.serverUrl}/xmlrpc.php`, { + const response = await fetch(`${cli.serverUrl}/xmlrpc.php`, { + method: 'POST', headers: { 'Content-Type': 'text/xml' }, - data: xmlPayload + body: xmlPayload }); const responseBody = await response.text(); - expect(responseBody).toContain(' { + test('XML-RPC pingbacks bypass the PoW gate by design', async () => { const xmlPayload = ` pingback.ping @@ -125,9 +132,10 @@ test.describe('Cardea - XML-RPC Surface', () => { `; - const response = await request.post(`${cli.serverUrl}/xmlrpc.php`, { + const response = await fetch(`${cli.serverUrl}/xmlrpc.php`, { + method: 'POST', headers: { 'Content-Type': 'text/xml' }, - data: xmlPayload + body: xmlPayload }); // The pingback path must never be rejected by Cardea's verification. From efbbda4cc44500364ee31cc9d3c159820ef8c4ca Mon Sep 17 00:00:00 2001 From: omikheev Date: Thu, 3 Sep 2026 16:33:51 -0400 Subject: [PATCH 3/3] architecture: extract Cardea_Comment_Gate, single version source, unified excludes, shared e2e fixtures --- Makefile | 58 +-- cardea.php | 3 + composer.json | 5 - includes/class-cardea-comment-gate.php | 209 ++++++++++ includes/class-cardea-core.php | 154 +------- tests/e2e/api-comments.spec.js | 40 +- tests/e2e/pow-comment.spec.js | 96 +---- tests/e2e/rest-challenge.spec.js | 40 +- tests/e2e/support/playground.js | 93 +++++ tests/e2e/trackback-comments.spec.js | 40 +- tests/e2e/xmlrpc-comments.spec.js | 40 +- tests/phpunit/Cardea_Comment_Gate_Test.php | 425 +++++++++++++++++++++ tests/phpunit/Cardea_Core_Test.php | 375 +----------------- tests/phpunit/bootstrap.php | 13 +- 14 files changed, 797 insertions(+), 794 deletions(-) create mode 100644 includes/class-cardea-comment-gate.php create mode 100644 tests/e2e/support/playground.js create mode 100644 tests/phpunit/Cardea_Comment_Gate_Test.php diff --git a/Makefile b/Makefile index f6a6b34..2c5fd6f 100644 --- a/Makefile +++ b/Makefile @@ -47,32 +47,22 @@ test-e2e: # ========================================== # PACKAGING # ========================================== +# Paths excluded from the distributed plugin. Single source of truth, shared +# by `make package` (zip) and `make sync-svn` (rsync) so the two can never +# drift apart. +EXCLUDE_DIRS := node_modules vendor tests dist .github .playwright-browsers playwright-report test-results wp-assets design +EXCLUDE_FILES := .phpunit.result.cache phpunit.xml playwright.config.js Dockerfile .dockerignore Makefile README.md +EXCLUDE_GLOBS := *.git* composer.* package*.json + +ZIP_EXCLUDES := $(foreach entry,$(EXCLUDE_DIRS),\"$(entry)/*\") $(foreach entry,$(EXCLUDE_FILES),\"$(entry)\") $(foreach entry,$(EXCLUDE_GLOBS),\"$(entry)\") +RSYNC_EXCLUDES := $(foreach entry,$(EXCLUDE_DIRS),--exclude=$(entry)/) $(foreach entry,$(EXCLUDE_FILES),--exclude=$(entry)) $(foreach entry,$(EXCLUDE_GLOBS),--exclude=$(entry)) + package: @echo "Packaging $(PLUGIN_SLUG) version $(VERSION)..." @mkdir -p dist @rm -f dist/$(PLUGIN_SLUG).zip - @# Use a temporary directory to ensure only the necessary files are zipped - @zip -r dist/$(PLUGIN_SLUG).zip . \ - -x "*.git*" \ - -x "node_modules/*" \ - -x "vendor/*" \ - -x "tests/*" \ - -x "dist/*" \ - -x ".github/*" \ - -x ".playwright-browsers/*" \ - -x "playwright-report/*" \ - -x "test-results/*" \ - -x ".phpunit.result.cache" \ - -x "phpunit.xml" \ - -x "playwright.config.js" \ - -x "composer.*" \ - -x "package*.json" \ - -x "Dockerfile" \ - -x ".dockerignore" \ - -x "Makefile" \ - -x "README.md" \ - -x "wp-assets/*" \ - -x "design/*" + @# Zip only the plugin files (exclusion list above keeps dev artifacts out) + @zip -r dist/$(PLUGIN_SLUG).zip . -x $(ZIP_EXCLUDES) @echo "Package created at dist/$(PLUGIN_SLUG).zip" # ========================================== @@ -86,29 +76,7 @@ sync-svn: @if [ ! -d "$(SVN_DIR)" ]; then echo "Error: SVN directory $(SVN_DIR) does not exist."; exit 1; fi @echo "--> Mirroring production files to $(SVN_DIR)/trunk/" - @rsync -av --delete \ - --exclude=".*" \ - --exclude="*.git*" \ - --exclude="node_modules/" \ - --exclude="vendor/" \ - --exclude="tests/" \ - --exclude="dist/" \ - --exclude=".github/" \ - --exclude=".playwright-browsers/" \ - --exclude="playwright-report/" \ - --exclude="test-results/" \ - --exclude=".phpunit.result.cache" \ - --exclude="phpunit.xml" \ - --exclude="playwright.config.js" \ - --exclude="composer.*" \ - --exclude="package*.json" \ - --exclude="Dockerfile" \ - --exclude=".dockerignore" \ - --exclude="Makefile" \ - --exclude="README.md" \ - --exclude="wp-assets/" \ - --exclude="design/" \ - ./ $(SVN_DIR)/trunk/ + @rsync -av --delete $(RSYNC_EXCLUDES) ./ $(SVN_DIR)/trunk/ @echo "--> Mirroring repository assets to $(SVN_DIR)/assets/" @rsync -av --delete ./wp-assets/ $(SVN_DIR)/assets/ diff --git a/cardea.php b/cardea.php index 6c91fc4..1bccd55 100644 --- a/cardea.php +++ b/cardea.php @@ -26,6 +26,7 @@ define( 'CARDEA_DEFAULT_WINDOW', 30 ); require_once CARDEA_PLUGIN_DIR . 'includes/class-cardea-core.php'; +require_once CARDEA_PLUGIN_DIR . 'includes/class-cardea-comment-gate.php'; require_once CARDEA_PLUGIN_DIR . 'includes/class-cardea-frontend.php'; require_once CARDEA_PLUGIN_DIR . 'includes/class-cardea-admin.php'; @@ -36,10 +37,12 @@ */ function cardea_init() { $core = new Cardea_Core(); + $gate = new Cardea_Comment_Gate( $core ); $frontend = new Cardea_Frontend( $core ); $admin = new Cardea_Admin( $core ); $core->init(); + $gate->init(); $frontend->init(); $admin->init(); } diff --git a/composer.json b/composer.json index 5bc929f..963de95 100644 --- a/composer.json +++ b/composer.json @@ -12,11 +12,6 @@ "phpcompatibility/php-compatibility": "^9.3", "wp-coding-standards/wpcs": "^3.0" }, - "autoload": { - "psr-4": { - "Cardea\\": "includes/" - } - }, "scripts": { "test": "phpunit", "phpcs": "phpcs --standard=WordPress --ignore=vendor/,node_modules/,tests/ --extensions=php .", diff --git a/includes/class-cardea-comment-gate.php b/includes/class-cardea-comment-gate.php new file mode 100644 index 0000000..492cdfb --- /dev/null +++ b/includes/class-cardea-comment-gate.php @@ -0,0 +1,209 @@ +core = $core; + } + + /** + * Initialize hooks. + */ + public function init() { + add_filter( 'preprocess_comment', array( $this, 'verify_form_submission' ) ); + add_filter( 'rest_pre_insert_comment', array( $this, 'verify_rest_submission' ), 10, 2 ); + } + + /** + * Verify a PoW on comment form submission. + * + * @param array $commentdata Comment data. + * @return array|WP_Error + */ + public function verify_form_submission( $commentdata ) { + $comment_type = isset( $commentdata['comment_type'] ) ? $commentdata['comment_type'] : ''; + + if ( $this->submission_is_exempt( $comment_type ) ) { + return $commentdata; + } + + /* phpcs:disable WordPress.Security.NonceVerification.Missing -- values are verified by wp_verify_nonce() in run_verification(). */ + $nonce = isset( $_POST['cardea_nonce'] ) ? sanitize_text_field( wp_unslash( $_POST['cardea_nonce'] ) ) : ''; + $timestamp = isset( $_POST['cardea_timestamp'] ) ? sanitize_text_field( wp_unslash( $_POST['cardea_timestamp'] ) ) : ''; + $salt = isset( $_POST['cardea_salt'] ) ? sanitize_text_field( wp_unslash( $_POST['cardea_salt'] ) ) : ''; + $solution = isset( $_POST['cardea_solution'] ) ? sanitize_text_field( wp_unslash( $_POST['cardea_solution'] ) ) : ''; + $signature = isset( $_POST['cardea_signature'] ) ? sanitize_text_field( wp_unslash( $_POST['cardea_signature'] ) ) : ''; + /* phpcs:enable */ + + if ( $this->run_verification( $nonce, $timestamp, $salt, $solution, $signature ) ) { + return $commentdata; + } + + $this->die_on_verification_failure(); + + return $commentdata; + } + + /** + * Verify a PoW on REST API comment submission. + * + * Anonymous submissions must provide the same challenge fields as the + * comment form and are verified by the identical pipeline. + * + * @param array $prepared_comment Prepared comment data. + * @param WP_REST_Request $request The request object. + * @return array|WP_Error + */ + public function verify_rest_submission( $prepared_comment, $request ) { + $comment_type = $request->get_param( 'comment_type' ) ? $request->get_param( 'comment_type' ) : ''; + + if ( $this->submission_is_exempt( $comment_type ) ) { + return $prepared_comment; + } + + $nonce = $this->get_rest_param( $request, 'cardea_nonce' ); + $timestamp = $this->get_rest_param( $request, 'cardea_timestamp' ); + $salt = $this->get_rest_param( $request, 'cardea_salt' ); + $solution = $this->get_rest_param( $request, 'cardea_solution' ); + $signature = $this->get_rest_param( $request, 'cardea_signature' ); + + if ( $this->run_verification( $nonce, $timestamp, $salt, $solution, $signature ) ) { + return $prepared_comment; + } + + /* + * One generic error for every failure mode: the client learns only that + * verification failed, never which check failed. + */ + return new WP_Error( + 'cardea_verification_failed', + Cardea_Core::failure_message(), + array( 'status' => 403 ) + ); + } + + /** + * Whether this submission is exempt from Proof-of-Work verification. + * + * Moderators, logged-in users, and non-comment types (pingbacks, + * trackbacks) are exempt. + * + * @param string $comment_type Comment type of the submission. + * @return bool + */ + private function submission_is_exempt( $comment_type ) { + if ( current_user_can( 'moderate_comments' ) ) { + return true; + } + + if ( in_array( $comment_type, array( 'pingback', 'trackback' ), true ) ) { + return true; + } + + return is_user_logged_in(); + } + + /** + * Run the shared verification pipeline over parsed submission fields. + * + * @param string $nonce Challenge nonce. + * @param string $timestamp Challenge timestamp. + * @param string $salt Challenge salt. + * @param string $solution Client solution. + * @param string $signature Challenge signature. + * @return bool True when the submission verifies. + */ + private function run_verification( $nonce, $timestamp, $salt, $solution, $signature ) { + if ( empty( $nonce ) || empty( $timestamp ) || empty( $salt ) || empty( $solution ) ) { + return false; + } + + if ( ! wp_verify_nonce( $nonce, 'cardea_challenge' ) ) { + return false; + } + + $challenge = array( + 'nonce' => $nonce, + 'timestamp' => $timestamp, + 'salt' => $salt, + 'signature' => $signature, + 'difficulty' => $this->core->get_difficulty(), + ); + + return ! is_wp_error( $this->core->verify_solution( $challenge, $solution ) ); + } + + /** + * Read and sanitize a plugin parameter from a REST request. + * + * @param object $request The request object. + * @param string $param Parameter name. + * @return string + */ + private function get_rest_param( $request, $param ) { + $value = $request->get_param( $param ); + return sanitize_text_field( wp_unslash( is_scalar( $value ) ? (string) $value : '' ) ); + } + + /** + * Terminate the request with the generic verification failure message. + * + * @codeCoverageIgnore + */ + private function die_on_verification_failure() { + wp_die( + esc_html( Cardea_Core::failure_message() ), + esc_html__( 'PoW Verification Failed', 'cardea' ), + array( 'response' => 403 ) + ); + } +} diff --git a/includes/class-cardea-core.php b/includes/class-cardea-core.php index a7bef3f..d4740d3 100644 --- a/includes/class-cardea-core.php +++ b/includes/class-cardea-core.php @@ -212,10 +212,10 @@ public function hash_meets_difficulty( $hash, $difficulty ) { /** * Initialize the plugin. + * + * Comment verification hooks are owned by Cardea_Comment_Gate. */ public function init() { - add_action( 'preprocess_comment', array( $this, 'verify_comment_pow' ) ); - add_filter( 'rest_pre_insert_comment', array( $this, 'verify_rest_comment' ), 10, 2 ); add_action( 'rest_api_init', array( $this, 'register_rest_routes' ) ); } @@ -244,154 +244,4 @@ public function rest_get_challenge( $request ) { $post_id = $request->get_param( 'post_id' ) ? (int) $request->get_param( 'post_id' ) : 0; return $this->generate_challenge( $post_id ); } - - /** - * Verify PoW on REST API comment submission. - * - * @param array $prepared_comment Prepared comment data. - * @param WP_REST_Request $request The request object. - * @return array|WP_Error - */ - /** - * Verify PoW on REST API comment submission. - * - * Anonymous submissions must provide the same challenge fields as the - * comment form; they are verified by the identical pipeline. - * - * @param array $prepared_comment Prepared comment data. - * @param WP_REST_Request $request The request object. - * @return array|WP_Error - */ - public function verify_rest_comment( $prepared_comment, $request ) { - if ( current_user_can( 'moderate_comments' ) ) { - return $prepared_comment; - } - - $comment_type = $request->get_param( 'comment_type' ) ? $request->get_param( 'comment_type' ) : ''; - if ( in_array( $comment_type, array( 'pingback', 'trackback' ), true ) ) { - return $prepared_comment; - } - - if ( is_user_logged_in() ) { - return $prepared_comment; - } - - $nonce = $this->get_sanitized_rest_param( $request, 'cardea_nonce' ); - $timestamp = $this->get_sanitized_rest_param( $request, 'cardea_timestamp' ); - $salt = $this->get_sanitized_rest_param( $request, 'cardea_salt' ); - $solution = $this->get_sanitized_rest_param( $request, 'cardea_solution' ); - $signature = $this->get_sanitized_rest_param( $request, 'cardea_signature' ); - - if ( empty( $nonce ) || empty( $timestamp ) || empty( $salt ) || empty( $solution ) ) { - return new WP_Error( - 'cardea_missing_fields', - self::failure_message(), - array( 'status' => 403 ) - ); - } - - if ( ! wp_verify_nonce( $nonce, 'cardea_challenge' ) ) { - return new WP_Error( - 'cardea_security_check', - self::failure_message(), - array( 'status' => 403 ) - ); - } - - $challenge = array( - 'nonce' => $nonce, - 'timestamp' => $timestamp, - 'salt' => $salt, - 'signature' => $signature, - 'difficulty' => $this->get_difficulty(), - ); - - $result = $this->verify_solution( $challenge, $solution ); - - if ( is_wp_error( $result ) ) { - return new WP_Error( - $result->get_error_code(), - $result->get_error_message(), - array( 'status' => 403 ) - ); - } - - return $prepared_comment; - } - - /** - * Read and sanitize a plugin parameter from a REST request. - * - * @param object $request The request object. - * @param string $param Parameter name. - * @return string - */ - private function get_sanitized_rest_param( $request, $param ) { - $value = $request->get_param( $param ); - return sanitize_text_field( wp_unslash( is_scalar( $value ) ? (string) $value : '' ) ); - } - - /** - * Verify PoW on comment submission. - * - * @param array $commentdata Comment data. - * @return array|WP_Error - */ - public function verify_comment_pow( $commentdata ) { - if ( current_user_can( 'moderate_comments' ) ) { - return $commentdata; - } - - $comment_type = isset( $commentdata['comment_type'] ) ? $commentdata['comment_type'] : ''; - if ( in_array( $comment_type, array( 'pingback', 'trackback' ), true ) ) { - return $commentdata; - } - - if ( is_user_logged_in() ) { - return $commentdata; - } - - $nonce = isset( $_POST['cardea_nonce'] ) ? sanitize_text_field( wp_unslash( $_POST['cardea_nonce'] ) ) : ''; - $timestamp = isset( $_POST['cardea_timestamp'] ) ? sanitize_text_field( wp_unslash( $_POST['cardea_timestamp'] ) ) : ''; - $salt = isset( $_POST['cardea_salt'] ) ? sanitize_text_field( wp_unslash( $_POST['cardea_salt'] ) ) : ''; - $solution = isset( $_POST['cardea_solution'] ) ? sanitize_text_field( wp_unslash( $_POST['cardea_solution'] ) ) : ''; - $signature = isset( $_POST['cardea_signature'] ) ? sanitize_text_field( wp_unslash( $_POST['cardea_signature'] ) ) : ''; - - if ( empty( $nonce ) || empty( $timestamp ) || empty( $salt ) || empty( $solution ) ) { - $this->die_on_verification_failure(); - } - - if ( ! wp_verify_nonce( $nonce, 'cardea_challenge' ) ) { - $this->die_on_verification_failure(); - } - - $challenge = array( - 'nonce' => $nonce, - 'timestamp' => $timestamp, - 'salt' => $salt, - 'signature' => $signature, - 'difficulty' => $this->get_difficulty(), - ); - - $result = $this->verify_solution( $challenge, $solution ); - - if ( is_wp_error( $result ) ) { - $this->die_on_verification_failure(); - } - - return $commentdata; - } - - /** - * Terminate the request with the generic verification failure message. - * - * @codeCoverageIgnore - */ - private function die_on_verification_failure() { - wp_die( - esc_html( self::failure_message() ), - esc_html__( 'PoW Verification Failed', 'cardea' ), - array( 'response' => 403 ) - ); - } } diff --git a/tests/e2e/api-comments.spec.js b/tests/e2e/api-comments.spec.js index e5f06d3..7cc28f5 100644 --- a/tests/e2e/api-comments.spec.js +++ b/tests/e2e/api-comments.spec.js @@ -22,7 +22,7 @@ */ const { test, expect } = require('@playwright/test'); -const { runCLI } = require('@wp-playground/cli'); +const { startPlayground } = require('./support/playground'); const crypto = require('crypto'); /** @@ -50,43 +50,7 @@ function solveChallenge(challenge) { let cli; test.beforeAll(async () => { - cli = await runCLI({ - command: 'server', - php: '8.3', - wp: 'latest', - login: false, - mount: [ - { - hostPath: './', - vfsPath: '/wordpress/wp-content/plugins/cardea', - }, - ], - blueprint: { - steps: [ - { - step: 'activatePlugin', - pluginPath: '/wordpress/wp-content/plugins/cardea/cardea.php', - }, - { - step: 'writeFile', - path: '/wordpress/wp-content/mu-plugins/disable-flood-check.php', - data: ' 'Test Post for REST API', - 'post_content' => 'This is a test post to verify REST API comments.', - 'post_status' => 'publish', - 'comment_status' => 'open', - ]); - `, - }, - ], - }, - }); + cli = await startPlayground( { postTitle: 'Test Post for REST API' }); }); test.afterAll(async () => { diff --git a/tests/e2e/pow-comment.spec.js b/tests/e2e/pow-comment.spec.js index f461dc9..5933408 100644 --- a/tests/e2e/pow-comment.spec.js +++ b/tests/e2e/pow-comment.spec.js @@ -22,48 +22,12 @@ */ const { test, expect } = require('@playwright/test'); -const { runCLI } = require('@wp-playground/cli'); +const { startPlayground } = require('./support/playground'); let cli; test.beforeAll(async () => { - cli = await runCLI({ - command: 'server', - php: '8.3', - wp: 'latest', - login: false, - mount: [ - { - hostPath: './', - vfsPath: '/wordpress/wp-content/plugins/cardea', - }, - ], - blueprint: { - steps: [ - { - step: 'activatePlugin', - pluginPath: '/wordpress/wp-content/plugins/cardea/cardea.php', - }, - { - step: 'writeFile', - path: '/wordpress/wp-content/mu-plugins/disable-flood-check.php', - data: ' 'Test Post for Comments', - 'post_content' => 'This is a test post to verify comment form.', - 'post_status' => 'publish', - 'comment_status' => 'open', - ]); - `, - }, - ], - }, - }); + cli = await startPlayground( { postTitle: 'Test Post for Comments' }); }); test.afterAll(async () => { @@ -278,53 +242,15 @@ test.describe('Cardea - Admin Dashboard Reply', () => { let adminCli; test.beforeAll(async () => { - adminCli = await runCLI({ - command: 'server', - php: '8.3', - wp: 'latest', - login: true, - adminLogin: true, - mount: [ - { - hostPath: './', - vfsPath: '/wordpress/wp-content/plugins/cardea', - }, - ], - blueprint: { - steps: [ - { - step: 'activatePlugin', - pluginPath: '/wordpress/wp-content/plugins/cardea/cardea.php', - }, - { - step: 'writeFile', - path: '/wordpress/wp-content/mu-plugins/disable-flood-check.php', - data: ' 'Test Post for Admin Reply', - 'post_content' => 'This is a test post for admin reply test.', - 'post_status' => 'publish', - 'comment_status' => 'open', - ]); - // Create a test comment - wp_insert_comment([ - 'comment_post_ID' => $post_id, - 'comment_content' => 'Test comment for admin reply test', - 'comment_author' => 'Test Commenter', - 'comment_author_email' => 'tester@test.com', - 'comment_approved' => 1, - ]); - `, - }, - ], - }, - }); - }); + adminCli = await startPlayground( { login: true, postTitle: 'Test Post for Admin Reply', extraRunPHP: ` wp_insert_comment([ + 'comment_post_ID' => $post_id, + 'comment_content' => 'Test comment for admin reply test', + 'comment_author' => 'Test Commenter', + 'comment_author_email' => 'tester@test.com', + 'comment_approved' => 1, + ]);` }); +}); + test.afterAll(async () => { if (adminCli) { diff --git a/tests/e2e/rest-challenge.spec.js b/tests/e2e/rest-challenge.spec.js index 2014590..19ddf5e 100644 --- a/tests/e2e/rest-challenge.spec.js +++ b/tests/e2e/rest-challenge.spec.js @@ -21,48 +21,12 @@ */ const { test, expect } = require('@playwright/test'); -const { runCLI } = require('@wp-playground/cli'); +const { startPlayground } = require('./support/playground'); let cli; test.beforeAll(async () => { - cli = await runCLI({ - command: 'server', - php: '8.3', - wp: 'latest', - login: false, - mount: [ - { - hostPath: './', - vfsPath: '/wordpress/wp-content/plugins/cardea', - }, - ], - blueprint: { - steps: [ - { - step: 'activatePlugin', - pluginPath: '/wordpress/wp-content/plugins/cardea/cardea.php', - }, - { - step: 'writeFile', - path: '/wordpress/wp-content/mu-plugins/disable-flood-check.php', - data: ' 'Test Post for REST Challenge', - 'post_content' => 'This is a test post.', - 'post_status' => 'publish', - 'comment_status' => 'open', - ]); - `, - }, - ], - }, - }); + cli = await startPlayground( { postTitle: 'Test Post for REST Challenge' }); }); test.afterAll(async () => { diff --git a/tests/e2e/support/playground.js b/tests/e2e/support/playground.js new file mode 100644 index 0000000..b96572f --- /dev/null +++ b/tests/e2e/support/playground.js @@ -0,0 +1,93 @@ +/** + * Cardea - Proof-of-Work Comment Spam Protection + * + * Copyright (C) 2024 Oleg Mikheev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * @package Cardea + */ + +/** + * Shared Playwright / WordPress Playground fixtures for the Cardea e2e suite. + * + * Every spec boots WordPress through @wp-playground/cli with the plugin + * mounted and activated. This helper is the single place that controls + * the WordPress version under test, so any release line can be exercised: + * + * WP_VERSION=7.0 make test-e2e + */ + +const { runCLI } = require('@wp-playground/cli'); + +/** WordPress version under test (defaults to the latest stable release). */ +const WP_VERSION = process.env.WP_VERSION || 'latest'; + +/** Disables the comment flood checks so rapid e2e submissions are not rate-limited. */ +const FLOOD_CHECK_BYPASS = '} The running CLI handle ({ serverUrl, [Symbol.asyncDispose] }). + */ +async function startPlayground({ login = false, postTitle = 'Test Post', extraRunPHP = '' } = {}) { + const options = { + command: 'server', + php: '8.3', + wp: WP_VERSION, + login: login, + mount: [ + { + hostPath: './', + vfsPath: '/wordpress/wp-content/plugins/cardea', + }, + ], + blueprint: { + steps: [ + { + step: 'activatePlugin', + pluginPath: '/wordpress/wp-content/plugins/cardea/cardea.php', + }, + { + step: 'writeFile', + path: '/wordpress/wp-content/mu-plugins/disable-flood-check.php', + data: FLOOD_CHECK_BYPASS, + }, + { + step: 'runPHP', + code: ` '${postTitle}', + 'post_content' => 'This is a test post.', + 'post_status' => 'publish', + 'comment_status' => 'open', + ]); + ${extraRunPHP} + `, + }, + ], + }, + }; + if (login) { + options.adminLogin = true; + } + + return runCLI(options); +} + +module.exports = { startPlayground, WP_VERSION }; diff --git a/tests/e2e/trackback-comments.spec.js b/tests/e2e/trackback-comments.spec.js index b5729c3..cf3af14 100644 --- a/tests/e2e/trackback-comments.spec.js +++ b/tests/e2e/trackback-comments.spec.js @@ -22,48 +22,12 @@ */ const { test, expect } = require('@playwright/test'); -const { runCLI } = require('@wp-playground/cli'); +const { startPlayground } = require('./support/playground'); let cli; test.beforeAll(async () => { - cli = await runCLI({ - command: 'server', - php: '8.3', - wp: 'latest', - login: false, - mount: [ - { - hostPath: './', - vfsPath: '/wordpress/wp-content/plugins/cardea', - }, - ], - blueprint: { - steps: [ - { - step: 'activatePlugin', - pluginPath: '/wordpress/wp-content/plugins/cardea/cardea.php', - }, - { - step: 'writeFile', - path: '/wordpress/wp-content/mu-plugins/disable-flood-check.php', - data: ' 'Test Post for Trackbacks', - 'post_content' => 'This is a test post to verify trackbacks.', - 'post_status' => 'publish', - 'comment_status' => 'open', - ]); - `, - }, - ], - }, - }); + cli = await startPlayground( { postTitle: 'Test Post for Trackbacks' }); }); test.afterAll(async () => { diff --git a/tests/e2e/xmlrpc-comments.spec.js b/tests/e2e/xmlrpc-comments.spec.js index 54cae42..3e613cc 100644 --- a/tests/e2e/xmlrpc-comments.spec.js +++ b/tests/e2e/xmlrpc-comments.spec.js @@ -22,48 +22,12 @@ */ const { test, expect } = require('@playwright/test'); -const { runCLI } = require('@wp-playground/cli'); +const { startPlayground } = require('./support/playground'); let cli; test.beforeAll(async () => { - cli = await runCLI({ - command: 'server', - php: '8.3', - wp: 'latest', - login: false, - mount: [ - { - hostPath: './', - vfsPath: '/wordpress/wp-content/plugins/cardea', - }, - ], - blueprint: { - steps: [ - { - step: 'activatePlugin', - pluginPath: '/wordpress/wp-content/plugins/cardea/cardea.php', - }, - { - step: 'writeFile', - path: '/wordpress/wp-content/mu-plugins/disable-flood-check.php', - data: ' 'Test Post for XML-RPC', - 'post_content' => 'This is a test post to verify XML-RPC comments.', - 'post_status' => 'publish', - 'comment_status' => 'open', - ]); - `, - }, - ], - }, - }); + cli = await startPlayground( { postTitle: 'Test Post for XML-RPC' }); }); test.afterAll(async () => { diff --git a/tests/phpunit/Cardea_Comment_Gate_Test.php b/tests/phpunit/Cardea_Comment_Gate_Test.php new file mode 100644 index 0000000..3e410c3 --- /dev/null +++ b/tests/phpunit/Cardea_Comment_Gate_Test.php @@ -0,0 +1,425 @@ +core = new Cardea_Core(); + $this->gate = new Cardea_Comment_Gate( $this->core ); + } + + /** + * Test form submission with missing fields fails with the generic message. + */ + public function test_verify_form_submission_missing_fields() { + $_POST['cardea_nonce'] = ''; + $_POST['cardea_timestamp'] = '1234567890'; + $_POST['cardea_salt'] = 'testsalt'; + $_POST['cardea_solution'] = ''; + $_POST['cardea_signature'] = 'testsig'; + + $this->expectException( Exception::class ); + $this->expectExceptionMessage( Cardea_Core::failure_message() ); + + $this->gate->verify_form_submission( array() ); + } + + /** + * Test form submission with an invalid nonce fails with the generic message. + */ + public function test_verify_form_submission_invalid_nonce() { + $_POST['cardea_nonce'] = 'invalid_nonce'; + $_POST['cardea_timestamp'] = (string) time(); + $_POST['cardea_salt'] = 'testsalt'; + $_POST['cardea_solution'] = '12345'; + $_POST['cardea_signature'] = 'testsig'; + + $this->expectException( Exception::class ); + $this->expectExceptionMessage( Cardea_Core::failure_message() ); + + $this->gate->verify_form_submission( array() ); + } + + /** + * Test form submission with an invalid solution fails with the generic message. + */ + public function test_verify_form_submission_invalid_solution() { + $challenge = $this->core->generate_challenge( 1 ); + + $_POST['cardea_nonce'] = $challenge['nonce']; + $_POST['cardea_timestamp'] = (string) $challenge['timestamp']; + $_POST['cardea_salt'] = $challenge['salt']; + $_POST['cardea_solution'] = 'invalid'; + $_POST['cardea_signature'] = $challenge['signature']; + + $this->expectException( Exception::class ); + $this->expectExceptionMessage( Cardea_Core::failure_message() ); + + $this->gate->verify_form_submission( array() ); + } + + /** + * Test form submission accepts a valid submission. + */ + public function test_verify_form_submission_success() { + $challenge = $this->core->generate_challenge( 1 ); + $solution = $this->find_solution( $this->core->build_challenge_string( $challenge ), $challenge['difficulty'] ); + + $_POST['cardea_nonce'] = $challenge['nonce']; + $_POST['cardea_timestamp'] = (string) $challenge['timestamp']; + $_POST['cardea_salt'] = $challenge['salt']; + $_POST['cardea_solution'] = $solution; + $_POST['cardea_signature'] = $challenge['signature']; + + $commentdata = array( 'comment_post_ID' => 1 ); + $result = $this->gate->verify_form_submission( $commentdata ); + + $this->assertEquals( $commentdata, $result ); + } + + /** + * Test form submission bypasses for users with moderate_comments capability. + */ + public function test_verify_form_submission_bypasses_for_moderator() { + global $current_user; + $current_user = new WP_User(); + $current_user->caps = array( 'moderate_comments' => true ); + + $_POST['cardea_nonce'] = ''; + + $commentdata = array( 'comment_post_ID' => 1 ); + $result = $this->gate->verify_form_submission( $commentdata ); + + $this->assertEquals( $commentdata, $result ); + + $current_user = null; + $_POST = array(); + } + + /** + * Test form submission bypasses for pingback comment type. + */ + public function test_verify_form_submission_bypasses_for_pingback() { + $_POST['cardea_nonce'] = ''; + + $commentdata = array( + 'comment_post_ID' => 1, + 'comment_type' => 'pingback', + ); + $result = $this->gate->verify_form_submission( $commentdata ); + + $this->assertEquals( $commentdata, $result ); + + $_POST = array(); + } + + /** + * Test form submission bypasses for trackback comment type. + */ + public function test_verify_form_submission_bypasses_for_trackback() { + $_POST['cardea_nonce'] = ''; + + $commentdata = array( + 'comment_post_ID' => 1, + 'comment_type' => 'trackback', + ); + $result = $this->gate->verify_form_submission( $commentdata ); + + $this->assertEquals( $commentdata, $result ); + + $_POST = array(); + } + + /** + * Test form submission bypasses for logged-in users. + */ + public function test_verify_form_submission_bypasses_for_logged_in_user() { + global $current_user; + $current_user = new WP_User(); + + $_POST['cardea_nonce'] = ''; + + $commentdata = array( + 'comment_post_ID' => 1, + 'comment_type' => 'comment', + ); + $result = $this->gate->verify_form_submission( $commentdata ); + + $this->assertEquals( $commentdata, $result ); + + $current_user = null; + $_POST = array(); + } + + /** + * Test form submission validates for logged-out users with comment type 'comment'. + */ + public function test_verify_form_submission_validates_for_logged_out_comment_type() { + $_POST['cardea_nonce'] = ''; + $_POST['cardea_timestamp'] = '1234567890'; + $_POST['cardea_salt'] = 'testsalt'; + $_POST['cardea_solution'] = ''; + $_POST['cardea_signature'] = 'testsig'; + + $commentdata = array( + 'comment_post_ID' => 1, + 'comment_type' => 'comment', + ); + + $this->expectException( Exception::class ); + $this->expectExceptionMessage( Cardea_Core::failure_message() ); + + $this->gate->verify_form_submission( $commentdata ); + } + + /** + * Test REST submission bypasses for moderators. + */ + public function test_verify_rest_submission_bypasses_for_moderator() { + global $current_user; + $current_user = new WP_User(); + $current_user->caps = array( 'moderate_comments' => true ); + + $request = $this->createMockWP_REST_Request( 'comment', 1 ); + $result = $this->gate->verify_rest_submission( array(), $request ); + + $this->assertEquals( array(), $result ); + + $current_user = null; + } + + /** + * Test REST submission bypasses for pingbacks. + */ + public function test_verify_rest_submission_bypasses_for_pingback() { + $request = $this->createMockWP_REST_Request( 'pingback', 1 ); + $result = $this->gate->verify_rest_submission( array(), $request ); + + $this->assertEquals( array(), $result ); + } + + /** + * Test REST submission bypasses for trackbacks. + */ + public function test_verify_rest_submission_bypasses_for_trackback() { + $request = $this->createMockWP_REST_Request( 'trackback', 1 ); + $result = $this->gate->verify_rest_submission( array(), $request ); + + $this->assertEquals( array(), $result ); + } + + /** + * Test REST submission bypasses for logged-in users. + */ + public function test_verify_rest_submission_bypasses_for_logged_in_user() { + global $current_user; + $current_user = new WP_User(); + + $request = $this->createMockWP_REST_Request( 'comment', 1 ); + $result = $this->gate->verify_rest_submission( array(), $request ); + + $this->assertEquals( array(), $result ); + + $current_user = null; + } + + /** + * Test REST submission rejects anonymous users without challenge fields. + */ + public function test_verify_rest_submission_rejects_missing_fields() { + $request = $this->createMockWP_REST_Request( 'comment', 1 ); + $result = $this->gate->verify_rest_submission( array(), $request ); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertEquals( 'cardea_verification_failed', $result->get_error_code() ); + $this->assertEquals( 403, $this->error_status( $result ) ); + } + + /** + * Test REST submission rejects anonymous submissions with an invalid nonce. + */ + public function test_verify_rest_submission_rejects_invalid_nonce() { + $request = $this->createMockWP_REST_Request( + 'comment', + 1, + array( + 'cardea_nonce' => 'invalid_nonce', + 'cardea_timestamp' => (string) time(), + 'cardea_salt' => 'testsalt', + 'cardea_solution' => '12345', + 'cardea_signature' => 'testsig', + ) + ); + + $result = $this->gate->verify_rest_submission( array(), $request ); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertEquals( 'cardea_verification_failed', $result->get_error_code() ); + } + + /** + * Test REST submission rejects anonymous submissions with an invalid solution. + */ + public function test_verify_rest_submission_rejects_invalid_solution() { + $challenge = $this->core->generate_challenge( 1 ); + + $request = $this->createMockWP_REST_Request( + 'comment', + 1, + array( + 'cardea_nonce' => $challenge['nonce'], + 'cardea_timestamp' => (string) $challenge['timestamp'], + 'cardea_salt' => $challenge['salt'], + 'cardea_solution' => 'invalid', + 'cardea_signature' => $challenge['signature'], + ) + ); + + $result = $this->gate->verify_rest_submission( array(), $request ); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertEquals( 'cardea_verification_failed', $result->get_error_code() ); + } + + /** + * Test REST submission accepts anonymous submissions with a valid PoW solution. + */ + public function test_verify_rest_submission_accepts_valid_solution() { + $challenge = $this->core->generate_challenge( 1 ); + $solution = $this->find_solution( $this->core->build_challenge_string( $challenge ), $challenge['difficulty'] ); + + $request = $this->createMockWP_REST_Request( + 'comment', + 1, + array( + 'cardea_nonce' => $challenge['nonce'], + 'cardea_timestamp' => (string) $challenge['timestamp'], + 'cardea_salt' => $challenge['salt'], + 'cardea_solution' => $solution, + 'cardea_signature' => $challenge['signature'], + ) + ); + + $prepared = array( 'post_id' => 1 ); + $result = $this->gate->verify_rest_submission( $prepared, $request ); + + $this->assertEquals( $prepared, $result ); + } + + /** + * Read the status from a WP_Error data payload (helper for assertions). + * + * @param WP_Error $error The error. + * @return int + */ + private function error_status( $error ) { + $data = $error->get_error_data(); + return is_array( $data ) && isset( $data['status'] ) ? (int) $data['status'] : 0; + } + + /** + * Create a mock WP_REST_Request object. + * + * @param string $comment_type Optional comment type to configure. + * @param int $post_id Optional post ID to configure. + * @param array $params Optional map of additional request parameters. + * @return object + */ + private function createMockWP_REST_Request( $comment_type = null, $post_id = null, $params = array() ) { + return new class( $comment_type, $post_id, $params ) { + private $comment_type; + private $post_id; + private $params; + + public function __construct( $comment_type = null, $post_id = null, $params = array() ) { + $this->comment_type = $comment_type; + $this->post_id = $post_id; + $this->params = $params; + } + + public function get_param( $param ) { + if ( 'comment_type' === $param ) { + return $this->comment_type; + } + if ( 'post_id' === $param ) { + return $this->post_id; + } + return isset( $this->params[ $param ] ) ? $this->params[ $param ] : null; + } + }; + } + + /** + * Find a valid solution for the given challenge (for testing). + * + * @param string $challenge Challenge string. + * @param int $difficulty Difficulty level. + * @return string The solution. + */ + private function find_solution( $challenge, $difficulty ) { + $counter = 0; + while ( true ) { + $hash = hash( 'sha256', $challenge . $counter ); + $prefix = str_repeat( '0', $difficulty ); + if ( strpos( $hash, $prefix ) === 0 ) { + return (string) $counter; + } + $counter++; + if ( $counter > 1000000 ) { + $this->fail( 'Could not find solution within timeout' ); + } + } + } +} diff --git a/tests/phpunit/Cardea_Core_Test.php b/tests/phpunit/Cardea_Core_Test.php index e5180d3..0173657 100644 --- a/tests/phpunit/Cardea_Core_Test.php +++ b/tests/phpunit/Cardea_Core_Test.php @@ -207,353 +207,46 @@ public function test_replay_attack_prevention() { } /** - * Test verify_comment_pow triggers wp_die for missing fields. - */ - public function test_verify_comment_pow_missing_fields() { - $_POST['cardea_nonce'] = ''; - $_POST['cardea_timestamp'] = '1234567890'; - $_POST['cardea_salt'] = 'testsalt'; - $_POST['cardea_solution'] = ''; - $_POST['cardea_signature'] = 'testsig'; - - $this->expectException( Exception::class ); - $this->expectExceptionMessage( Cardea_Core::failure_message() ); - - $this->core->verify_comment_pow( array() ); - } - - /** - * Test verify_comment_pow triggers wp_die for invalid nonce. - */ - public function test_verify_comment_pow_invalid_nonce() { - $_POST['cardea_nonce'] = 'invalid_nonce'; - $_POST['cardea_timestamp'] = (string) time(); - $_POST['cardea_salt'] = 'testsalt'; - $_POST['cardea_solution'] = '12345'; - $_POST['cardea_signature'] = 'testsig'; - - $this->expectException( Exception::class ); - $this->expectExceptionMessage( Cardea_Core::failure_message() ); - - $this->core->verify_comment_pow( array() ); - } - - /** - * Test verify_comment_pow triggers wp_die for invalid solution. - */ - public function test_verify_comment_pow_invalid_solution() { - $challenge = $this->core->generate_challenge( 1 ); - - $_POST['cardea_nonce'] = $challenge['nonce']; - $_POST['cardea_timestamp'] = (string) $challenge['timestamp']; - $_POST['cardea_salt'] = $challenge['salt']; - $_POST['cardea_solution'] = 'invalid'; - $_POST['cardea_signature'] = $challenge['signature']; - - $this->expectException( Exception::class ); - $this->expectExceptionMessage( Cardea_Core::failure_message() ); - - $this->core->verify_comment_pow( array() ); - } - - /** - * Test verify_comment_pow accepts valid submission. - */ - public function test_verify_comment_pow_success() { - $challenge = $this->core->generate_challenge( 1 ); - $challenge_string = $this->core->build_challenge_string( $challenge ); - $solution = $this->find_solution( $challenge_string, $challenge['difficulty'] ); - - $_POST['cardea_nonce'] = $challenge['nonce']; - $_POST['cardea_timestamp'] = (string) $challenge['timestamp']; - $_POST['cardea_salt'] = $challenge['salt']; - $_POST['cardea_solution'] = $solution; - $_POST['cardea_signature'] = $challenge['signature']; - - $commentdata = array( 'comment_post_ID' => 1 ); - $result = $this->core->verify_comment_pow( $commentdata ); - - $this->assertEquals( $commentdata, $result ); - - // Clean up - $_POST = array(); - } - - /** - * Test verify_comment_pow bypasses for users with moderate_comments capability. - */ - public function test_verify_comment_pow_bypasses_for_moderator() { - global $current_user; - $current_user = new WP_User(); - $current_user->caps = array( 'moderate_comments' => true ); - - $_POST['cardea_nonce'] = ''; - - $commentdata = array( 'comment_post_ID' => 1 ); - $result = $this->core->verify_comment_pow( $commentdata ); - - $this->assertEquals( $commentdata, $result ); - - $_POST = array(); - $current_user = null; - } - - /** - * Test verify_comment_pow bypasses for pingback comment type. - */ - public function test_verify_comment_pow_bypasses_for_pingback() { - global $current_user; - $current_user = null; - - $_POST['cardea_nonce'] = ''; - - $commentdata = array( - 'comment_post_ID' => 1, - 'comment_type' => 'pingback', - ); - $result = $this->core->verify_comment_pow( $commentdata ); - - $this->assertEquals( $commentdata, $result ); - - $_POST = array(); - } - - /** - * Test verify_comment_pow bypasses for trackback comment type. - */ - public function test_verify_comment_pow_bypasses_for_trackback() { - global $current_user; - $current_user = null; - - $_POST['cardea_nonce'] = ''; - - $commentdata = array( - 'comment_post_ID' => 1, - 'comment_type' => 'trackback', - ); - $result = $this->core->verify_comment_pow( $commentdata ); - - $this->assertEquals( $commentdata, $result ); - - $_POST = array(); - } - - /** - * Test verify_comment_pow bypasses for logged-in user. - */ - public function test_verify_comment_pow_bypasses_for_logged_in_user() { - global $current_user; - $current_user = new WP_User(); - - $_POST['cardea_nonce'] = ''; - - $commentdata = array( - 'comment_post_ID' => 1, - 'comment_type' => 'comment', - ); - $result = $this->core->verify_comment_pow( $commentdata ); - - $this->assertEquals( $commentdata, $result ); - - $_POST = array(); - $current_user = null; - } - - /** - * Test verify_comment_pow validates for logged-out user with comment type 'comment'. - */ - public function test_verify_comment_pow_validates_for_logged_out_comment_type() { - global $current_user; - $current_user = null; - - $_POST['cardea_nonce'] = ''; - $_POST['cardea_timestamp'] = '1234567890'; - $_POST['cardea_salt'] = 'testsalt'; - $_POST['cardea_solution'] = ''; - $_POST['cardea_signature'] = 'testsig'; - - $commentdata = array( - 'comment_post_ID' => 1, - 'comment_type' => 'comment', - ); - - $this->expectException( Exception::class ); - $this->expectExceptionMessage( Cardea_Core::failure_message() ); - - $this->core->verify_comment_pow( $commentdata ); - - $_POST = array(); - } - - /** - * Test verify_rest_comment bypasses for moderators. - */ - public function test_verify_rest_comment_bypasses_for_moderator() { - global $current_user; - $current_user = new WP_User(); - $current_user->caps = array( 'moderate_comments' => true ); - - $request = $this->createMockWP_REST_Request(); - $result = $this->core->verify_rest_comment( array(), $request ); - - $this->assertEquals( array(), $result ); - - $current_user = null; - } - - /** - * Test verify_rest_comment bypasses for pingbacks. - */ - public function test_verify_rest_comment_bypasses_for_pingback() { - global $current_user; - $current_user = null; - - $request = $this->createMockWP_REST_Request( 'pingback' ); - $result = $this->core->verify_rest_comment( array(), $request ); - - $this->assertEquals( array(), $result ); - } - - /** - * Test verify_rest_comment bypasses for trackbacks. + * Test rest_get_challenge returns valid challenge data. */ - public function test_verify_rest_comment_bypasses_for_trackback() { - global $current_user; - $current_user = null; - - $request = $this->createMockWP_REST_Request( 'trackback' ); - $result = $this->core->verify_rest_comment( array(), $request ); + public function test_rest_get_challenge() { + $request = $this->createMockWP_REST_Request( null, 1 ); + $result = $this->core->rest_get_challenge( $request ); - $this->assertEquals( array(), $result ); + $this->assertIsArray( $result ); + $this->assertArrayHasKey( 'nonce', $result ); + $this->assertArrayHasKey( 'timestamp', $result ); + $this->assertArrayHasKey( 'salt', $result ); + $this->assertArrayHasKey( 'signature', $result ); + $this->assertArrayHasKey( 'difficulty', $result ); } /** - * Test verify_rest_comment bypasses for logged-in users. + * Test rest_get_challenge works without post_id. */ - public function test_verify_rest_comment_bypasses_for_logged_in_user() { - global $current_user; - $current_user = new WP_User(); - + public function test_rest_get_challenge_without_post_id() { $request = $this->createMockWP_REST_Request(); - $result = $this->core->verify_rest_comment( array(), $request ); - - $this->assertEquals( array(), $result ); - - $current_user = null; - } - - /** - * Test verify_rest_comment blocks anonymous users without challenge fields. - */ - public function test_verify_rest_comment_blocks_unauthenticated() { - global $current_user; - $current_user = null; - - $request = $this->createMockWP_REST_Request( 'comment' ); - $result = $this->core->verify_rest_comment( array(), $request ); - - $this->assertInstanceOf( WP_Error::class, $result ); - $this->assertEquals( 'cardea_missing_fields', $result->get_error_code() ); - } - - /** - * Test verify_rest_comment accepts anonymous submissions with a valid PoW solution. - */ - public function test_verify_rest_comment_accepts_valid_solution() { - global $current_user; - $current_user = null; - - $challenge = $this->core->generate_challenge( 1 ); - $solution = $this->find_solution( $this->core->build_challenge_string( $challenge ), $challenge['difficulty'] ); - - $request = $this->createMockWP_REST_Request( - 'comment', - 1, - array( - 'cardea_nonce' => $challenge['nonce'], - 'cardea_timestamp' => (string) $challenge['timestamp'], - 'cardea_salt' => $challenge['salt'], - 'cardea_solution' => $solution, - 'cardea_signature' => $challenge['signature'], - ) - ); - - $prepared = array( 'post_id' => 1 ); - $result = $this->core->verify_rest_comment( $prepared, $request ); - - $this->assertEquals( $prepared, $result ); - } - - /** - * Test verify_rest_comment rejects anonymous submissions with an invalid solution. - */ - public function test_verify_rest_comment_rejects_invalid_solution() { - global $current_user; - $current_user = null; - - $challenge = $this->core->generate_challenge( 1 ); - - $request = $this->createMockWP_REST_Request( - 'comment', - 1, - array( - 'cardea_nonce' => $challenge['nonce'], - 'cardea_timestamp' => (string) $challenge['timestamp'], - 'cardea_salt' => $challenge['salt'], - 'cardea_solution' => 'invalid', - 'cardea_signature' => $challenge['signature'], - ) - ); - - $result = $this->core->verify_rest_comment( array(), $request ); - - $this->assertInstanceOf( WP_Error::class, $result ); - $this->assertEquals( 'cardea_invalid', $result->get_error_code() ); - } - - /** - * Test verify_rest_comment rejects anonymous submissions with an invalid nonce. - */ - public function test_verify_rest_comment_rejects_invalid_nonce() { - global $current_user; - $current_user = null; - - $request = $this->createMockWP_REST_Request( - 'comment', - 1, - array( - 'cardea_nonce' => 'invalid_nonce', - 'cardea_timestamp' => (string) time(), - 'cardea_salt' => 'testsalt', - 'cardea_solution' => '12345', - 'cardea_signature' => 'testsig', - ) - ); - - $result = $this->core->verify_rest_comment( array(), $request ); + $result = $this->core->rest_get_challenge( $request ); - $this->assertInstanceOf( WP_Error::class, $result ); - $this->assertEquals( 'cardea_security_check', $result->get_error_code() ); + $this->assertIsArray( $result ); + $this->assertArrayHasKey( 'nonce', $result ); } /** - * Create a mock WP_REST_Request object. + * Create a minimal mock request object (for the REST challenge endpoint). * * @param string $comment_type Optional comment type to configure. * @param int $post_id Optional post ID to configure. - * @param array $params Optional map of additional request parameters. * @return object */ - private function createMockWP_REST_Request( $comment_type = null, $post_id = null, $params = array() ) { - return new class( $comment_type, $post_id, $params ) { + private function createMockWP_REST_Request( $comment_type = null, $post_id = null ) { + return new class( $comment_type, $post_id ) { private $comment_type; private $post_id; - private $params; - public function __construct( $comment_type = null, $post_id = null, $params = array() ) { + public function __construct( $comment_type = null, $post_id = null ) { $this->comment_type = $comment_type; - $this->post_id = $post_id; - $this->params = $params; + $this->post_id = $post_id; } public function get_param( $param ) { @@ -563,37 +256,11 @@ public function get_param( $param ) { if ( $param === 'post_id' ) { return $this->post_id; } - return isset( $this->params[ $param ] ) ? $this->params[ $param ] : null; + return null; } }; } - /** - * Test rest_get_challenge returns valid challenge data. - */ - public function test_rest_get_challenge() { - $request = $this->createMockWP_REST_Request( null, 1 ); - $result = $this->core->rest_get_challenge( $request ); - - $this->assertIsArray( $result ); - $this->assertArrayHasKey( 'nonce', $result ); - $this->assertArrayHasKey( 'timestamp', $result ); - $this->assertArrayHasKey( 'salt', $result ); - $this->assertArrayHasKey( 'signature', $result ); - $this->assertArrayHasKey( 'difficulty', $result ); - } - - /** - * Test rest_get_challenge works without post_id. - */ - public function test_rest_get_challenge_without_post_id() { - $request = $this->createMockWP_REST_Request(); - $result = $this->core->rest_get_challenge( $request ); - - $this->assertIsArray( $result ); - $this->assertArrayHasKey( 'nonce', $result ); - } - /** * Find a valid solution for the given challenge (for testing). * diff --git a/tests/phpunit/bootstrap.php b/tests/phpunit/bootstrap.php index 9b91012..d9db20f 100644 --- a/tests/phpunit/bootstrap.php +++ b/tests/phpunit/bootstrap.php @@ -24,13 +24,24 @@ define( 'ABSPATH', '/var/www/html/' ); define( 'CARDEA_PLUGIN_DIR', __DIR__ . '/../../' ); define( 'CARDEA_PLUGIN_URL', 'https://example.com/wp-content/plugins/cardea/' ); -define( 'CARDEA_VERSION', '1.0.1' ); define( 'CARDEA_DEFAULT_DIFFICULTY', 4 ); define( 'CARDEA_DEFAULT_WINDOW', 30 ); +/* + * Single source of truth: the plugin version is parsed from the cardea.php + * header instead of being re-typed here. + */ +$cardea_header = file_get_contents( CARDEA_PLUGIN_DIR . 'cardea.php' ); +if ( preg_match( '/^\s*\*\s*Version:\s*(\S+)/m', $cardea_header, $cardea_matches ) ) { + define( 'CARDEA_VERSION', $cardea_matches[1] ); +} else { + define( 'CARDEA_VERSION', '0.0.0' ); +} + require_once __DIR__ . '/../../vendor/autoload.php'; require_once CARDEA_PLUGIN_DIR . 'includes/class-cardea-core.php'; +require_once CARDEA_PLUGIN_DIR . 'includes/class-cardea-comment-gate.php'; function get_option( $option, $default = false ) { global $wp_options;