diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 7c63eb9..665fafd 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -36,7 +36,7 @@ Recent cleanup keeps `MainActivity` as the Android boundary while moving cluster 1. App starts and loads encrypted WebUI settings (`SettingsRepository`). The bundled dashboard origin default is blank so WebUI owns dashboard auto-detect and persistence. 2. WebView boots with hardened configuration, default HTTP cache behavior, DOM storage, and service-worker cache settings for WebUI-managed assets. 3. The Compose root fills the full window background, then applies `WindowInsets.safeDrawing` around the WebView shell and native snackbar so Android 15 edge-to-edge enforcement does not put content under status or navigation bars. -4. Android WebView compatibility shims stay scoped to Hermes WebUI. Android keeps native long-click enabled without a consuming listener so message text remains selectable while Hermes WebUI's own touch timer drives its action menus. A document-start hybrid viewport polyfill fixes the Android WebView bug where CSS viewport units (`vh`, `dvh`, `svh`, `lvh`) evaluate to `0px` instead of actual dimensions before WebUI boot code measures the page: the polyfill injects CSS custom properties (`--vh`, `--dvh`) with stable layout-viewport values plus separate visual-viewport height/top values for keyboard-constrained prompts, applies baseline CSS for root/layout containers, and uses generic collapse detection to find and repair elements that appear collapsed due to the viewport-unit bug. Generic repair changes only height constraints, preserving the element's original overflow contract so it neither creates a new clipping container nor loses an existing inline overflow declaration; visible repairs retain their measured constraints until hidden because repaired geometry cannot prove the underlying viewport-unit rule recovered. Approval and Clarify surfaces are excluded from generic repair and instead shift above the visual-viewport bottom before fitting the measured space below the titlebar/visual-viewport top, dropping below WebUI's preferred 180px floor when necessary and scrolling internally. Runtime application remains as a fallback for already-loaded content. Android also injects a document-start microphone fallback so WebUI voice input uses its MediaRecorder path instead of Web Speech API. Clarify keyboard compatibility keys its one-shot suppression to WebUI's current Clarify ID/signature (with a DOM fallback), so replacing a visible card starts a new focus contract: only that request's first automatic `#clarifyInput` focus is suppressed, while direct Android touches, hardware Tab navigation, the **Other** action, and later validation/error refocus remain available. Unrelated editable dialogs are never inspected or mutated. Attached-WebView instrumentation executes these focus, real-touch, geometry, and overflow contracts in required PR and release gates. The official dashboard is not rendered in an app WebView. +4. Android WebView compatibility shims stay scoped to Hermes WebUI. Android keeps native long-click enabled without a consuming listener so message text remains selectable while Hermes WebUI's own touch timer drives its action menus. Native WebView zoom gestures are enabled without deprecated on-screen controls, and a trusted-origin document-start shim overrides restrictive viewport directives so pinch-to-zoom remains available. A document-start hybrid viewport polyfill fixes the Android WebView bug where CSS viewport units (`vh`, `dvh`, `svh`, `lvh`) evaluate to `0px` instead of actual dimensions before WebUI boot code measures the page: the polyfill injects CSS custom properties (`--vh`, `--dvh`) with stable layout-viewport values plus separate visual-viewport height/top values for keyboard-constrained prompts, applies baseline CSS for root/layout containers, and uses generic collapse detection to find and repair elements that appear collapsed due to the viewport-unit bug. Generic repair changes only height constraints, preserving the element's original overflow contract so it neither creates a new clipping container nor loses an existing inline overflow declaration; visible repairs retain their measured constraints until hidden because repaired geometry cannot prove the underlying viewport-unit rule recovered. Approval and Clarify surfaces are excluded from generic repair and instead shift above the visual-viewport bottom before fitting the measured space below the titlebar/visual-viewport top, dropping below WebUI's preferred 180px floor when necessary and scrolling internally. Runtime application remains as a fallback for already-loaded content. Android also injects a document-start microphone fallback so WebUI voice input uses its MediaRecorder path instead of Web Speech API. Clarify keyboard compatibility keys its one-shot suppression to WebUI's current Clarify ID/signature (with a DOM fallback), so replacing a visible card starts a new focus contract: only that request's first automatic `#clarifyInput` focus is suppressed, while direct Android touches, hardware Tab navigation, the **Other** action, and later validation/error refocus remain available. Unrelated editable dialogs are never inspected or mutated. Attached-WebView instrumentation executes these focus, real-touch, geometry, and overflow contracts in required PR and release gates. The official dashboard is not rendered in an app WebView. 5. On the Hermes WebUI route, Android does not write `/api/dashboard/config` or overwrite WebUI's Official Hermes Dashboard setting. WebUI owns dashboard auto-detect, persistence, rendering, and behavior for the dashboard link in its rail/sidebar. 6. Official Hermes Dashboard links are treated as secondary browser surfaces. When Android has an explicitly configured local dashboard origin, it handles matching WebView new-window requests and dashboard-origin navigations by launching a Chrome Custom Tab with title/share UI minimized, instead of replacing the primary Hermes WebUI WebView. OAuth/OIDC callbacks are handled before this dashboard matching so a configured dashboard origin cannot steal `/auth/callback` from the primary Hermes WebView. 7. Hermes WebUI OAuth/OIDC sign-in stays inside Android once a trusted authorization code flow starts. Android parses the authorization request `redirect_uri`, keeps popup or top-level HTTP/HTTPS provider redirects in-app only when the declared callback returns to the configured Hermes WebUI origin, and loads the verified callback endpoint back into the primary WebView when it returns with a `code` or `error`. Scheme compatibility is asymmetric: an HTTP origin may upgrade to HTTPS for public-IP/proxy deployments, but an HTTPS origin and declared callback can never downgrade to HTTP. A separate bounded return state keeps the callback and all same-origin redirects in the primary WebView until a finished page proves it is Hermes WebUI through its bundle or shell DOM marker, covering popup callbacks, `onPageStarted` callback ordering, 302 chains, JavaScript redirects, and same-origin interstitials without allowing a dashboard Custom Tab match to steal the return. Callback URLs are never persisted as startup state. During the provider flow window, Android temporarily enables third-party cookies and restores the stricter default once the provider flow ends or times out. diff --git a/app/src/androidTest/java/com/hermeswebui/android/webui/HermesWebUiCompatibilityTest.kt b/app/src/androidTest/java/com/hermeswebui/android/webui/HermesWebUiCompatibilityTest.kt index d88b454..2fd2d37 100644 --- a/app/src/androidTest/java/com/hermeswebui/android/webui/HermesWebUiCompatibilityTest.kt +++ b/app/src/androidTest/java/com/hermeswebui/android/webui/HermesWebUiCompatibilityTest.kt @@ -41,6 +41,69 @@ class HermesWebUiCompatibilityTest { } } + @Test + fun runtimeOriginGuard_staleHermesCallbackDoesNotMutateCurrentProviderPage() { + loadFixture( + body = "
OAuth provider
", + baseUrl = "https://oauth.provider.test/" + ) + val guardedPinchZoomScript = HermesWebUiScripts.buildOriginGuardedRuntimeScript( + trustedOrigin = "https://hermes.test", + script = HermesWebUiScripts.pinchZoomScript + ) + + // Models a delayed runtime fallback queued from a stale Hermes page callback: the + // current document is already the provider page when evaluateJavascript executes. + evaluate(guardedPinchZoomScript) + + assertThat(evaluate("document.querySelector('meta[name=\"viewport\"]').content")) + .isEqualTo("\"width=device-width,initial-scale=1\"") + assertThat(evaluateBoolean("window.location.origin === 'https://oauth.provider.test'")) + .isTrue() + } + + @Test + fun runtimeOriginGuard_executesOnConfiguredHermesOrigin() { + loadFixture(body = "
Hermes
") + val guardedPinchZoomScript = HermesWebUiScripts.buildOriginGuardedRuntimeScript( + trustedOrigin = "https://hermes.test", + script = HermesWebUiScripts.pinchZoomScript + ) + + evaluate(guardedPinchZoomScript) + + assertThat(evaluate("document.querySelector('meta[name=\"viewport\"]').content")) + .isEqualTo("\"width=device-width, initial-scale=1, maximum-scale=5, user-scalable=yes\"") + } + + @Test + fun runtimeOriginGuard_resistsProviderPageReplacingTheUrlConstructor() { + loadFixture( + body = "
OAuth provider
", + baseUrl = "https://oauth.provider.test/" + ) + + // A hostile/foreign page can replace window.URL before the delayed evaluateJavascript + // runs. A guard that resolved its trusted origin through `new URL(...)` would get this + // page's own origin back and execute. The guard must compare a literal instead. + evaluate( + """ + window.URL = function() { return { origin: window.location.origin }; }; + """.trimIndent() + ) + + val guardedPinchZoomScript = HermesWebUiScripts.buildOriginGuardedRuntimeScript( + trustedOrigin = "https://hermes.test", + script = HermesWebUiScripts.pinchZoomScript + ) + evaluate(guardedPinchZoomScript) + + assertThat(evaluate("document.querySelector('meta[name=\"viewport\"]').content")) + .isEqualTo("\"width=device-width,initial-scale=1\"") + assertThat(evaluateBoolean("window.location.origin === 'https://oauth.provider.test'")) + .isTrue() + } + @Test fun clarifyAutofocus_suppressesOnlyAutomaticClarifyFocus() { loadFixture( @@ -345,7 +408,7 @@ class HermesWebUiCompatibilityTest { } @SuppressLint("SetJavaScriptEnabled") - private fun loadFixture(body: String) { + private fun loadFixture(body: String, baseUrl: String = "https://hermes.test/") { val loaded = CountDownLatch(1) composeTestRule.setContent { WebViewHost { view -> @@ -360,7 +423,7 @@ class HermesWebUiCompatibilityTest { } } view.loadDataWithBaseURL( - "https://hermes.test/", + baseUrl, "$body", "text/html", "UTF-8", diff --git a/app/src/main/java/com/hermeswebui/android/MainActivity.kt b/app/src/main/java/com/hermeswebui/android/MainActivity.kt index 9403767..8a5e051 100644 --- a/app/src/main/java/com/hermeswebui/android/MainActivity.kt +++ b/app/src/main/java/com/hermeswebui/android/MainActivity.kt @@ -194,6 +194,7 @@ class MainActivity : ComponentActivity() { private var pendingLocalNetworkPermissionAction: (() -> Unit)? = null private var pendingLocalNetworkPermissionDeniedAction: (() -> Unit)? = null private var viewportFixScriptHandler: ScriptHandler? = null + private var pinchZoomScriptHandler: ScriptHandler? = null private var microphoneFallbackScriptHandler: ScriptHandler? = null private var notificationBridgeScriptHandler: ScriptHandler? = null private var routeRecoveryScriptHandler: ScriptHandler? = null @@ -1626,15 +1627,24 @@ class MainActivity : ComponentActivity() { } private fun applyHermesWebUiRuntimeScripts(view: WebView) { - view.evaluateJavascript(HermesWebUiScripts.viewportFixScript, null) - view.evaluateJavascript(HermesWebUiScripts.microphoneFallbackScript, null) - view.evaluateJavascript(HermesWebUiScripts.suppressClarifyAutofocusScript, null) - view.evaluateJavascript(buildHermesWebUiNotificationBridgeScript(), null) - view.evaluateJavascript(buildHermesWebUiRouteRecoveryScript(), null) - if (EnableAppSettingsSidebarShim) { - view.evaluateJavascript(HermesWebUiScripts.appSettingsEntryScript, null) + val settings = viewModel.uiState.value.settings + val trustedOrigin = UrlOrigins.pageOrigin(settings.serverUrl) ?: return + val scripts = buildList { + add(HermesWebUiScripts.viewportFixScript) + add(HermesWebUiScripts.pinchZoomScript) + add(HermesWebUiScripts.microphoneFallbackScript) + add(HermesWebUiScripts.suppressClarifyAutofocusScript) + add(buildHermesWebUiNotificationBridgeScript()) + add(buildHermesWebUiRouteRecoveryScript()) + if (EnableAppSettingsSidebarShim) add(HermesWebUiScripts.appSettingsEntryScript) + add("window.__hermesAndroidHardwareKeyboard = ${isHardwareKeyboardAttached()};") + } + scripts.forEach { script -> + view.evaluateJavascript( + HermesWebUiScripts.buildOriginGuardedRuntimeScript(trustedOrigin, script), + null + ) } - syncHardwareKeyboardState(view) } private fun isHardwareKeyboardAttached(): Boolean { @@ -1668,6 +1678,11 @@ class MainActivity : ComponentActivity() { originRule, HermesWebUiScripts.viewportFixScript ) + pinchZoomScriptHandler = addDocumentStartScript( + view, + originRule, + HermesWebUiScripts.pinchZoomScript + ) microphoneFallbackScriptHandler = addDocumentStartScript( view, originRule, @@ -1705,6 +1720,7 @@ class MainActivity : ComponentActivity() { private fun removeHermesWebUiDocumentStartFixes() { if (!WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT)) return viewportFixScriptHandler?.remove() + pinchZoomScriptHandler?.remove() microphoneFallbackScriptHandler?.remove() notificationBridgeScriptHandler?.remove() routeRecoveryScriptHandler?.remove() @@ -1712,6 +1728,7 @@ class MainActivity : ComponentActivity() { enterKeyNewlineScriptHandler?.remove() suppressClarifyAutofocusScriptHandler?.remove() viewportFixScriptHandler = null + pinchZoomScriptHandler = null microphoneFallbackScriptHandler = null notificationBridgeScriptHandler = null routeRecoveryScriptHandler = null diff --git a/app/src/main/java/com/hermeswebui/android/core/security/UrlPolicy.kt b/app/src/main/java/com/hermeswebui/android/core/security/UrlPolicy.kt index 84e0d9b..a3b0c3b 100644 --- a/app/src/main/java/com/hermeswebui/android/core/security/UrlPolicy.kt +++ b/app/src/main/java/com/hermeswebui/android/core/security/UrlPolicy.kt @@ -47,6 +47,14 @@ class UrlPolicy(private val allowedHosts: Set) { } object UrlOrigins { + /** + * Sentinel returned by [ipv4FromNumericHost] when a host IS a numeric-IPv4 candidate but is + * out of range (e.g. `http://999.1.1.1`). A browser rejects such a host outright, so the + * caller must fail closed rather than fall through and emit the raw spelling. Compared by + * identity (`===`), never by value. + */ + private val INVALID_NUMERIC_HOST = String() + fun hostFrom(url: String): String? { return url.toUriOrNull()?.normalizedHost()?.takeIf { it.isNotBlank() } } @@ -110,6 +118,322 @@ object UrlOrigins { return "$scheme://$hostRule$portRule" } + /** + * The origin exactly as a page reports it in `window.location.origin`: scheme + host, with + * the port omitted when it is the scheme's default (80/http, 443/https). + * + * This is deliberately NOT [documentStartOriginRule] — that builds a WebViewCompat allow-rule, + * which keeps an explicitly-specified default port (`http://host:80`) that the browser drops. + * Comparing against the rule would silently fail the guard for such a server URL. Canonicalizing + * here, natively, is what lets the injected guard compare a literal string literal instead of + * calling the page-controlled `URL` constructor. + * + * Returns null for any host we cannot serialize the way a browser would. That is deliberate: + * the caller skips script injection entirely rather than emitting a literal that can never + * match, which would silently disable every runtime shim. + */ + fun pageOrigin(url: String): String? { + val uri = url.toUriOrNull() ?: return null + val scheme = uri.scheme + ?.lowercase(Locale.US) + ?.takeIf { it == "http" || it == "https" } + ?: return null + // java.net.URI is RFC 2396-strict and returns a null host for spellings a browser accepts + // (a trailing-dot IPv4 like `127.0.0.1.`, or `0x.0x.0x.0x`). Fall back to reading the raw + // authority so those still canonicalize instead of silently disabling every runtime shim. + // When URI rejects the host it also reports port -1, so the fallback recovers both. + val host: String + var port = uri.port + val normalized = uri.normalizedHost() + if (normalized != null && normalized.isNotBlank()) { + host = normalized + } else { + val raw = rawAuthorityHostPort(url) ?: return null + host = raw.first + raw.second?.let { port = parsePort(it) ?: return null } + } + // A browser rejects an out-of-range port; java.net.URI does NOT, so validate here too. + // Fail closed rather than synthesize a valid literal from an invalid URL. + if (port != -1 && port !in 0..65535) return null + val canonicalHost = canonicalBrowserHost(host) ?: return null + val defaultPort = if (scheme == "https") 443 else 80 + val portPart = if (port != -1 && port != defaultPort) ":$port" else "" + return "$scheme://$canonicalHost$portPart" + } + + /** Parse a port the way a browser does: ASCII digits only, in 0..65535. Anything else is null. */ + private fun parsePort(text: String): Int? { + if (text.isEmpty() || text.any { it !in '0'..'9' }) return null + return text.toIntOrNull()?.takeIf { it in 0..65535 } + } + + /** + * Read (host, port?) from the raw authority, lowercased, for URLs `java.net.URI` parses but + * whose host it rejects. This fallback exists ONLY to recover the numeric/ASCII hosts a + * browser accepts but java.net.URI does not (trailing-dot IPv4, `0x.0x.0x.0x`); it does NOT + * implement WHATWG percent-decoding or IDNA. So it fails closed (returns null) on any host + * carrying a `%` escape or a non-ASCII character, rather than emit a literal that would never + * match the browser's decoded/punycode origin. Userinfo (credentials) is stripped to match a + * browser's `location.origin`. + */ + private fun rawAuthorityHostPort(url: String): Pair? { + val afterScheme = url.substringAfter("://", "").ifEmpty { return null } + var authority = afterScheme.substringBefore('/').substringBefore('?').substringBefore('#') + if (authority.isEmpty()) return null + // A browser drops userinfo from the origin (`user:pass@host` → `host`). + if (authority.contains('@')) authority = authority.substringAfterLast('@') + if (authority.isEmpty()) return null + val host: String + var port: String? = null + if (authority.startsWith("[")) { + val end = authority.indexOf(']') + if (end < 0) return null + host = authority.substring(0, end + 1) + val rest = authority.substring(end + 1) + if (rest.startsWith(":")) port = rest.substring(1).ifEmpty { null } + } else { + val colon = authority.indexOf(':') + if (colon >= 0) { + host = authority.substring(0, colon) + port = authority.substring(colon + 1).ifEmpty { null } + } else { + host = authority + } + } + val lowered = host.lowercase(Locale.US) + if (lowered.isEmpty()) return null + // This fallback exists only to recover hosts java.net.URI wrongly rejects while a browser + // accepts them. It does NOT implement WHATWG percent-encoding or IDNA, so it accepts a host + // verbatim ONLY when every character is one a browser also keeps verbatim in a host: the + // LDH set plus `_` and `~` (which covers every real hostname and IP spelling — verified + // against Chromium). Anything else (`*`, space, `(`, non-ASCII, `%`, …) a browser would + // percent-encode or reject, so fail closed rather than emit a divergent literal. A + // bracketed IPv6 literal is already accepted by URI and never reaches here. + if (lowered.startsWith("[")) return null + if (!lowered.all { it in 'a'..'z' || it in '0'..'9' || it == '.' || it == '-' || it == '_' || it == '~' }) { + return null + } + return lowered to port + } + + /** + * Serialize a host the way a browser does when it builds `location.origin`. + * + * Browsers apply WHATWG host parsing, which `java.net.URI` does not: + * - a bare number or hex literal is an IPv4 address (`2130706433` → `127.0.0.1`, + * `0x7f000001` → `127.0.0.1`); + * - IPv6 literals are compressed to their shortest form (`[0:0:0:0:0:0:0:1]` → `[::1]`). + * + * Emitting the un-canonicalized spelling would make the guard's literal comparison fail + * forever on such a configured server, silently suppressing every runtime script. Anything we + * cannot canonicalize confidently returns null so the caller can skip injection instead. + */ + private fun canonicalBrowserHost(host: String): String? { + if (host.startsWith("[") && host.endsWith("]")) { + val compressed = compressIpv6(host.substring(1, host.length - 1)) ?: return null + return "[$compressed]" + } + if (host.contains(":")) { + val compressed = compressIpv6(host) ?: return null + return "[$compressed]" + } + ipv4FromNumericHost(host)?.let { return if (it === INVALID_NUMERIC_HOST) null else it } + // Ordinary DNS names pass through verbatim — including a trailing dot, which a browser + // KEEPS in location.origin for a name (`http://example.com.`) even though it drops one + // from a numeric address (`http://2130706433.` → `http://127.0.0.1`). + return host + } + + /** + * WHATWG numeric-host handling. A host "ends in a number" when its last label (after dropping + * one trailing empty label) is all ASCII digits, or parses as an IPv4 number. Such a host MUST + * be a valid IPv4 address or a browser REJECTS it — so this returns [INVALID_NUMERIC_HOST] + * (caller fails closed), never a DNS pass-through. A host that does NOT end in a number is an + * ordinary DNS name and returns null so the caller passes it through unchanged. + * + * Examples: `2130706433`→`127.0.0.1`; `010.0.0.1`→`8.0.0.1`; `foo.1`, `example.99`, `09`, + * `1..2.3` all end in a number but fail IPv4 parsing → rejected; `foo.1..` and + * `hermes.example.com` do not end in a number → DNS pass-through. + */ + private fun ipv4FromNumericHost(host: String): String? { + // Split on '.', dropping exactly ONE trailing empty label (a single trailing dot). + var parts = host.split(".") + if (parts.size > 1 && parts.last().isEmpty()) parts = parts.dropLast(1) + if (parts.isEmpty()) return null + val last = parts.last() + // "Ends in a number" is a SYNTAX test, independent of whether the value fits in a Long: + // `0x8000000000000000` ends in a number (and overflows) — it must fail closed, not be + // mistaken for a DNS name. + if (!ipv4PartLooksNumeric(last)) return null // Ordinary DNS name — pass through unchanged. + // Ends in a number ⇒ must be a valid IPv4 address, else the browser rejects the whole host. + if (parts.size > 4) return INVALID_NUMERIC_HOST + if (parts.any { it.isEmpty() }) return INVALID_NUMERIC_HOST + val numbers = parts.map { parseIpv4Part(it) ?: return INVALID_NUMERIC_HOST } + val lastMax = 1L shl (8 * (4 - numbers.size + 1)) + if (numbers.last() >= lastMax) return INVALID_NUMERIC_HOST + if (numbers.dropLast(1).any { it > 255 }) return INVALID_NUMERIC_HOST + var value = numbers.last() + numbers.dropLast(1).forEachIndexed { index, part -> + value += part shl (8 * (3 - index)) + } + return "${(value shr 24) and 0xFF}.${(value shr 16) and 0xFF}.${(value shr 8) and 0xFF}.${value and 0xFF}" + } + + /** + * True when [part] "looks like" a WHATWG IPv4 number — the SYNTAX test that decides whether a + * host "ends in a number", independent of magnitude AND of octal validity: + * - any non-empty run of ASCII digits (`09`, `019`, `999`, an overflowing decimal) — note + * `09` looks numeric even though it is an INVALID octal, because a browser still treats it + * as a (failed) IPv4 address and rejects the host rather than treating it as a DNS name; + * - a `0x`/`0X` prefix followed by zero or more VALID hex digits (`0x`, `0xff`, + * `0x8000000000000000`) — but NOT `0x1g`, whose bad hex digit makes it an ordinary name. + * Magnitude/octal-digit validity is enforced later by [parseIpv4Part]. + */ + private fun ipv4PartLooksNumeric(part: String): Boolean { + if (part.isEmpty()) return false + if (part.all { it in '0'..'9' }) return true + if (part.length >= 2 && (part.startsWith("0x") || part.startsWith("0X"))) { + val hex = part.substring(2) + return hex.isEmpty() || hex.all { Character.digit(it, 16) >= 0 } + } + return false + } + + /** + * Parse one IPv4 part to its value, or null if it is not a valid numeric part (bad octal digit + * like the `9` in `09`, a bad hex digit, or an overflow of Long). Callers that have already + * established the host "ends in a number" via [ipv4PartLooksNumeric] treat a null here as + * INVALID_NUMERIC_HOST (fail closed), not as a DNS name. + * + * A bare `0`, `0x` or `0X` (empty payload after the prefix) is the number zero, matching + * Chromium (`http://0x` → `http://0.0.0.0`). + */ + private fun parseIpv4Part(part: String): Long? { + if (part.isEmpty()) return null + val (radix, digits) = when { + part.length >= 2 && (part.startsWith("0x") || part.startsWith("0X")) -> 16 to part.substring(2) + part.startsWith("0") -> 8 to part.substring(1) + else -> 10 to part + } + if (digits.isEmpty()) return 0L + if (digits.any { Character.digit(it, radix) < 0 }) return null + return digits.toLongOrNull(radix)?.takeIf { it >= 0 } + } + + /** + * Parse and re-serialize an IPv6 literal to its shortest browser form (RFC 5952), or null if + * it is not a valid IPv6 literal. + * + * Implemented with pure string handling rather than [InetAddress] on purpose: this runs on the + * main thread during script injection, and we must never risk a name-resolution call here. + */ + private fun compressIpv6(literal: String): String? { + val groups = parseIpv6Groups(literal) ?: return null + + // RFC 5952: compress the LONGEST run of two-or-more zero groups; leftmost run wins a tie. + var bestStart = -1 + var bestLen = 0 + var runStart = -1 + var runLen = 0 + for (i in 0..8) { + val isZero = i < 8 && groups[i] == 0 + if (isZero) { + if (runStart < 0) runStart = i + runLen++ + } else { + if (runLen > bestLen && runLen >= 2) { + bestStart = runStart + bestLen = runLen + } + runStart = -1 + runLen = 0 + } + } + + val out = StringBuilder() + var i = 0 + while (i < 8) { + if (i == bestStart) { + out.append("::") + i += bestLen + continue + } + if (out.isNotEmpty() && !out.endsWith(":")) out.append(':') + out.append(Integer.toHexString(groups[i])) + i++ + } + return out.toString().ifEmpty { "::" } + } + + /** + * Parse an IPv6 literal into its 8 16-bit groups, honoring `::` compression and an optional + * trailing dotted-quad (`::ffff:127.0.0.1`). + */ + private fun parseIpv6Groups(literal: String): IntArray? { + if (literal.isEmpty() || literal.contains('%')) return null + // At most one `::`, and a lone `:` may not dangle at either end. + if (literal.indexOf("::") != literal.lastIndexOf("::")) return null + if (literal.startsWith(":") && !literal.startsWith("::")) return null + if (literal.endsWith(":") && !literal.endsWith("::")) return null + + val doubleColon = literal.indexOf("::") + val leftText = if (doubleColon >= 0) literal.substring(0, doubleColon) else literal + val rightText = if (doubleColon >= 0) literal.substring(doubleColon + 2) else "" + + val left = if (leftText.isEmpty()) mutableListOf() else leftText.split(":").toMutableList() + val right = if (rightText.isEmpty()) mutableListOf() else rightText.split(":").toMutableList() + + // A dotted-quad may only appear as the very last token, and expands to two groups. + val tailSide = if (right.isNotEmpty()) right else left + var ipv4: IntArray? = null + if (tailSide.isNotEmpty() && tailSide.last().contains('.')) { + ipv4 = ipv4ToGroups(tailSide.removeAt(tailSide.size - 1)) ?: return null + } + // A '.' anywhere else is invalid. + if (left.any { it.contains('.') } || right.any { it.contains('.') }) return null + + val leftGroups = left.map { parseIpv6Group(it) ?: return null } + val rightGroups = right.map { parseIpv6Group(it) ?: return null } + val extra = ipv4?.size ?: 0 + val total = leftGroups.size + rightGroups.size + extra + + val result = IntArray(8) + if (doubleColon < 0) { + if (total != 8) return null + leftGroups.forEachIndexed { i, v -> result[i] = v } + ipv4?.forEachIndexed { i, v -> result[leftGroups.size + i] = v } + return result + } + // `::` must stand for at least one elided zero group. + if (total > 7) return null + leftGroups.forEachIndexed { i, v -> result[i] = v } + val tailStart = 8 - rightGroups.size - extra + rightGroups.forEachIndexed { i, v -> result[tailStart + i] = v } + ipv4?.forEachIndexed { i, v -> result[8 - extra + i] = v } + return result + } + + /** Convert a dotted-quad into the two 16-bit groups it occupies inside an IPv6 literal. */ + private fun ipv4ToGroups(text: String): IntArray? { + val quad = text.split(".") + if (quad.size != 4) return null + // Chromium applies the same radix-aware part parsing here as for a bare IPv4 host, so + // `[::ffff:127.0.0.010]` is `…:7f00:8` (octal 010 == 8), not `…:7f00:a`. Each of the four + // components must still fit in one byte. + val bytes = quad.map { part -> + val value = parseIpv4Part(part) ?: return null + if (value > 255) return null + value.toInt() + } + return intArrayOf((bytes[0] shl 8) or bytes[1], (bytes[2] shl 8) or bytes[3]) + } + + private fun parseIpv6Group(text: String): Int? { + if (text.isEmpty() || text.length > 4) return null + if (text.any { Character.digit(it, 16) < 0 }) return null + return text.toIntOrNull(16) + } + fun normalizeOriginUrl(url: String): String { val trimmed = url.trim() val parsed = trimmed.toUriOrNull() ?: return trimmed diff --git a/app/src/main/java/com/hermeswebui/android/webui/HermesWebUiScripts.kt b/app/src/main/java/com/hermeswebui/android/webui/HermesWebUiScripts.kt index 50aa404..e9cb649 100644 --- a/app/src/main/java/com/hermeswebui/android/webui/HermesWebUiScripts.kt +++ b/app/src/main/java/com/hermeswebui/android/webui/HermesWebUiScripts.kt @@ -3,6 +3,63 @@ package com.hermeswebui.android.webui import org.json.JSONObject object HermesWebUiScripts { + /** + * Wraps a runtime fallback script with an execution-time origin check. WebView evaluates + * JavaScript asynchronously, so the page may have navigated after the native route check. + * + * [trustedOrigin] must already be canonicalized natively (see `UrlOrigins.pageOrigin`) so the + * guard can compare `window.location.origin` against a quoted string LITERAL. It deliberately + * does not call `new URL(...)`: `URL` is a page-controlled global that a hostile origin can + * replace before this asynchronously-evaluated script runs, making the constructor return that + * page's own origin and defeating the check. + */ + fun buildOriginGuardedRuntimeScript(trustedOrigin: String, script: String): String { + val quotedOrigin = JSONObject.quote(trustedOrigin) + return """ + (function() { + 'use strict'; + if (window.location.origin !== $quotedOrigin) return; + $script + })(); + """.trimIndent() + } + + /** + * Keeps pinch-to-zoom available even when Hermes WebUI's viewport metadata disables + * browser scaling. The observer covers the document-start case where the meta element + * is parsed after this script runs. + */ + val pinchZoomScript = """ + (function() { + 'use strict'; + + var enablePinchZoom = function() { + var viewport = document.querySelector('meta[name="viewport"]'); + if (!viewport) return false; + + var directives = viewport.content + .split(',') + .map(function(value) { return value.trim(); }) + .filter(function(value) { + return value && + !/^user-scalable\s*=/i.test(value) && + !/^maximum-scale\s*=/i.test(value); + }); + directives.push('maximum-scale=5'); + directives.push('user-scalable=yes'); + viewport.content = directives.join(', '); + return true; + }; + + if (enablePinchZoom()) return; + + var observer = new MutationObserver(function() { + if (enablePinchZoom()) observer.disconnect(); + }); + observer.observe(document, { childList: true, subtree: true }); + })(); + """.trimIndent() + /** * Hybrid Viewport Polyfill for Android WebView * diff --git a/app/src/main/java/com/hermeswebui/android/webview/HermesWebViewConfigurator.kt b/app/src/main/java/com/hermeswebui/android/webview/HermesWebViewConfigurator.kt index 84fee82..30bde8a 100644 --- a/app/src/main/java/com/hermeswebui/android/webview/HermesWebViewConfigurator.kt +++ b/app/src/main/java/com/hermeswebui/android/webview/HermesWebViewConfigurator.kt @@ -21,6 +21,9 @@ object HermesWebViewConfigurator { allowFileAccess = false allowContentAccess = false loadsImagesAutomatically = true + setSupportZoom(true) + builtInZoomControls = true + displayZoomControls = false mediaPlaybackRequiresUserGesture = true mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE javaScriptCanOpenWindowsAutomatically = true @@ -51,6 +54,9 @@ object HermesWebViewConfigurator { allowFileAccess = false allowContentAccess = false loadsImagesAutomatically = true + setSupportZoom(true) + builtInZoomControls = true + displayZoomControls = false mediaPlaybackRequiresUserGesture = true mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE setSupportMultipleWindows(true) diff --git a/app/src/test/java/com/hermeswebui/android/UrlPolicyTest.kt b/app/src/test/java/com/hermeswebui/android/UrlPolicyTest.kt index 1253e9b..3e832fb 100644 --- a/app/src/test/java/com/hermeswebui/android/UrlPolicyTest.kt +++ b/app/src/test/java/com/hermeswebui/android/UrlPolicyTest.kt @@ -113,4 +113,209 @@ class UrlPolicyTest { assertThat(UrlOrigins.normalizeOriginUrl(" https://hermes.example.com:8455/dashboard?x=1#status ")) .isEqualTo("https://hermes.example.com:8455") } + + @Test + fun `page origin keeps a non-default port`() { + assertThat(UrlOrigins.pageOrigin("https://hermes.example.com:8443/path")) + .isEqualTo("https://hermes.example.com:8443") + assertThat(UrlOrigins.pageOrigin("http://hermes.example.com:8787/path")) + .isEqualTo("http://hermes.example.com:8787") + } + + @Test + fun `page origin drops an explicitly-specified default port`() { + // A browser reports window.location.origin WITHOUT the default port, so the guard literal + // must drop it too. documentStartOriginRule deliberately keeps it (it builds an allow-rule), + // which is exactly why the guard uses pageOrigin instead. + assertThat(UrlOrigins.pageOrigin("http://hermes.example.com:80/path")) + .isEqualTo("http://hermes.example.com") + assertThat(UrlOrigins.pageOrigin("https://hermes.example.com:443/path")) + .isEqualTo("https://hermes.example.com") + assertThat(UrlOrigins.documentStartOriginRule("http://hermes.example.com:80/path")) + .isEqualTo("http://hermes.example.com:80") + } + + @Test + fun `page origin omits an absent port and lowercases the host`() { + assertThat(UrlOrigins.pageOrigin("https://Hermes.Example.COM/path")) + .isEqualTo("https://hermes.example.com") + } + + @Test + fun `page origin rejects non-web schemes and malformed urls`() { + assertThat(UrlOrigins.pageOrigin("file:///etc/passwd")).isNull() + assertThat(UrlOrigins.pageOrigin("javascript:alert(1)")).isNull() + assertThat(UrlOrigins.pageOrigin("not a url")).isNull() + } + + @Test + fun `page origin brackets an ipv6 host`() { + assertThat(UrlOrigins.pageOrigin("http://[::1]:8787/path")) + .isEqualTo("http://[::1]:8787") + } + + @Test + fun `page origin canonicalizes an expanded ipv6 literal the way a browser does`() { + // A browser reports location.origin after WHATWG host parsing, which compresses IPv6. + // Emitting the expanded spelling would make the guard literal never match, silently + // suppressing every runtime script on such a configured server. + assertThat(UrlOrigins.pageOrigin("http://[0:0:0:0:0:0:0:1]:80")) + .isEqualTo("http://[::1]") + assertThat(UrlOrigins.pageOrigin("http://[2001:0db8:0000:0000:0000:0000:1428:57ab]:9000")) + .isEqualTo("http://[2001:db8::1428:57ab]:9000") + assertThat(UrlOrigins.pageOrigin("http://[fe80:0:0:0:0:0:0:1]")) + .isEqualTo("http://[fe80::1]") + assertThat(UrlOrigins.pageOrigin("http://[0:0:0:0:0:0:0:0]")) + .isEqualTo("http://[::]") + // Longest zero-run wins; a shorter run stays expanded. + assertThat(UrlOrigins.pageOrigin("http://[1:0:0:2:0:0:0:3]:8787")) + .isEqualTo("http://[1:0:0:2::3]:8787") + // An embedded dotted-quad is re-serialized as hextets. + assertThat(UrlOrigins.pageOrigin("http://[::ffff:127.0.0.1]:8787")) + .isEqualTo("http://[::ffff:7f00:1]:8787") + // The embedded quad uses the SAME radix-aware part parsing as a bare IPv4 host, so + // octal 010 == 8 (…:7f00:8), not decimal 10 (…:7f00:a). + assertThat(UrlOrigins.pageOrigin("http://[::ffff:127.0.0.010]:18770")) + .isEqualTo("http://[::ffff:7f00:8]:18770") + assertThat(UrlOrigins.pageOrigin("http://[::ffff:1.2.3.04]:80")) + .isEqualTo("http://[::ffff:102:304]") + } + + @Test + fun `page origin canonicalizes numeric ipv4 hosts the way a browser does`() { + assertThat(UrlOrigins.pageOrigin("http://2130706433")).isEqualTo("http://127.0.0.1") + assertThat(UrlOrigins.pageOrigin("http://0x7f000001")).isEqualTo("http://127.0.0.1") + // A leading zero means octal: 010 == 8. + assertThat(UrlOrigins.pageOrigin("http://010.0.0.1")).isEqualTo("http://8.0.0.1") + // A bare `0x` is an empty hex payload, which is zero. + assertThat(UrlOrigins.pageOrigin("http://0x")).isEqualTo("http://0.0.0.0") + assertThat(UrlOrigins.pageOrigin("http://0x.0x.0x.0x")).isEqualTo("http://0.0.0.0") + // A numeric host drops a single trailing dot. + assertThat(UrlOrigins.pageOrigin("http://2130706433.")).isEqualTo("http://127.0.0.1") + assertThat(UrlOrigins.pageOrigin("http://127.0.0.1.")).isEqualTo("http://127.0.0.1") + // Already-canonical dotted-decimal is untouched. + assertThat(UrlOrigins.pageOrigin("http://192.168.1.10:8787")) + .isEqualTo("http://192.168.1.10:8787") + } + + @Test + fun `page origin keeps a trailing dot on a dns name`() { + // A browser drops a trailing dot from a NUMERIC host but keeps it on a name. + assertThat(UrlOrigins.pageOrigin("http://hermes.example.com.")) + .isEqualTo("http://hermes.example.com.") + } + + @Test + fun `page origin recovers a host java URI rejects for a trailing-dot numeric address`() { + // java.net.URI returns a null host (and port -1) for these; the raw-authority fallback + // recovers both, so the runtime shims are not silently disabled on such a server URL. + assertThat(UrlOrigins.pageOrigin("http://127.0.0.1.:8787")) + .isEqualTo("http://127.0.0.1:8787") + assertThat(UrlOrigins.pageOrigin("http://2130706433.:8080")) + .isEqualTo("http://127.0.0.1:8080") + assertThat(UrlOrigins.pageOrigin("http://127.0.0.1.:80")) + .isEqualTo("http://127.0.0.1") + } + + @Test + fun `page origin strips userinfo in the raw-authority fallback like a browser`() { + // A browser drops credentials from location.origin. On the URI-rejected fallback path the + // host is still recovered without the userinfo. + assertThat(UrlOrigins.pageOrigin("http://user:pass@127.0.0.1.:8787")) + .isEqualTo("http://127.0.0.1:8787") + } + + @Test + fun `page origin fails closed on an invalid or out-of-range port`() { + // java.net.URI does not range-check the port; a browser rejects these outright, so the + // guard must too rather than synthesize a valid literal from an invalid URL. + assertThat(UrlOrigins.pageOrigin("http://127.0.0.1.:65536")).isNull() + assertThat(UrlOrigins.pageOrigin("http://127.0.0.1.:-1")).isNull() + assertThat(UrlOrigins.pageOrigin("http://127.0.0.1.:+80")).isNull() + assertThat(UrlOrigins.pageOrigin("http://127.0.0.1.:8_7")).isNull() + // The high boundary is valid. + assertThat(UrlOrigins.pageOrigin("http://127.0.0.1.:65535")) + .isEqualTo("http://127.0.0.1:65535") + } + + @Test + fun `page origin fails closed on hosts needing percent-decoding or IDNA`() { + // The raw-authority fallback recovers only ASCII/numeric hosts; it does NOT implement + // WHATWG percent-decoding or punycode, so it fails closed rather than emit a literal that + // would never match a browser's decoded/punycode origin. (A real self-hosted server URL is + // an IP or an ASCII hostname, both of which java.net.URI already accepts.) + assertThat(UrlOrigins.pageOrigin("http://foo%2ebar")).isNull() + assertThat(UrlOrigins.pageOrigin("http://%31%32%37.0.0.1")).isNull() + assertThat(UrlOrigins.pageOrigin("http://münchen.de:8080")).isNull() + } + + @Test + fun `page origin fails closed on an out-of-range numeric host`() { + // These are numeric candidates the browser rejects outright. Returning the raw spelling + // would emit a literal that can never match; null makes the caller skip injection. + assertThat(UrlOrigins.pageOrigin("http://999.1.1.1")).isNull() + assertThat(UrlOrigins.pageOrigin("http://256.1.1.1")).isNull() + assertThat(UrlOrigins.pageOrigin("http://4294967296")).isNull() + assertThat(UrlOrigins.pageOrigin("http://1.2.3.4.5")).isNull() + } + + @Test + fun `page origin fails closed on a host that ends in a number but is not valid ipv4`() { + // WHATWG: if a host's last label ends in a number, the whole host must parse as IPv4 or + // the browser rejects it. Passing these through as DNS names would emit a literal that + // never matches location.origin. + assertThat(UrlOrigins.pageOrigin("http://foo.1")).isNull() + assertThat(UrlOrigins.pageOrigin("http://example.99")).isNull() + assertThat(UrlOrigins.pageOrigin("http://09")).isNull() + assertThat(UrlOrigins.pageOrigin("http://1..2.3")).isNull() + assertThat(UrlOrigins.pageOrigin("http://1.2.3.09")).isNull() + assertThat(UrlOrigins.pageOrigin("http://a.b.c.1")).isNull() + // A syntactically-numeric part that OVERFLOWS a Long still "ends in a number" and must + // fail closed, not be mistaken for a DNS name. + assertThat(UrlOrigins.pageOrigin("http://0x8000000000000000")).isNull() + assertThat(UrlOrigins.pageOrigin("http://1.2.3.0x8000000000000000")).isNull() + } + + @Test + fun `page origin recovers a name with underscore or tilde that java URI rejects`() { + // java.net.URI rejects `_`; a browser keeps it verbatim. The fallback accepts the LDH set + // plus `_` and `~` (every real hostname), so Docker/internal names still get the shims. + assertThat(UrlOrigins.pageOrigin("http://foo_bar")).isEqualTo("http://foo_bar") + assertThat(UrlOrigins.pageOrigin("http://my_host.local:8787")) + .isEqualTo("http://my_host.local:8787") + assertThat(UrlOrigins.pageOrigin("http://foo~bar")).isEqualTo("http://foo~bar") + } + + @Test + fun `page origin fails closed on a raw host with a browser-encoded character`() { + // These reach the raw-authority fallback (URI rejects them) and carry a char a browser + // percent-encodes (`*`→`%2A`, space→`%20`). We do not encode, so we fail closed rather + // than emit a divergent literal. No real self-hosted server URL uses these. + assertThat(UrlOrigins.pageOrigin("http://foo*bar")).isNull() + assertThat(UrlOrigins.pageOrigin("http://foo(bar)")).isNull() + assertThat(UrlOrigins.pageOrigin("http://foo bar")).isNull() + } + + @Test + fun `page origin passes through a dns name that does not end in a number`() { + // Not-ending-in-a-number is an ordinary DNS name, kept verbatim (incl. a trailing dot, + // which a browser keeps for a name but drops for a numeric address). + assertThat(UrlOrigins.pageOrigin("http://web3.example.com")) + .isEqualTo("http://web3.example.com") + assertThat(UrlOrigins.pageOrigin("http://node1.local")) + .isEqualTo("http://node1.local") + assertThat(UrlOrigins.pageOrigin("http://foo.1..")) + .isEqualTo("http://foo.1..") + } + + @Test + fun `page origin returns null for a host it cannot canonicalize`() { + // Better to skip injection than to emit a literal that can never match. + assertThat(UrlOrigins.pageOrigin("http://[not-an-ip]:8787")).isNull() + assertThat(UrlOrigins.pageOrigin("http://[::1::2]:8787")).isNull() + // A URI-ADMITTED host that canonicalization must still reject. This one matters for + // mutation coverage: java.net.URI accepts it, so it reaches canonicalBrowserHost and the + // assertion fails if canonicalization is reduced to `return host`. + assertThat(UrlOrigins.pageOrigin("http://[1:2:3:4:5:6:7:8:9]")).isNull() + } } diff --git a/app/src/test/java/com/hermeswebui/android/webui/HermesWebUiScriptsTest.kt b/app/src/test/java/com/hermeswebui/android/webui/HermesWebUiScriptsTest.kt index e9a8bad..b2ada73 100644 --- a/app/src/test/java/com/hermeswebui/android/webui/HermesWebUiScriptsTest.kt +++ b/app/src/test/java/com/hermeswebui/android/webui/HermesWebUiScriptsTest.kt @@ -4,6 +4,45 @@ import com.google.common.truth.Truth.assertThat import org.junit.Test class HermesWebUiScriptsTest { + @Test + fun `runtime script builder checks current origin before executing payload`() { + val script = HermesWebUiScripts.buildOriginGuardedRuntimeScript( + trustedOrigin = "https://hermes.example.com:8443", + script = "window.__runtimePayloadExecuted = true;" + ) + + assertThat(script).contains( + "if (window.location.origin !== \"https://hermes.example.com:8443\") return;" + ) + assertThat(script).contains("window.__runtimePayloadExecuted = true;") + } + + @Test + fun `runtime guard compares a literal and never calls the page-controlled URL constructor`() { + // A hostile page can replace window.URL before this asynchronously-evaluated script runs. + // If the guard resolved the trusted origin via `new URL(...)`, the replacement would return + // the hostile page's own origin and the payload would execute off-origin. + val script = HermesWebUiScripts.buildOriginGuardedRuntimeScript( + trustedOrigin = "https://hermes.example.com:8443", + script = "window.__runtimePayloadExecuted = true;" + ) + + assertThat(script).doesNotContain("new URL(") + assertThat(script).doesNotContain("trustedOrigin") + } + + @Test + fun `pinch zoom script overrides restrictive viewport directives`() { + val script = HermesWebUiScripts.pinchZoomScript + + assertThat(script).contains("meta[name=\"viewport\"]") + assertThat(script).contains("/^user-scalable\\s*=/i") + assertThat(script).contains("/^maximum-scale\\s*=/i") + assertThat(script).contains("directives.push('maximum-scale=5')") + assertThat(script).contains("directives.push('user-scalable=yes')") + assertThat(script).contains("new MutationObserver") + } + @Test fun `app settings script preserves folded navigation selectors`() { val script = HermesWebUiScripts.appSettingsEntryScript