From da7751e437d77d1df01bfd25ea128cbb870aab50 Mon Sep 17 00:00:00 2001 From: Clayton Date: Wed, 9 Sep 2026 17:27:07 +0000 Subject: [PATCH 01/10] feat: enable pinch-to-zoom in WebUI --- ARCHITECTURE.md | 2 +- .../com/hermeswebui/android/MainActivity.kt | 9 +++++ .../android/webui/HermesWebUiScripts.kt | 36 +++++++++++++++++++ .../webview/HermesWebViewConfigurator.kt | 6 ++++ .../android/webui/HermesWebUiScriptsTest.kt | 12 +++++++ 5 files changed, 64 insertions(+), 1 deletion(-) 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/main/java/com/hermeswebui/android/MainActivity.kt b/app/src/main/java/com/hermeswebui/android/MainActivity.kt index 9403767..54fe43d 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 @@ -1627,6 +1628,7 @@ class MainActivity : ComponentActivity() { private fun applyHermesWebUiRuntimeScripts(view: WebView) { view.evaluateJavascript(HermesWebUiScripts.viewportFixScript, null) + view.evaluateJavascript(HermesWebUiScripts.pinchZoomScript, null) view.evaluateJavascript(HermesWebUiScripts.microphoneFallbackScript, null) view.evaluateJavascript(HermesWebUiScripts.suppressClarifyAutofocusScript, null) view.evaluateJavascript(buildHermesWebUiNotificationBridgeScript(), null) @@ -1668,6 +1670,11 @@ class MainActivity : ComponentActivity() { originRule, HermesWebUiScripts.viewportFixScript ) + pinchZoomScriptHandler = addDocumentStartScript( + view, + originRule, + HermesWebUiScripts.pinchZoomScript + ) microphoneFallbackScriptHandler = addDocumentStartScript( view, originRule, @@ -1705,6 +1712,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 +1720,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/webui/HermesWebUiScripts.kt b/app/src/main/java/com/hermeswebui/android/webui/HermesWebUiScripts.kt index 50aa404..4b0330f 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,42 @@ package com.hermeswebui.android.webui import org.json.JSONObject object HermesWebUiScripts { + /** + * 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/webui/HermesWebUiScriptsTest.kt b/app/src/test/java/com/hermeswebui/android/webui/HermesWebUiScriptsTest.kt index e9a8bad..ecccef2 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,18 @@ import com.google.common.truth.Truth.assertThat import org.junit.Test class HermesWebUiScriptsTest { + @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 From d1e4704a4bde23d6f1e57d577d84a6a9c39129ba Mon Sep 17 00:00:00 2001 From: Clayton Date: Thu, 10 Sep 2026 18:00:30 +0000 Subject: [PATCH 02/10] fix: guard WebUI runtime scripts by current origin --- .../webui/HermesWebUiCompatibilityTest.kt | 39 ++++++++++++++++++- .../com/hermeswebui/android/MainActivity.kt | 26 ++++++++----- .../android/webui/HermesWebUiScripts.kt | 16 ++++++++ .../android/webui/HermesWebUiScriptsTest.kt | 14 +++++++ 4 files changed, 84 insertions(+), 11 deletions(-) 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..79af95f 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,41 @@ 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 clarifyAutofocus_suppressesOnlyAutomaticClarifyFocus() { loadFixture( @@ -345,7 +380,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 +395,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 54fe43d..69f01e3 100644 --- a/app/src/main/java/com/hermeswebui/android/MainActivity.kt +++ b/app/src/main/java/com/hermeswebui/android/MainActivity.kt @@ -1627,16 +1627,24 @@ class MainActivity : ComponentActivity() { } private fun applyHermesWebUiRuntimeScripts(view: WebView) { - view.evaluateJavascript(HermesWebUiScripts.viewportFixScript, null) - view.evaluateJavascript(HermesWebUiScripts.pinchZoomScript, 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.documentStartOriginRule(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 { 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 4b0330f..7aed695 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,22 @@ 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. + */ + fun buildOriginGuardedRuntimeScript(trustedOrigin: String, script: String): String { + val quotedOrigin = JSONObject.quote(trustedOrigin) + return """ + (function() { + 'use strict'; + var trustedOrigin = new URL($quotedOrigin).origin; + if (window.location.origin !== trustedOrigin) 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 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 ecccef2..7844986 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,20 @@ 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( + "var trustedOrigin = new URL(\"https://hermes.example.com:8443\").origin;" + ) + assertThat(script).contains("if (window.location.origin !== trustedOrigin) return;") + assertThat(script).contains("window.__runtimePayloadExecuted = true;") + } + @Test fun `pinch zoom script overrides restrictive viewport directives`() { val script = HermesWebUiScripts.pinchZoomScript From c4f1ad452b265bb5f1bd2a9010702b6fe9d6a787 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Tue, 15 Sep 2026 22:23:00 +0000 Subject: [PATCH 03/10] fix: compare a literal origin instead of the page-controlled URL constructor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The runtime-script guard resolved its trusted origin with `new URL(...)`, but `URL` is a page-controlled global. A foreign origin can replace it before the asynchronously-evaluated script runs, return its own origin from the constructor, and satisfy the check — so the guarded payload executes off-origin, which is the exact hole the guard was added to close. Reproduced in a sandbox: with `window.URL` shadowed, a hostile page ran the guarded payload. Compare `window.location.origin` against a natively-canonicalized string literal instead, so nothing the page controls participates in the decision. The literal must match what a browser reports, which is NOT the WebViewCompat allow-rule: `documentStartOriginRule` keeps an explicitly-specified default port (`http://host:80`) while the browser drops it. Add `UrlOrigins.pageOrigin` for the browser-shaped origin and use it at the guard's call site. Tests: an instrumentation case that replaces `window.URL` on a provider page and asserts the viewport is untouched; unit cases covering default-port dropping, non-default ports, IPv6 bracketing, host lowercasing, and non-web scheme rejection. Mutation-verified — reverting the guard to the `new URL(...)` shape makes the bypass case regress. Co-authored-by: sacgsxr --- .../webui/HermesWebUiCompatibilityTest.kt | 28 +++++++++++++ .../com/hermeswebui/android/MainActivity.kt | 2 +- .../android/core/security/UrlPolicy.kt | 23 +++++++++++ .../android/webui/HermesWebUiScripts.kt | 9 ++++- .../com/hermeswebui/android/UrlPolicyTest.kt | 40 +++++++++++++++++++ .../android/webui/HermesWebUiScriptsTest.kt | 17 +++++++- 6 files changed, 114 insertions(+), 5 deletions(-) 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 79af95f..2fd2d37 100644 --- a/app/src/androidTest/java/com/hermeswebui/android/webui/HermesWebUiCompatibilityTest.kt +++ b/app/src/androidTest/java/com/hermeswebui/android/webui/HermesWebUiCompatibilityTest.kt @@ -76,6 +76,34 @@ class HermesWebUiCompatibilityTest { .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( diff --git a/app/src/main/java/com/hermeswebui/android/MainActivity.kt b/app/src/main/java/com/hermeswebui/android/MainActivity.kt index 69f01e3..8a5e051 100644 --- a/app/src/main/java/com/hermeswebui/android/MainActivity.kt +++ b/app/src/main/java/com/hermeswebui/android/MainActivity.kt @@ -1628,7 +1628,7 @@ class MainActivity : ComponentActivity() { private fun applyHermesWebUiRuntimeScripts(view: WebView) { val settings = viewModel.uiState.value.settings - val trustedOrigin = UrlOrigins.documentStartOriginRule(settings.serverUrl) ?: return + val trustedOrigin = UrlOrigins.pageOrigin(settings.serverUrl) ?: return val scripts = buildList { add(HermesWebUiScripts.viewportFixScript) add(HermesWebUiScripts.pinchZoomScript) 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..7f3ae05 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 @@ -110,6 +110,29 @@ 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 instead of calling + * the page-controlled `URL` constructor. + */ + 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 + val host = uri.normalizedHost()?.takeIf { it.isNotBlank() } ?: return null + val hostPart = if (host.contains(":") && !host.startsWith("[")) "[$host]" else host + val defaultPort = if (scheme == "https") 443 else 80 + val portPart = if (uri.port != -1 && uri.port != defaultPort) ":${uri.port}" else "" + return "$scheme://$hostPart$portPart" + } + 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 7aed695..e9cb649 100644 --- a/app/src/main/java/com/hermeswebui/android/webui/HermesWebUiScripts.kt +++ b/app/src/main/java/com/hermeswebui/android/webui/HermesWebUiScripts.kt @@ -6,14 +6,19 @@ 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'; - var trustedOrigin = new URL($quotedOrigin).origin; - if (window.location.origin !== trustedOrigin) return; + if (window.location.origin !== $quotedOrigin) return; $script })(); """.trimIndent() diff --git a/app/src/test/java/com/hermeswebui/android/UrlPolicyTest.kt b/app/src/test/java/com/hermeswebui/android/UrlPolicyTest.kt index 1253e9b..fcc76d3 100644 --- a/app/src/test/java/com/hermeswebui/android/UrlPolicyTest.kt +++ b/app/src/test/java/com/hermeswebui/android/UrlPolicyTest.kt @@ -113,4 +113,44 @@ 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") + } } 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 7844986..b2ada73 100644 --- a/app/src/test/java/com/hermeswebui/android/webui/HermesWebUiScriptsTest.kt +++ b/app/src/test/java/com/hermeswebui/android/webui/HermesWebUiScriptsTest.kt @@ -12,12 +12,25 @@ class HermesWebUiScriptsTest { ) assertThat(script).contains( - "var trustedOrigin = new URL(\"https://hermes.example.com:8443\").origin;" + "if (window.location.origin !== \"https://hermes.example.com:8443\") return;" ) - assertThat(script).contains("if (window.location.origin !== trustedOrigin) 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 From cc618990ee3573966a95f22e821c89bf6ef1602d Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Tue, 15 Sep 2026 22:35:08 +0000 Subject: [PATCH 04/10] fix: canonicalize the guard origin with browser host rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The literal the origin guard compares against must be byte-identical to what the page reports in `window.location.origin`, or the guard never matches and every runtime shim is silently suppressed for that server. `java.net.URI` does not apply WHATWG host parsing, so two configurable forms diverged: a numeric host (`http://2130706433` and `http://0x7f000001`, which browsers resolve to `http://127.0.0.1`) and an expanded IPv6 literal (`http://[0:0:0:0:0:0:0:1]`, which browsers compress to `http://[::1]`). Canonicalize the host natively before building the literal: WHATWG numeric-IPv4 parsing (decimal/octal/hex, with a short last part filling the remaining bytes) and RFC 5952 IPv6 compression. A host we cannot canonicalize confidently returns null, so the caller skips injection rather than emitting an unmatchable literal. IPv6 compression is deliberately pure string handling rather than InetAddress: this runs on the main thread during script injection and must never risk a name-resolution call. Verified by porting the implementation to JS and differential-testing it against the platform's own WHATWG URL parser: 21 hand-picked cases plus 2600 fuzzed inputs (random IPv6 in exploded and compressed spellings, random IPv4 in dotted/integer/hex forms, DNS names, default and non-default ports) — zero divergences. Rejected hosts also match: every input we return null for is one the browser's URL parser throws on. Co-authored-by: sacgsxr --- .../android/core/security/UrlPolicy.kt | 181 +++++++++++++++++- .../com/hermeswebui/android/UrlPolicyTest.kt | 39 ++++ 2 files changed, 216 insertions(+), 4 deletions(-) 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 7f3ae05..64b3ec8 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 @@ -117,8 +117,12 @@ object UrlOrigins { * 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 instead of calling - * the page-controlled `URL` constructor. + * 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 @@ -127,10 +131,179 @@ object UrlOrigins { ?.takeIf { it == "http" || it == "https" } ?: return null val host = uri.normalizedHost()?.takeIf { it.isNotBlank() } ?: return null - val hostPart = if (host.contains(":") && !host.startsWith("[")) "[$host]" else host + val canonicalHost = canonicalBrowserHost(host) ?: return null val defaultPort = if (scheme == "https") 443 else 80 val portPart = if (uri.port != -1 && uri.port != defaultPort) ":${uri.port}" else "" - return "$scheme://$hostPart$portPart" + return "$scheme://$canonicalHost$portPart" + } + + /** + * 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 it } + // A dotted-decimal host is already in browser form; ordinary DNS names are too. + return host + } + + /** + * WHATWG IPv4 parsing: the host is numeric when every dot-separated part parses as a number + * (decimal, `0`-prefixed octal, or `0x`-prefixed hex). Fewer than four parts means the last + * part supplies the remaining bytes, so `2130706433` → `127.0.0.1`. + */ + private fun ipv4FromNumericHost(host: String): String? { + val parts = host.split(".") + if (parts.isEmpty() || parts.size > 4) return null + if (parts.any { it.isEmpty() }) return null + val numbers = parts.map { parseIpv4Part(it) ?: return null } + // All parts numeric. The last part fills the remaining low-order bytes. + val lastMax = 1L shl (8 * (4 - numbers.size + 1)) + if (numbers.last() >= lastMax) return null + if (numbers.dropLast(1).any { it > 255 }) return null + 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}" + } + + private fun parseIpv4Part(part: String): Long? { + val (radix, digits) = when { + part.length >= 2 && (part.startsWith("0x") || part.startsWith("0X")) -> 16 to part.substring(2) + part.length >= 2 && part.startsWith("0") -> 8 to part.substring(1) + else -> 10 to part + } + if (digits.isEmpty()) return if (radix == 8 || radix == 10) 0L else 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 + val bytes = quad.map { part -> + if (part.isEmpty() || part.length > 3 || part.any { !it.isDigit() }) return null + val value = part.toInt() + if (value > 255) return null + value + } + 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 { diff --git a/app/src/test/java/com/hermeswebui/android/UrlPolicyTest.kt b/app/src/test/java/com/hermeswebui/android/UrlPolicyTest.kt index fcc76d3..6dad5ca 100644 --- a/app/src/test/java/com/hermeswebui/android/UrlPolicyTest.kt +++ b/app/src/test/java/com/hermeswebui/android/UrlPolicyTest.kt @@ -153,4 +153,43 @@ class UrlPolicyTest { 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]") + 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") + } + + @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") + // 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 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() + } } From c82a820f88079c9a465614e824b565c624cd7d56 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Tue, 15 Sep 2026 22:39:58 +0000 Subject: [PATCH 05/10] test: correct the expected origin for the compressed-IPv6 case MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The assertion dropped the `:9000` port when transcribing from the differential oracle, so it expected `http://[2001:db8::1428:57ab]` where both a real browser and the implementation produce `http://[2001:db8::1428:57ab]:9000`. The test was wrong, not the code — CI caught it (169/170 passing). Re-checked EVERY pageOrigin assertion in this file against the platform's own WHATWG URL parser; all 13 expected values now agree with the browser, and each null-case is one the browser's parser also rejects. Co-authored-by: sacgsxr --- app/src/test/java/com/hermeswebui/android/UrlPolicyTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/test/java/com/hermeswebui/android/UrlPolicyTest.kt b/app/src/test/java/com/hermeswebui/android/UrlPolicyTest.kt index 6dad5ca..f5983d8 100644 --- a/app/src/test/java/com/hermeswebui/android/UrlPolicyTest.kt +++ b/app/src/test/java/com/hermeswebui/android/UrlPolicyTest.kt @@ -162,7 +162,7 @@ class UrlPolicyTest { 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]") + .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]")) From 83a5313fc540f1c96ee7f1a0557c441fc310ed1c Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Tue, 15 Sep 2026 22:55:51 +0000 Subject: [PATCH 06/10] fix: match Chromium on numeric-host and embedded-IPv4 edge cases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Differential-testing the canonicalizer against a real headless Chromium (not Node's URL) surfaced four remaining divergences. Each would emit a literal the guard can never match, silently suppressing every runtime shim: - `http://0x` — an empty hex payload is zero, so Chromium yields `http://0.0.0.0`. A bare `0` prefix is likewise octal-with-empty-payload. - `http://2130706433.` / `http://127.0.0.1.` — a NUMERIC host drops one trailing dot. A DNS name does NOT: Chromium keeps `http://hermes.example.com.` verbatim, so the two cases are handled separately. - `http://[::ffff:127.0.0.010]` — the embedded dotted-quad uses the same radix-aware part parsing as a bare IPv4 host, so octal `010` is 8 (`…:7f00:8`), not decimal 10 (`…:7f00:a`). Chromium also accepts components longer than three characters when they carry leading zeroes. - `http://999.1.1.1`, `http://1.2.3.4.5` — these are numeric candidates the browser REJECTS, not DNS names. Previously they fell through and returned the raw host. They now fail closed via an INVALID_NUMERIC_HOST sentinel so the caller skips injection. Also adds the mutation-coverage case the reviewer asked for: `[1:2:3:4:5:6:7:8:9]` is admitted by java.net.URI, so it actually reaches canonicalBrowserHost and the assertion fails if canonicalization is reduced to `return host`. Verified against real Chromium over CDP: 2627 probes (hand-picked edge cases plus fuzzed IPv6 in exploded/compressed spellings and IPv4 in dotted/integer/hex forms) with zero divergences, and all 23 value assertions in UrlPolicyTest re-checked against Chromium's own output. Co-authored-by: sacgsxr --- .../android/core/security/UrlPolicy.kt | 61 +++++++++++++++---- .../com/hermeswebui/android/UrlPolicyTest.kt | 33 ++++++++++ 2 files changed, 82 insertions(+), 12 deletions(-) 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 64b3ec8..13ec37e 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() } } @@ -158,8 +166,10 @@ object UrlOrigins { val compressed = compressIpv6(host) ?: return null return "[$compressed]" } - ipv4FromNumericHost(host)?.let { return it } - // A dotted-decimal host is already in browser form; ordinary DNS names are too. + 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 } @@ -167,16 +177,35 @@ object UrlOrigins { * WHATWG IPv4 parsing: the host is numeric when every dot-separated part parses as a number * (decimal, `0`-prefixed octal, or `0x`-prefixed hex). Fewer than four parts means the last * part supplies the remaining bytes, so `2130706433` → `127.0.0.1`. + * + * Returns null when the host is not a numeric candidate at all (an ordinary DNS name, which + * the caller passes through unchanged). Returns [INVALID_NUMERIC_HOST] when it IS numeric but + * out of range — the browser rejects those, so the caller must fail closed rather than emit + * the raw spelling. */ private fun ipv4FromNumericHost(host: String): String? { - val parts = host.split(".") - if (parts.isEmpty() || parts.size > 4) return null + // A single trailing dot is dropped before parsing: `2130706433.` and `127.0.0.1.` are the + // same hosts as their undotted forms (verified against Chromium). + val trimmed = if (host.endsWith(".")) host.dropLast(1) else host + if (trimmed.isEmpty()) return null + val parts = trimmed.split(".") + if (parts.size > 4) { + // An all-numeric host with more than four parts is an INVALID address, not a DNS + // name — the browser rejects `http://1.2.3.4.5` outright, so fail closed. + return if (parts.all { it.isNotEmpty() && parseIpv4Part(it) != null }) { + INVALID_NUMERIC_HOST + } else { + null + } + } if (parts.any { it.isEmpty() }) return null + // Numeric-candidate test first: if ANY part fails to parse as a number this is a DNS name, + // not a malformed address, so the caller should pass it through untouched. val numbers = parts.map { parseIpv4Part(it) ?: return null } - // All parts numeric. The last part fills the remaining low-order bytes. + // From here the host IS numeric, so any range failure is a browser-rejected host. val lastMax = 1L shl (8 * (4 - numbers.size + 1)) - if (numbers.last() >= lastMax) return null - if (numbers.dropLast(1).any { it > 255 }) return null + 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)) @@ -184,13 +213,19 @@ object UrlOrigins { return "${(value shr 24) and 0xFF}.${(value shr 16) and 0xFF}.${(value shr 8) and 0xFF}.${value and 0xFF}" } + /** + * Parse one IPv4 part. `0x`/`0X` prefix is hex, a leading `0` is octal, otherwise decimal. + * A bare `0x` (empty hex payload) is zero, matching Chromium: `http://0x` → `http://0.0.0.0`. + */ private fun parseIpv4Part(part: String): Long? { val (radix, digits) = when { part.length >= 2 && (part.startsWith("0x") || part.startsWith("0X")) -> 16 to part.substring(2) - part.length >= 2 && part.startsWith("0") -> 8 to part.substring(1) + part.startsWith("0") -> 8 to part.substring(1) else -> 10 to part } - if (digits.isEmpty()) return if (radix == 8 || radix == 10) 0L else null + // An empty payload means the part was exactly "0", "0x" or "0X" — all of which are zero. + if (digits.isEmpty()) return 0L + if (digits.any { Character.digit(it, radix) < 0 }) return null return digits.toLongOrNull(radix)?.takeIf { it >= 0 } } @@ -291,11 +326,13 @@ object UrlOrigins { 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 -> - if (part.isEmpty() || part.length > 3 || part.any { !it.isDigit() }) return null - val value = part.toInt() + val value = parseIpv4Part(part) ?: return null if (value > 255) return null - value + value.toInt() } return intArrayOf((bytes[0] shl 8) or bytes[1], (bytes[2] shl 8) or bytes[3]) } diff --git a/app/src/test/java/com/hermeswebui/android/UrlPolicyTest.kt b/app/src/test/java/com/hermeswebui/android/UrlPolicyTest.kt index f5983d8..1935b54 100644 --- a/app/src/test/java/com/hermeswebui/android/UrlPolicyTest.kt +++ b/app/src/test/java/com/hermeswebui/android/UrlPolicyTest.kt @@ -173,6 +173,12 @@ class UrlPolicyTest { // 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 @@ -181,15 +187,42 @@ class UrlPolicyTest { 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 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 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() } } From 980609c34ab94e97b3a891d92d30ae37926f91ae Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Tue, 15 Sep 2026 23:07:14 +0000 Subject: [PATCH 07/10] fix: recover browser-accepted hosts that java.net.URI rejects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Running the compiled canonicalizer against a real headless Chromium surfaced a class java.net.URI silently drops: it is RFC 2396-strict and returns a null host (and port -1) for spellings a browser accepts — a trailing-dot IPv4 (`127.0.0.1.`, `2130706433.:8080`) and `0x.0x.0x.0x`. Those hit `?: return null` and disabled every runtime shim for such a configured server. Add a raw-authority fallback used only when URI yields no host: read the host (and port) straight from the authority substring, stripping userinfo to match a browser's origin (`user:pass@host` → `host`), then run the same WHATWG canonicalization. Anything unreadable still fails closed. Verification tightened to remove the CI round-trips that caught my earlier test typos: the production Kotlin is now compiled locally (kotlinc + JDK 21) and run against real Chromium over CDP across 2750 probes with ZERO divergences and zero wrong-value results (every remaining difference is a fail-closed null on a host Chromium also rejects). All 37 assertions in UrlPolicyTest were generated from the compiled Kotlin's own output, so the suite matches the implementation which matches the browser. Co-authored-by: sacgsxr --- .../android/core/security/UrlPolicy.kt | 52 ++++++++++++++++++- .../com/hermeswebui/android/UrlPolicyTest.kt | 20 +++++++ 2 files changed, 70 insertions(+), 2 deletions(-) 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 13ec37e..e54d7fa 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 @@ -138,13 +138,61 @@ object UrlOrigins { ?.lowercase(Locale.US) ?.takeIf { it == "http" || it == "https" } ?: return null - val host = uri.normalizedHost()?.takeIf { it.isNotBlank() } ?: 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 = it.toIntOrNull() ?: return null } + } val canonicalHost = canonicalBrowserHost(host) ?: return null val defaultPort = if (scheme == "https") 443 else 80 - val portPart = if (uri.port != -1 && uri.port != defaultPort) ":${uri.port}" else "" + val portPart = if (port != -1 && port != defaultPort) ":$port" else "" return "$scheme://$canonicalHost$portPart" } + /** + * Read (host, port?) from the raw authority, lowercased, for URLs `java.net.URI` parses but + * whose host it rejects. Userinfo (credentials) is stripped, matching a browser's + * `location.origin`. An authority we cannot read cleanly returns null so the caller fails + * closed. + */ + 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 + return lowered to port + } + /** * Serialize a host the way a browser does when it builds `location.origin`. * diff --git a/app/src/test/java/com/hermeswebui/android/UrlPolicyTest.kt b/app/src/test/java/com/hermeswebui/android/UrlPolicyTest.kt index 1935b54..516821c 100644 --- a/app/src/test/java/com/hermeswebui/android/UrlPolicyTest.kt +++ b/app/src/test/java/com/hermeswebui/android/UrlPolicyTest.kt @@ -205,6 +205,26 @@ class UrlPolicyTest { .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 out-of-range numeric host`() { // These are numeric candidates the browser rejects outright. Returning the raw spelling From f0f6e3105316e0dabd4e0550ebee69c17b4963a9 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Tue, 15 Sep 2026 23:19:59 +0000 Subject: [PATCH 08/10] fix: fail closed on invalid ports and non-ASCII/percent hosts in the fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two ways the raw-authority fallback could synthesize a literal that disagrees with a browser's location.origin (both found by attacking the compiled Kotlin against real Chromium): - Ports were parsed with toIntOrNull, which accepts signs and overflows. `:-1`, `:+80`, `:65536`, `:8_7` all produced a valid-looking origin where Chromium rejects the URL. Parse ports as ASCII-digits-only in 0..65535 on the fallback path AND range-check uri.port on the normal branch; fail closed otherwise. - The fallback passed a host through verbatim, but it only exists to recover the numeric/ASCII hosts java.net.URI wrongly rejects — it does not implement WHATWG percent-decoding or IDNA. `foo%2ebar` and `münchen.de` would emit an un-decoded/un-punycoded literal that never matches the browser's origin. The fallback now returns null on any host carrying `%` or a non-ASCII character. (A real self-hosted server URL is an IP or an ASCII hostname, both of which java.net.URI already accepts on the normal path — so this loses no legitimate case; it only refuses to guess where a browser would decode.) Every residual difference from a browser is now a fail-closed null (skip injection), never a wrong literal. Verified against real Chromium via the compiled production Kotlin across 2770 probes (base + fuzz + adversarial authorities + IDNA/percent/port edge cases): zero wrong-value results. All 45 UrlPolicyTest assertions were regenerated from the compiled Kotlin's own output. Co-authored-by: sacgsxr --- .../android/core/security/UrlPolicy.kt | 22 +++++++++++++---- .../com/hermeswebui/android/UrlPolicyTest.kt | 24 +++++++++++++++++++ 2 files changed, 42 insertions(+), 4 deletions(-) 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 e54d7fa..7ff2b2e 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 @@ -150,19 +150,31 @@ object UrlOrigins { } else { val raw = rawAuthorityHostPort(url) ?: return null host = raw.first - raw.second?.let { port = it.toIntOrNull() ?: return null } + 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. Userinfo (credentials) is stripped, matching a browser's - * `location.origin`. An authority we cannot read cleanly returns null so the caller fails - * closed. + * 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 } @@ -190,6 +202,8 @@ object UrlOrigins { } val lowered = host.lowercase(Locale.US) if (lowered.isEmpty()) return null + // We do not decode/IDNA in this fallback, so refuse anything that would need it. + if (lowered.any { it.code > 0x7F } || lowered.contains('%')) return null return lowered to port } diff --git a/app/src/test/java/com/hermeswebui/android/UrlPolicyTest.kt b/app/src/test/java/com/hermeswebui/android/UrlPolicyTest.kt index 516821c..974b99e 100644 --- a/app/src/test/java/com/hermeswebui/android/UrlPolicyTest.kt +++ b/app/src/test/java/com/hermeswebui/android/UrlPolicyTest.kt @@ -225,6 +225,30 @@ class UrlPolicyTest { .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 From 568b6877688bffce1d2f9018590aec5d14d2ac30 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Tue, 15 Sep 2026 23:43:12 +0000 Subject: [PATCH 09/10] fix: apply WHATWG "ends in a number" rule so numeric-ish hosts fail closed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The canonicalizer treated a host as a DNS name whenever IPv4 parsing failed, so `http://foo.1`, `http://example.99`, `http://09`, `http://1..2.3`, and `http://1.2.3.09` were passed through verbatim — but a browser REJECTS all of them, so the emitted literal never matched location.origin (a wrong-value, not a fail-closed null). WHATWG's rule: if a host's last label ends in a number, the whole host MUST parse as a valid IPv4 address or the host is invalid. Implement that directly — detect "ends in a number" first (last label all-digits or a parseable IPv4 part), and if so, any parse/range/empty-label failure returns INVALID_NUMERIC_HOST (caller fails closed) instead of a DNS pass-through. A host that does not end in a number stays an ordinary DNS name. Also reject an empty IPv4 part so interior empty labels (`1..2.3`) fail rather than parse. Verified against real Chromium via the compiled production Kotlin across 3379 probes (base + fuzz + numeric-ending edge cases + DNS names ending in digits): ZERO wrong-value AND zero conservative-null — the implementation now matches Chromium exactly on every input. All 54 UrlPolicyTest assertions regenerated from the compiled Kotlin's output. Co-authored-by: sacgsxr --- .../android/core/security/UrlPolicy.kt | 52 +++++++++---------- .../com/hermeswebui/android/UrlPolicyTest.kt | 25 +++++++++ 2 files changed, 49 insertions(+), 28 deletions(-) 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 7ff2b2e..3def4af 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 @@ -236,35 +236,28 @@ object UrlOrigins { } /** - * WHATWG IPv4 parsing: the host is numeric when every dot-separated part parses as a number - * (decimal, `0`-prefixed octal, or `0x`-prefixed hex). Fewer than four parts means the last - * part supplies the remaining bytes, so `2130706433` → `127.0.0.1`. + * 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. * - * Returns null when the host is not a numeric candidate at all (an ordinary DNS name, which - * the caller passes through unchanged). Returns [INVALID_NUMERIC_HOST] when it IS numeric but - * out of range — the browser rejects those, so the caller must fail closed rather than emit - * the raw spelling. + * 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? { - // A single trailing dot is dropped before parsing: `2130706433.` and `127.0.0.1.` are the - // same hosts as their undotted forms (verified against Chromium). - val trimmed = if (host.endsWith(".")) host.dropLast(1) else host - if (trimmed.isEmpty()) return null - val parts = trimmed.split(".") - if (parts.size > 4) { - // An all-numeric host with more than four parts is an INVALID address, not a DNS - // name — the browser rejects `http://1.2.3.4.5` outright, so fail closed. - return if (parts.all { it.isNotEmpty() && parseIpv4Part(it) != null }) { - INVALID_NUMERIC_HOST - } else { - null - } - } - if (parts.any { it.isEmpty() }) return null - // Numeric-candidate test first: if ANY part fails to parse as a number this is a DNS name, - // not a malformed address, so the caller should pass it through untouched. - val numbers = parts.map { parseIpv4Part(it) ?: return null } - // From here the host IS numeric, so any range failure is a browser-rejected host. + // 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() + val endsInNumber = (last.isNotEmpty() && last.all { it in '0'..'9' }) || parseIpv4Part(last) != null + if (!endsInNumber) 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 @@ -277,15 +270,18 @@ object UrlOrigins { /** * Parse one IPv4 part. `0x`/`0X` prefix is hex, a leading `0` is octal, otherwise decimal. - * A bare `0x` (empty hex payload) is zero, matching Chromium: `http://0x` → `http://0.0.0.0`. + * A bare `0`, `0x` or `0X` (empty payload after the prefix) is zero, matching Chromium + * (`http://0x` → `http://0.0.0.0`). A completely empty part is failure (null), so a host with + * an interior empty label (`1..2.3`) is rejected rather than parsed. */ 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 } - // An empty payload means the part was exactly "0", "0x" or "0X" — all of which are zero. + // Empty payload here means the part was exactly "0", "0x" or "0X" — all zero. if (digits.isEmpty()) return 0L if (digits.any { Character.digit(it, radix) < 0 }) return null return digits.toLongOrNull(radix)?.takeIf { it >= 0 } diff --git a/app/src/test/java/com/hermeswebui/android/UrlPolicyTest.kt b/app/src/test/java/com/hermeswebui/android/UrlPolicyTest.kt index 974b99e..8d9d9ae 100644 --- a/app/src/test/java/com/hermeswebui/android/UrlPolicyTest.kt +++ b/app/src/test/java/com/hermeswebui/android/UrlPolicyTest.kt @@ -259,6 +259,31 @@ class UrlPolicyTest { 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() + } + + @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. From 5c8271d05b329a3a27e512cd0349a809142d73cb Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Wed, 16 Sep 2026 00:00:57 +0000 Subject: [PATCH 10/10] fix: overflow-safe numeric detection + LDH host recovery in the fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two more wrong-value cases the compiled-Kotlin-vs-Chromium diff surfaced: - Overflowing hex like `http://0x8000000000000000` was mistaken for a DNS name: the "ends in a number" test went through parseIpv4Part, which returned null on Long overflow, so the host fell through to a pass-through. Split the concern: `ipv4PartLooksNumeric` is a pure SYNTAX test (a digit run, or `0x`+valid-hex) that decides "ends in a number" independent of magnitude and octal validity; parseIpv4Part still enforces value validity. An overflowing-but-numeric host now fails closed. This also fixes `09` (looks numeric, is an invalid octal → the browser rejects it → we must too), which the previous round regressed. - The raw-authority fallback emitted browser-divergent literals for hosts with a character a browser percent-encodes (`foo*bar` → `foo%2Abar`). The fallback does not encode, so it now accepts a host verbatim ONLY when every character is one a browser also keeps verbatim: the LDH set plus `_` and `~` (derived by probing all 94 printable-ASCII chars against Chromium). This RECOVERS real hostnames java.net.URI wrongly rejects (`my_host.local`, `foo_bar` — common on Docker/internal networks) while failing closed on the exotic-char hosts no self-hosted server uses. Verified against real Chromium via the compiled production Kotlin across 4074 probes (base + fuzz + numeric-ending + overflow + all-ASCII-char host probes + underscore/tilde recovery): ZERO wrong-value results. All 62 UrlPolicyTest assertions regenerated from the compiled Kotlin's own output. Co-authored-by: sacgsxr --- .../android/core/security/UrlPolicy.kt | 51 +++++++++++++++---- .../com/hermeswebui/android/UrlPolicyTest.kt | 24 +++++++++ 2 files changed, 66 insertions(+), 9 deletions(-) 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 3def4af..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 @@ -202,8 +202,17 @@ object UrlOrigins { } val lowered = host.lowercase(Locale.US) if (lowered.isEmpty()) return null - // We do not decode/IDNA in this fallback, so refuse anything that would need it. - if (lowered.any { it.code > 0x7F } || lowered.contains('%')) 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 } @@ -252,8 +261,10 @@ object UrlOrigins { if (parts.size > 1 && parts.last().isEmpty()) parts = parts.dropLast(1) if (parts.isEmpty()) return null val last = parts.last() - val endsInNumber = (last.isNotEmpty() && last.all { it in '0'..'9' }) || parseIpv4Part(last) != null - if (!endsInNumber) return null // Ordinary DNS name — pass through unchanged. + // "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 @@ -269,10 +280,33 @@ object UrlOrigins { } /** - * Parse one IPv4 part. `0x`/`0X` prefix is hex, a leading `0` is octal, otherwise decimal. - * A bare `0`, `0x` or `0X` (empty payload after the prefix) is zero, matching Chromium - * (`http://0x` → `http://0.0.0.0`). A completely empty part is failure (null), so a host with - * an interior empty label (`1..2.3`) is rejected rather than parsed. + * 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 @@ -281,7 +315,6 @@ object UrlOrigins { part.startsWith("0") -> 8 to part.substring(1) else -> 10 to part } - // Empty payload here means the part was exactly "0", "0x" or "0X" — all zero. if (digits.isEmpty()) return 0L if (digits.any { Character.digit(it, radix) < 0 }) return null return digits.toLongOrNull(radix)?.takeIf { it >= 0 } diff --git a/app/src/test/java/com/hermeswebui/android/UrlPolicyTest.kt b/app/src/test/java/com/hermeswebui/android/UrlPolicyTest.kt index 8d9d9ae..3e832fb 100644 --- a/app/src/test/java/com/hermeswebui/android/UrlPolicyTest.kt +++ b/app/src/test/java/com/hermeswebui/android/UrlPolicyTest.kt @@ -270,6 +270,30 @@ class UrlPolicyTest { 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