FlawOpen

flawopen.com/xss/Kotlin

● CWE-79 · High
FlawOpen Security Research

Cross-Site Scripting (XSS) in Kotlin (Ktor, kotlinx.html, Android WebView)

Kotlin XSS can enter through raw HTML or interpolated WebView scripts. See how Ktor escapes output and how to pass data safely.

💡 Plain English Explainer (ELI5)

Think of a Ktor page as a signboard: kotlinx.html +text engraves a value as text, while unsafe { } pastes it in as markup. A WebView script is a different channel: interpolating a name into evaluateJavascript changes the script itself. Pass the name as a JavaScript value with a safe argument API or JSONObject.quote; do not treat HTML escaping as JavaScript quoting.

Core Concepts & Subsystem Terms

String templates in HTML responses
"<h1>Results for $q</h1>" is plain string interpolation; nothing is escaped. Sent with ContentType.Text.Html, the browser parses whatever q contained as markup.
kotlinx.html text vs unsafe { }
In the kotlinx.html DSL used by Ktor's respondHtml, +text inside an element is HTML-escaped. unsafe { +raw } inserts the string as markup with no escaping and should only receive sanitized or constant content.
WebView.evaluateJavascript
evaluateJavascript runs a string of JavaScript in the page loaded by the WebView. Interpolating a value between quotes lets a ' in the value end the string literal and add new statements, which then run with the page's origin and any addJavascriptInterface bridges.
JSONObject.quote
org.json.JSONObject.quote(value) returns a double-quoted JSON string literal with quotes, backslashes and control characters escaped. JSON string literals are valid JavaScript, so the result can be passed to a script function as a single, inert argument.
OWASP Java HTML Sanitizer
For user-supplied HTML, a policy such as Sanitizers.FORMATTING.and(Sanitizers.LINKS) keeps an allowlist of tags and attributes and drops scripts, event handlers and javascript: URLs before the value reaches unsafe { }.

Framework mappings

Step-by-Step Attack Flow

Step 1

Input arrives from outside

A Ktor route reads q or bio from the query string; an Android activity receives name from a deep link or push notification.

Step 2

The value bypasses escaping

A string template builds the HTML response, unsafe { } inserts the bio, or evaluateJavascript interpolates the name into a script. The benign test value <img src=x onerror=alert(1)> (or ');alert(1);// for the script) passes through intact.

Step 3

The browser or WebView runs it

The page contains a real <img> element whose onerror fires, or the WebView executes the extra statement appended to showGreeting(...).

Step 4

The script acts with the page's privileges

On the web it can call the site's APIs with the victim's cookies. In a WebView it can also call any object exposed through addJavascriptInterface, which may reach native app data.

Source Code: Flaw vs. Secure Implementation

UNPATCHED FLAW
// ProfileRoutes.kt: each path below lets outside text run as HTML or script
fun Application.profileRoutes() {
    routing {
        // the string template puts q into the HTML unescaped
        get("/search") {
            val q = call.request.queryParameters["q"].orEmpty()
            call.respondText("<h1>Results for $q</h1>", ContentType.Text.Html)
        }

        // unsafe { } inserts the bio as raw markup
        get("/profile") {
            val bio = call.request.queryParameters["bio"].orEmpty()
            call.respondHtml { body { div { unsafe { +bio } } } }
        }
    }
}

// a quote in name ends the string and adds a statement
fun showGreeting(webView: WebView, name: String) {
    webView.settings.javaScriptEnabled = true
    webView.evaluateJavascript("showGreeting('$name')", null)
}
HARDENED SECURE PATCH
// ProfileRoutes.kt: the DSL escapes text; HTML is sanitized, script args are JSON
private val bioPolicy = Sanitizers.FORMATTING.and(Sanitizers.LINKS)

fun Application.profileRoutes() {
    routing {
        // +text inside the DSL is HTML-escaped
        get("/search") {
            val q = call.request.queryParameters["q"].orEmpty()
            call.respondHtml { body { h1 { +"Results for $q" } } }
        }

        // the sanitizer keeps formatting and links before unsafe { }
        get("/profile") {
            val bio = call.request.queryParameters["bio"].orEmpty()
            call.respondHtml { body { div { unsafe { +bioPolicy.sanitize(bio) } } } }
        }
    }
}

// JSONObject.quote produces one inert string argument
fun showGreeting(webView: WebView, name: String) {
    webView.settings.javaScriptEnabled = true
    webView.evaluateJavascript("showGreeting(${JSONObject.quote(name)})", null)
}

Engineering & System Hardening Checklist

Sources