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

+1
View File
@@ -13,6 +13,7 @@ DerivedData/
*.xcuserstate *.xcuserstate
xcuserdata/ xcuserdata/
ios/Yuvomi.xcodeproj/ ios/Yuvomi.xcodeproj/
ios/Info.plist
.swiftpm/ .swiftpm/
.build/ .build/
artifacts/ artifacts/
+6
View File
@@ -0,0 +1,6 @@
{
"version": 1,
"indentation": { "spaces": 4 },
"lineLength": 120,
"respectsExistingLineBreaks": true
}
+7 -5
View File
@@ -6,10 +6,12 @@ let package = Package(
name: "YuvomiPolicy", name: "YuvomiPolicy",
products: [.library(name: "Yuvomi", targets: ["Yuvomi"])], products: [.library(name: "Yuvomi", targets: ["Yuvomi"])],
targets: [ targets: [
.target(name: "Yuvomi", path: "Sources", exclude: [ .target(
"AuthenticationView.swift", "BrowserModel.swift", "ContentView.swift", name: "Yuvomi", path: "Sources",
"ServerProbe.swift", "YuvomiApp.swift", "PrivacyInfo.xcprivacy" exclude: [
], sources: ["ServerAddress.swift"]), "AuthenticationView.swift", "BrowserModel.swift", "ContentView.swift",
.testTarget(name: "YuvomiPolicyTests", dependencies: ["Yuvomi"], path: "Tests") "ServerProbe.swift", "YuvomiApp.swift", "PrivacyInfo.xcprivacy", "Assets.xcassets",
], sources: ["ServerAddress.swift", "ServerMetadata.swift"]),
.testTarget(name: "YuvomiPolicyTests", dependencies: ["Yuvomi"], path: "Tests"),
] ]
) )
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

@@ -0,0 +1,14 @@
{
"images": [
{
"filename": "AppIcon.png",
"idiom": "universal",
"platform": "ios",
"size": "1024x1024"
}
],
"info": {
"author": "Yuvomi Mobile contributors",
"version": 1
}
}
+28 -8
View File
@@ -7,7 +7,7 @@ struct AuthenticationView: View {
var body: some View { var body: some View {
NavigationStack { NavigationStack {
IsolatedAuthentication(url: destination, model: model) IsolatedAuthentication(url: destination, model: model)
.navigationTitle("Isolated web sign-in") .navigationTitle(model.authenticationHost)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { Button("Close") { model.authentication = nil } } .toolbar { Button("Close") { model.authentication = nil } }
} }
@@ -33,15 +33,26 @@ struct IsolatedAuthentication: UIViewRepresentable {
} }
func updateUIView(_ uiView: WKWebView, context: Context) {} func updateUIView(_ uiView: WKWebView, context: Context) {}
static func dismantleUIView(_ uiView: WKWebView, coordinator: Coordinator) { static func dismantleUIView(_ uiView: WKWebView, coordinator: Coordinator) {
uiView.stopLoading(); uiView.navigationDelegate = nil; uiView.uiDelegate = nil uiView.stopLoading()
uiView.navigationDelegate = nil
uiView.uiDelegate = nil
} }
final class Coordinator: NSObject, WKNavigationDelegate, WKUIDelegate { final class Coordinator: NSObject, WKNavigationDelegate, WKUIDelegate {
let model: BrowserModel let model: BrowserModel
init(_ model: BrowserModel) { self.model = model } init(_ model: BrowserModel) { self.model = model }
func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { model.authenticationHost = webView.url?.host ?? "Sign in"
}
func webView(
_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction,
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void
) {
guard let url = navigationAction.request.url, url.scheme == "https", guard let url = navigationAction.request.url, url.scheme == "https",
url.user == nil, url.password == nil else { decisionHandler(.cancel); return } url.user == nil, url.password == nil
else {
decisionHandler(.cancel)
return
}
decisionHandler(.allow) decisionHandler(.allow)
} }
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
@@ -50,8 +61,17 @@ struct IsolatedAuthentication: UIViewRepresentable {
model.web.load(URLRequest(url: url)) model.web.load(URLRequest(url: url))
} }
} }
func webView(_ webView: WKWebView, requestMediaCapturePermissionFor origin: WKSecurityOrigin, func webView(
initiatedByFrame frame: WKFrameInfo, type: WKMediaCaptureType, _ webView: WKWebView, requestMediaCapturePermissionFor origin: WKSecurityOrigin,
decisionHandler: @escaping (WKPermissionDecision) -> Void) { decisionHandler(.deny) } initiatedByFrame frame: WKFrameInfo, type: WKMediaCaptureType,
decisionHandler: @escaping (WKPermissionDecision) -> Void
) { decisionHandler(.deny) }
func webView(
_ webView: WKWebView, runOpenPanelWith parameters: WKOpenPanelParameters,
initiatedByFrame frame: WKFrameInfo,
completionHandler: @escaping @MainActor @Sendable ([URL]?) -> Void
) {
completionHandler(nil)
}
} }
} }
+110 -49
View File
@@ -13,6 +13,7 @@ final class BrowserModel: NSObject, ObservableObject, WKNavigationDelegate, WKUI
@Published var error: String? @Published var error: String?
@Published var externalURL: URL? @Published var externalURL: URL?
@Published var authentication: WebDestination? @Published var authentication: WebDestination?
@Published var authenticationHost = "Sign in"
@Published var download: WebDestination? @Published var download: WebDestination?
private(set) var server: ServerAddress? private(set) var server: ServerAddress?
private(set) var web: WKWebView private(set) var web: WKWebView
@@ -45,9 +46,10 @@ final class BrowserModel: NSObject, ObservableObject, WKNavigationDelegate, WKUI
web.allowsBackForwardNavigationGestures = true web.allowsBackForwardNavigationGestures = true
web.isOpaque = false web.isOpaque = false
if #available(iOS 16.4, *) { web.isInspectable = false } if #available(iOS 16.4, *) { web.isInspectable = false }
observations.append(web.observe(\.canGoBack, options: [.new]) { [weak self] view, _ in observations.append(
self?.canGoBack = view.canGoBack web.observe(\.canGoBack, options: [.new]) { [weak self] view, _ in
}) self?.canGoBack = view.canGoBack
})
} }
static var exportDirectory: URL { static var exportDirectory: URL {
@@ -65,7 +67,8 @@ final class BrowserModel: NSObject, ObservableObject, WKNavigationDelegate, WKUI
func rememberPath() { func rememberPath() {
guard let url = web.url, server?.owns(url) == true else { return } guard let url = web.url, server?.owns(url) == true else { return }
let path = url.path 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") UserDefaults.standard.set(path, forKey: "path")
} }
@@ -80,58 +83,79 @@ final class BrowserModel: NSObject, ObservableObject, WKNavigationDelegate, WKUI
web.navigationDelegate = nil web.navigationDelegate = nil
web.uiDelegate = nil web.uiDelegate = nil
observations.removeAll() observations.removeAll()
WKWebsiteDataStore.default().removeData(ofTypes: WKWebsiteDataStore.allWebsiteDataTypes(), WKWebsiteDataStore.default().removeData(
modifiedSince: .distantPast) { [weak self] in ofTypes: WKWebsiteDataStore.allWebsiteDataTypes(),
modifiedSince: .distantPast
) { [weak self] in
guard let self else { return } guard let self else { return }
self.web = Self.makeWebView() self.web = Self.makeWebView()
self.configureWebView() self.configureWebView()
self.canGoBack = false; self.loading = false; self.error = nil self.canGoBack = false
self.loading = false
self.error = nil
completion() completion()
} }
} }
func startAuthentication(_ url: URL) { func startAuthentication(_ url: URL) {
guard url.scheme == "https", url.user == nil, url.password == nil else { return } guard url.scheme == "https", url.user == nil, url.password == nil else { return }
authenticationHost = url.host ?? "Sign in"
authentication = WebDestination(url: url) authentication = WebDestination(url: url)
} }
func removeDownload() { func removeDownload() {
download = nil 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 downloadLocation = nil
} }
func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, func webView(
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { _ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction,
guard let url = navigationAction.request.url else { decisionHandler(.cancel); return } decisionHandler: @escaping (WKNavigationActionPolicy) -> Void
if server?.owns(url) == true { ) {
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) decisionHandler(navigationAction.shouldPerformDownload ? .download : .allow)
} else { } else {
decisionHandler(.cancel) decisionHandler(.cancel)
if navigationAction.targetFrame?.isMainFrame != false { if navigationAction.targetFrame?.isMainFrame != false {
if url.scheme == "https", url.host != nil, url.user == nil, url.password == nil { if url.scheme == "https", url.host != nil, url.user == nil, url.password == nil {
externalURL = url 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, func webView(
decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void) { _ webView: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse,
decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void
) {
if let response = navigationResponse.response as? HTTPURLResponse, 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))." error = "The server is unavailable (HTTP \(response.statusCode))."
} }
guard let url = navigationResponse.response.url, server?.owns(url) == true else { guard let url = navigationResponse.response.url, server?.ownsDownload(url) == true else {
decisionHandler(.cancel); return decisionHandler(.cancel)
return
} }
if !navigationResponse.canShowMIMEType { if !navigationResponse.canShowMIMEType {
guard navigationResponse.response.expectedContentLength <= 100 * 1024 * 1024 else { guard navigationResponse.response.expectedContentLength <= 100 * 1024 * 1024 else {
error = "This file exceeds the 100 MB download limit." error = "This file exceeds the 100 MB download limit."
decisionHandler(.cancel); return decisionHandler(.cancel)
return
} }
decisionHandler(.download) decisionHandler(.download)
} else { decisionHandler(.allow) } } else {
decisionHandler(.allow)
}
} }
func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) { 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!) { 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, 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) { private func failed(_ failure: Error) {
loading = false; deadline?.invalidate() loading = false
deadline?.invalidate()
guard (failure as NSError).code != NSURLErrorCancelled else { return } 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) { func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
error = "Yuvomi's browser was interrupted. Tap Retry to restore the page." error = "Yuvomi's browser was interrupted. Tap Retry to restore the page."
loading = false loading = false
} }
func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration, func webView(
for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? { _ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration,
for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures
) -> WKWebView? {
if let url = navigationAction.request.url { if let url = navigationAction.request.url {
if server?.owns(url) == true { web.load(navigationAction.request) } if server?.owns(url) == true {
else if url.scheme == "https", url.user == nil, url.password == nil { externalURL = url } web.load(navigationAction.request)
} else if url.scheme == "https", url.user == nil, url.password == nil {
externalURL = url
}
} }
return nil return nil
} }
func webView(_ webView: WKWebView, requestMediaCapturePermissionFor origin: WKSecurityOrigin, func webView(
initiatedByFrame frame: WKFrameInfo, type: WKMediaCaptureType, _ webView: WKWebView, requestMediaCapturePermissionFor origin: WKSecurityOrigin,
decisionHandler: @escaping (WKPermissionDecision) -> Void) { 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. // File input capture uses Apple's picker. Live camera/microphone access is not required by upstream.
decisionHandler(.deny) decisionHandler(.deny)
} }
@@ -181,28 +218,46 @@ final class BrowserModel: NSObject, ObservableObject, WKNavigationDelegate, WKUI
} }
private func attach(_ download: WKDownload) { private func attach(_ download: WKDownload) {
guard activeDownload == nil, self.download == nil else { 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, func download(
suggestedFilename: String, completionHandler: @escaping (URL?) -> Void) { _ download: WKDownload, decideDestinationUsing response: URLResponse,
guard let url = response.url, server?.owns(url) == true, suggestedFilename: String, completionHandler: @escaping (URL?) -> Void
response.expectedContentLength <= 100 * 1024 * 1024 else { completionHandler(nil); return } ) {
guard let url = response.url, server?.ownsDownload(url) == true,
response.expectedContentLength <= 100 * 1024 * 1024
else {
completionHandler(nil)
return
}
do { do {
let folder = Self.exportDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) 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]) attributes: [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication])
var values = URLResourceValues(); values.isExcludedFromBackup = true var values = URLResourceValues()
var directory = folder; try directory.setResourceValues(values) values.isExcludedFromBackup = true
let name = String(suggestedFilename.map { $0.isLetter || $0.isNumber || "._ -".contains($0) ? $0 : "_" }.prefix(120)) 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 let safeName = name.isEmpty || name == "." || name == ".." ? "download" : name
downloadLocation = folder.appendingPathComponent(safeName) downloadLocation = folder.appendingPathComponent(safeName)
completionHandler(downloadLocation) 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, func download(
newRequest request: URLRequest, decisionHandler: @escaping (WKDownload.RedirectPolicy) -> Void) { _ download: WKDownload, willPerformHTTPRedirection response: HTTPURLResponse,
newRequest request: URLRequest, decisionHandler: @escaping (WKDownload.RedirectPolicy) -> Void
) {
// Do not replay session credentials to a redirect target. // Do not replay session credentials to a redirect target.
decisionHandler(.cancel) decisionHandler(.cancel)
} }
@@ -210,15 +265,19 @@ final class BrowserModel: NSObject, ObservableObject, WKNavigationDelegate, WKUI
guard activeDownload === download else { return } guard activeDownload === download else { return }
activeDownload = nil activeDownload = nil
guard let location = downloadLocation, guard let location = downloadLocation,
let size = try? location.resourceValues(forKeys: [.fileSizeKey]).fileSize, let size = try? location.resourceValues(forKeys: [.fileSizeKey]).fileSize,
size <= 100 * 1024 * 1024 else { size <= 100 * 1024 * 1024
removeDownload(); error = "Download exceeded the size limit."; return else {
removeDownload()
error = "Download exceeded the size limit."
return
} }
self.download = WebDestination(url: location) self.download = WebDestination(url: location)
} }
func download(_ download: WKDownload, didFailWithError error: Error, resumeData: Data?) { func download(_ download: WKDownload, didFailWithError error: Error, resumeData: Data?) {
guard activeDownload === download else { return } guard activeDownload === download else { return }
activeDownload = nil; removeDownload() activeDownload = nil
removeDownload()
self.error = "Download failed. Check your connection and sign-in, then retry." self.error = "Download failed. Check your connection and sign-in, then retry."
} }
} }
@@ -243,6 +302,8 @@ struct ExportView: UIViewControllerRepresentable {
let completion: () -> Void let completion: () -> Void
init(_ completion: @escaping () -> Void) { self.completion = completion } init(_ completion: @escaping () -> Void) { self.completion = completion }
func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { completion() } func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { completion() }
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { completion() } func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
completion()
}
} }
} }
+48 -18
View File
@@ -22,7 +22,10 @@ struct ContentView: View {
Image(systemName: "wifi.exclamationmark").font(.largeTitle) Image(systemName: "wifi.exclamationmark").font(.largeTitle)
Text("Connection interrupted").font(.title2).bold() Text("Connection interrupted").font(.title2).bold()
Text(error) Text(error)
Button("Retry") { browser.error = nil; browser.web.reload() } Button("Retry") {
browser.error = nil
browser.web.reload()
}
Button("Keep viewing") { browser.error = nil } Button("Keep viewing") { browser.error = nil }
Button("Server settings") { settings = true } Button("Server settings") { settings = true }
} }
@@ -30,12 +33,18 @@ struct ContentView: View {
.background(Color(uiColor: .systemGroupedBackground)) .background(Color(uiColor: .systemGroupedBackground))
.accessibilityElement(children: .contain) .accessibilityElement(children: .contain)
} }
if browser.loading { ProgressView("Loading Yuvomi").padding().background(.regularMaterial).cornerRadius(12) } if browser.loading {
ProgressView("Loading Yuvomi").padding().background(.regularMaterial).cornerRadius(12)
}
} }
.toolbar { .toolbar {
ToolbarItem(placement: .navigationBarLeading) { ToolbarItem(placement: .navigationBarLeading) {
Button { browser.web.goBack() } label: { Image(systemName: "chevron.backward") } Button {
.disabled(!browser.canGoBack).accessibilityLabel("Back") browser.web.goBack()
} label: {
Image(systemName: "chevron.backward")
}
.disabled(!browser.canGoBack).accessibilityLabel("Back")
} }
ToolbarItem(placement: .navigationBarTrailing) { ToolbarItem(placement: .navigationBarTrailing) {
Button("Server settings") { settings = true } Button("Server settings") { settings = true }
@@ -70,10 +79,11 @@ struct ContentView: View {
} }
.task { .task {
if server == nil, let existing = try? ServerAddress(input) { if server == nil, let existing = try? ServerAddress(input) {
server = existing; browser.open(existing) server = existing
browser.open(existing)
} }
} }
.onChange(of: scenePhase) { phase in .onChange(of: scenePhase) { _, phase in
if phase == .background { browser.rememberPath() } if phase == .background { browser.rememberPath() }
} }
.overlay { .overlay {
@@ -93,31 +103,49 @@ struct ContentView: View {
} }
} }
Section { Section {
Text("Changing servers erases cookies, storage and cached household data. Exported files remain. Sign out in Yuvomi first to revoke the server session.") Text(
"Changing servers erases cookies, storage and cached household data. Exported files remain. Sign out in Yuvomi first to revoke the server session."
)
Button("Erase local data and change server", role: .destructive) { Button("Erase local data and change server", role: .destructive) {
settings = false; erasing = true settings = false
erasing = true
browser.erase { browser.erase {
UserDefaults.standard.removeObject(forKey: "origin") UserDefaults.standard.removeObject(forKey: "origin")
UserDefaults.standard.removeObject(forKey: "path") UserDefaults.standard.removeObject(forKey: "path")
server = nil; input = ""; erasing = false server = nil
input = ""
erasing = false
} }
} }
} }
Section("About") { Section("About") {
Text("Independent community contribution. MIT licensed. Not endorsed by upstream Yuvomi. No mobile telemetry. Your server's own privacy settings still apply.") Text(
"Independent community contribution. MIT licensed. Not endorsed by upstream Yuvomi. No mobile telemetry. Your server's own privacy settings still apply."
)
} }
}.navigationTitle("Server settings") }.navigationTitle("Server settings")
.toolbar { Button("Done") { settings = false } } .toolbar { Button("Done") { settings = false } }
} }
} }
.alert("External destination", isPresented: Binding( .alert(
get: { browser.externalURL != nil }, set: { if !$0 { browser.externalURL = nil } } "External destination",
), presenting: browser.externalURL) { url in isPresented: Binding(
Button("Open in browser") { UIApplication.shared.open(url); browser.externalURL = nil } get: { browser.externalURL != nil }, set: { if !$0 { browser.externalURL = nil } }
Button("Isolated sign-in window") { browser.startAuthentication(url); browser.externalURL = nil } ), presenting: browser.externalURL
) { url in
Button("Open in browser") {
UIApplication.shared.open(url)
browser.externalURL = nil
}
Button("Isolated sign-in window") {
browser.startAuthentication(url)
browser.externalURL = nil
}
Button("Cancel", role: .cancel) { browser.externalURL = nil } Button("Cancel", role: .cancel) { browser.externalURL = nil }
} message: { url in } message: { url in
Text("Destination: \(url.host ?? "")\nFor SSO, use the isolated window only if you trust this provider. Browser sessions do not transfer into the app.") Text(
"Destination: \(url.host ?? "")\nFor SSO, use the isolated window only if you trust this provider. Browser sessions do not transfer into the app."
)
} }
.sheet(item: $browser.authentication) { destination in .sheet(item: $browser.authentication) { destination in
AuthenticationView(destination: destination.url, model: browser) AuthenticationView(destination: destination.url, model: browser)
@@ -130,13 +158,15 @@ struct ContentView: View {
private func connect() { private func connect() {
do { do {
let candidate = try ServerAddress(input) let candidate = try ServerAddress(input)
connecting = true; message = nil connecting = true
message = nil
Task { @MainActor in Task { @MainActor in
defer { connecting = false } defer { connecting = false }
do { do {
try await ServerProbe.check(candidate) try await ServerProbe.check(candidate)
UserDefaults.standard.set(candidate.url.absoluteString, forKey: "origin") UserDefaults.standard.set(candidate.url.absoluteString, forKey: "origin")
server = candidate; browser.open(candidate) server = candidate
browser.open(candidate)
} catch { message = ConnectionError.unavailable.localizedDescription } } catch { message = ConnectionError.unavailable.localizedDescription }
} }
} catch { message = ConnectionError.invalidAddress.localizedDescription } } catch { message = ConnectionError.invalidAddress.localizedDescription }
+18 -8
View File
@@ -6,13 +6,14 @@ struct ServerAddress: Equatable {
init(_ input: String) throws { init(_ input: String) throws {
let value = input.trimmingCharacters(in: .whitespacesAndNewlines) let value = input.trimmingCharacters(in: .whitespacesAndNewlines)
guard !value.contains("\\"), !value.contains(where: { $0.isWhitespace }), guard !value.contains("\\"), !value.contains(where: { $0.isWhitespace }),
var parts = URLComponents(string: value), var parts = URLComponents(string: value),
parts.scheme?.lowercased() == "https", parts.scheme?.lowercased() == "https",
let host = parts.host, !host.isEmpty, let host = parts.host, !host.isEmpty,
parts.user == nil, parts.password == nil, parts.user == nil, parts.password == nil,
parts.query == nil, parts.fragment == nil, parts.query == nil, parts.fragment == nil,
parts.percentEncodedPath.isEmpty || parts.percentEncodedPath == "/", parts.percentEncodedPath.isEmpty || parts.percentEncodedPath == "/",
parts.port == nil || (1...65535).contains(parts.port!) else { parts.port == nil || (1...65535).contains(parts.port!)
else {
throw ConnectionError.invalidAddress throw ConnectionError.invalidAddress
} }
parts.scheme = "https" parts.scheme = "https"
@@ -25,11 +26,20 @@ struct ServerAddress: Equatable {
func owns(_ candidate: URL) -> Bool { func owns(_ candidate: URL) -> Bool {
guard let parts = URLComponents(url: candidate, resolvingAgainstBaseURL: false), guard let parts = URLComponents(url: candidate, resolvingAgainstBaseURL: false),
let own = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return false } let own = URLComponents(url: url, resolvingAgainstBaseURL: false)
else { return false }
return parts.scheme?.lowercased() == "https" && parts.user == nil && parts.password == nil return parts.scheme?.lowercased() == "https" && parts.user == nil && parts.password == nil
&& parts.host?.lowercased() == own.host?.lowercased() && parts.host?.lowercased() == own.host?.lowercased()
&& (parts.port ?? 443) == (own.port ?? 443) && (parts.port ?? 443) == (own.port ?? 443)
} }
func ownsDownload(_ candidate: URL) -> Bool {
if owns(candidate) { return true }
guard candidate.scheme == "blob",
let inner = URL(string: String(candidate.absoluteString.dropFirst(5)))
else { return false }
return owns(inner)
}
} }
enum ConnectionError: LocalizedError { enum ConnectionError: LocalizedError {
+19
View File
@@ -0,0 +1,19 @@
import Foundation
enum ServerMetadata {
private struct Bootstrap: Decodable {
let appName: String
let setupRequired: Bool
let passwordResetEnabled: Bool
}
private struct Authentication: Decodable {
let enabled: Bool
let passwordLoginEnabled: Bool
}
static func validate(bootstrap: Data, authentication: Data) throws {
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
_ = try decoder.decode(Bootstrap.self, from: bootstrap)
_ = try decoder.decode(Authentication.self, from: authentication)
}
}
+9 -15
View File
@@ -1,10 +1,12 @@
import Foundation import Foundation
final class NoRedirect: NSObject, URLSessionTaskDelegate { final class NoRedirect: NSObject, URLSessionTaskDelegate {
func urlSession(_ session: URLSession, task: URLSessionTask, func urlSession(
willPerformHTTPRedirection response: HTTPURLResponse, _ session: URLSession, task: URLSessionTask,
newRequest request: URLRequest, willPerformHTTPRedirection response: HTTPURLResponse,
completionHandler: @escaping (URLRequest?) -> Void) { newRequest request: URLRequest,
completionHandler: @escaping (URLRequest?) -> Void
) {
completionHandler(nil) completionHandler(nil)
} }
} }
@@ -19,16 +21,11 @@ enum ServerProbe {
let session = URLSession(configuration: configuration, delegate: NoRedirect(), delegateQueue: nil) let session = URLSession(configuration: configuration, delegate: NoRedirect(), delegateQueue: nil)
defer { session.invalidateAndCancel() } defer { session.invalidateAndCancel() }
let version = try await json(session, server.url.appendingPathComponent("api/v1/version")) let version = try await json(session, server.url.appendingPathComponent("api/v1/version"))
guard version["app_name"] is String,
version["setup_required"] is Bool,
version["password_reset_enabled"] is Bool else { throw ConnectionError.incompatible }
let auth = try await json(session, server.url.appendingPathComponent("api/v1/auth/oidc/config")) let auth = try await json(session, server.url.appendingPathComponent("api/v1/auth/oidc/config"))
guard auth["enabled"] is Bool, auth["password_login_enabled"] is Bool else { try ServerMetadata.validate(bootstrap: version, authentication: auth)
throw ConnectionError.incompatible
}
} }
private static func json(_ session: URLSession, _ url: URL) async throws -> [String: Any] { private static func json(_ session: URLSession, _ url: URL) async throws -> Data {
var request = URLRequest(url: url) var request = URLRequest(url: url)
request.setValue("application/json", forHTTPHeaderField: "Accept") request.setValue("application/json", forHTTPHeaderField: "Accept")
let (stream, response) = try await session.bytes(for: request) let (stream, response) = try await session.bytes(for: request)
@@ -38,9 +35,6 @@ enum ServerProbe {
guard data.count < 65_536 else { throw ConnectionError.incompatible } guard data.count < 65_536 else { throw ConnectionError.incompatible }
data.append(byte) data.append(byte)
} }
guard let result = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { return data
throw ConnectionError.incompatible
}
return result
} }
} }
+20 -8
View File
@@ -1,4 +1,5 @@
import XCTest import XCTest
@testable import Yuvomi @testable import Yuvomi
final class ServerAddressTests: XCTestCase { final class ServerAddressTests: XCTestCase {
@@ -8,21 +9,32 @@ final class ServerAddressTests: XCTestCase {
XCTAssertEqual(try ServerAddress("https://[::1]:8443").url.port, 8443) XCTAssertEqual(try ServerAddress("https://[::1]:8443").url.port, 8443)
} }
func testUnsafeConfigurations() { func testUnsafeConfigurations() {
for value in ["http://family.example", "javascript:alert(1)", "https://u:p@family.example", for value in [
"https://family.example/subpath", "https://family.example?token=secret", "http://family.example", "javascript:alert(1)", "https://u:p@family.example",
"https://family.example/#token", "https://family.example:0", "https://family.example/subpath", "https://family.example?token=secret",
"https://family.example:65536", "https://family.example/%2f", "https://family.example/#token", "https://family.example:0",
"https://family.example\\@evil.test", "https:///", ""] { "https://family.example:65536", "https://family.example/%2f",
"https://family.example\\@evil.test", "https:///", "",
] {
XCTAssertThrowsError(try ServerAddress(value), value) XCTAssertThrowsError(try ServerAddress(value), value)
} }
} }
func testOriginIsolation() throws { func testOriginIsolation() throws {
let server = try ServerAddress("https://family.example") let server = try ServerAddress("https://family.example")
XCTAssertTrue(server.owns(URL(string: "https://FAMILY.example:443/tasks?id=1")!)) XCTAssertTrue(server.owns(URL(string: "https://FAMILY.example:443/tasks?id=1")!))
for value in ["https://family.example.evil.test", "https://family.example@evil.test", for value in [
"https://user@family.example", "http://family.example", "https://family.example:8443", "https://family.example.evil.test", "https://family.example@evil.test",
"file:///etc/passwd", "data:text/html,hi"] { "https://user@family.example", "http://family.example", "https://family.example:8443",
"file:///etc/passwd", "data:text/html,hi",
] {
XCTAssertFalse(server.owns(URL(string: value)!), value) XCTAssertFalse(server.owns(URL(string: value)!), value)
} }
} }
func testBlobExportsAreOriginBound() throws {
let server = try ServerAddress("https://family.example")
XCTAssertTrue(server.ownsDownload(URL(string: "blob:https://family.example/unique-id")!))
XCTAssertFalse(server.ownsDownload(URL(string: "blob:https://evil.example/unique-id")!))
XCTAssertFalse(server.ownsDownload(URL(string: "blob:http://family.example/unique-id")!))
XCTAssertFalse(server.ownsDownload(URL(string: "file:///tmp/export")!))
}
} }
+21
View File
@@ -0,0 +1,21 @@
import XCTest
@testable import Yuvomi
final class ServerMetadataTests: XCTestCase {
private let bootstrap = Data(#"{"app_name":"Yuvomi","setup_required":false,"password_reset_enabled":true}"#.utf8)
private let authentication = Data(#"{"enabled":false,"password_login_enabled":true}"#.utf8)
func testAcceptsActualAnonymousMetadataWithoutVersion() throws {
try ServerMetadata.validate(bootstrap: bootstrap, authentication: authentication)
}
func testRejectsNumericBooleanAndMissingAuthenticationMetadata() {
let numeric = Data(#"{"enabled":1,"password_login_enabled":true}"#.utf8)
XCTAssertThrowsError(try ServerMetadata.validate(bootstrap: bootstrap, authentication: numeric))
XCTAssertThrowsError(try ServerMetadata.validate(bootstrap: bootstrap, authentication: Data("{}".utf8)))
}
func testRejectsProxyHtmlAndInvalidJson() {
XCTAssertThrowsError(
try ServerMetadata.validate(bootstrap: Data("<html>SSO proxy</html>".utf8), authentication: authentication))
XCTAssertThrowsError(try ServerMetadata.validate(bootstrap: Data("null".utf8), authentication: authentication))
}
}
+2 -1
View File
@@ -2,7 +2,7 @@ name: Yuvomi
options: options:
bundleIdPrefix: community.yuvomi bundleIdPrefix: community.yuvomi
deploymentTarget: deploymentTarget:
iOS: "16.0" iOS: "18.4"
settings: settings:
base: base:
SWIFT_VERSION: "5.0" SWIFT_VERSION: "5.0"
@@ -40,6 +40,7 @@ targets:
base: base:
PRODUCT_BUNDLE_IDENTIFIER: community.yuvomi.mobile PRODUCT_BUNDLE_IDENTIFIER: community.yuvomi.mobile
TARGETED_DEVICE_FAMILY: "1,2" TARGETED_DEVICE_FAMILY: "1,2"
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
dependencies: dependencies:
- sdk: WebKit.framework - sdk: WebKit.framework
YuvomiTests: YuvomiTests:
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env python3
"""Generate the original community Y mark (not the upstream logo), without dependencies."""
import json
from pathlib import Path
import struct
import zlib
ROOT = Path(__file__).resolve().parents[1]
def png(size):
raw = bytearray()
segments = [(32, 29, 54, 54), (54, 54, 76, 29), (54, 54, 54, 80)]
for y in range(size):
raw.append(0)
for x in range(size):
coverage = 0
for dx, dy in [(0.25, 0.25), (0.75, 0.25), (0.25, 0.75), (0.75, 0.75)]:
px, py = (x + dx) * 108 / size, (y + dy) * 108 / size
for ax, ay, bx, by in segments:
projection = max(0, min(1, ((px-ax)*(bx-ax)+(py-ay)*(by-ay))/((bx-ax)**2+(by-ay)**2)))
if (px-ax-projection*(bx-ax))**2 + (py-ay-projection*(by-ay))**2 <= 4.5**2:
coverage += 1
break
raw.extend(round(background + (255-background)*coverage/4) for background in (108, 58, 237))
def chunk(name, data):
return struct.pack(">I", len(data)) + name + data + struct.pack(">I", zlib.crc32(name+data))
return b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack(">IIBBBBB", size, size, 8, 2, 0, 0, 0)) + chunk(b"IDAT", zlib.compress(raw, 9)) + chunk(b"IEND", b"")
if __name__ == "__main__":
folder = ROOT / "ios/Sources/Assets.xcassets/AppIcon.appiconset"
folder.mkdir(parents=True, exist_ok=True)
(folder / "AppIcon.png").write_bytes(png(1024))
(folder / "Contents.json").write_text(json.dumps({"images": [
{"filename": "AppIcon.png", "idiom": "universal", "platform": "ios", "size": "1024x1024"}
], "info": {"author": "Yuvomi Mobile contributors", "version": 1}}, indent=2) + "\n")