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