{"id":"GHSA-6jj5-j4j8-8473","summary":"LeafKit's HTML escaping may be skipped for Collection values, enabling XSS","details":"### Summary\nLeafKit 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.\n\n### Details\nLeafKit attempts to escape expressions during serialization, but due to [`LeafData.htmlEscaped()`](https://github.com/vapor/leaf-kit/blob/8ff06839d8b3ddf74032d2ade01e3453eb556d30/Sources/LeafKit/LeafData/LeafData.swift#L322)'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.\n\n\u003e **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.\n\n#### The issue's detailed analysis:\n1. Leaf expression serialization eventually reaches `LeafSerializer`'s `serialize` private function below.  This is where the `leafData` is `.htmlEscaped()`, and then serialized.\n\nhttps://github.com/vapor/leaf-kit/blob/8ff06839d8b3ddf74032d2ade01e3453eb556d30/Sources/LeafKit/LeafSerialize/LeafSerializer.swift#L60-L66\n\n2. The `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):\n\nhttps://github.com/vapor/leaf-kit/blob/8ff06839d8b3ddf74032d2ade01e3453eb556d30/Sources/LeafKit/LeafData/LeafData.swift#L321-L328\n\n\n3. Regarding why `.string` may return nil, if the escaped value is not a string already, a convesion is attempted, which may fail.\n\nhttps://github.com/vapor/leaf-kit/blob/8ff06839d8b3ddf74032d2ade01e3453eb556d30/Sources/LeafKit/LeafData/LeafData.swift#L211-L216\n\nIn this specific case, the conversion fails at line 303 below, when `conversion.is \u003e= level` is checked. The check fails because [`.array` and `.dictionary` conversions to `.string` are deemed `.ambiguous`](https://github.com/vapor/leaf-kit/blob/8ff06839d8b3ddf74032d2ade01e3453eb556d30/Sources/LeafKit/LeafData/LeafData.swift#L525-L535). If we forcefully allow ambiguous conversions, the vulnerability disappears, as the conversion is successful.\n\nhttps://github.com/vapor/leaf-kit/blob/8ff06839d8b3ddf74032d2ade01e3453eb556d30/Sources/LeafKit/LeafData/LeafData.swift#L295-L319\n\n5. Coming back to `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](https://github.com/vapor/leaf-kit/blob/8ff06839d8b3ddf74032d2ade01e3453eb556d30/Sources/LeafKit/LeafData/LeafDataStorage.swift#L52-L63), where `.map` / `.mapValues` is called, unsafely serializing each element of the dictionary.\n\n### PoC\n\u003c!-- _Complete instructions, including specific configuration details, to reproduce the vulnerability._ --\u003e\n\nIn a new Vapor project created with `vapor new poc -n --leaf`, use a simple leaf template like the following:\n```html\n\u003c!doctype html\u003e\n\u003chtml\u003e\n    \u003cbody\u003e\n    \u003ch1\u003e#(username)\u003c/h1\u003e\n      \u003ch2\u003esomeDict:\u003c/h2\u003e\n      \u003cp\u003e#(someDict)\u003c/p\u003e\n  \u003c/body\u003e\n\u003c/html\u003e\n```\n\nAnd the following `routes.swift`:\n```swift\nimport Vapor\n\nstruct User: Encodable {\n    var username: String\n    var someDict: [String: String]\n}\n\nfunc routes(_ app: Application) throws {\n    app.get { req async throws in\n        try await req.view.render(\"index\", User(\n            username: \"Escaped XSS - \u003cimg src=x onerror=alert(1)\u003e\",\n            someDict: [\"\u003cimg src=x onerror=alert(1337)\u003e\":\"\u003cimg src=x onerror=alert(31337)\u003e\"]\n        ))\n    }\n}\n\n```\n\nBy 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.\n\nAlso note that, in a real concerning scenario, the array / dictionary would contain (i.e. reflect) data inputted by the user.\n\n### Impact\nThis 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.","aliases":["CVE-2026-28499"],"modified":"2026-05-07T13:36:23.511908Z","published":"2026-03-16T16:22:56Z","database_specific":{"nvd_published_at":"2026-03-18T02:16:24Z","github_reviewed_at":"2026-03-16T16:22:56Z","severity":"MODERATE","cwe_ids":["CWE-116","CWE-79","CWE-80"],"github_reviewed":true},"references":[{"type":"WEB","url":"https://github.com/vapor/leaf-kit/security/advisories/GHSA-6jj5-j4j8-8473"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-28499"},{"type":"WEB","url":"https://github.com/vapor/leaf-kit/commit/6044b844caa858a0c5f2505ac166f5a057c990dc"},{"type":"PACKAGE","url":"https://github.com/vapor/leaf-kit"},{"type":"WEB","url":"https://github.com/vapor/leaf-kit/releases/tag/1.14.2"}],"affected":[{"package":{"name":"github.com/vapor/leaf-kit","ecosystem":"SwiftURL","purl":"pkg:swift/github.com/vapor/leaf-kit"},"ranges":[{"type":"SEMVER","events":[{"introduced":"0"},{"fixed":"1.14.2"}]}],"database_specific":{"source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/03/GHSA-6jj5-j4j8-8473/GHSA-6jj5-j4j8-8473.json"}}],"schema_version":"1.7.5","severity":[{"type":"CVSS_V4","score":"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"}]}