@protobase/mobile 0.55.11 → 0.57.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,34 @@
1
+ import Foundation
2
+
3
+ /**
4
+ * The request headers ProtobaseAuth sends of those page script gives, as Android's AuthHeaders.java keeps them: the
5
+ * string-valued ones, without the Fetch spec's forbidden request header names, which a page's Request never carries.
6
+ * So page script cannot set the cookie, the origin, or the host a shared ingress routes by, nor override the method.
7
+ * A name that is no HTTP token, or a value holding CR, LF or NUL, is dropped too.
8
+ */
9
+ enum AuthHeaders {
10
+ private static let forbidden: Set<String> = [
11
+ "accept-charset", "accept-encoding", "access-control-request-headers", "access-control-request-method",
12
+ "connection", "content-length", "cookie", "cookie2", "date", "dnt", "expect", "host", "keep-alive", "origin",
13
+ "referer", "set-cookie", "te", "trailer", "transfer-encoding", "upgrade", "via", "x-http-method",
14
+ "x-http-method-override", "x-method-override",
15
+ ]
16
+
17
+ // RFC 9110's tchar.
18
+ private static let token = CharacterSet(charactersIn: "!#$%&'*+-.^_`|~0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
19
+
20
+ static func allowed(_ given: [String: Any]?) -> [String: String] {
21
+ var headers: [String: String] = [:]
22
+ for (name, value) in given ?? [:] {
23
+ guard let text = value as? String, !isForbidden(name), !text.contains(where: { "\r\n\0".contains($0) }) else { continue }
24
+ headers[name] = text
25
+ }
26
+ return headers
27
+ }
28
+
29
+ private static func isForbidden(_ name: String) -> Bool {
30
+ let lower = name.lowercased()
31
+ let isToken = !name.isEmpty && name.unicodeScalars.allSatisfy { token.contains($0) }
32
+ return !isToken || forbidden.contains(lower) || lower.hasPrefix("proxy-") || lower.hasPrefix("sec-")
33
+ }
34
+ }
@@ -0,0 +1,66 @@
1
+ import Capacitor
2
+ import Foundation
3
+
4
+ /**
5
+ * One sign-in request of ProtobaseAuth, checked in the order of Android's ProtobaseAuth.java and sent through
6
+ * Capacitor's own request type, which refuses redirects, with the device's cookie jar. The call's options are never
7
+ * handed to Capacitor code, so page script cannot add params, a file body, timeouts or redirects.
8
+ */
9
+ enum AuthRequest {
10
+ static func send(_ call: CAPPluginCall, config: PluginConfig, capConfig: InstanceConfiguration?) {
11
+ guard let apiOrigin = config.getString("apiOrigin"), let origin = config.getString("origin") else {
12
+ call.reject("ProtobaseAuth has no apiOrigin or origin in its config")
13
+ return
14
+ }
15
+ guard let text = call.options["url"] as? String, let url = AuthUrl.allowed(text, apiOrigin: apiOrigin) else {
16
+ call.reject("ProtobaseAuth calls \(apiOrigin)\(AuthUrl.authBasePath)/ only")
17
+ return
18
+ }
19
+ let method = (call.getString("method") ?? "GET").uppercased()
20
+ // All Better Auth serves.
21
+ guard method == "GET" || method == "POST" else {
22
+ call.reject("ProtobaseAuth sends GET or POST only, not \(method)")
23
+ return
24
+ }
25
+ let data = call.options["data"]
26
+ guard data == nil || data is String else {
27
+ call.reject("ProtobaseAuth sends a text body only")
28
+ return
29
+ }
30
+ let body = data as? String
31
+ // URLSession fails a GET with a body, and the shell never sends one.
32
+ guard body == nil || method == "POST" else {
33
+ call.reject("ProtobaseAuth sends a body with POST only")
34
+ return
35
+ }
36
+ var headers = AuthHeaders.allowed(call.getObject("headers"))
37
+ headers["Origin"] = origin
38
+
39
+ let request = CapacitorUrlRequest(url, method: method)
40
+ request.setRequestHeaders(headers)
41
+ if let body { request.request.httpBody = Data(body.utf8) }
42
+ // The session holds its delegate, the request that refuses redirects, until it is invalidated.
43
+ let session = URLSession(configuration: .default, delegate: request, delegateQueue: nil)
44
+ let task = session.dataTask(with: request.getUrlRequest()) { data, response, error in
45
+ session.invalidateAndCancel()
46
+ if let error {
47
+ call.reject(error.localizedDescription, (error as NSError).domain, error, nil)
48
+ return
49
+ }
50
+ guard let response = response as? HTTPURLResponse else {
51
+ call.reject("ProtobaseAuth got no HTTP answer")
52
+ return
53
+ }
54
+ HttpRequestHandler.setCookiesFromResponse(response, capConfig)
55
+ var output = HttpRequestHandler.buildResponse(data, response, responseType: .text)
56
+ // Set-Cookie stays in the jar; buildResponse lower-cases the names.
57
+ if var answerHeaders = output["headers"] as? [String: Any] {
58
+ answerHeaders.removeValue(forKey: "set-cookie")
59
+ answerHeaders.removeValue(forKey: "set-cookie2")
60
+ output["headers"] = answerHeaders
61
+ }
62
+ call.resolve(output)
63
+ }
64
+ task.resume()
65
+ }
66
+ }
@@ -0,0 +1,36 @@
1
+ import Foundation
2
+
3
+ /** The sign-in addresses ProtobaseAuth calls: `<apiOrigin>/api/auth/...`, as Android's AuthUrl.java allows them. */
4
+ enum AuthUrl {
5
+ /** Where Better Auth's endpoints are served, as packages/schema/src/auth-base-path.ts names it. */
6
+ static let authBasePath = "/api/auth"
7
+
8
+ private struct Parts {
9
+ let scheme: String
10
+ let host: String
11
+ let port: Int
12
+ let path: String
13
+ let hasUser: Bool
14
+ }
15
+
16
+ private static func parse(_ text: String) -> Parts? {
17
+ guard let url = URL(string: text), let parts = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return nil }
18
+ let scheme = (parts.scheme ?? "").lowercased()
19
+ guard scheme == "http" || scheme == "https", let host = parts.percentEncodedHost, !host.isEmpty else { return nil }
20
+ let port = parts.port ?? (scheme == "https" ? 443 : 80)
21
+ let hasUser = parts.percentEncodedUser != nil || parts.percentEncodedPassword != nil
22
+ return Parts(scheme: scheme, host: host.lowercased(), port: port, path: parts.percentEncodedPath, hasUser: hasUser)
23
+ }
24
+
25
+ /** The URL to send when `text` is allowed, so the URL checked is the URL sent; `nil` otherwise. */
26
+ static func allowed(_ text: String, apiOrigin: String) -> URL? {
27
+ guard let origin = parse(apiOrigin), let target = parse(text), origin.path.isEmpty, !target.hasUser else { return nil }
28
+ guard target.scheme == origin.scheme, target.host == origin.host, target.port == origin.port else { return nil }
29
+ let path = target.path
30
+ guard path.hasPrefix(authBasePath + "/"), !path.contains("\\") else { return nil }
31
+ let lower = path.lowercased()
32
+ guard !["%2e", "%2f", "%5c"].contains(where: { lower.contains($0) }) else { return nil }
33
+ guard !path.split(separator: "/", omittingEmptySubsequences: false).contains(where: { $0 == "." || $0 == ".." }) else { return nil }
34
+ return URL(string: text)
35
+ }
36
+ }
@@ -0,0 +1,48 @@
1
+ import WebKit
2
+
3
+ /**
4
+ * Stands in front of the WebView's UI delegate, Capacitor's WebViewDelegationHandler, which answers a JSON `prompt` of
5
+ * type `CapacitorCookies.set` or `CapacitorCookies.get` without a dialog, whether CapacitorCookies is enabled or not,
6
+ * and crashes on a `set` without its fields. Those prompts answer `null`; everything else goes to Capacitor's delegate.
7
+ */
8
+ final class CookiePromptFilter: NSObject, WKUIDelegate {
9
+ private static let refusedTypes: Set<String> = ["CapacitorCookies.set", "CapacitorCookies.get"]
10
+
11
+ private let original: NSObject & WKUIDelegate
12
+
13
+ private init(original: NSObject & WKUIDelegate) {
14
+ self.original = original
15
+ }
16
+
17
+ /** Puts the filter in front; the caller keeps it, as the WebView holds its UI delegate weakly. */
18
+ static func install(on webView: WKWebView) -> CookiePromptFilter {
19
+ guard let original = webView.uiDelegate as? NSObject & WKUIDelegate else { fatalError("ProtobaseAuth needs Capacitor's UI delegate on the WebView") }
20
+ let filter = CookiePromptFilter(original: original)
21
+ webView.uiDelegate = filter
22
+ return filter
23
+ }
24
+
25
+ func webView(_ webView: WKWebView, runJavaScriptTextInputPanelWithPrompt prompt: String, defaultText: String?, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping (String?) -> Void) {
26
+ // Parsed as WebViewDelegationHandler.swift parses it.
27
+ if let data = prompt.data(using: .utf8),
28
+ let payload = try? JSONSerialization.jsonObject(with: data, options: .fragmentsAllowed) as? [String: AnyObject],
29
+ let type = payload["type"] as? String,
30
+ Self.refusedTypes.contains(type) {
31
+ completionHandler(nil)
32
+ return
33
+ }
34
+ guard let forward = original.webView(_:runJavaScriptTextInputPanelWithPrompt:defaultText:initiatedByFrame:completionHandler:) else {
35
+ completionHandler(nil)
36
+ return
37
+ }
38
+ forward(webView, prompt, defaultText, frame, completionHandler)
39
+ }
40
+
41
+ override func responds(to aSelector: Selector!) -> Bool {
42
+ super.responds(to: aSelector) || original.responds(to: aSelector)
43
+ }
44
+
45
+ override func forwardingTarget(for aSelector: Selector!) -> Any? {
46
+ original
47
+ }
48
+ }
@@ -0,0 +1,50 @@
1
+ import Capacitor
2
+ import ObjectiveC
3
+ import WebKit
4
+
5
+ /**
6
+ * Capacitor's asset handler with LocalFileGuard in front. WebKit holds the handler from the WebView's creation, before
7
+ * any plugin loads, so `install` switches the live instance to this class, which adds no stored state; the guard is an
8
+ * associated object. One lock covers the check and the serving, and `setAssetPath`, so the base path cannot change
9
+ * between them.
10
+ */
11
+ final class GuardedAssetHandler: WebViewAssetHandler {
12
+ private static var guardKey: UInt8 = 0
13
+
14
+ static func install(on handler: WebViewAssetHandler, _ fileGuard: LocalFileGuard) {
15
+ objc_setAssociatedObject(handler, &guardKey, fileGuard, .OBJC_ASSOCIATION_RETAIN)
16
+ object_setClass(handler, GuardedAssetHandler.self)
17
+ }
18
+
19
+ private var fileGuard: LocalFileGuard {
20
+ guard let fileGuard = objc_getAssociatedObject(self, &Self.guardKey) as? LocalFileGuard else { fatalError("GuardedAssetHandler has no LocalFileGuard") }
21
+ return fileGuard
22
+ }
23
+
24
+ override func setAssetPath(_ assetPath: String) {
25
+ let fileGuard = fileGuard
26
+ fileGuard.withLock {
27
+ fileGuard.basePath = assetPath
28
+ super.setAssetPath(assetPath)
29
+ }
30
+ }
31
+
32
+ override func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) {
33
+ let fileGuard = fileGuard
34
+ fileGuard.withLock {
35
+ if fileGuard.refuses(path: urlSchemeTask.request.url?.path ?? "") {
36
+ forbid(urlSchemeTask)
37
+ return
38
+ }
39
+ super.webView(webView, start: urlSchemeTask)
40
+ }
41
+ }
42
+
43
+ // An empty 403, as Android's LocalServerGuard answers.
44
+ private func forbid(_ urlSchemeTask: WKURLSchemeTask) {
45
+ let url = urlSchemeTask.request.url ?? URL(fileURLWithPath: "/")
46
+ urlSchemeTask.didReceive(HTTPURLResponse(url: url, statusCode: 403, httpVersion: nil, headerFields: [:])!)
47
+ urlSchemeTask.didReceive(Data())
48
+ urlSchemeTask.didFinish()
49
+ }
50
+ }
@@ -0,0 +1,59 @@
1
+ import Capacitor
2
+ import Foundation
3
+
4
+ /**
5
+ * Decides which `capacitor://localhost` paths the app's asset handler may serve to page script: none of the local file
6
+ * or native HTTP routes, no `..`, and from a base path outside the app bundle only files that are really inside the
7
+ * live updater's bundles folder. Each check runs on the decoded path, the one the asset handler opens.
8
+ */
9
+ final class LocalFileGuard {
10
+ // Where the live updater unzips bundles (capacitor-live-update's LiveUpdate.swift) and Capacitor serves a persisted
11
+ // server base path from (CAPBridgeViewController.swift).
12
+ private static let bundlesFolder = "Library/NoCloud/ionic_built_snapshots"
13
+ private static let refusedPrefixes = [
14
+ CapacitorBridge.fileStartIdentifier,
15
+ CapacitorBridge.httpInterceptorStartIdentifier,
16
+ CapacitorBridge.httpsInterceptorStartIdentifier,
17
+ ]
18
+
19
+ private let lock = NSLock()
20
+ private let appBundle: String?
21
+ // Resolved once: with no bundles folder at launch, nothing outside the app bundle is served.
22
+ private let bundles: String?
23
+ var basePath: String
24
+
25
+ init(basePath: String) {
26
+ self.basePath = basePath
27
+ appBundle = Self.realPath(Bundle.main.bundlePath)
28
+ bundles = Self.realPath(NSHomeDirectory() + "/" + Self.bundlesFolder)
29
+ }
30
+
31
+ func withLock<T>(_ body: () throws -> T) rethrows -> T {
32
+ lock.lock()
33
+ defer { lock.unlock() }
34
+ return try body()
35
+ }
36
+
37
+ func refuses(path: String) -> Bool {
38
+ if Self.refusedPrefixes.contains(where: { path.hasPrefix($0) }) { return true }
39
+ if path.contains("\0") || path.split(separator: "/", omittingEmptySubsequences: false).contains("..") { return true }
40
+ guard let base = Self.realPath(basePath) else { return true }
41
+ if let appBundle, Self.isInside(base, appBundle) { return false }
42
+ guard let bundles else { return true }
43
+ // The file the asset handler opens, by Capacitor's own routing.
44
+ var router = CapacitorRouter()
45
+ router.basePath = basePath
46
+ guard let file = Self.realPath(router.route(for: path)) else { return true }
47
+ return !Self.isInside(file, bundles)
48
+ }
49
+
50
+ private static func isInside(_ path: String, _ folder: String) -> Bool {
51
+ path == folder || path.hasPrefix(folder.hasSuffix("/") ? folder : folder + "/")
52
+ }
53
+
54
+ private static func realPath(_ path: String) -> String? {
55
+ guard let resolved = realpath(path, nil) else { return nil }
56
+ defer { free(resolved) }
57
+ return String(cString: resolved)
58
+ }
59
+ }
@@ -0,0 +1,70 @@
1
+ import Capacitor
2
+ import WebKit
3
+
4
+ /**
5
+ * Stands in front of Capacitor's `bridge` script message handler, the way page script calls native code. It drops
6
+ * messages from any frame but the main one of the app's own origin, as Capacitor Android's MessageHandler does, and
7
+ * refuses `CapacitorHttp`, `CapacitorCookies` and `WebView`, which hand over or plant the session, and any plugin id
8
+ * the bridge has not loaded: Capacitor would otherwise load a plugin by class name, such as `CAPHttpPlugin`.
9
+ */
10
+ final class PageScriptFilter: NSObject, WKScriptMessageHandler {
11
+ private static let handlerName = "bridge"
12
+ private static let refusedPlugins: Set<String> = ["CapacitorHttp", "CapacitorCookies", "WebView"]
13
+
14
+ private weak var bridge: CapacitorBridge?
15
+
16
+ private init(bridge: CapacitorBridge) {
17
+ self.bridge = bridge
18
+ }
19
+
20
+ static func install(on bridge: CapacitorBridge) {
21
+ let contentController = bridge.webViewDelegationHandler.contentController
22
+ contentController.removeScriptMessageHandler(forName: handlerName)
23
+ contentController.add(PageScriptFilter(bridge: bridge), name: handlerName)
24
+ }
25
+
26
+ func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
27
+ guard let bridge, message.frameInfo.isMainFrame, isAppOrigin(message.frameInfo.securityOrigin, bridge.config.serverURL) else { return }
28
+ guard let body = message.body as? [String: Any], let type = body["type"] as? String else { return }
29
+ if type == "js.error" || type == "cordova" {
30
+ bridge.webViewDelegationHandler.userContentController(userContentController, didReceive: message)
31
+ return
32
+ }
33
+ guard type == "message" else { return }
34
+ let pluginId = body["pluginId"] as? String ?? ""
35
+ if !Self.refusedPlugins.contains(pluginId), bridge.plugin(withName: pluginId) != nil {
36
+ bridge.webViewDelegationHandler.userContentController(userContentController, didReceive: message)
37
+ return
38
+ }
39
+ refuse(body, pluginId: pluginId, in: message.webView)
40
+ }
41
+
42
+ // Rejects the call's promise, as Capacitor answers a failed call (CapacitorBridge.swift, toJs).
43
+ private func refuse(_ body: [String: Any], pluginId: String, in webView: WKWebView?) {
44
+ guard let callbackId = body["callbackId"] as? String else { return }
45
+ let answer: [String: Any] = [
46
+ "callbackId": callbackId,
47
+ "pluginId": pluginId,
48
+ "methodName": body["methodName"] as? String ?? "",
49
+ "success": false,
50
+ "error": ["message": "\(pluginId) is not available to page script in a Protobase app"],
51
+ ]
52
+ guard let json = try? JSONSerialization.data(withJSONObject: answer), let text = String(data: json, encoding: .utf8) else { return }
53
+ webView?.evaluateJavaScript("window.Capacitor.fromNative(\(text))")
54
+ }
55
+
56
+ private func isAppOrigin(_ origin: WKSecurityOrigin, _ serverURL: URL) -> Bool {
57
+ let scheme = (serverURL.scheme ?? "").lowercased()
58
+ let serverPort = serverURL.port ?? defaultPort(scheme)
59
+ let originPort = origin.port == 0 ? defaultPort(origin.protocol.lowercased()) : origin.port
60
+ return origin.protocol.lowercased() == scheme && origin.host.lowercased() == (serverURL.host ?? "").lowercased() && originPort == serverPort
61
+ }
62
+
63
+ private func defaultPort(_ scheme: String) -> Int {
64
+ switch scheme {
65
+ case "http": return 80
66
+ case "https": return 443
67
+ default: return 0
68
+ }
69
+ }
70
+ }
@@ -0,0 +1,28 @@
1
+ import Capacitor
2
+ import WebKit
3
+
4
+ /**
5
+ * The shell's sign-in requests (AuthRequest), and what keeps page script away from the session: it filters the
6
+ * bridge's script messages and cookie prompts, and guards the asset handler's local file routes. The bridge loads
7
+ * plugins once the WebView, its delegates and its scheme handler exist and before any page script runs.
8
+ * `apiOrigin` and `origin` come from capacitor.config.json, which the CLI writes outside the app's web part.
9
+ */
10
+ @objc(ProtobaseAuthPlugin)
11
+ public class ProtobaseAuthPlugin: CAPPlugin, CAPBridgedPlugin {
12
+ public let identifier = "ProtobaseAuthPlugin"
13
+ public let jsName = "ProtobaseAuth"
14
+ public let pluginMethods: [CAPPluginMethod] = [CAPPluginMethod(name: "request", returnType: CAPPluginReturnPromise)]
15
+
16
+ private var promptFilter: CookiePromptFilter?
17
+
18
+ override public func load() {
19
+ guard let bridge = bridge as? CapacitorBridge, let webView = bridge.webView else { fatalError("ProtobaseAuth needs Capacitor's own bridge and WebView") }
20
+ GuardedAssetHandler.install(on: bridge.webViewAssetHandler, LocalFileGuard(basePath: bridge.config.appLocation.path))
21
+ PageScriptFilter.install(on: bridge)
22
+ promptFilter = CookiePromptFilter.install(on: webView)
23
+ }
24
+
25
+ @objc func request(_ call: CAPPluginCall) {
26
+ AuthRequest.send(call, config: getConfig(), capConfig: bridge?.config)
27
+ }
28
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@protobase/mobile",
3
- "version": "0.55.11",
3
+ "version": "0.57.0",
4
4
  "description": "The Capacitor that protobase mobile builds a Protobase app's iOS and Android projects with, at fixed versions, and the native plugins that keep page script away from the session and give the iOS app its bounce; installed by the CLI on first use.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {