PT-2026-25815 · Swifturl · Leafkit

Published

2026-03-16

·

Updated

2026-03-16

·

CVE-2026-28499

CVSS v4.0
6.9
VectorAV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:N/SC:L/SI:N/SA:N

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:

  1. Leaf expression serialization eventually reaches
    LeafSerializer
    's
    serialize
    private function below. This is where the
    leafData
    is
    .htmlEscaped()
    , and then serialized.
  1. 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):
  1. Regarding why
    .string
    may 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.
  1. 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, where
    .map
    /
    .mapValues
    is called, unsafely serializing each element of the dictionary.

PoC

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.

Impact

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.

Fix

XSS

Improper Encoding or Escaping of Output

Weakness Enumeration

Related Identifiers

CVE-2026-28499
GHSA-6JJ5-J4J8-8473

Affected Products

Leafkit