diff --git a/client/android/app/src/main/java/com/liketrek/trek/SignInRedirect.java b/client/android/app/src/main/java/com/liketrek/trek/SignInRedirect.java new file mode 100644 index 000000000..5c764187a --- /dev/null +++ b/client/android/app/src/main/java/com/liketrek/trek/SignInRedirect.java @@ -0,0 +1,57 @@ +package com.liketrek.trek; + +import java.net.URI; +import java.util.Locale; + +/** + * A sign-in page in front of the server that the address screen never saw. + * A proxy can let the app's check through (an Authelia or Authentik rule that + * bypasses /api) and still send the page itself to its sign-in. Handed to the + * browser, that sign-in leaves its session there and the app stays blank on + * every launch. So a redirect away from the server, while the server's first + * page is still loading, is taken as the proxy's sign-in: it loads in the web + * view and its host is kept, the way the address screen keeps the ones it saw. + * A link the user follows is no redirect, and once a page of the server has + * shown, nothing is learned any more. + */ +final class SignInRedirect { + + private SignInRedirect() {} + + /** The host (host:port with an explicit port) to keep as a sign-in host, or null. */ + static String learnedHost(String server, String target, boolean mainFrame, boolean redirect, boolean serverShown) { + if (!mainFrame || !redirect || serverShown || server == null || target == null) return null; + URI from; + URI to; + try { + from = new URI(server); + to = new URI(target); + } catch (Exception e) { + return null; + } + String fromHost = lower(from.getHost()); + String toHost = lower(to.getHost()); + String toScheme = lower(to.getScheme()); + if (fromHost == null || toHost == null) return null; + if (!"https".equals(toScheme) && !"http".equals(toScheme)) return null; + // Never from https down to plain http: the sign-in would send a password in the clear. + if ("https".equals(lower(from.getScheme())) && !"https".equals(toScheme)) return null; + if (toHost.equals(fromHost)) return null; + return to.getPort() == -1 ? toHost : toHost + ":" + to.getPort(); + } + + /** Whether `url` is on the server's host, where the app's own pages come from. */ + static boolean onServer(String server, String url) { + if (server == null || url == null) return false; + try { + String serverHost = lower(new URI(server).getHost()); + return serverHost != null && serverHost.equals(lower(new URI(url).getHost())); + } catch (Exception e) { + return false; + } + } + + private static String lower(String value) { + return value == null ? null : value.toLowerCase(Locale.ROOT); + } +} diff --git a/client/android/app/src/main/java/com/liketrek/trek/TrekServerStore.java b/client/android/app/src/main/java/com/liketrek/trek/TrekServerStore.java index d4739608c..b134f67a7 100644 --- a/client/android/app/src/main/java/com/liketrek/trek/TrekServerStore.java +++ b/client/android/app/src/main/java/com/liketrek/trek/TrekServerStore.java @@ -89,6 +89,13 @@ final class TrekServerStore { return hosts == null ? Collections.emptySet() : Collections.unmodifiableSet(new HashSet<>(hosts)); } + /** Keeps one more sign-in host for the remembered server, as the web view met it (SignInRedirect). */ + static void addSignInHost(Context context, String host) { + Set hosts = new HashSet<>(getSignInHosts(context)); + if (!hosts.add(host.toLowerCase(Locale.ROOT))) return; + prefs(context).edit().putStringSet(KEY_SIGN_IN_HOSTS, hosts).commit(); + } + /** Start the next launch on the bundled address screen, even with a server remembered. */ static void showShellOnce(Context context) { prefs(context).edit().putBoolean(KEY_SHELL_ONCE, true).commit(); diff --git a/client/android/app/src/main/java/com/liketrek/trek/TrekWebViewClient.java b/client/android/app/src/main/java/com/liketrek/trek/TrekWebViewClient.java index f997b2571..96a029a07 100644 --- a/client/android/app/src/main/java/com/liketrek/trek/TrekWebViewClient.java +++ b/client/android/app/src/main/java/com/liketrek/trek/TrekWebViewClient.java @@ -9,6 +9,7 @@ import android.webkit.WebView; import com.getcapacitor.Bridge; import com.getcapacitor.BridgeWebViewClient; import com.liketrek.trek.clientcert.TrekClientIdentity; +import java.util.HashSet; import java.util.Locale; import java.util.Set; @@ -24,11 +25,13 @@ class TrekWebViewClient extends BridgeWebViewClient { private final Runnable onUnreachable; private final Set signInHosts; + /** A page of the server has shown since launch; from then on no sign-in host is learned (SignInRedirect). */ + private boolean serverShown; TrekWebViewClient(Bridge bridge, Runnable onUnreachable, Set signInHosts) { super(bridge); this.onUnreachable = onUnreachable; - this.signInHosts = signInHosts; + this.signInHosts = new HashSet<>(signInHosts); } /** @@ -43,6 +46,18 @@ class TrekWebViewClient extends BridgeWebViewClient { @Override public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) { if (request.isForMainFrame() && isSignInHost(request.getUrl())) return false; + String learned = SignInRedirect.learnedHost( + TrekServerStore.getUrl(view.getContext()), + request.getUrl().toString(), + request.isForMainFrame(), + request.isRedirect(), + serverShown + ); + if (learned != null) { + signInHosts.add(learned); + TrekServerStore.addSignInHost(view.getContext(), learned); + return false; + } return super.shouldOverrideUrlLoading(view, request); } @@ -50,6 +65,7 @@ class TrekWebViewClient extends BridgeWebViewClient { @Override public void onPageFinished(WebView view, String url) { super.onPageFinished(view, url); + if (SignInRedirect.onServer(TrekServerStore.getUrl(view.getContext()), url)) serverShown = true; String welcome = TrekServerStore.takeWelcomeUrl(view.getContext()); if (welcome != null) view.loadUrl(welcome); } diff --git a/client/android/app/src/test/java/com/liketrek/trek/NativeLogicTest.kt b/client/android/app/src/test/java/com/liketrek/trek/NativeLogicTest.kt index 929e482de..fabac8968 100644 --- a/client/android/app/src/test/java/com/liketrek/trek/NativeLogicTest.kt +++ b/client/android/app/src/test/java/com/liketrek/trek/NativeLogicTest.kt @@ -13,11 +13,33 @@ import java.time.LocalDate import org.json.JSONArray import org.json.JSONObject import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Test /** The native side's own decisions, apart from the views. */ class NativeLogicTest { + @Test + fun aRedirectAwayWhileTheServerLoadsIsTakenAsItsSignIn() { + val server = "https://trek.example.com" + // Authelia in front of the page, its check let through on /api. + assertEquals("auth.example.com", SignInRedirect.learnedHost(server, "https://auth.example.com/?rd=https%3A%2F%2Ftrek.example.com%2F", true, true, false)) + assertEquals("auth.example.com:9091", SignInRedirect.learnedHost(server, "https://Auth.example.com:9091/", true, true, false)) + // A link the user follows, a frame, or anything after the server's page has shown: the browser, as before. + assertNull(SignInRedirect.learnedHost(server, "https://auth.example.com/", true, false, false)) + assertNull(SignInRedirect.learnedHost(server, "https://auth.example.com/", false, true, false)) + assertNull(SignInRedirect.learnedHost(server, "https://auth.example.com/", true, true, true)) + // The server's own host, another scheme, or a step down to plain http. + assertNull(SignInRedirect.learnedHost(server, "https://trek.example.com/login", true, true, false)) + assertNull(SignInRedirect.learnedHost(server, "intent://auth.example.com/", true, true, false)) + assertNull(SignInRedirect.learnedHost(server, "http://auth.example.com/", true, true, false)) + assertEquals("auth.lan", SignInRedirect.learnedHost("http://trek.lan:3000", "http://auth.lan/", true, true, false)) + assertNull(SignInRedirect.learnedHost(null, "https://auth.example.com/", true, true, false)) + assertTrue(SignInRedirect.onServer(server, "https://TREK.example.com/dashboard")) + assertFalse(SignInRedirect.onServer(server, "https://auth.example.com/")) + } + @Test fun pushesLandInTheChannelOfTheirEvent() { assertEquals(TrekPushService.CHANNEL_CHAT, TrekPushService.channelFor("collab_message")) diff --git a/client/ios/App/App/TrekBridgeViewController.swift b/client/ios/App/App/TrekBridgeViewController.swift index 94dd95fa2..3a2ce111a 100644 --- a/client/ios/App/App/TrekBridgeViewController.swift +++ b/client/ios/App/App/TrekBridgeViewController.swift @@ -60,10 +60,10 @@ class TrekBridgeViewController: CAPBridgeViewController { /// Capacitor answers bridge messages from any page in the web view. With a /// proxy's sign-in page loading there, its messages are dropped before they - /// reach the plugins. + /// reach the plugins. Always in place: a sign-in host can also be learned + /// while the app runs (TrekSignInRedirect). private func guardBridgeMessages() { - guard !TrekServerStore.signInHosts.isEmpty, - let handler = (bridge as? CapacitorBridge)?.webViewDelegationHandler, + guard let handler = (bridge as? CapacitorBridge)?.webViewDelegationHandler, let controller = webView?.configuration.userContentController else { return } let guarded = TrekBridgeMessageGuard(inner: handler) controller.removeScriptMessageHandler(forName: TrekBridgeMessageGuard.name) @@ -185,6 +185,13 @@ enum TrekServerStore { } } + /// Keeps one more sign-in host for the remembered server, as the web view met it (TrekSignInRedirect). + static func addSignInHost(_ host: String) { + var hosts = signInHosts + guard hosts.insert(host.lowercased()).inserted else { return } + signInHosts = hosts + } + /// Whether `url` is one of the sign-in hosts, matched on host or host:port. static func isSignInHost(_ url: URL?) -> Bool { guard let url, ["https", "http"].contains(url.scheme?.lowercased() ?? ""), let host = url.host?.lowercased() else { return false } @@ -428,12 +435,44 @@ final class TrekTileSchemeHandler: NSObject, WKURLSchemeHandler { } } +/// A sign-in page in front of the server that the address screen never saw. +/// A proxy can let the app's check through (an Authelia or Authentik rule that +/// bypasses /api) and still send the page itself to its sign-in. Handed to +/// Safari, that sign-in leaves its session there and the app stays blank on +/// every launch. So a redirect away from the server, while the server's first +/// page is still loading, is taken as the proxy's sign-in: it loads in the web +/// view and its host is kept, the way the address screen keeps the ones it saw. +/// A link the user taps is no redirect, and once a page of the server has +/// shown, nothing is learned any more. +enum TrekSignInRedirect { + /// The host (host:port with an explicit port) to keep as a sign-in host, or nil. + static func learnedHost(server: String?, target: URL?, mainFrame: Bool, redirect: Bool, serverShown: Bool) -> String? { + guard mainFrame, redirect, !serverShown, + let server, let from = URL(string: server), let fromHost = from.host?.lowercased(), + let target, let toHost = target.host?.lowercased(), + let toScheme = target.scheme?.lowercased(), ["https", "http"].contains(toScheme) else { return nil } + // Never from https down to plain http: the sign-in would send a password in the clear. + if from.scheme?.lowercased() == "https", toScheme != "https" { return nil } + guard toHost != fromHost else { return nil } + if let port = target.port { return "\(toHost):\(port)" } + return toHost + } + + /// Whether `url` is on the server's host, where the app's own pages come from. + static func onServer(server: String?, url: URL?) -> Bool { + guard let server, let host = URL(string: server)?.host?.lowercased() else { return false } + return url?.host?.lowercased() == host + } +} + /// Stands in front of Capacitor's navigation delegate and hands it everything /// but the failures of a navigation that another one replaced: a cancelled /// load (NSURLErrorCancelled) or an interrupted frame load (WebKit 102). Those /// are no fault, and Capacitor would answer them with its error page. final class TrekNavigationProxy: NSObject, WKNavigationDelegate { private let inner: WKNavigationDelegate + /// A page of the server has shown since launch; from then on no sign-in host is learned (TrekSignInRedirect). + private var serverShown = false init(inner: WKNavigationDelegate) { self.inner = inner @@ -462,6 +501,18 @@ final class TrekNavigationProxy: NSObject, WKNavigationDelegate { decisionHandler(.allow) return } + // WebKit asks again for every server redirect, as a navigation of type .other. + if let host = TrekSignInRedirect.learnedHost( + server: TrekServerStore.url, + target: navigationAction.request.url, + mainFrame: navigationAction.targetFrame?.isMainFrame == true, + redirect: navigationAction.navigationType == .other, + serverShown: serverShown + ) { + TrekServerStore.addSignInHost(host) + decisionHandler(.allow) + return + } let selector = NSSelectorFromString("webView:decidePolicyForNavigationAction:decisionHandler:") guard (inner as AnyObject).responds(to: selector) else { decisionHandler(.allow) @@ -474,6 +525,7 @@ final class TrekNavigationProxy: NSObject, WKNavigationDelegate { /// page once (TrekServerStore.takeWelcomeURL); it is on the server's origin. func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { inner.webView?(webView, didFinish: navigation) + if TrekSignInRedirect.onServer(server: TrekServerStore.url, url: webView.url) { serverShown = true } if let welcome = TrekServerStore.takeWelcomeURL() { webView.load(URLRequest(url: welcome)) }