iOS WKWebView — source audit
TL;DR: WKWebView is iOS’s modern WebView. Source-audit risks:
addUserScriptinjecting privileged JS,WKScriptMessageHandlercallbacks trusting any postMessage caller,loadFileURLwith attacker-controlled paths,decidePolicyForcallbacks that follow arbitrary schemes, andserverTrustChallengeaccepting any cert. Companion to ios-source-review-methodology and android-webview-audit.
Greps
1
2
3
4
5
grep -rn 'WKWebView\|WKWebViewConfiguration' .
grep -rn 'WKUserContentController\|addUserScript\|add(_:name:)\|WKScriptMessageHandler\|WKScriptMessageHandlerWithReply' .
grep -rn 'loadFileURL\|loadHTMLString' .
grep -rn 'decidePolicyFor navigationAction\|decidePolicyFor navigationResponse' .
grep -rn 'didReceive challenge\|serverTrust' .
The bridge — WKScriptMessageHandler
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
configuration.userContentController.add(self, name: "AppBridge")
// JS side:
// window.webkit.messageHandlers.AppBridge.postMessage({cmd: "openFile", arg: "..."})
func userContentController(_ uc: WKUserContentController, didReceive m: WKScriptMessage) {
guard let body = m.body as? [String: Any],
let cmd = body["cmd"] as? String else { return }
switch cmd {
case "openFile":
if let path = body["arg"] as? String {
try? FileManager.default.contents(atPath: path) // BAD: arbitrary read
}
case "exec":
...
}
}
Three problems:
- JS in the WebView is the attacker if the page is remote or attacker-influenced.
- No origin/frame check (
m.frameInfo.securityOrigin). - The handler exposes filesystem semantics directly.
Fixes:
- Check
m.frameInfo.request.url.hostagainst an allowlist. - Make commands type-safe (enum, schema-validated args).
- Never expose primitive FS/Network APIs through the bridge.
loadFileURL and path-confined reads
1
2
3
let url = URL(fileURLWithPath: untrustedPath)
let dir = url.deletingLastPathComponent()
webView.loadFileURL(url, allowingReadAccessTo: dir)
allowingReadAccessTo: is the sandbox for the WebView. If dir is the user’s whole Documents directory, every file under it becomes readable from in-WebView JS.
Audit:
allowingReadAccessTois the tightest directory that contains the loaded file.untrustedPathis validated against an allowlist or canonicalised.
decidePolicyFor
1
2
3
4
5
6
7
8
func webView(_ wv: WKWebView, decidePolicyFor action: WKNavigationAction,
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
if action.request.url?.scheme == "tel" {
UIApplication.shared.open(action.request.url!) // BAD if 'url' is attacker-shaped
decisionHandler(.cancel); return
}
decisionHandler(.allow)
}
Bugs:
- Hand off any
URLtoUIApplication.shared.open→ external scheme dispatch (mailto, tel, custom). - Allow
javascript:orfile://for top-level navigations. - Trust
_blanktargets and open them in a fresh, less-restricted WKWebView.
Server trust challenge
1
2
3
4
5
6
func webView(_ wv: WKWebView, didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
if let trust = challenge.protectionSpace.serverTrust {
completionHandler(.useCredential, URLCredential(trust: trust)) // BAD: any cert
}
}
Treats any serverTrust as valid → MITM. The right shape: use SecTrustEvaluateWithError and bail on failure, or pin via URLSessionDelegate (see mobile-cert-pinning-source-audit).
User scripts injected at document-start
1
2
3
let script = WKUserScript(source: "window.appConfig = " + json,
injectionTime: .atDocumentStart, forMainFrameOnly: true)
configuration.userContentController.addUserScript(script)
Audit:
jsonis a trusted server response or built from constants — not from URL parameters.forMainFrameOnly: trueprevents injection into subframes.- If injecting credentials or auth tokens via window globals — never do this. Use cookies/headers via
URLRequest.
Mixed content and NSAppTransportSecurity
A WKWebView inherits ATS rules. If NSAllowsArbitraryLoads is true, HTTP content loads inside an HTTPS-served page. Audit Info.plist:
1
2
3
4
5
6
7
8
9
10
11
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key><true/> <!-- flag -->
<key>NSExceptionDomains</key>
<dict>
<key>example.com</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key><true/> <!-- per-domain HTTP -->
</dict>
</dict>
</dict>
Process pool sharing
1
2
3
let pool = WKProcessPool()
configWebA.processPool = pool
configWebB.processPool = pool
Shared cookie/session storage across WKWebView instances. Useful — also a bug source if one webview is for first-party content and the other loads third-party.
Source-audit checklist
WKScriptMessageHandlercallbacks validateframeInfoorigin.- Bridge commands are typed and constrained; no FS/network primitives exposed.
loadFileURLconfinesallowingReadAccessToto the smallest needed dir.decidePolicyForrejectsfile://,javascript:, attacker-shaped external schemes.- Server-trust challenge evaluates trust; no blanket
URLCredential(trust:). NSAllowsArbitraryLoadsnot set; per-domain exceptions justified.- Process pool sharing intentional and documented.