Leaf-kit html escaping does not work on characters that are part of extended grapheme cluster
Description
Leafkit is a templating language with Swift-inspired syntax. Prior to 1.4.1, htmlEscaped in leaf-kit will only escape html special characters if the extended grapheme clusters match, which allows bypassing escaping by using an extended grapheme cluster containing both the special html character and some additional characters. In the case of html attributes, this can lead to XSS if there is a leaf variable in the attribute that is user controlled. This vulnerability is fixed in 1.4.1.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Leaf-kit before 1.4.1 fails to escape HTML special characters when they are part of extended grapheme clusters, enabling XSS in attributes.
CVE-2026-27120 is a vulnerability in leaf-kit, a templating language with Swift-inspired syntax, prior to version 1.4.1. The htmlEscaped escaping function relies on Swift's string comparison which operates on extended grapheme clusters, rather than individual Unicode scalar values. This causes characters such as the double quote (") to not be replaced when they are combined with combining characters like the acute accent (U+0301) into a single grapheme cluster, allowing the escaping mechanism to be bypassed [1][3].
Exploitation requires an attacker to control a user-supplied value placed inside an HTML attribute in a template. By crafting input that uses an extended grapheme cluster containing a special HTML character (e.g., " followed by a combining character), the attacker can break out of the attribute without the quote being escaped, thereby injecting arbitrary HTML and JavaScript [3]. The attack is described as primarily affecting attribute contexts because the bypass relies on the escaping being skipped for a character that is part of a larger cluster [3].
Successful exploitation results in stored or reflected cross-site scripting (XSS) within the context of the vulnerable application, allowing attackers to execute scripts, steal cookies, or perform actions on behalf of the victim [1][3].
The vulnerability has been fixed in leaf-kit version 1.4.1. The fix, visible in commit 8919e39476c3a4ba05c28b71546bb9195f87ef34, replaces the previous replacing or replacingOccurrences(of:) calls with a reduction over unicodeScalars, ensuring that each Unicode scalar is escaped independently and preventing bypass via grapheme clusters [4]. Users are strongly advised to upgrade to version 1.4.1 or later [1].
AI Insight generated on May 19, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
github.com/vapor/leaf-kitSwiftURL | < 1.4.1 | 1.4.1 |
Affected products
2- vapor/leaf-kitv5Range: < 1.4.1
Patches
18919e39476c3Merge commit from fork
2 files changed · +34 −16
Sources/LeafKit/String+HTMLEscape.swift+9 −16 modified@@ -1,22 +1,15 @@ -import Foundation - extension String { /// Escapes HTML entities in a `String`. public func htmlEscaped() -> String { - if #available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *) { - self - .replacing("&", with: "&") - .replacing("\"", with: """) - .replacing("'", with: "'") - .replacing("<", with: "<") - .replacing(">", with: ">") - } else { - self - .replacingOccurrences(of: "&", with: "&") - .replacingOccurrences(of: "\"", with: """) - .replacingOccurrences(of: "'", with: "'") - .replacingOccurrences(of: "<", with: "<") - .replacingOccurrences(of: ">", with: ">") + self.unicodeScalars.reduce(into: "") { result, scalar in + switch scalar { + case "&": result += "&" + case "\"": result += """ + case "'": result += "'" + case "<": result += "<" + case ">": result += ">" + default: result.unicodeScalars.append(scalar) + } } } }
Tests/LeafKitTests/HTMLEscapeTests.swift+25 −0 modified@@ -9,6 +9,31 @@ final class HTMLEscapeTests: XCTestCase { XCTAssertEqual("abc&".htmlEscaped(), "abc&") } + func testExtendedGraphemeClusterBypass() { + let quoteWithCombining = "\u{0022}\u{0301}" // "́ + let escaped = quoteWithCombining.htmlEscaped() + + XCTAssertEqual(escaped, ""\u{0301}") + + let maliciousInput = "\"\u{0301}=1 autofocus tabindex=0 onfocus=alert(1)" + let escapedMalicious = maliciousInput.htmlEscaped() + + XCTAssertFalse(escapedMalicious.contains("\"\u{0301}")) + XCTAssertTrue(escapedMalicious.unicodeScalars.starts(with: """.unicodeScalars)) + + let ampersandWithCombining = "&\u{0301}" // &́ + XCTAssertEqual(ampersandWithCombining.htmlEscaped(), "&\u{0301}") + + let lessThanWithCombining = "<\u{0301}" // <́ + XCTAssertEqual(lessThanWithCombining.htmlEscaped(), "<\u{0301}",) + + let greaterThanWithCombining = ">\u{0301}" // >́ + XCTAssertEqual(greaterThanWithCombining.htmlEscaped(), ">\u{0301}") + + let apostropheWithCombining = "'\u{0301}" // '́ + XCTAssertEqual(apostropheWithCombining.htmlEscaped(), "'\u{0301}") + } + #if !os(Android) func testShortStringNoReplacements() { let string = "abcde12345"
Vulnerability mechanics
Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
4- github.com/advisories/GHSA-4hfh-fch3-5q7pghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-27120ghsaADVISORY
- github.com/vapor/leaf-kit/commit/8919e39476c3a4ba05c28b71546bb9195f87ef34ghsax_refsource_MISCWEB
- github.com/vapor/leaf-kit/security/advisories/GHSA-4hfh-fch3-5q7pghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.