fix(ios): restrict authentication capabilities and validate bootstrap types

Require iOS 18.4 for explicit upload denial in the isolated authentication window. Add origin-bound generated download policy, app-switcher privacy, typed metadata regressions and original community icon assets. Seven portable tests and Swift formatting checks pass; Apple SDK validation remains external.
This commit is contained in:
Yuvomi Mobile Contributors committed 2026-10-20 23:45:01 +02:00
1 parent 74c2c9e6e8
commit 69c48169e2
15 files changed
+341 -112

No files matched your search

+110 -49
View File
@@ -13,6 +13,7 @@ final class BrowserModel: NSObject, ObservableObject, WKNavigationDelegate, WKUI
@Published var error: String?
@Published var externalURL: URL?
@Published var authentication: WebDestination?
@Published var authenticationHost = "Sign in"
@Published var download: WebDestination?
private(set) var server: ServerAddress?
private(set) var web: WKWebView
@@ -45,9 +46,10 @@ final class BrowserModel: NSObject, ObservableObject, WKNavigationDelegate, WKUI
web.allowsBackForwardNavigationGestures = true
web.isOpaque = false
if #available(iOS 16.4, *) { web.isInspectable = false }
observations.append(web.observe(\.canGoBack, options: [.new]) { [weak self] view, _ in
self?.canGoBack = view.canGoBack
})
observations.append(
web.observe(\.canGoBack, options: [.new]) { [weak self] view, _ in
self?.canGoBack = view.canGoBack
})
}
static var exportDirectory: URL {
@@ -65,7 +67,8 @@ final class BrowserModel: NSObject, ObservableObject, WKNavigationDelegate, WKUI
func rememberPath() {
guard let url = web.url, server?.owns(url) == true else { return }
let path = url.path
guard !["auth", "reset", "invite", "login"].contains(where: { path.localizedCaseInsensitiveContains($0) }) else { return }
guard !["auth", "reset", "invite", "login"].contains(where: { path.localizedCaseInsensitiveContains($0) })
else { return }
UserDefaults.standard.set(path, forKey: "path")
}
@@ -80,58 +83,79 @@ final class BrowserModel: NSObject, ObservableObject, WKNavigationDelegate, WKUI
web.navigationDelegate = nil
web.uiDelegate = nil
observations.removeAll()
WKWebsiteDataStore.default().removeData(ofTypes: WKWebsiteDataStore.allWebsiteDataTypes(),
modifiedSince: .distantPast) { [weak self] in
WKWebsiteDataStore.default().removeData(
ofTypes: WKWebsiteDataStore.allWebsiteDataTypes(),
modifiedSince: .distantPast
) { [weak self] in
guard let self else { return }
self.web = Self.makeWebView()
self.configureWebView()
self.canGoBack = false; self.loading = false; self.error = nil
self.canGoBack = false
self.loading = false
self.error = nil
completion()
}
}
func startAuthentication(_ url: URL) {
guard url.scheme == "https", url.user == nil, url.password == nil else { return }
authenticationHost = url.host ?? "Sign in"
authentication = WebDestination(url: url)
}
func removeDownload() {
download = nil
if let location = downloadLocation { try? FileManager.default.removeItem(at: location.deletingLastPathComponent()) }
if let location = downloadLocation {
try? FileManager.default.removeItem(at: location.deletingLastPathComponent())
}
downloadLocation = nil
}
func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction,
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
guard let url = navigationAction.request.url else { decisionHandler(.cancel); return }
if server?.owns(url) == true {
func webView(
_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction,
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void
) {
guard let url = navigationAction.request.url else {
decisionHandler(.cancel)
return
}
if server?.owns(url) == true || (navigationAction.shouldPerformDownload && server?.ownsDownload(url) == true) {
decisionHandler(navigationAction.shouldPerformDownload ? .download : .allow)
} else {
decisionHandler(.cancel)
if navigationAction.targetFrame?.isMainFrame != false {
if url.scheme == "https", url.host != nil, url.user == nil, url.password == nil {
externalURL = url
} else { error = "This link type is blocked for your security." }
} else {
error = "This link type is blocked for your security."
}
}
}
}
func webView(_ webView: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse,
decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void) {
func webView(
_ webView: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse,
decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void
) {
if let response = navigationResponse.response as? HTTPURLResponse,
navigationResponse.isForMainFrame, response.statusCode >= 400 {
navigationResponse.isForMainFrame, response.statusCode >= 400
{
error = "The server is unavailable (HTTP \(response.statusCode))."
}
guard let url = navigationResponse.response.url, server?.owns(url) == true else {
decisionHandler(.cancel); return
guard let url = navigationResponse.response.url, server?.ownsDownload(url) == true else {
decisionHandler(.cancel)
return
}
if !navigationResponse.canShowMIMEType {
guard navigationResponse.response.expectedContentLength <= 100 * 1024 * 1024 else {
error = "This file exceeds the 100 MB download limit."
decisionHandler(.cancel); return
decisionHandler(.cancel)
return
}
decisionHandler(.download)
} else { decisionHandler(.allow) }
} else {
decisionHandler(.allow)
}
}
func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
@@ -146,30 +170,43 @@ final class BrowserModel: NSObject, ObservableObject, WKNavigationDelegate, WKUI
}
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
loading = false; deadline?.invalidate(); rememberPath()
loading = false
deadline?.invalidate()
rememberPath()
}
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { failed(error) }
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { failed(error) }
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
failed(error)
}
private func failed(_ failure: Error) {
loading = false; deadline?.invalidate()
loading = false
deadline?.invalidate()
guard (failure as NSError).code != NSURLErrorCancelled else { return }
error = "Unable to connect securely. Check your network, VPN and server certificate. Cached pages may still be available."
error =
"Unable to connect securely. Check your network, VPN and server certificate. Cached pages may still be available."
}
func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
error = "Yuvomi's browser was interrupted. Tap Retry to restore the page."
loading = false
}
func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration,
for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? {
func webView(
_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration,
for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures
) -> WKWebView? {
if let url = navigationAction.request.url {
if server?.owns(url) == true { web.load(navigationAction.request) }
else if url.scheme == "https", url.user == nil, url.password == nil { externalURL = url }
if server?.owns(url) == true {
web.load(navigationAction.request)
} else if url.scheme == "https", url.user == nil, url.password == nil {
externalURL = url
}
}
return nil
}
func webView(_ webView: WKWebView, requestMediaCapturePermissionFor origin: WKSecurityOrigin,
initiatedByFrame frame: WKFrameInfo, type: WKMediaCaptureType,
decisionHandler: @escaping (WKPermissionDecision) -> Void) {
func webView(
_ webView: WKWebView, requestMediaCapturePermissionFor origin: WKSecurityOrigin,
initiatedByFrame frame: WKFrameInfo, type: WKMediaCaptureType,
decisionHandler: @escaping (WKPermissionDecision) -> Void
) {
// File input capture uses Apple's picker. Live camera/microphone access is not required by upstream.
decisionHandler(.deny)
}
@@ -181,28 +218,46 @@ final class BrowserModel: NSObject, ObservableObject, WKNavigationDelegate, WKUI
}
private func attach(_ download: WKDownload) {
guard activeDownload == nil, self.download == nil else {
download.cancel { _ in }; error = "Finish the current download before starting another."; return
download.cancel { _ in }
error = "Finish the current download before starting another."
return
}
activeDownload = download; download.delegate = self
activeDownload = download
download.delegate = self
}
func download(_ download: WKDownload, decideDestinationUsing response: URLResponse,
suggestedFilename: String, completionHandler: @escaping (URL?) -> Void) {
guard let url = response.url, server?.owns(url) == true,
response.expectedContentLength <= 100 * 1024 * 1024 else { completionHandler(nil); return }
func download(
_ download: WKDownload, decideDestinationUsing response: URLResponse,
suggestedFilename: String, completionHandler: @escaping (URL?) -> Void
) {
guard let url = response.url, server?.ownsDownload(url) == true,
response.expectedContentLength <= 100 * 1024 * 1024
else {
completionHandler(nil)
return
}
do {
let folder = Self.exportDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true,
try FileManager.default.createDirectory(
at: folder, withIntermediateDirectories: true,
attributes: [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication])
var values = URLResourceValues(); values.isExcludedFromBackup = true
var directory = folder; try directory.setResourceValues(values)
let name = String(suggestedFilename.map { $0.isLetter || $0.isNumber || "._ -".contains($0) ? $0 : "_" }.prefix(120))
var values = URLResourceValues()
values.isExcludedFromBackup = true
var directory = folder
try directory.setResourceValues(values)
let name = String(
suggestedFilename.map { $0.isLetter || $0.isNumber || "._ -".contains($0) ? $0 : "_" }.prefix(120))
let safeName = name.isEmpty || name == "." || name == ".." ? "download" : name
downloadLocation = folder.appendingPathComponent(safeName)
completionHandler(downloadLocation)
} catch { completionHandler(nil); self.error = "Could not prepare the download." }
} catch {
completionHandler(nil)
self.error = "Could not prepare the download."
}
}
func download(_ download: WKDownload, willPerformHTTPRedirection response: HTTPURLResponse,
newRequest request: URLRequest, decisionHandler: @escaping (WKDownload.RedirectPolicy) -> Void) {
func download(
_ download: WKDownload, willPerformHTTPRedirection response: HTTPURLResponse,
newRequest request: URLRequest, decisionHandler: @escaping (WKDownload.RedirectPolicy) -> Void
) {
// Do not replay session credentials to a redirect target.
decisionHandler(.cancel)
}
@@ -210,15 +265,19 @@ final class BrowserModel: NSObject, ObservableObject, WKNavigationDelegate, WKUI
guard activeDownload === download else { return }
activeDownload = nil
guard let location = downloadLocation,
let size = try? location.resourceValues(forKeys: [.fileSizeKey]).fileSize,
size <= 100 * 1024 * 1024 else {
removeDownload(); error = "Download exceeded the size limit."; return
let size = try? location.resourceValues(forKeys: [.fileSizeKey]).fileSize,
size <= 100 * 1024 * 1024
else {
removeDownload()
error = "Download exceeded the size limit."
return
}
self.download = WebDestination(url: location)
}
func download(_ download: WKDownload, didFailWithError error: Error, resumeData: Data?) {
guard activeDownload === download else { return }
activeDownload = nil; removeDownload()
activeDownload = nil
removeDownload()
self.error = "Download failed. Check your connection and sign-in, then retry."
}
}
@@ -243,6 +302,8 @@ struct ExportView: UIViewControllerRepresentable {
let completion: () -> Void
init(_ completion: @escaping () -> Void) { self.completion = completion }
func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { completion() }
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { completion() }
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
completion()
}
}
}