Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 13 additions & 3 deletions Sources/FormbricksSDK/WebView/FormbricksViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,12 @@ final class FormbricksViewModel: ObservableObject {
self.surveyId = surveyId
if let webviewDataJson = WebViewData(workspaceResponse: workspaceResponse, surveyId: surveyId).getJsonString(),
let surveyScriptUrl = FormbricksWorkspace.surveyScriptUrlString {
htmlString = htmlTemplate.replacingOccurrences(of: "{{WEBVIEW_DATA}}", with: webviewDataJson)
// Base64-encode the payload before injecting it into the HTML. Base64 output is
// limited to [A-Za-z0-9+/=], so survey content can no longer contain characters
// (backticks, `${...}`, quotes) that would break out of the surrounding JS string
// literal and execute as code. The WebView decodes it back to JSON at runtime.
let webviewDataBase64 = Data(webviewDataJson.utf8).base64EncodedString()
htmlString = htmlTemplate.replacingOccurrences(of: "{{WEBVIEW_DATA}}", with: webviewDataBase64)
.replacingOccurrences(of: "{{SURVEY_SCRIPT_URL}}", with: surveyScriptUrl)
}
}
Expand All @@ -33,7 +38,9 @@ private extension FormbricksViewModel {
</body>

<script type="text/javascript">
const json = `{{WEBVIEW_DATA}}`
// {{WEBVIEW_DATA}} is a base64-encoded JSON string (see FormbricksViewModel).
// Decode it as UTF-8 and parse it as JSON; it is never interpreted as code.
const json = new TextDecoder().decode(Uint8Array.from(atob("{{WEBVIEW_DATA}}"), c => c.charCodeAt(0)));
let surveyProps = '';

function onClose() {
Expand Down Expand Up @@ -130,7 +137,10 @@ private class WebViewData {
func getJsonString() -> String? {
do {
let jsonData = try JSONSerialization.data(withJSONObject: data, options: [])
return String(data: jsonData, encoding: .utf8)?.replacingOccurrences(of: "\\\"", with: "'")
// Return valid JSON as-is. It is base64-encoded before being embedded in the
// WebView HTML, so there is no need to mangle embedded quotes here (doing so
// previously corrupted survey text that legitimately contained double quotes).
return String(data: jsonData, encoding: .utf8)
} catch {
Formbricks.logger?.error(error.message)
return nil
Expand Down
67 changes: 59 additions & 8 deletions Sources/FormbricksSDK/WebView/SurveyWebView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,14 @@
webView.configuration.defaultWebpagePreferences.allowsContentJavaScript = true
webView.isOpaque = false
webView.backgroundColor = UIColor.clear
// Web Inspector is a debugging aid only; never expose the survey WebView
// to inspection in release builds (avoids leaking payload/responses and
// allowing DOM/JS tampering on production devices).
#if DEBUG
if #available(iOS 16.4, *) {
webView.isInspectable = true
}
#endif
webView.navigationDelegate = context.coordinator
webView.uiDelegate = context.coordinator
webView.scrollView.isScrollEnabled = false
Expand Down Expand Up @@ -91,11 +96,29 @@
completionHandler()
}

func webView(_ webView: WKWebView, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
if let serverTrust = challenge.protectionSpace.serverTrust {
completionHandler(.useCredential, URLCredential(trust: serverTrust))
func webView(_ webView: WKWebView, didReceive _: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {

Check warning on line 99 in Sources/FormbricksSDK/WebView/SurveyWebView.swift

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove the unused function parameter "webView" or name it "_".

See more on https://sonarcloud.io/project/issues?id=formbricks_ios&issues=AZ-DZfnT9RJath4CZjCJ&open=AZ-DZfnT9RJath4CZjCJ&pullRequest=51
// Let the OS perform standard certificate-chain validation. Never force-trust
// arbitrary certificates, as that would disable TLS validation and expose the
// survey WebView traffic to man-in-the-middle interception.
completionHandler(.performDefaultHandling, nil)
}

func webView(_: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
let url = navigationAction.request.url
// The survey is an in-memory document (loaded via loadHTMLString) rendered by the
// JS library; it never legitimately navigates its own frame. Allow that base
// document, and treat any other navigation — a link tap, window.location, meta
// refresh or form submit from survey markup — as an attempt to leave the survey.
// Route those through the same http/https allowlist as the JS bridge so a
// `<a href="tel:...">` (etc.) can't reach WKWebView's native scheme handling and
// trigger unexpected native actions.
if JsMessageHandler.shouldAllowInWebViewNavigation(to: url) {
decisionHandler(.allow)
} else {
completionHandler(.useCredential, nil)
decisionHandler(.cancel)
if let url {
JsMessageHandler.openExternalURL(url)
}
}
}
}
Expand All @@ -106,11 +129,38 @@
final class JsMessageHandler: NSObject, WKScriptMessageHandler {

let surveyId: String

init(surveyId: String) {
self.surveyId = surveyId
}


/// Whether an external URL from survey content is safe to hand to the OS.
/// Only web links are allowed; other schemes (tel, sms, custom app deep links,
/// etc.) are refused so survey content cannot trigger unexpected native actions.
static func isAllowedExternalURL(_ url: URL) -> Bool {
guard let scheme = url.scheme?.lowercased() else { return false }
return scheme == "http" || scheme == "https"
}

/// Whether a WebView navigation to `url` may proceed in-place. Only the in-memory
/// survey document (`about:blank`, i.e. loaded with a nil base URL) loads in-frame;
/// every other navigation is handled as an external link (see `openExternalURL`).
static func shouldAllowInWebViewNavigation(to url: URL?) -> Bool {
guard let scheme = url?.scheme?.lowercased() else { return true }
return scheme == "about"
}

/// Opens an external URL through the http/https allowlist, or blocks and logs it.
/// Shared by the JS bridge (`onOpenExternalURL`) and the navigation delegate so the
/// scheme allowlist is enforced on both paths.
static func openExternalURL(_ url: URL) {
guard isAllowedExternalURL(url) else {
Formbricks.logger?.warning("Blocked external URL with disallowed scheme: \(url.scheme ?? "nil")")
return
}
UIApplication.shared.open(url)
}

func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
Formbricks.logger?.debug(message.body)

Expand All @@ -131,8 +181,9 @@

/// Happens when the survey wants to open an external link in the default browser.
case .onOpenExternalURL:
if let message = try? JSONDecoder().decode(OpenExternalUrlMessage.self, from: data), let url = URL(string: message.onOpenExternalURLParams.url) {
UIApplication.shared.open(url)
if let message = try? JSONDecoder().decode(OpenExternalUrlMessage.self, from: data),
let url = URL(string: message.onOpenExternalURLParams.url) {
JsMessageHandler.openExternalURL(url)
}

/// Happens when the survey library fails to load.
Expand Down
Loading
Loading