FlawOpen

flawopen.com/xss/Swift

● CWE-79 · High
FlawOpen Security Research

Cross-Site Scripting (XSS) in Swift (WKWebView, Leaf)

Interpolating user text into loadHTMLString or evaluateJavaScript creates HTML or JavaScript injection in your app’s WKWebView (CWE-79). Apple documents baseURL as the base for relative URLs; it is not an HTML sanitizer or a sandbox switch. Escape for the exact output context, pass script values as arguments, and scope native message handlers.

💡 Plain English Explainer (ELI5)

In WKWebView, think of an HTML template like a print form: if user text is inserted as markup, WebKit treats tags as layout instructions; if it is pasted into a JavaScript string, punctuation can change the program. Pass text as data, not source. baseURL only resolves relative URLs; setting it to nil does not sanitize HTML or disable scripts. This is the boundary in loadHTMLString and evaluateJavaScript; callAsyncJavaScript(arguments:) passes the value as data.

Core Concepts & Subsystem Terms

loadHTMLString(_:baseURL:)
Apple documents baseURL as the URL used to resolve relative URLs in the HTML string. It does not sanitize markup, disable JavaScript, or by itself establish a sandbox or origin guarantee. Decide the document origin and navigation policy separately; do not use nil as an XSS control.
evaluateJavaScript with \(value)
"showGreeting('\(name)')" builds JavaScript source by string interpolation. A ' in name ends the string literal, and anything after it runs as code in the page.
callAsyncJavaScript(arguments:)
Available since iOS 14 and macOS 11, it runs a function body and passes arguments as real JavaScript values, not as source text. showGreeting(name) receives the string untouched, so quotes and </script> inside it are just characters.
Leaf #(value) vs #unsafeHTML
In Vapor's Leaf templates, #(bio) HTML-escapes the value. #unsafeHTML(bio) inserts it as raw markup and should only receive sanitized or constant content, exactly like Html.Raw or |safe in other frameworks.
WKScriptMessageHandler bridges
WKUserContentController.add(_:name:) exposes a message function to page JavaScript. Scripts in that page world, including injected scripts, can call it. The overload that accepts a WKContentWorld scopes which world can call the handler; content worlds isolate JavaScript variables, not the shared DOM. Validate every message and keep native actions narrow.

Framework mappings

Step-by-Step Attack Flow

Step 1

Input reaches the app

A profile bio is fetched from your API, and a display name arrives in a push notification or universal link. Both are strings controlled by another user or by whoever sent the link.

Step 2

The value becomes page code

loadHTMLString places the bio into markup, and evaluateJavaScript places the name into script source. The benign test value <img src=x onerror=alert(1)> (or ');alert(1);// for the script) passes through intact.

Step 3

WebKit runs it

The web view builds a real <img> element and fires onerror, or evaluates the extra statement appended to showGreeting(...).

Step 4

The script acts with the page's privileges

The script runs in the document’s WebKit context and can manipulate its DOM and objects available there. A handler registered with the page-world add(_:name:) API is callable from page JavaScript. The document’s origin and navigation access depend on how it is loaded and configured; Apple documents baseURL as relative-URL resolution, not as a sanitizer.

Source Code: Flaw vs. Secure Implementation

UNPATCHED FLAW
// ProfileController.swift: each method below turns outside text into page code
import WebKit

final class ProfileController {
    let webView: WKWebView

    init(webView: WKWebView) {
        self.webView = webView
    }

    // the bio is placed into markup unescaped, with the site's origin
    func showBio(_ bio: String) {
        webView.loadHTMLString("<div class=\"bio\">\(bio)</div>", baseURL: URL(string: "https://app.example.com"))
    }

    // a quote in name ends the string literal and adds a statement
    func greet(_ name: String) {
        webView.evaluateJavaScript("showGreeting('\(name)')")
    }
}
HARDENED SECURE PATCH
// ProfileController.swift: markup is escaped, script data is passed as arguments
import WebKit

final class ProfileController {
    let webView: WKWebView

    init(webView: WKWebView) {
        self.webView = webView
    }

    // baseURL controls relative-URL resolution; nil is not an XSS or sandbox control
    func showBio(_ bio: String) {
        let html = "<div class=\"bio\">" + bio.htmlEscaped + "</div>"
        webView.loadHTMLString(html, baseURL: nil)
    }

    // name is passed as a JavaScript value, never as source text
    func greet(_ name: String) {
        webView.callAsyncJavaScript("showGreeting(name)", arguments: ["name": name], in: nil, in: .page, completionHandler: nil)
    }
}

extension String {
    // Escape for an HTML text node only; do not reuse for attributes, URLs, CSS, or JavaScript
    var htmlEscaped: String {
        var out = ""
        for ch in self {
            switch ch {
            case "&": out += "&amp;"
            case "<": out += "&lt;"
            case ">": out += "&gt;"
            case "\"": out += "&quot;"
            case "'": out += "&#39;"
            default: out.append(ch)
            }
        }
        return out
    }
}

Engineering & System Hardening Checklist

Sources