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 52dbb63..d4740d3 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()
);
}
@@ -199,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' ) );
}
@@ -231,95 +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
- */
- 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;
- }
-
- return new WP_Error(
- 'cardea_missing_fields',
- __( 'Missing challenge fields.', 'cardea' ),
- array( 'status' => 403 )
- );
- }
-
- /**
- * 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 ) ) {
- wp_die(
- esc_html__( 'Missing challenge fields.', 'cardea' ),
- esc_html__( 'PoW Verification Failed', 'cardea' ),
- array( 'response' => 403 )
- );
- }
-
- if ( ! wp_verify_nonce( $nonce, 'cardea_challenge' ) ) {
- wp_die(
- esc_html__( 'Security check failed.', 'cardea' ),
- esc_html__( 'PoW Verification Failed', 'cardea' ),
- array( 'response' => 403 )
- );
- }
-
- $challenge = array(
- 'nonce' => $nonce,
- 'timestamp' => $timestamp,
- 'salt' => $salt,
- 'signature' => $signature,
- 'difficulty' => (int) get_option( self::OPTION_DIFFICULTY, CARDEA_DEFAULT_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 )
- );
- }
-
- return $commentdata;
- }
}
diff --git a/readme.txt b/readme.txt
index 144c928..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** - Flawlessly protects frontend forms and blocks XML-RPC botnets, while seamlessly allowing native Trackbacks and authenticated REST API 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.
@@ -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. 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 3d43d0f..7cc28f5 100644
--- a/tests/e2e/api-comments.spec.js
+++ b/tests/e2e/api-comments.spec.js
@@ -22,48 +22,35 @@
*/
const { test, expect } = require('@playwright/test');
-const { runCLI } = require('@wp-playground/cli');
+const { startPlayground } = require('./support/playground');
+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;
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 () => {
@@ -89,6 +76,52 @@ test.describe('Cardea - REST API Comment Protection', () => {
expect(status).toBeLessThan(300);
});
+ 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,
+ author_name: 'Anonymous',
+ author_email: 'anon@example.com',
+ content: 'Anonymous comment without challenge fields.'
+ }
+ });
+
+ expect(response.status()).toBe(401);
+ const body = await response.text();
+ expect(body).toContain('rest_comment_login_required');
+ });
+
+ 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();
+ 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(401);
+ const body = await response.text();
+ expect(body).toContain('rest_comment_login_required');
+ });
+
test('should allow pingbacks via REST API', async ({ request }) => {
const response = await request.post(`${cli.serverUrl}/wp-json/wp/v2/comments`, {
data: {
@@ -101,6 +134,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..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 () => {
@@ -131,7 +95,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 +121,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 +147,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 +211,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 }) => {
@@ -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) {
@@ -365,7 +291,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 +300,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/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 7b00593..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 () => {
@@ -87,7 +51,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 +68,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..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 () => {
@@ -72,8 +36,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 () => {
+ // 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
@@ -95,20 +69,24 @@ test.describe('Cardea - XML-RPC Comment Protection', () => {
`;
- 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();
- // 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);
+ // Depending on how the Playground WASM transport delivers the request,
+ // xmlrpc.php either (a) rejects it at the transport layer or (b) answers
+ // with a structured XML-RPC fault for the unknown method. Both prove the
+ // point; a success envelope (fault-free methodResponse) must not appear.
+ const rejected = response.status !== 200 ||
+ responseBody.includes('XML-RPC server accepts POST requests only') ||
+ responseBody.includes(' {
+ test('XML-RPC pingbacks bypass the PoW gate by design', async () => {
const xmlPayload = `
pingback.ping
@@ -118,12 +96,14 @@ test.describe('Cardea - XML-RPC Comment Protection', () => {
`;
- 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.
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_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 ee72292..0173657 100644
--- a/tests/phpunit/Cardea_Core_Test.php
+++ b/tests/phpunit/Cardea_Core_Test.php
@@ -207,257 +207,33 @@ 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( 'Missing challenge fields.' );
-
- $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( 'Security check failed.' );
-
- $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( 'Proof-of-Work verification failed.' );
-
- $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( 'Missing challenge fields.' );
-
- $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 unauthenticated users.
- */
- 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 );
+ $result = $this->core->rest_get_challenge( $request );
- $this->assertInstanceOf( WP_Error::class, $result );
- $this->assertEquals( 'cardea_missing_fields', $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.
@@ -485,32 +261,6 @@ public function get_param( $param ) {
};
}
- /**
- * 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;