CVE-2026-28499
LeafKit's HTML escaping may be skipped for Collection values, enabling XSS
LeafKit is a templating language with Swift-inspired syntax. Prior to version 1.14.2, HTML escaping doesn't work correctly when a template prints a collection (Array / Dictionary) via #(value). This can result in XSS, allowing potentially untrusted input to be rendered unescaped. Version 1.14.2 fixes the issue.
Attack profile
How easy it is to exploit
- Attack vector
- Network
- Complexity
- Low
- Privileges needed
- None
- User interaction
- None
What an attacker can affect
- Confidentiality
- None
- Integrity
- None
- Availability
- None
Exploit likelihood
What each source reports
CVE List Score used
6.9
LeafKit's HTML escaping may be skipped for Collection values, enabling XSS
LeafKit is a templating language with Swift-inspired syntax. Prior to version 1.14.2, HTML escaping doesn't work correctly when a template prints a collection (Array / Dictionary) via #(value). This can result in XSS, allowing potentially untrusted input to be rendered unescaped. Version 1.14.2 fixes the issue.
- Record
CVE-2026-28499- CVSS 4.0
- 6.9 Medium
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:N/SC:L/SI:N/SA:N - Weakness
- CWE-79, CWE-80, CWE-116
- Updated
- 2026-11-02
EUVDEUVD-2026-12696
6.9
LeafKit is a templating language with Swift-inspired syntax. Prior to version 1.14.2, HTML escaping doesn't work correctly when a template prints a collection (Array / Dictionary) via #(value). This can result in XSS, allowing potentially untrusted input to be rendered unescaped. Version 1.14.2 fixes the issue.
- Record
EUVD-2026-12696- CVSS 4.0
- 6.9 Medium
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:N/SC:L/SI:N/SA:N - Updated
- 2026-11-02
GitHub AdvisoriesGHSA-6jj5-j4j8-8473
6.9
LeafKit's HTML escaping may be skipped for Collection values, enabling XSS
Summary
LeafKit HTML-escaping is not working correctly when a template prints a collection (Array / Dictionary) via #(value). This can result in XSS, allowing potentially untrusted input to be rendered unescaped.
Details
LeafKit attempts to escape expressions during serialization, but due to LeafData.htmlEscaped()'s implementation, when the escaped type's conversion to String is marked as .ambiguous (as it is the case for Arrays and Dictionaries), an unescaped self is returned.
Note: I recommend first looking at the POC, before taking a look at the details below, as it is simple. In the detailed, verbose analysis below, I explored the functions involved in more detail, in hopes that it will help you understand and locate this issue.
The issue's detailed analysis:
- Leaf expression serialization eventually reaches
LeafSerializer'sserializeprivate function below. This is where theleafDatais.htmlEscaped(), and then serialized.
- The
LeafData.htmlEscaped()method uses theLeafData.stringcomputed property to convert itself to a string. Then, it calls thehtmlEscaped()method on it. However, if the string conversion fails, notice that an unescaped, unsafeselfis returned (line 324 below):
- Regarding why
.stringmay return nil, if the escaped value is not a string already, a convesion is attempted, which may fail.
In this specific case, the conversion fails at line 303 below, when conversion.is >= level is checked. The check fails because .array and .dictionary conversions to .string are deemed .ambiguous. If we forcefully allow ambiguous conversions, the vulnerability disappears, as the conversion is successful.
- Coming back to
LeafSerializer'sserializeprivate method, we are now interested in finding out what happens afterLeafData.htmlEscaped()returns self. Recall from1.that the output was then.serialized(). Thus, the unescapedLeafDatafollows the normal serialization path, as if it were HTML-escaped. More specifically, serialization is done here, where.map/.mapValuesis called, unsafely serializing each element of the dictionary.
PoC
<!-- _Complete instructions, including specific configuration details, to reproduce the vulnerability._ -->
In a new Vapor project created with vapor new poc -n --leaf, use a simple leaf template like the following:
<!doctype html>
<html>
<body>
<h1>#(username)</h1>
<h2>someDict:</h2>
<p>#(someDict)</p>
</body>
</html>
And the following routes.swift:
import Vapor
struct User: Encodable {
var username: String
var someDict: [String: String]
}
func routes(_ app: Application) throws {
app.get { req async throws in
try await req.view.render("index", User(
username: "Escaped XSS - <img src=x onerror=alert(1)>",
someDict: ["<img src=x onerror=alert(1337)>":"<img src=x onerror=alert(31337)>"]
))
}
}
By running and accessing the server in a browser, XSS should be triggered…
- Record
GHSA-6jj5-j4j8-8473- CVSS 4.0
- 6.9 Medium
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:N/SC:L/SI:N/SA:N - Weakness
- CWE-79, CWE-80, CWE-116
- Updated
- 2026-11-02
NVD
6.9
LeafKit is a templating language with Swift-inspired syntax. Prior to version 1.14.2, HTML escaping doesn't work correctly when a template prints a collection (Array / Dictionary) via #(value). This can result in XSS, allowing potentially untrusted input to be rendered unescaped. Version 1.14.2 fixes the issue.
- Record
CVE-2026-28499- CVSS 4.0
- 6.9 Medium
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:N/SC:L/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X - CVSS 3.1
- 6.1 Medium
CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N - Weakness
- CWE-79, CWE-80, CWE-116
- Updated
- 2026-11-02
OSV
6.9
LeafKit's HTML escaping may be skipped for Collection values, enabling XSS
LeafKit is a templating language with Swift-inspired syntax. Prior to version 1.14.2, HTML escaping doesn't work correctly when a template prints a collection (Array / Dictionary) via #(value). This can result in XSS, allowing potentially untrusted input to be rendered unescaped. Version 1.14.2 fixes the issue.
- Record
CVE-2026-28499- CVSS 4.0
- 6.9 Medium
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:N/SC:L/SI:N/SA:N - Weakness
- CWE-116, CWE-79, CWE-80
- Updated
- 2026-11-02
OSVGHSA-6jj5-j4j8-8473
6.9
LeafKit's HTML escaping may be skipped for Collection values, enabling XSS
Summary
LeafKit HTML-escaping is not working correctly when a template prints a collection (Array / Dictionary) via #(value). This can result in XSS, allowing potentially untrusted input to be rendered unescaped.
Details
LeafKit attempts to escape expressions during serialization, but due to LeafData.htmlEscaped()'s implementation, when the escaped type's conversion to String is marked as .ambiguous (as it is the case for Arrays and Dictionaries), an unescaped self is returned.
Note: I recommend first looking at the POC, before taking a look at the details below, as it is simple. In the detailed, verbose analysis below, I explored the functions involved in more detail, in hopes that it will help you understand and locate this issue.
The issue's detailed analysis:
- Leaf expression serialization eventually reaches
LeafSerializer'sserializeprivate function below. This is where theleafDatais.htmlEscaped(), and then serialized.
- The
LeafData.htmlEscaped()method uses theLeafData.stringcomputed property to convert itself to a string. Then, it calls thehtmlEscaped()method on it. However, if the string conversion fails, notice that an unescaped, unsafeselfis returned (line 324 below):
- Regarding why
.stringmay return nil, if the escaped value is not a string already, a convesion is attempted, which may fail.
In this specific case, the conversion fails at line 303 below, when conversion.is >= level is checked. The check fails because .array and .dictionary conversions to .string are deemed .ambiguous. If we forcefully allow ambiguous conversions, the vulnerability disappears, as the conversion is successful.
- Coming back to
LeafSerializer'sserializeprivate method, we are now interested in finding out what happens afterLeafData.htmlEscaped()returns self. Recall from1.that the output was then.serialized(). Thus, the unescapedLeafDatafollows the normal serialization path, as if it were HTML-escaped. More specifically, serialization is done here, where.map/.mapValuesis called, unsafely serializing each element of the dictionary.
PoC
<!-- _Complete instructions, including specific configuration details, to reproduce the vulnerability._ -->
In a new Vapor project created with vapor new poc -n --leaf, use a simple leaf template like the following:
<!doctype html>
<html>
<body>
<h1>#(username)</h1>
<h2>someDict:</h2>
<p>#(someDict)</p>
</body>
</html>
And the following routes.swift:
import Vapor
struct User: Encodable {
var username: String
var someDict: [String: String]
}
func routes(_ app: Application) throws {
app.get { req async throws in
try await req.view.render("index", User(
username: "Escaped XSS - <img src=x onerror=alert(1)>",
someDict: ["<img src=x onerror=alert(1337)>":"<img src=x onerror=alert(31337)>"]
))
}
}
By running and accessing the server in a browser, XSS should be triggered…
- Record
GHSA-6jj5-j4j8-8473- CVSS 4.0
- 6.9 Medium
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:N/SC:L/SI:N/SA:N - Weakness
- CWE-116, CWE-79, CWE-80
- Updated
- 2026-11-02
Affected products
Affected packages
| Package | Affected | Fixed |
|---|---|---|
github.com/vapor/leaf-kitSwiftURL |
before 1.14.2 | 1.14.2 |
History
| Recorded | Changed | Sources |
|---|---|---|
| first indexed | CVE List, EUVD, GitHub Advisories, NVD, OSV |
Dates are when Every CVE recorded each change. Upstream dates may differ.
In short
CVE-2026-28499 is a medium vulnerability (CVSS 6.9) in Vapor Leafkit, published March 16, 2026. No source lists public exploit code. EPSS puts the chance of exploitation in the next 30 days at 0.28%, the 20th percentile. Fixed in github.com/vapor/leaf-kit 1.14.2.
References
github.com/vapor/leaf-kit/security/advisories/GHSA-6jj5-j4j8-8473
github.com/vapor/leaf-kit/commit/6044b844caa858a0c5f2505ac166f5a057c990dc
github.com/vapor/leaf-kit/releases/tag/1.14.2
nvd.nist.gov/vuln/detail/CVE-2026-28499
github.com/advisories/GHSA-6jj5-j4j8-8473
github.com/CVEProject/cvelistV5/tree/main/cves/2026/28xxx/CVE-2026-28499.json
github.com/vapor/leaf-kit
Left out here: the rest of 2 source descriptions. The JSON record has them.