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.
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.
LeafSerializer's serialize private function below. This is where the leafData is .htmlEscaped(), and then serialized.https://github.com/vapor/leaf-kit/blob/8ff06839d8b3ddf74032d2ade01e3453eb556d30/Sources/LeafKit/LeafSerialize/LeafSerializer.swift#L60-L66
LeafData.htmlEscaped() method uses the LeafData.string computed property to convert itself to a string. Then, it calls the htmlEscaped() method on it. However, if the string conversion fails, notice that an unescaped, unsafe self is returned (line 324 below):https://github.com/vapor/leaf-kit/blob/8ff06839d8b3ddf74032d2ade01e3453eb556d30/Sources/LeafKit/LeafData/LeafData.swift#L321-L328
.string may return nil, if the escaped value is not a string already, a convesion is attempted, which may fail.https://github.com/vapor/leaf-kit/blob/8ff06839d8b3ddf74032d2ade01e3453eb556d30/Sources/LeafKit/LeafData/LeafData.swift#L211-L216
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.
https://github.com/vapor/leaf-kit/blob/8ff06839d8b3ddf74032d2ade01e3453eb556d30/Sources/LeafKit/LeafData/LeafData.swift#L295-L319
LeafSerializer's serialize private method, we are now interested in finding out what happens after LeafData.htmlEscaped() returns self. Recall from 1. that the output was then .serialized(). Thus, the unescaped LeafData follows the normal serialization path, as if it were HTML-escaped. More specifically, serialization is done here, where .map / .mapValues is called, unsafely serializing each element of the dictionary.<!-- 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 twice (with alert(1337) and alert(31337)). var someDict: [String: String] could also be replaced with an array / dictionary of a different type, such as another Encodable stuct.
Also note that, in a real concerning scenario, the array / dictionary would contain (i.e. reflect) data inputted by the user.
This is a cross-site scripting (XSS) vulnerability in rendered Leaf templates. Vapor/Leaf applications that render user-controlled data inside arrays or dictionaries using #(value) may be impacted.
{
"nvd_published_at": "2026-03-18T02:16:24Z",
"severity": "MODERATE",
"github_reviewed": true,
"cwe_ids": [
"CWE-116",
"CWE-79",
"CWE-80"
],
"github_reviewed_at": "2026-03-16T16:22:56Z"
}