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

+7 -5
View File
@@ -6,10 +6,12 @@ let package = Package(
name: "YuvomiPolicy",
products: [.library(name: "Yuvomi", targets: ["Yuvomi"])],
targets: [
.target(name: "Yuvomi", path: "Sources", exclude: [
"AuthenticationView.swift", "BrowserModel.swift", "ContentView.swift",
"ServerProbe.swift", "YuvomiApp.swift", "PrivacyInfo.xcprivacy"
], sources: ["ServerAddress.swift"]),
.testTarget(name: "YuvomiPolicyTests", dependencies: ["Yuvomi"], path: "Tests")
.target(
name: "Yuvomi", path: "Sources",
exclude: [
"AuthenticationView.swift", "BrowserModel.swift", "ContentView.swift",
"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 {
NavigationStack {
IsolatedAuthentication(url: destination, model: model)
.navigationTitle("Isolated web sign-in")
.navigationTitle(model.authenticationHost)
.navigationBarTitleDisplayMode(.inline)
.toolbar { Button("Close") { model.authentication = nil } }
}
@@ -33,15 +33,26 @@ struct IsolatedAuthentication: UIViewRepresentable {
}
func updateUIView(_ uiView: WKWebView, context: Context) {}
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 {
let model: BrowserModel
init(_ model: BrowserModel) { self.model = model }
func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction,
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
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",
url.user == nil, url.password == nil else { decisionHandler(.cancel); return }
url.user == nil, url.password == nil
else {
decisionHandler(.cancel)
return
}
decisionHandler(.allow)
}
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
@@ -50,8 +61,17 @@ struct IsolatedAuthentication: UIViewRepresentable {
model.web.load(URLRequest(url: url))
}
}
func webView(_ webView: WKWebView, requestMediaCapturePermissionFor origin: WKSecurityOrigin,
initiatedByFrame frame: WKFrameInfo, type: WKMediaCaptureType,
decisionHandler: @escaping (WKPermissionDecision) -> Void) { decisionHandler(.deny) }
func webView(
_ webView: WKWebView, requestMediaCapturePermissionFor origin: WKSecurityOrigin,
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 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()
}
}
}
+48 -18
View File
@@ -22,7 +22,10 @@ struct ContentView: View {
Image(systemName: "wifi.exclamationmark").font(.largeTitle)
Text("Connection interrupted").font(.title2).bold()
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("Server settings") { settings = true }
}
@@ -30,12 +33,18 @@ struct ContentView: View {
.background(Color(uiColor: .systemGroupedBackground))
.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 {
ToolbarItem(placement: .navigationBarLeading) {
Button { browser.web.goBack() } label: { Image(systemName: "chevron.backward") }
.disabled(!browser.canGoBack).accessibilityLabel("Back")
Button {
browser.web.goBack()
} label: {
Image(systemName: "chevron.backward")
}
.disabled(!browser.canGoBack).accessibilityLabel("Back")
}
ToolbarItem(placement: .navigationBarTrailing) {
Button("Server settings") { settings = true }
@@ -70,10 +79,11 @@ struct ContentView: View {
}
.task {
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() }
}
.overlay {
@@ -93,31 +103,49 @@ struct ContentView: View {
}
}
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) {
settings = false; erasing = true
settings = false
erasing = true
browser.erase {
UserDefaults.standard.removeObject(forKey: "origin")
UserDefaults.standard.removeObject(forKey: "path")
server = nil; input = ""; erasing = false
server = nil
input = ""
erasing = false
}
}
}
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")
.toolbar { Button("Done") { settings = false } }
}
}
.alert("External destination", isPresented: Binding(
get: { browser.externalURL != nil }, set: { if !$0 { 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 }
.alert(
"External destination",
isPresented: Binding(
get: { browser.externalURL != nil }, set: { if !$0 { 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 }
} 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
AuthenticationView(destination: destination.url, model: browser)
@@ -130,13 +158,15 @@ struct ContentView: View {
private func connect() {
do {
let candidate = try ServerAddress(input)
connecting = true; message = nil
connecting = true
message = nil
Task { @MainActor in
defer { connecting = false }
do {
try await ServerProbe.check(candidate)
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.invalidAddress.localizedDescription }
+18 -8
View File
@@ -6,13 +6,14 @@ struct ServerAddress: Equatable {
init(_ input: String) throws {
let value = input.trimmingCharacters(in: .whitespacesAndNewlines)
guard !value.contains("\\"), !value.contains(where: { $0.isWhitespace }),
var parts = URLComponents(string: value),
parts.scheme?.lowercased() == "https",
let host = parts.host, !host.isEmpty,
parts.user == nil, parts.password == nil,
parts.query == nil, parts.fragment == nil,
parts.percentEncodedPath.isEmpty || parts.percentEncodedPath == "/",
parts.port == nil || (1...65535).contains(parts.port!) else {
var parts = URLComponents(string: value),
parts.scheme?.lowercased() == "https",
let host = parts.host, !host.isEmpty,
parts.user == nil, parts.password == nil,
parts.query == nil, parts.fragment == nil,
parts.percentEncodedPath.isEmpty || parts.percentEncodedPath == "/",
parts.port == nil || (1...65535).contains(parts.port!)
else {
throw ConnectionError.invalidAddress
}
parts.scheme = "https"
@@ -25,11 +26,20 @@ struct ServerAddress: Equatable {
func owns(_ candidate: URL) -> Bool {
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
&& parts.host?.lowercased() == own.host?.lowercased()
&& (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 {
+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
final class NoRedirect: NSObject, URLSessionTaskDelegate {
func urlSession(_ session: URLSession, task: URLSessionTask,
willPerformHTTPRedirection response: HTTPURLResponse,
newRequest request: URLRequest,
completionHandler: @escaping (URLRequest?) -> Void) {
func urlSession(
_ session: URLSession, task: URLSessionTask,
willPerformHTTPRedirection response: HTTPURLResponse,
newRequest request: URLRequest,
completionHandler: @escaping (URLRequest?) -> Void
) {
completionHandler(nil)
}
}
@@ -19,16 +21,11 @@ enum ServerProbe {
let session = URLSession(configuration: configuration, delegate: NoRedirect(), delegateQueue: nil)
defer { session.invalidateAndCancel() }
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"))
guard auth["enabled"] is Bool, auth["password_login_enabled"] is Bool else {
throw ConnectionError.incompatible
}
try ServerMetadata.validate(bootstrap: version, authentication: auth)
}
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)
request.setValue("application/json", forHTTPHeaderField: "Accept")
let (stream, response) = try await session.bytes(for: request)
@@ -38,9 +35,6 @@ enum ServerProbe {
guard data.count < 65_536 else { throw ConnectionError.incompatible }
data.append(byte)
}
guard let result = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
throw ConnectionError.incompatible
}
return result
return data
}
}
+20 -8
View File
@@ -1,4 +1,5 @@
import XCTest
@testable import Yuvomi
final class ServerAddressTests: XCTestCase {
@@ -8,21 +9,32 @@ final class ServerAddressTests: XCTestCase {
XCTAssertEqual(try ServerAddress("https://[::1]:8443").url.port, 8443)
}
func testUnsafeConfigurations() {
for value in ["http://family.example", "javascript:alert(1)", "https://u:p@family.example",
"https://family.example/subpath", "https://family.example?token=secret",
"https://family.example/#token", "https://family.example:0",
"https://family.example:65536", "https://family.example/%2f",
"https://family.example\\@evil.test", "https:///", ""] {
for value in [
"http://family.example", "javascript:alert(1)", "https://u:p@family.example",
"https://family.example/subpath", "https://family.example?token=secret",
"https://family.example/#token", "https://family.example:0",
"https://family.example:65536", "https://family.example/%2f",
"https://family.example\\@evil.test", "https:///", "",
] {
XCTAssertThrowsError(try ServerAddress(value), value)
}
}
func testOriginIsolation() throws {
let server = try ServerAddress("https://family.example")
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",
"https://user@family.example", "http://family.example", "https://family.example:8443",
"file:///etc/passwd", "data:text/html,hi"] {
for value in [
"https://family.example.evil.test", "https://family.example@evil.test",
"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)
}
}
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:
bundleIdPrefix: community.yuvomi
deploymentTarget:
iOS: "16.0"
iOS: "18.4"
settings:
base:
SWIFT_VERSION: "5.0"
@@ -40,6 +40,7 @@ targets:
base:
PRODUCT_BUNDLE_IDENTIFIER: community.yuvomi.mobile
TARGETED_DEVICE_FAMILY: "1,2"
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
dependencies:
- sdk: WebKit.framework
YuvomiTests: