Introduction
An unauthenticated attacker could plant markup in a TranslatePress site's translation dictionary and have it served back as live HTML on the site's own origin. The plugin's WordPress.org listing reports more than 400,000 active installations, so multilingual WordPress estates should confirm that the fix in version 3.3.9 is in place.
TranslatePress is a WordPress plugin for translating sites visually from the front end, with built-in AI translation. It is developed by Cozmoslabs, and the legal product owner is S.C. Reflection Media S.R.L. of Timisoara, Romania. The 400,000+ install count gives any flaw in the plugin a large potential footprint, although that figure says nothing about how many sites remain unpatched.
Wordfence published the advisory on November 6, 2026, and NVD followed on November 7. The reviewed material identifies no exploitation campaign or threat actor. That reflects only what has been publicly reported. It does not show that the flaw has gone unused.
Technical information
At a glance
- Weakness: CWE-79, Improper Neutralization of Input During Web Page Generation
- CVSS 3.1: 7.2 (High), vector
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:L/I:L/A:N - Privileges required: none (unauthenticated)
- Affected: all versions up to and including 3.3.8
- Fixed: 3.3.9
- Researcher: Klaudia Gackowska
Two stages: storage and exfiltration
The Wordfence advisory splits the issue into two parts:
- Storage: the
commentparameter, meaning content from a comment attribute, ends up stored as an original string in the translation dictionary. - Exfiltration: that content comes back out through the
trp-ajax.phpdictionary endpoint, driven by theoriginals,languageandoriginal_languageparameters.
The advisory gives the root cause as insufficient input sanitization and output escaping. The output side is the more interesting half, and it is the half the patch addresses.
The sink: a standalone endpoint outside WordPress
trp-ajax.php is a standalone endpoint for fast translation of dynamic strings, and it does not load WordPress. Instead it reads database credentials directly from wp-config.php.
In 3.3.8, the constructor does perform several input checks:
- It requires the POST action
trp_get_translations_domchanges. - It requires nonempty
originals,languageandoriginal_languagevalues. - It validates both language codes before continuing.
- It JSON-decodes the submitted originals, trims them and passes them through
mysqli_real_escape_string().
output_translations() then queries the trp_dictionary_<from>_<to> table for the requested language pair. It retrieves the original, translated and status columns, sorts the results and echoes them with json_encode(). The 3.3.8 output calls looked like this:
echo json_encode( 'TranslatePress Error: Invalid language code' );
echo json_encode($dictionary_by_original);
echo json_encode( 'error' );
Together, these produced two weaknesses:
- No
Content-Typeheader was sent. PHP therefore fell back to its defaulttext/html. A browser that opened the response as a top-level document parsed it as HTML. - Plain
json_encode()leaves<,>,&,'and"unescaped. A dictionary original such as an<svg onload=...>fragment taken from a comment attribute therefore appeared in the response body as literal, live markup.
The input checks are real, but they solve a different problem. Language validation and SQL string escaping protect the query. They do nothing for the browser context the response lands in. Version 3.3.8 had all of those checks and was still vulnerable, because validating a request does not make the response safe.
Attack flow
Based on the advisory and the patch analysis, the chain works like this:
- An unauthenticated attacker gets content containing HTML, carried in a comment attribute, stored as an original in a
trp_dictionary_*table. - A request to
trp-ajax.phpwith actiontrp_get_translations_domchangesand validoriginals,languageandoriginal_languagevalues causes the endpoint to look up that original. - The endpoint echoes the matching dictionary rows via
json_encode()with no explicit content type, so the response is served astext/htmlwith the stored markup intact. - When a user's browser renders that response as a document, the injected script runs on the site's origin.
The advisory does not publish a complete exploit sequence or payload. It also does not detail how a victim's browser is brought to the rendered response. The NVD record additionally references functions.php (line 206) and class-translation-render.php (line 1061) in the 3.3.8 tag as relevant code locations.
A note on the CVSS vector
The published vector records UI:N. The narrative description, however, says scripts execute when a user accesses an injected page. We reproduce both as published rather than adjusting either. The documented impact is script execution in the victim's browser. Nothing in the reviewed material supports claims of server-side code execution or demonstrated site takeover.
Patch information
The fix ships in TranslatePress 3.3.9. Wordfence cites WordPress.org changeset 3781398 against includes/trp-ajax.php. The vendor changelog describes it as: Fixed XSS by serving trp-ajax.php responses as inert JSON.
The Trac changeset page blocked automated access, so the changes below come from comparing includes/trp-ajax.php in the SVN tags 3.3.8 and 3.3.9. The patch is output-side defense in depth, in two layers.
Layer 1: hex-escape HTML-significant characters inside the JSON
A new class constant collects the JSON_HEX_* flags:
const SAFE_JSON_FLAGS = JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT;
It is applied to every json_encode() call in the file: the invalid language error, the dictionary output and return_error():
echo json_encode( 'TranslatePress Error: Invalid language code', self::SAFE_JSON_FLAGS );
echo json_encode( $dictionary_by_original, self::SAFE_JSON_FLAGS );
echo json_encode( 'error', self::SAFE_JSON_FLAGS );
With these flags, the encoder rewrites the dangerous characters:
<becomes\u003Cand>becomes\u003E.&becomes\u0026.- Quotes become
\u0027and\u0022.
The raw bytes can no longer form tags or break out of attributes. JSON.parse() restores the original string, so the plugin's own dynamic translation script still receives the exact text. The code comment notes that legitimate consumers are unaffected.
Layer 2: make the response inert at the browser level
The constructor now sets an explicit content type and disables MIME sniffing. This happens right after the action, originals and language checks and before any output:
if ( ! headers_sent() ) { header( 'Content-Type: application/json; charset=utf-8' ); header( 'X-Content-Type-Options: nosniff' ); }
The developers' comment explains the reasoning. The file runs outside WordPress and "is directly requestable", so the response must be declared as JSON so the browser never renders it as HTML, whatever the dictionary contains. nosniff prevents the browser from overriding the declared type. According to the comment, these headers together with SAFE_JSON_FLAGS close the stored XSS sink.
Why both layers matter
Either layer alone breaks the chain:
- The header stops HTML rendering at the document level.
- The hex escaping keeps the body free of literal markup even if the header is stripped. It also covers cases where the output lands in an HTML context some other way, for example through a proxy or cache that rewrites headers.
Applying both is the standard hardening pattern for JSON endpoints that sit outside a framework's normal response handling.
What the patch does not do
The fix does not sanitize or reject the malicious comment attribute string at storage time. Hostile originals can still end up in trp_dictionary_* tables. Rows stored before the update remain in the database, but trp-ajax.php now serves them safely.
Other changes in the same release
The 3.3.9 diff of trp-ajax.php also contains an unrelated functional fix: multisite table prefix handling based on a posted blog_id. This matches the changelog entry about multisite dynamic translation.
The 3.3.9 changelog also lists a stored XSS fix for a PCRE backtrack bypass of the URL scheme neutralizer. That is a separate issue, tracked as CVE-2026-119167, and is not part of this CVE's fix.
To get the fix, update TranslatePress to 3.3.9 or later. The reviewed sources document no workaround, firewall rule or configuration change that substitutes for the update.
Affected systems and versions
- Product: TranslatePress (Translate Multilingual sites with AI Translation) for WordPress, plugin slug
translatepress-multilingual - Vulnerable: all versions up to and including 3.3.8
- Patched: 3.3.9 and later
- Vulnerable component:
includes/trp-ajax.php, the standalone dictionary endpoint
Version 3.3.8 shipped a separate fix for unauthenticated stored XSS via #TRPLINKPROCESSED, yet it is explicitly inside the affected range for this CVE. Sites that updated to 3.3.8 for that earlier issue are still exposed to this one.
Vendor security history
The TranslatePress changelog records security work across consecutive releases:
- 3.3.7: prevents XSS through
trp-gettextmarkers hiding JavaScript schemes in URL attributes. - 3.3.8: fixes unauthenticated stored XSS via
#TRPLINKPROCESSED, credited to daroo and the Wordfence team. - 3.3.9: serves
trp-ajax.phpresponses as inert JSON (this CVE). It also fixes a PCRE backtrack bypass of the URL scheme neutralizer (CVE-2026-119167).
The fixes move from marker and URL handling to dictionary response handling. That suggests several distinct security-sensitive boundaries in the plugin rather than a single recurring bug. The vendor publishes its fixes openly. Each release still needs to be checked against the exact advisory, because an earlier XSS fix did not cover this issue.
References
- NVD: CVE-2026-120537
- CVE.org record: CVE-2026-120537
- Wordfence advisory: TranslatePress 3.3.8 and earlier, unauthenticated stored XSS via the trp-ajax.php dictionary endpoint
- WordPress.org changeset 3781398 for trp-ajax.php
- trp-ajax.php in SVN tag 3.3.9 (patched)
- trp-ajax.php in SVN tag 3.3.8 (vulnerable)
- trp-ajax.php 3.3.8, line 20
- trp-ajax.php 3.3.8, line 257
- functions.php 3.3.8, line 206
- class-translation-render.php 3.3.8, line 1061
- TranslatePress Free changelog
- TranslatePress on WordPress.org
- About TranslatePress
- TranslatePress at Cozmoslabs
- VulDB entry for CVE-2026-120537
- CISA Known Exploited Vulnerabilities feed



