CVE-2026-97279 Choosing both the placeholder and its replacement in Polylang
Intrudify's autonomous pentesting engine discovered a stored cross-site scripting vulnerability in Polylang, affecting all versions up to and including 3.8.9. The plugin registers custom attributes on core navigation blocks and, at render time, replaces a placeholder string in the block markup with the raw pll_flag value. A Contributor controls where the placeholder sits and what replaces it.
Summary
Polylang is WordPress's most widely installed multilingual plugin. Intrudify's autonomous testing engine found that it registers six custom attributes on the core navigation-link and navigation-submenu blocks and filters their rendering, and that one of those attributes is concatenated into the output with no escaping.
A Contributor can add a core navigation block to a post and set arbitrary attributes on it through the block editor or the REST API, so both halves of the substitution are under attacker control.
Technical detail
The filter and the sink
In src/modules/Blocks/Language_Switcher/Navigation/Block.php, Polylang hooks the render of both core navigation blocks, and the handler runs whenever those six attributes are present:
if ( $instance->attributes['pll_show_flags'] ) {
$link_label .= $instance->attributes['pll_flag']; // raw, no escaping
}
...
return str_replace( static::PLACEHOLDER, $link_label, $overridden_block_content );The placeholder is the literal string %pll%. There is no check that pll_flag is a known flag code, and the only guard is an isset() over the six attributes, every one of which the attacker sets in the block markup.
Why the attacker controls where the value lands
This is the part worth pausing on, and it is what separates this from an ordinary unescaped output bug. The substitution is a blind string replacement over the already-rendered block. So the attacker chooses the destination as well as the payload: putting %pll% in the block's title places the raw value inside a title="..." attribute.
A pll_flag beginning with a double quote then terminates that attribute, and the rest becomes attributes on the anchor:
<a hreflang="en" lang="en" class="wp-block-navigation-item__content" href="#"
title="" autofocus tabindex="1" onfocus="...">autofocus focuses the anchor as soon as the document loads, so the handler fires with no click and no hover.
Why the payload survives sanitisation
The payload contains no HTML tag, only attribute text, so the wp_kses pass WordPress applies to content saved by users without unfiltered_html removes nothing. We confirmed against the running plugin that the block returned by the REST API is byte for byte what was submitted.
Both preconditions are attacker-supplied rather than environmental. The block is a WordPress core block, not a Polylang one, and Polylang attaches its filter based purely on the presence of its own attributes. Nothing about the site has to be configured for the language switcher; the attacker simply declares the attributes that make the filter run.
Impact
Contributor is the lowest role that can create post content, and it is routinely handed to guest authors. This gives that role arbitrary JavaScript execution in the site origin.
Publication is not required. The payload executes as soon as an administrator or editor opens the preview of the pending submission, which is the ordinary review step for Contributor content, so the script runs inside an authenticated administrator session. From there, same-origin requests to admin-ajax or the REST API are a path to full site compromise. Once the post is published, the same handler executes for every visitor.
We reproduced the whole chain over HTTP alone against a default installation of the latest version with two languages configured through the normal setup.
Remediation
Update Polylang to 3.8.10 or later.
For maintainers, do not concatenate pll_flag into markup. Restrict it to a known flag code, which is the only legitimate value it can hold, or escape it before the substitution: esc_attr() where it lands in attribute text and esc_html() where it lands in element text. Since a blind str_replace cannot know which context it is writing into, the allow-list is the more reliable of the two.
The wider lesson
Placeholder substitution is the interesting part here. Escaping decisions depend on context, and a template that writes a value into a fixed, known location can be escaped correctly because the developer knows what that location is. A blind str_replace over rendered markup has no such knowledge. The same call can write into element text, an attribute value, or a URL, depending on where the placeholder happens to appear.
When the attacker also controls the markup the replacement runs over, they choose the context themselves, and any single escaping function the developer picks will be wrong for some of the possibilities. That is why the allow-list is the right fix rather than adding an esc_attr() call: the underlying problem is that the destination is unknowable at the point of substitution.
There is also a boundary worth naming. Polylang attaches its filter to blocks belonging to WordPress core, keyed on the presence of its own attributes. That is a reasonable extension mechanism, but it means the plugin's rendering code runs on content it did not define, in documents it does not control, with attribute values that core happily persists because they are not core's to validate. Any plugin that filters the render of a core block inherits responsibility for validating everything it reads out of that block.
Disclosure timeline
- 2026-09-22 Vulnerability identified and validated by Intrudify, reported via Patchstack
- 2026-09-30 Public disclosure · CVE-2026-97279 assigned
Questions
What is CVE-2026-97279?
CVE-2026-97279 is a stored cross-site scripting vulnerability in the Polylang WordPress plugin, versions 3.8.9 and earlier. It was discovered autonomously by the Intrudify AI penetration testing engine. Polylang registers custom attributes on core navigation blocks and substitutes the raw pll_flag value into the rendered block wherever a placeholder string appears, with no escaping and no validation that the value is a known flag code.
Does CVE-2026-97279 require the post to be published?
No. The payload executes as soon as an administrator or editor opens the preview of the pending submission, which is the ordinary review step for Contributor content, so the script runs inside an authenticated administrator session. Once published it executes for every visitor.
How do I fix CVE-2026-97279?
Update Polylang to version 3.8.10 or later. The underlying fix is to restrict pll_flag to a known flag code, or to escape it with esc_attr when it lands in attribute text and esc_html when it lands in element text, before the substitution runs.