From a6b6b08f2980933bbf73ac4bd2ce95f8905b205e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 19:28:24 +0000 Subject: [PATCH] fix(browser): one consent gate for every page-initiated download Builds on the reported fix: every download a page starts (WebView DownloadListener or the browser.download bridge message) becomes a DownloadOffer shown on the consent card before anything is fetched or written. - Per-surface DownloadCooldown that actually runs (the old gate's cooldown stamp was never written), plus one offer prepared at a time so a page can't queue 25 MiB decodes. - Cookies read on the main thread from the tab's own WebView profile again (the lambda was invoked on the download thread). - Use the listener's contentLength instead of a pre-consent HEAD probe: no request leaves before the user says yes, no 10s card delay. - The card shows and the save writes the same sanitized name; unknown sizes are hidden instead of "-1 B"; long names keep their extension. - Inline data: URLs decode off the main thread; the rules (risky extensions, data: URL decoding with a hard cap, cooldown) move to commons with unit tests. - Drop the hand-written values-es strings (Crowdin-managed) and the unused gate code. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E3cqjbY7FX2zrkGXCVXpFD --- .../loggedIn/browser/EmbeddedPageRequests.kt | 6 +- .../browser/EmbeddedWebAppController.kt | 2 +- .../screen/loggedIn/browser/WebAppScreen.kt | 2 +- .../commons/browser/BrowserDownloadRules.kt | 186 ++++++++++++ .../browser/BrowserDownloadRulesTest.kt | 99 +++++++ .../composeResources/values-es/strings.xml | 3 - .../browser/ui/pill/DownloadPromptCard.kt | 26 +- .../amethyst/napplethost/BrowserChromeHost.kt | 6 +- .../napplethost/BrowserDownloadGate.kt | 226 --------------- .../amethyst/napplethost/BrowserDownloads.kt | 268 ++++++++---------- .../napplethost/BrowserExtrasScript.kt | 8 +- .../napplethost/NappletBrowserActivity.kt | 120 ++++---- .../napplethost/NappletBrowserContract.kt | 15 +- .../napplethost/NappletBrowserService.kt | 153 +++++----- 14 files changed, 547 insertions(+), 573 deletions(-) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/BrowserDownloadRules.kt create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/browser/BrowserDownloadRulesTest.kt delete mode 100644 nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserDownloadGate.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedPageRequests.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedPageRequests.kt index dd1e18c379..67119d18c3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedPageRequests.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedPageRequests.kt @@ -42,9 +42,9 @@ data class EmbeddedPermissionRequest( ) /** - * An inline download (`browser.download`) from an embedded page, waiting for consent before its bytes - * are written into the shared Downloads collection. [fileName]/[sizeBytes] are the sanitized name and - * exact decoded size the sandbox reports; [origin] is the WebView-reported origin, not a page field. + * A download an embedded page started, waiting for consent before anything is fetched or written into + * the shared Downloads collection. [fileName]/[sizeBytes] are the sanitized name and size (-1 when + * unknown) the sandbox reports; [origin] is the WebView-reported origin, not a page field. */ data class EmbeddedDownloadRequest( val id: Long, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedWebAppController.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedWebAppController.kt index 44707deeaf..df043eb826 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedWebAppController.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedWebAppController.kt @@ -386,7 +386,7 @@ class EmbeddedWebAppController( id = id, origin = origin, fileName = name, - sizeBytes = data.getLong(NappletBrowserContract.KEY_DOWNLOAD_SIZE, 0L), + sizeBytes = data.getLong(NappletBrowserContract.KEY_DOWNLOAD_SIZE, -1L), risky = data.getBoolean(NappletBrowserContract.KEY_DOWNLOAD_RISKY, false), ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/WebAppScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/WebAppScreen.kt index 16b391755d..36b516a1b7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/WebAppScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/WebAppScreen.kt @@ -410,7 +410,7 @@ private fun EmbeddedPageUi( } } - // A page's inline download: nothing reaches the shared Downloads collection until this is answered. + // A download the page started: nothing is fetched or saved until this is answered. // The card shows the sanitized file name and exact byte count the sandbox will write, headed by the // WebView-reported origin (never a page-supplied field), with a warning for installer/script-like // extensions — the social-engineering payload names the disclosure calls out ("invoice.apk"). diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/BrowserDownloadRules.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/BrowserDownloadRules.kt new file mode 100644 index 0000000000..6b85a01384 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/BrowserDownloadRules.kt @@ -0,0 +1,186 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.browser + +import kotlin.io.encoding.Base64 +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds +import kotlin.time.TimeMark +import kotlin.time.TimeSource + +/** + * The platform-free rules behind the in-app browser's download consent: which file names deserve a + * warning, how a page-supplied `data:` URL turns into the bytes a consent card describes, and how often + * one site may ask. + * + * A page can start a download without any gesture (a navigation to an attachment, a script `.click()` + * on an ``, or a hand-forged bridge envelope), so every page-initiated save asks the user + * first. The card shows exactly the name and size these rules produce, and the save writes exactly that. + */ +object BrowserDownloadRules { + /** Cap for bytes a page hands over inline (a `blob:`/`data:` download travels as base64 over the bridge). */ + const val MAX_INLINE_BYTES = 25 * 1024 * 1024 + + /** + * Extensions something can install or run from. The consent card flags them; the name itself is + * never rewritten, so the user judges the real one. + */ + val RISKY_EXTENSIONS = + setOf( + "apk", + "apks", + "apkm", + "xapk", + "aab", + "jar", + "dex", + "so", + "exe", + "msi", + "bat", + "cmd", + "com", + "scr", + "hta", + "ps1", + "vbs", + "wsf", + "lnk", + "url", + "dmg", + "pkg", + "app", + "command", + "deb", + "rpm", + "appimage", + "run", + "sh", + "zsh", + "bash", + "desktop", + "html", + "htm", + "xhtml", + "svg", + ) + + /** Whether [fileName]'s last extension is one a user should double-check before saving. */ + fun isRisky(fileName: String): Boolean = fileName.substringAfterLast('.', "").trim().lowercase() in RISKY_EXTENSIONS + + /** A decoded `data:` URL: the declared MIME type (null when absent) and the payload bytes. */ + class DataUrl( + val mimeType: String?, + val bytes: ByteArray, + ) + + private val lenientBase64 = Base64.Mime.withPadding(Base64.PaddingOption.PRESENT_OPTIONAL) + + /** + * Decodes `data:[][;param=v…][;base64],`, base64 or percent-encoded. Null when it is + * not a data URL, is malformed, or decodes to more than [maxBytes] — a refused download, never a + * truncated one. The encoded length is checked first, so an oversized payload is never decoded. + */ + fun decodeDataUrl( + dataUrl: String, + maxBytes: Int = MAX_INLINE_BYTES, + ): DataUrl? { + if (!dataUrl.startsWith("data:", ignoreCase = true)) return null + val comma = dataUrl.indexOf(',') + if (comma < 0) return null + val header = dataUrl.substring(5, comma) + val params = header.split(';') + val mime = + params + .first() + .trim() + .lowercase() + .takeIf { it.isNotEmpty() } + val isBase64 = params.drop(1).any { it.trim().equals("base64", ignoreCase = true) } + val payloadLength = dataUrl.length - comma - 1 + val bytes = + if (isBase64) { + // 4 chars per 3 bytes, plus slack for padding and the line breaks a lenient decoder skips. + if (payloadLength > maxBytes / 3 * 4 + 1024) return null + runCatching { lenientBase64.decode(dataUrl, comma + 1, dataUrl.length) }.getOrNull() ?: return null + } else { + // A percent escape is 3 chars per byte, a literal char up to 4 UTF-8 bytes. + if (payloadLength > maxBytes) return null + percentDecode(dataUrl, comma + 1) ?: return null + } + if (bytes.size > maxBytes) return null + return DataUrl(mime, bytes) + } + + /** RFC 3986 percent-decoding of [text] from [start] (a `+` stays a `+`); null on a broken escape. */ + private fun percentDecode( + text: String, + start: Int, + ): ByteArray? { + val source = text.substring(start).encodeToByteArray() + val out = ByteArray(source.size) + var read = 0 + var written = 0 + while (read < source.size) { + val b = source[read] + if (b == '%'.code.toByte()) { + if (read + 2 >= source.size) return null + val hi = hexValue(source[read + 1]) + val lo = hexValue(source[read + 2]) + if (hi < 0 || lo < 0) return null + out[written++] = ((hi shl 4) or lo).toByte() + read += 3 + } else { + out[written++] = b + read++ + } + } + return out.copyOf(written) + } + + private fun hexValue(b: Byte): Int = + when (val c = b.toInt().toChar()) { + in '0'..'9' -> c - '0' + in 'a'..'f' -> c - 'a' + 10 + in 'A'..'F' -> c - 'A' + 10 + else -> -1 + } +} + +/** + * How often one browser surface (a window, or one embedded tab) lets a site show a download card: after + * the user answers a site's card, that site can't raise another for [window]. Without it a page that is + * refused can re-ask in a loop, so Cancel would never make the card go away. Main-thread only. + */ +class DownloadCooldown( + private val window: Duration = 1.seconds, + private val timeSource: TimeSource = TimeSource.Monotonic, +) { + private val answeredAt = HashMap() + + /** Whether [origin] may show a card now. */ + fun allows(origin: String): Boolean = answeredAt[origin]?.let { it.elapsedNow() >= window } ?: true + + /** Starts [origin]'s cooldown: the user just answered (or the surface dismissed) its card. */ + fun answered(origin: String) { + answeredAt[origin] = timeSource.markNow() + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/browser/BrowserDownloadRulesTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/browser/BrowserDownloadRulesTest.kt new file mode 100644 index 0000000000..e186292c62 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/browser/BrowserDownloadRulesTest.kt @@ -0,0 +1,99 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.browser + +import kotlin.io.encoding.Base64 +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds +import kotlin.time.TestTimeSource + +class BrowserDownloadRulesTest { + @Test + fun flagsInstallableAndScriptExtensions() { + assertTrue(BrowserDownloadRules.isRisky("invoice.apk")) + assertTrue(BrowserDownloadRules.isRisky("invoice.pdf.APK")) + assertTrue(BrowserDownloadRules.isRisky("page.html")) + assertFalse(BrowserDownloadRules.isRisky("photo.jpg")) + assertFalse(BrowserDownloadRules.isRisky("apk")) + assertFalse(BrowserDownloadRules.isRisky("download")) + } + + @Test + fun decodesBase64DataUrl() { + val payload = "hello world".encodeToByteArray() + val url = "data:text/plain;charset=utf-8;base64," + Base64.encode(payload) + val decoded = BrowserDownloadRules.decodeDataUrl(url)!! + assertEquals("text/plain", decoded.mimeType) + assertContentEquals(payload, decoded.bytes) + } + + @Test + fun decodesUnpaddedAndWrappedBase64() { + assertContentEquals("hi".encodeToByteArray(), BrowserDownloadRules.decodeDataUrl("data:;base64,aGk")!!.bytes) + assertContentEquals("hello world".encodeToByteArray(), BrowserDownloadRules.decodeDataUrl("data:;base64,aGVsbG8g\r\nd29ybGQ=")!!.bytes) + } + + @Test + fun decodesPercentEncodedDataUrl() { + val decoded = BrowserDownloadRules.decodeDataUrl("DATA:,a%20b+c%C3%A9")!! + assertNull(decoded.mimeType) + assertEquals("a b+cé", decoded.bytes.decodeToString()) + } + + @Test + fun refusesMalformedDataUrls() { + assertNull(BrowserDownloadRules.decodeDataUrl("https://example.com/a.apk")) + assertNull(BrowserDownloadRules.decodeDataUrl("data:text/plain;base64")) + assertNull(BrowserDownloadRules.decodeDataUrl("data:,broken%2")) + assertNull(BrowserDownloadRules.decodeDataUrl("data:,broken%zz")) + } + + @Test + fun refusesOversizedPayloadsInsteadOfTruncating() { + val bytes = ByteArray(100) { it.toByte() } + val url = "data:application/octet-stream;base64," + Base64.encode(bytes) + assertEquals(100, BrowserDownloadRules.decodeDataUrl(url, maxBytes = 100)!!.bytes.size) + assertNull(BrowserDownloadRules.decodeDataUrl(url, maxBytes = 99)) + assertNull(BrowserDownloadRules.decodeDataUrl("data:," + "a".repeat(11), maxBytes = 10)) + } + + @Test + fun cooldownHoldsOffOnlyTheAnsweredOrigin() { + val clock = TestTimeSource() + val cooldown = DownloadCooldown(1.seconds, clock) + assertTrue(cooldown.allows("https://a.example")) + + cooldown.answered("https://a.example") + assertFalse(cooldown.allows("https://a.example")) + assertTrue(cooldown.allows("https://b.example")) + + clock += 999.milliseconds + assertFalse(cooldown.allows("https://a.example")) + clock += 1.milliseconds + assertTrue(cooldown.allows("https://a.example")) + } +} diff --git a/commonsUI/src/commonMain/composeResources/values-es/strings.xml b/commonsUI/src/commonMain/composeResources/values-es/strings.xml index 52d054cc83..c278f19263 100644 --- a/commonsUI/src/commonMain/composeResources/values-es/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-es/strings.xml @@ -2997,7 +2997,4 @@ No se pudo crear una factura de Lightning. Mensaje de %1$s: %2$s. Buscar o introducir dirección NApplet sin título - ¿Descargar este archivo? - Este tipo de archivo puede ejecutar código. Comprueba que el nombre coincide con lo que querías obtener. - Guardar diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/DownloadPromptCard.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/DownloadPromptCard.kt index 28b40df411..09d52f47a1 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/DownloadPromptCard.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/DownloadPromptCard.kt @@ -52,14 +52,14 @@ import com.vitorpamplona.amethyst.commons.resources.browser_pill_cancel import com.vitorpamplona.amethyst.commons.resources.browser_pill_download_risky import com.vitorpamplona.amethyst.commons.resources.browser_pill_download_save import com.vitorpamplona.amethyst.commons.resources.browser_pill_download_title +import com.vitorpamplona.amethyst.commons.ui.note.types.formatBytes import com.vitorpamplona.amethyst.commons.ui.stringRes -import com.vitorpamplona.amethyst.commons.util.DecimalPatternFormatter /** - * A page's inline download waiting for consent before its bytes are written to the shared Downloads - * collection. The bridge envelope is forgeable (any top-frame script can post it, no gesture needed), - * so the gate lives here: the card names the exact file the sink would write — the WebView-reported - * origin, never a page-supplied field — and nothing is saved until the user taps Save. + * A download the page started, waiting for consent before anything is fetched or written to the shared + * Downloads collection. A page can start one without any gesture, so the card names the site (the + * WebView-reported origin, never a page-supplied field), the exact file name that would be saved and its + * size ([sizeBytes] is -1 when the server didn't say), and nothing is saved until the user taps Save. */ @Composable fun DownloadPromptCard( @@ -90,8 +90,11 @@ fun DownloadPromptCard( } Spacer(Modifier.width(14.dp)) Column { - Text(fileName, style = MaterialTheme.typography.titleSmall, maxLines = 2, overflow = TextOverflow.Ellipsis) - Text(formatBytes(sizeBytes), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + // Never cut the end off: the extension is what tells "invoice.pdf" from "invoice.pdf.apk". + Text(fileName, style = MaterialTheme.typography.titleSmall, maxLines = 3, overflow = TextOverflow.MiddleEllipsis) + if (sizeBytes >= 0) { + Text(formatBytes(sizeBytes), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } } } if (risky) { @@ -117,12 +120,3 @@ fun DownloadPromptCard( } } } - -/** Rounded byte count for the consent card ("412 B", "1.2 MB", "25.0 MB"). */ -private fun formatBytes(bytes: Long): String { - if (bytes < 1024L) return "$bytes B" - val kb = bytes / 1024.0 - if (kb < 1024) return "${DecimalPatternFormatter("0.0").format(kb)} KB" - val mb = kb / 1024 - return "${DecimalPatternFormatter("0.0").format(mb)} MB" -} diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserChromeHost.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserChromeHost.kt index 4a13007331..ad595fd2fb 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserChromeHost.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserChromeHost.kt @@ -132,9 +132,9 @@ class BrowserChromeHost( ) /** - * An inline download (`browser.download` bridge message) waiting for consent before its bytes are - * written into the shared Downloads collection. [fileName]/[sizeBytes] describe exactly what the - * native sink would write; nothing is saved until [answer] runs with true. + * A download the page started, waiting for consent before anything is fetched or written into the + * shared Downloads collection. [fileName]/[sizeBytes] (-1 when unknown) describe exactly what would + * be saved; nothing is saved until [answer] runs with true. */ class PendingDownload( val host: String?, diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserDownloadGate.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserDownloadGate.kt deleted file mode 100644 index 3ef91c013d..0000000000 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserDownloadGate.kt +++ /dev/null @@ -1,226 +0,0 @@ -/* - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.napplethost - -import android.os.SystemClock -import android.webkit.URLUtil -import com.vitorpamplona.quartz.utils.Log - -/** - * The ONE consent and gate for every page-initiated native file write into the shared public Downloads - * collection, so both entry paths into [BrowserDownloads] — the `browser.download` bridge envelope and - * the WebView `DownloadListener` — pass through exactly one gate. - * - * Why it lives in the `:napplet` sandbox and not in the injected script: the WebView bridge accepts - * envelopes from any origin the user browses (`addWebMessageListener(..., setOf("*"))`), and - * BrowserWebTools.share's own comment documents pages posting bridge envelopes directly, bypassing the - * injected script's checks. So a hostile top frame can ask for a native file write with no gesture and - * no consent. The gate is keyed on data the page cannot forge — the WebView-reported origin — and it is - * deliberately NOT the broker's per-origin launch token: that token is minted automatically for any - * logged-in origin that asks (NappletBrokerService's MSG_MINT_BROWSER_TOKEN handler shows no prompt), - * so it is a session handle, not a consent grant. - * - * Three layers: - * - * 1. **One-shot native consent** — every page-initiated save shows [com.vitorpamplona.amethyst.commons.browser.ui.pill.DownloadPromptCard] - * first: the exact sanitized name, the true size (decoded base64 length, or the server's Content-Length - * when the page gave a network URL), and the WebView-reported origin. Nothing reaches [BrowserDownloads] - * until the user taps Save. The immediately-user-initiated context-menu "Download image" brushes past - * the prompt (that tap IS the gesture). - * 2. **One live prompt per surface** — a second consent request while one is already live for the same - * surface (this full-screen window, or one embedded tab) is refused ([beginConsent]), so a page can't - * swap a card's name under the user's finger; the callers also answer the displaced card before showing - * a successor. - * 3. **Per-origin cooldown** — after a prompt is answered, the next request from the same origin on the - * same surface can't flash another card for [DOWNLOAD_COOLDOWN_MS] ([shouldPrompt]). - */ -object BrowserDownloadGate { - private const val TAG = "BrowserDownloadGate" - - /** Minimum spacing between consent prompts per origin on one surface, mirroring BrowserWebTools.share. */ - const val DOWNLOAD_COOLDOWN_MS = 1_000L - - /** Extensions an install-or-run prompt exists for. Reported (never rewritten) so the user can judge the real name. */ - val RISKY_EXTENSIONS = - setOf( - "apk", - "apks", - "apkm", - "xapk", - "aab", - "jar", - "dex", - "so", - "exe", - "msi", - "bat", - "cmd", - "com", - "scr", - "hta", - "ps1", - "vbs", - "wsf", - "dmg", - "pkg", - "app", - "deb", - "rpm", - "appimage", - "run", - "html", - "htm", - "xhtml", - "svg", - "sh", - "zsh", - "bash", - "command", - "lnk", - "url", - "desktop", - ) - - /** Everything the consent card needs to show before a single byte is saved. */ - class Spec( - val fileName: String, - val sizeBytes: Long, - val risky: Boolean, - ) - - /** - * One surface's live consent, handed out by [beginConsent] and ended by [endConsent]. Holding it - * is what stops a second prompt for the same surface from displacing the card under the user's - * finger; the page has no way to observe it. Callers must [endConsent] on every answer path. - */ - class Consent internal constructor( - internal val surfaceKey: String, - internal val origin: String, - ) - - /** - * The post-consent save for already-allowed inline bytes. Kept here (and exposed as the save a - * [com.vitorpamplona.amethyst.commons.browser.ui.pill.DownloadPromptCard] allow runs) because exactly - * the same save is shared by the card's allow and by any caller that already holds consented bytes. - */ - class InlineSave internal constructor( - val fileName: String, - val mimeType: String?, - val bytes: ByteArray, - ) { - /** Writes the consented bytes into the shared Downloads collection, exactly as the card named them. */ - fun save(context: android.content.Context) = BrowserDownloads.saveInlineBytes(context, fileName, mimeType, bytes) - } - - /** - * Builds the post-consent inline save for a `browser.download` envelope's data URL: the sanitized - * name and the decoded bytes, so the eventual save is exactly what the card showed. Null when the - * URL is malformed or over [BrowserDownloads.MAX_INLINE_BYTES] — the caller treats null as a refused - * download and shows no prompt. - */ - fun preludeInlineSave( - dataUrl: String, - suggestedName: String?, - ): InlineSave? { - val header = dataUrl.substringBefore(',', "") - if (!dataUrl.contains(',') || !header.startsWith("data:", ignoreCase = true) || !header.endsWith(";base64", ignoreCase = true)) return null - val payload = dataUrl.substringAfter(',', "") - if (payload.length > BrowserDownloads.MAX_INLINE_BYTES / 3 * 4 + 8) return null - val bytes = runCatching { android.util.Base64.decode(payload, android.util.Base64.DEFAULT) }.getOrNull() ?: return null - if (bytes.size > BrowserDownloads.MAX_INLINE_BYTES) return null - // The MIME inside the data URL drives safeName's fallback extension when the page sent no name. - val mime = - header - .removePrefix("data:") - .removeSuffix(";base64") - .substringBefore(';') - .ifBlank { null } - return InlineSave(BrowserDownloads.sanitize(suggestedName, mime), mime, bytes) - } - - /** What the consent card shows for a network download, before any bytes are fetched. */ - fun networkSpec( - fileName: String, - sizeBytes: Long, - ): Spec = Spec(fileName, sizeBytes.coerceAtLeast(0L), isRisky(fileName)) - - /** Guesses a network download's file name the same way the eventual save does. */ - fun guessNetworkName( - url: String, - contentDisposition: String?, - mimeType: String?, - ): String = BrowserDownloads.sanitize(URLUtil.guessFileName(url, contentDisposition, mimeType), mimeType) - - /** - * Whether a consent prompt may be shown for [origin] on [surfaceKey] right now. A `false` return - * means a prompt for this origin on this surface was just answered and this request is silently - * dropped — the throttle that keeps a spamming page from flashing dialogs forever. - */ - fun shouldPrompt( - surfaceKey: String, - origin: String, - ): Boolean { - val now = SystemClock.elapsedRealtime() - val last = lastAnsweredAt[surfaceKey]?.get(origin) ?: 0L - if (now - last < DOWNLOAD_COOLDOWN_MS) { - Log.d(TAG) { "Download consent for $origin throttled" } - return false - } - return true - } - - /** - * Claims the one live consent slot for [surfaceKey], or returns null when one is already live for - * that surface — the anti-swap rule, released by [endConsent]. A `false` [shouldPrompt] caller - * never even gets here (dropped before the slot is taken). - */ - fun beginConsent( - surfaceKey: String, - origin: String, - ): Consent? { - if (liveSurfaces.contains(surfaceKey)) return null - liveSurfaces.add(surfaceKey) - return Consent(surfaceKey, origin) - } - - /** - * Releases [consent]'s slot and starts its per-origin cooldown. Called on every answer path — - * Save, Don't save, dismissal, and the surface being destroyed — so a torn-down window can never - * wedge the gate. Idempotent. - */ - fun endConsent(consent: Consent) { - liveSurfaces.remove(consent.surfaceKey) - lastAnsweredAt.getOrPut(consent.surfaceKey) { HashMap() }[consent.origin] = SystemClock.elapsedRealtime() - } - - /** Drops this surface's throttle and live-slot state — when the window/tab hosting its WebView goes away. */ - fun clearSurface(surfaceKey: String) { - liveSurfaces.remove(surfaceKey) - lastAnsweredAt.remove(surfaceKey) - } - - /** Whether [fileName]'s extension is one a user should double-check before saving. */ - fun isRisky(fileName: String): Boolean = fileName.substringAfterLast('.', "").lowercase() in RISKY_EXTENSIONS - - // Main-thread only: one live surface's consent, and per-surface then per-origin last-answered stamps. - private val liveSurfaces = HashSet() - private val lastAnsweredAt = HashMap>() -} diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserDownloads.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserDownloads.kt index fbfcef6a96..31a7d57ae1 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserDownloads.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserDownloads.kt @@ -27,16 +27,14 @@ import android.os.Environment import android.os.Handler import android.os.Looper import android.provider.MediaStore -import android.util.Base64 import android.webkit.MimeTypeMap import android.webkit.URLUtil import android.widget.Toast +import com.vitorpamplona.amethyst.commons.browser.BrowserDownloadRules import com.vitorpamplona.quartz.utils.Log import okhttp3.Request import java.io.File -import java.io.IOException import java.io.OutputStream -import java.net.URLDecoder import java.util.concurrent.Executors import java.util.concurrent.TimeUnit import com.vitorpamplona.amethyst.commons.R as CommonsR @@ -46,9 +44,11 @@ import com.vitorpamplona.amethyst.commons.R as CommonsR * `blob:` URLs (which only the page can read, so the browser-extras script hands their bytes over) — into * the system Downloads collection, the way Chrome does. * - * Page-initiated saves (both the `browser.download` bridge envelope and the WebView `DownloadListener`) - * reach the write sinks here only through [BrowserDownloadGate]'s one-shot consent; the - * immediately-user-initiated "Download image" context-menu action is the one exception. + * A page can start a download without any gesture, so everything it starts arrives as a [DownloadOffer] + * that the surface shows on a consent card; nothing is fetched or written until the user taps Save. That + * covers both entry points: the WebView `DownloadListener` ([offerListenerDownload]) and the + * `browser.download` bridge envelope ([offerInline]), which any top-frame script can post directly. The + * long-press "Download image" item ([download]) is the one ungated path: the user's own tap starts it. * * Network downloads follow the page's own route: through the Tor SOCKS proxy when the site is on Tor (OkHttp * leaves SOCKS hosts unresolved, so even DNS goes through Tor), directly otherwise. They carry the page's @@ -58,15 +58,37 @@ object BrowserDownloads { private const val TAG = "BrowserDownloads" /** Cap for bytes a page hands over for a blob:/data: download (they travel as base64 over the bridge). */ - const val MAX_INLINE_BYTES = 25 * 1024 * 1024 + const val MAX_INLINE_BYTES = BrowserDownloadRules.MAX_INLINE_BYTES private val io = Executors.newSingleThreadExecutor { Thread(it, "napplet-downloads").apply { isDaemon = true } } + + // Decoding an inline payload (up to 25 MiB) is kept off the main thread, and off [io] so a long + // transfer already running there doesn't hold the next consent card back. + private val decoder = Executors.newSingleThreadExecutor { Thread(it, "napplet-download-decode").apply { isDaemon = true } } private val main = Handler(Looper.getMainLooper()) + private val unsafeNameChars = Regex("[\\u0000-\\u001f:*?\"<>|]") + /** - * The immediately-user-initiated download (the long-press "Download image" context-menu item): that - * tap IS the gesture, so this brushes past the consent prompt. The transfer itself runs on a - * background thread. + * A page-initiated download, resolved to exactly what its consent card shows: [save] writes a file + * named [fileName] and nothing else, and until it runs no byte has been fetched or written. + */ + class DownloadOffer internal constructor( + val fileName: String, + /** The exact size in bytes, or -1 when the server didn't say. */ + val sizeBytes: Long, + private val start: (Context) -> Unit, + ) { + /** Whether [fileName] is something that can be installed or run, which the card warns about. */ + val risky: Boolean get() = BrowserDownloadRules.isRisky(fileName) + + fun save(context: Context) = start(context.applicationContext) + } + + /** + * The long-press "Download image" item: the user's tap is the gesture, so it saves without a card. + * [cookie] must be read on the main thread (from the tab's own profile) before calling; the transfer + * itself runs on a background thread. */ fun download( context: Context, @@ -83,69 +105,70 @@ object BrowserDownloads { return } if (!isHttp(url)) return - val name = URLUtil.guessFileName(url, contentDisposition, mimeType) - toast(app, app.getString(CommonsR.string.browser_download_started, name)) - io.execute { - runNetworkDownload(app, url, name, userAgent, mimeType, cookie, proxyPort) - } + startNetworkDownload(app, url, networkName(url, contentDisposition, mimeType), userAgent, mimeType, cookie, proxyPort) } /** - * A WebView `DownloadListener` hit — a PAGE-initiated save (a `` navigation or - * `Content-Disposition: attachment`), so it routes through the one consent gate exactly like the - * `browser.download` bridge envelope. [cookieHolder] is invoked (main-thread, on the WebView's own - * profile) when cookies are needed; the transfer itself only runs when the user allows — nothing is - * fetched until then. + * A WebView `DownloadListener` hit: a download the page started. Calls [onReady] exactly once, on the + * main thread, with the offer for the consent card (null when the URL can't be saved). [contentLength] is + * the size the listener reported (<= 0 when unknown); [cookie] must be read on the main thread from + * the tab's own profile. Nothing is fetched until the offer's save runs. */ - fun downloadWithConsent( - context: Context, + fun offerListenerDownload( url: String, + userAgent: String?, contentDisposition: String?, mimeType: String?, - cookieHolder: () -> String?, - userAgent: String?, + contentLength: Long, + cookie: String?, proxyPort: Int, - showPrompt: (fileName: String, sizeBytes: Long, risky: Boolean, consent: Consent) -> Unit, + onReady: (DownloadOffer?) -> Unit, ) { - val app = context.applicationContext if (url.startsWith("data:", ignoreCase = true)) { - // A data: URL from the listener is the same forgeable surface as a bridge envelope: gate it. - val save = BrowserDownloadGate.preludeInlineSave(url, null) ?: return - showPrompt(save.fileName, save.bytes.size.toLong(), BrowserDownloadGate.isRisky(save.fileName), Consent { save.save(app) }) + offerInline(url, null, onReady) return } - if (!isHttp(url)) return - val guessedName = URLUtil.guessFileName(url, contentDisposition, mimeType) - io.execute { - // Ask the server for the exact size up front (fast-timed-out HEAD): a host that won't say - // leaves it -1 and the card shows the name alone. The GET itself stays unbounded — a large - // file over Tor takes minutes; only this probe is bounded. - val size = runCatching { probeContentLength(url, userAgent, cookieHolder(), proxyPort) }.getOrDefault(-1L) - main.post { - showPrompt( - sanitize(guessedName, mimeType), - size, - BrowserDownloadGate.isRisky(guessedName), - Consent { - io.execute { runNetworkDownload(app, url, guessedName, userAgent, mimeType, cookieHolder(), proxyPort) } - }, - ) - } + if (!isHttp(url)) { + onReady(null) + return } + val name = networkName(url, contentDisposition, mimeType) + onReady( + DownloadOffer(name, contentLength.takeIf { it > 0 } ?: -1L) { app -> + startNetworkDownload(app, url, name, userAgent, mimeType, cookie, proxyPort) + }, + ) } /** - * The action the consent card's Save button runs: the transfer fires only on an explicit allow, so - * bytes are never pulled into a prompt closure before the user has said yes. + * A page's inline download (a `browser.download` envelope's `data:` URL). Decodes it off the main + * thread, then calls [onReady] exactly once, on the main thread, with the offer (its size is the true + * decoded length), or null when the payload is malformed or oversized and is refused without a card. */ - fun interface Consent { - fun run() + fun offerInline( + dataUrl: String, + suggestedName: String?, + onReady: (DownloadOffer?) -> Unit, + ) { + decoder.execute { + val offer = + BrowserDownloadRules.decodeDataUrl(dataUrl)?.let { data -> + val name = safeName(suggestedName, data.mimeType) + DownloadOffer(name, data.bytes.size.toLong()) { app -> writeInBackground(app, name, data.mimeType, data.bytes) } + } + main.post { onReady(offer) } + } } private fun isHttp(url: String) = url.startsWith("https://", ignoreCase = true) || url.startsWith("http://", ignoreCase = true) - /** The actual transfer + toasts; runs on [io]. Shared by the consent prompt's allow and the context menu. */ - private fun runNetworkDownload( + private fun networkName( + url: String, + contentDisposition: String?, + mimeType: String?, + ) = safeName(URLUtil.guessFileName(url, contentDisposition, mimeType), mimeType) + + private fun startNetworkDownload( app: Context, url: String, name: String, @@ -154,131 +177,62 @@ object BrowserDownloads { cookie: String?, proxyPort: Int, ) { - val ok = - runCatching { - val request = request(url, userAgent, cookie).get().build() - val client = client(proxyPort) - client.newCall(request).execute().use { response -> - if (!response.isSuccessful) error("HTTP ${response.code}") - val type = mimeType?.takeIf { it.isNotBlank() && it != "application/octet-stream" } ?: response.body.contentType()?.let { "${it.type}/${it.subtype}" } - write(app, name, type) { out -> response.body.byteStream().use { it.copyTo(out) } } - } - }.onFailure { Log.w(TAG, "Download failed for $url", it) } - .getOrDefault(false) - toast(app, app.getString(if (ok) CommonsR.string.browser_download_saved else CommonsR.string.browser_download_failed, name)) - } - - /** The Content-Length a HEAD to [url] reports, or -1 when the server won't say / the probe fails. */ - private fun probeContentLength( - url: String, - userAgent: String?, - cookie: String?, - proxyPort: Int, - ): Long { - val request = request(url, userAgent, cookie).head().build() - // A small-BODY-time probe (not the unbounded transfer the eventual GET gets). A server that - // answers slowly leaves the size unknown rather than holding the prompt; a server that breaks - // on HEAD simply never fills it in. - val client = - NappletBlobHttp - .client(proxyPort) - .newBuilder() - .callTimeout(10, TimeUnit.SECONDS) - .build() - return client.newCall(request).execute().use { response -> - if (!response.isSuccessful) throw IOException("HTTP ${response.code}") - response.body.contentLength() + toast(app, app.getString(CommonsR.string.browser_download_started, name)) + io.execute { + val ok = + runCatching { + val request = + Request + .Builder() + .url(url) + .apply { + userAgent?.takeIf { it.isNotBlank() }?.let { header("User-Agent", it) } + cookie?.takeIf { it.isNotBlank() }?.let { header("Cookie", it) } + }.get() + .build() + // No end-to-end call timeout: a large file over Tor legitimately takes minutes. The + // client's read timeout still ends a transfer that stalls completely. + val client = + NappletBlobHttp + .client(proxyPort) + .newBuilder() + .callTimeout(0, TimeUnit.SECONDS) + .build() + client.newCall(request).execute().use { response -> + if (!response.isSuccessful) error("HTTP ${response.code}") + val type = mimeType?.takeIf { it.isNotBlank() && it != "application/octet-stream" } ?: response.body.contentType()?.let { "${it.type}/${it.subtype}" } + write(app, name, type) { out -> response.body.byteStream().use { it.copyTo(out) } } + } + }.onFailure { Log.w(TAG, "Download failed for $url", it) } + .getOrDefault(false) + toast(app, app.getString(if (ok) CommonsR.string.browser_download_saved else CommonsR.string.browser_download_failed, name)) } } - /** The OkHttp client for transfers through [proxyPort]: no end-to-end call timeout, as documented above. */ - private fun client(proxyPort: Int) = - NappletBlobHttp - .client(proxyPort) - .newBuilder() - .callTimeout(0, TimeUnit.SECONDS) - .build() - - private fun request( - url: String, - userAgent: String?, - cookie: String?, - ): Request.Builder = - Request - .Builder() - .url(url) - .apply { - userAgent?.takeIf { it.isNotBlank() }?.let { header("User-Agent", it) } - cookie?.takeIf { it.isNotBlank() }?.let { header("Cookie", it) } - } - - /** Saves a `data:` URL (`data:[mime][;base64],payload`). */ + /** Saves a `data:` URL (`data:[mime][;base64],payload`) the user asked for directly. */ fun saveDataUrl( context: Context, dataUrl: String, suggestedName: String?, ) { - val app = context.applicationContext - val header = dataUrl.substringBefore(',', "") - val payload = dataUrl.substringAfter(',', "") - val mime = header.removePrefix("data:").substringBefore(';').ifBlank { "application/octet-stream" } - val bytes = - runCatching { - if (header.endsWith(";base64", ignoreCase = true)) { - Base64.decode(payload, Base64.DEFAULT) - } else { - URLDecoder.decode(payload, "UTF-8").toByteArray() - } - }.getOrNull() ?: return - saveBytes(app, suggestedName, mime, bytes) + val data = BrowserDownloadRules.decodeDataUrl(dataUrl) ?: return + val mime = data.mimeType ?: "application/octet-stream" + writeInBackground(context.applicationContext, safeName(suggestedName, mime), mime, data.bytes) } - /** Saves bytes a page handed over (a `blob:` download, via the browser-extras script). */ - fun saveBytes( - context: Context, - suggestedName: String?, - mimeType: String?, - bytes: ByteArray, - ) { - if (bytes.size > MAX_INLINE_BYTES) return - val app = context.applicationContext - val name = safeName(suggestedName, mimeType) - io.execute { - val ok = runCatching { write(app, name, mimeType) { it.write(bytes) } }.getOrDefault(false) - toast(app, app.getString(if (ok) CommonsR.string.browser_download_saved else CommonsR.string.browser_download_failed, name)) - } - } - - /** - * Saves bytes a page handed over AFTER native consent: [name] is the already-sanitized, - * already-approved file name and [bytes] were decoded (and bounded) before the prompt, so this is - * exactly what the user saw on the consent card. Runs on the downloads executor and toasts the - * outcome, like every other path into [write]. - */ - fun saveInlineBytes( - context: Context, + private fun writeInBackground( + app: Context, name: String, mimeType: String?, bytes: ByteArray, ) { if (bytes.size > MAX_INLINE_BYTES) return - val app = context.applicationContext io.execute { val ok = runCatching { write(app, name, mimeType) { it.write(bytes) } }.getOrDefault(false) toast(app, app.getString(if (ok) CommonsR.string.browser_download_saved else CommonsR.string.browser_download_failed, name)) } } - /** - * The plain file name the sink would use for a page's suggestion: without path parts or control - * characters, else "download" + the MIME's extension. Exposed so a consent prompt can show — and - * approve — the exact name [write] will store, before any bytes move. - */ - fun sanitize( - suggested: String?, - mimeType: String?, - ): String = safeName(suggested, mimeType) - /** * Writes into the public Downloads collection (Android 10+, no permission needed), or into the app's * own Downloads folder on older versions, where writing the shared one would need a storage permission @@ -324,7 +278,7 @@ object BrowserDownloads { suggested ?.substringAfterLast('/') ?.substringAfterLast('\\') - ?.replace(Regex("[\\u0000-\\u001f:*?\"<>|]"), "_") + ?.replace(unsafeNameChars, "_") ?.trim() ?.takeIf { it.isNotEmpty() && it != "." && it != ".." } if (base != null) return base.take(120) diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserExtrasScript.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserExtrasScript.kt index b53275020b..cbb8c7246b 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserExtrasScript.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserExtrasScript.kt @@ -30,13 +30,11 @@ package com.vitorpamplona.amethyst.napplethost * or the colour scheme changes, so the window can tint its system bars and Recents entry. * - `blob:` / `data:` downloads — only the page can read a `blob:` URL, so a click on (or a programmatic * `.click()` of) an `` pointing at one is turned into its bytes (`browser.download`). The - * native side treats that type as a request, not an authority: the sink stays gated behind this - * origin's consent token plus a per-download confirmation, so a page that forges the envelope gains - * nothing over using the script. + * native side treats it as a request: the user confirms every save on a consent card, so a page that + * posts the envelope itself gains nothing over using the script. * * Messages travel over the same origin-scoped native bridge as NIP-07; the host handles `browser.*` types - * itself and never forwards them to the broker's capability router (which is not a consent exemption — - * the download type is consent-gated by the host itself). + * itself and never forwards them to the broker. */ object BrowserExtrasScript { val JS: String = diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt index 4ebed03439..d724037235 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt @@ -84,6 +84,7 @@ import com.vitorpamplona.amethyst.commons.browser.BrowserChrome import com.vitorpamplona.amethyst.commons.browser.BrowserChrome.Action import com.vitorpamplona.amethyst.commons.browser.BrowserSitePermission import com.vitorpamplona.amethyst.commons.browser.BrowserSitePermission.Decision +import com.vitorpamplona.amethyst.commons.browser.DownloadCooldown import com.vitorpamplona.amethyst.commons.browser.OmniboxInput import com.vitorpamplona.amethyst.commons.browser.ui.pill.BrowserPillEvent import com.vitorpamplona.amethyst.commons.browser.ui.pill.BrowserPillUi @@ -225,6 +226,8 @@ class NappletBrowserActivity : ComponentActivity() { private val originTokens = mutableMapOf() private val pendingByOrigin = mutableMapOf>() private val mintInFlight = mutableSetOf() + private val downloadCooldown = DownloadCooldown() + private var preparingDownload = false /** * Back walks out of fullscreen video, then the find bar, then the page's history, then leaves. Enabled @@ -361,17 +364,18 @@ class NappletBrowserActivity : ComponentActivity() { wv.webViewClient = BrowserClient() wv.webChromeClient = BrowserChromeClient() wv.setFindListener { active, total, _ -> chrome?.setFindResult(active, total) } - wv.setDownloadListener { url, userAgent, contentDisposition, mimeType, _ -> - BrowserDownloads.downloadWithConsent( - context = this, - url = url, - contentDisposition = contentDisposition, - mimeType = mimeType, - cookieHolder = { BrowserWebTools.cookieManager(wv).getCookie(url) }, - userAgent = userAgent, - proxyPort = if (useTor) proxyPort else -1, - ) { fileName, sizeBytes, risky, consent -> - offerListenerDownloadConsent(fileName, sizeBytes, risky, consent) + wv.setDownloadListener { url, userAgent, contentDisposition, mimeType, contentLength -> + val origin = + chrome + ?.ui + ?.chrome + ?.url + ?.let(BrowserChrome::originOf) ?: return@setDownloadListener + if (!canOfferDownload(origin)) return@setDownloadListener + // Read on the main thread: the cookie jar is the tab's own per-account WebView profile. + val cookie = BrowserWebTools.cookieManager(wv).getCookie(url) + prepareDownloadOffer(origin) { ready -> + BrowserDownloads.offerListenerDownload(url, userAgent, contentDisposition, mimeType, contentLength, cookie, if (useTor) proxyPort else -1, ready) } } wv.setBackgroundColor(resolveThemeColor(android.R.attr.colorBackground)) @@ -903,14 +907,11 @@ class NappletBrowserActivity : ComponentActivity() { val raw = message.data ?: return val envelope = parseJsonObjectOrNull(raw) ?: return - // The origin the WebView REPORTS — the page cannot forge this — keys every consent decision, - // including browser.* ones. Page-supplied fields are never trusted for that. + // The origin the WebView reports, which the page can't forge, keys every decision below. val origin = trustedOrigin(sourceOrigin) ?: return - // Browser conveniences (share, theme colour, blob downloads) are handled here, never forwarded - // to the broker's capability router. That is NOT a consent exemption: browser.download writes - // into the shared Downloads collection, so it is gated below by [offerDownloadConsent] on - // this WebView-reported origin. + // Browser conveniences (share, theme colour, blob downloads) are handled here, never brokered. + // A download still asks the user first ([offerInlineDownload]): any top-frame script can post one. when (envelope.stringOrNull("type")) { "browser.share" -> { if (resumed) BrowserWebTools.share(this, envelope.stringOrNull("title"), envelope.stringOrNull("text"), envelope.stringOrNull("url")) @@ -923,7 +924,7 @@ class NappletBrowserActivity : ComponentActivity() { return } "browser.download" -> { - offerDownloadConsent(origin, envelope) + offerInlineDownload(origin, envelope) return } } @@ -957,66 +958,54 @@ class NappletBrowserActivity : ComponentActivity() { } /** - * The one-shot native consent card for a `browser.download` envelope, then the save. Shown keyed on - * the WebView-reported [origin] only; nothing about the envelope can trigger the save alone. The - * byte payload is fully decoded here — before the prompt — so the dialog names the true size and - * malformed or oversized payloads are refused without ever reaching MediaStore. Exactly one card is - * live per origin on this window at a time (a second request drops), so a page can't swap the name - * under the user's finger. + * A `browser.download` envelope: the bytes a page wants saved (a `blob:` download the extras script + * read, or one a script forged). Keyed on the WebView-reported [origin], never an envelope field. */ - private fun offerDownloadConsent( + private fun offerInlineDownload( origin: String, envelope: JsonObject, ) { - val data = envelope.stringOrNull("data")?.takeIf { it.startsWith("data:", ignoreCase = true) } ?: return - val save = BrowserDownloadGate.preludeInlineSave(data, envelope.stringOrNull("name")) ?: return - val host = chrome ?: return - if (!BrowserDownloadGate.shouldPrompt(SURFACE_KEY, origin)) return - // One live prompt per window: a second request while a card is up is dropped, and a live card is - // never replaced — the anti-swap rule the fingerprint under the user's finger relies on. - if (host.downloadPrompt != null) return - host.downloadPrompt = - BrowserChromeHost.PendingDownload( - host = BrowserChrome.displayHost(origin), - security = BrowserChrome.security(host.ui.chrome), - fileName = save.fileName, - sizeBytes = save.bytes.size.toLong(), - risky = BrowserDownloadGate.isRisky(save.fileName), - ) { allowed -> - if (allowed) BrowserDownloads.saveInlineBytes(this, save.fileName, save.mimeType, save.bytes) - } + if (!canOfferDownload(origin)) return + val data = envelope.stringOrNull("data") ?: return + prepareDownloadOffer(origin) { ready -> BrowserDownloads.offerInline(data, envelope.stringOrNull("name"), ready) } } /** - * The consent card for a WebView `DownloadListener` hit (a page-initiated `` navigation - * or `Content-Disposition: attachment`), offered by [BrowserDownloads.downloadWithConsent] after it - * probed the size. Shares the exact same gate and anti-swap rule as the `browser.download` bridge - * envelope: one live card per window, a live card is never replaced. + * Whether [origin] may put a download card up now: never over a card already showing (so a page can't + * swap the name under the user's finger) or one still being prepared (so a page can't queue decodes), + * and not within the cooldown after its last card was answered. */ - private fun offerListenerDownloadConsent( - fileName: String, - sizeBytes: Long, - risky: Boolean, - consent: BrowserDownloads.Consent, + private fun canOfferDownload(origin: String) = chrome?.downloadPrompt == null && !preparingDownload && downloadCooldown.allows(origin) + + /** Runs [prepare] (which answers exactly once, on the main thread) and shows the offer it produces. */ + private fun prepareDownloadOffer( + origin: String, + prepare: ((BrowserDownloads.DownloadOffer?) -> Unit) -> Unit, + ) { + preparingDownload = true + prepare { offer -> + preparingDownload = false + if (offer != null) showDownloadOffer(origin, offer) + } + } + + /** Shows [offer]'s consent card; the file is fetched or written only if the user taps Save. */ + private fun showDownloadOffer( + origin: String, + offer: BrowserDownloads.DownloadOffer, ) { - val origin = - chrome - ?.ui - ?.chrome - ?.url - ?.let(BrowserChrome::originOf) ?: return - if (!BrowserDownloadGate.shouldPrompt(SURFACE_KEY, origin)) return val host = chrome ?: return - if (host.downloadPrompt != null) return + if (isDestroyed || !canOfferDownload(origin)) return host.downloadPrompt = BrowserChromeHost.PendingDownload( host = BrowserChrome.displayHost(origin), security = BrowserChrome.security(host.ui.chrome), - fileName = fileName, - sizeBytes = sizeBytes, - risky = risky, + fileName = offer.fileName, + sizeBytes = offer.sizeBytes, + risky = offer.risky, ) { allowed -> - if (allowed) consent.run() + downloadCooldown.answered(origin) + if (allowed) offer.save(this) } } @@ -1729,11 +1718,6 @@ class NappletBrowserActivity : ComponentActivity() { companion object { private const val TAG = "NappletBrowserActivity" - /** - * The one consent-gate surface key for this single-window Activity: unlike the embedded Service, - * one Activity hosts exactly one WebView, so there is only ever one surface to book. - */ - private const val SURFACE_KEY = "full-screen" private const val ACTIVITY_CLASS = "com.vitorpamplona.amethyst.napplethost.NappletBrowserActivity" /** How often a resumed browser renews its foreground lease (well under the broker's 90s TTL). */ diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserContract.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserContract.kt index f140673e0a..0cb87270e3 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserContract.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserContract.kt @@ -185,13 +185,12 @@ object NappletBrowserContract { const val MSG_EXIT_FULLSCREEN = 33 /** - * Provider → client: a page's inline download needs the user's consent before its bytes are - * written into the shared Downloads collection ([browser.download] reaches a native file-write - * sink, so the gate lives at the sink, keyed on the WebView-reported [KEY_BROWSER_ORIGIN] — never - * a page-supplied field). Carries [KEY_DOWNLOAD_ID], the sanitized [KEY_DOWNLOAD_NAME], the exact - * byte size in [KEY_DOWNLOAD_SIZE], and [KEY_DOWNLOAD_RISKY] (an install-or-script-like extension). - * Answered with [MSG_DOWNLOAD_CONSENT_RESULT] on every outcome; the bytes themselves never leave - * the `:napplet` process. + * Provider → client: a download the page started (an attachment, ``, or an inline + * `browser.download`) needs the user's consent before anything is fetched or written into the shared + * Downloads collection. Carries [KEY_DOWNLOAD_ID], the WebView-reported [KEY_BROWSER_ORIGIN] (never a + * page-supplied field), the sanitized [KEY_DOWNLOAD_NAME], [KEY_DOWNLOAD_SIZE], and + * [KEY_DOWNLOAD_RISKY] (an install-or-script-like extension). Answered with + * [MSG_DOWNLOAD_CONSENT_RESULT] on every outcome; the bytes themselves never leave the `:napplet` process. */ const val MSG_DOWNLOAD_CONSENT = 34 @@ -226,7 +225,7 @@ object NappletBrowserContract { /** The sanitized file name the consented download will be stored under (already path/control-char stripped). */ const val KEY_DOWNLOAD_NAME = "downloadName" - /** The exact decoded byte count the consented download will write. */ + /** The size in bytes the consented download will write, or -1 when the server didn't say. */ const val KEY_DOWNLOAD_SIZE = "downloadSize" /** Whether [KEY_DOWNLOAD_NAME]'s extension is one the user should double-check (installer/script-like). */ diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt index 00b5c5d95d..c8e0697818 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt @@ -65,6 +65,7 @@ import androidx.webkit.WebViewCompat import androidx.webkit.WebViewFeature import com.vitorpamplona.amethyst.commons.browser.BrowserChrome import com.vitorpamplona.amethyst.commons.browser.BrowserSitePermission +import com.vitorpamplona.amethyst.commons.browser.DownloadCooldown import com.vitorpamplona.amethyst.commons.browser.OmniboxInput import com.vitorpamplona.amethyst.commons.napplet.NappletWebContract import com.vitorpamplona.amethyst.commons.util.parseJsonObjectOrNull @@ -146,6 +147,11 @@ class NappletBrowserService : Service() { val pendingByOrigin = mutableMapOf>() val mintInFlight = mutableSetOf() + // Page-initiated downloads: how often each site may ask, and whether an offer is being prepared + // (decoded) — one at a time, so a page can't queue up decodes. + val downloadCooldown = DownloadCooldown() + var preparingDownload = false + val replyMessenger = Messenger(Handler(Looper.getMainLooper()) { onBrokerReply(this, it) }) } @@ -154,11 +160,15 @@ class NappletBrowserService : Service() { // WebView's PermissionRequest → our relay id, so a page's cancellation can withdraw the prompt. private val pendingWebPermissions = mutableMapOf() - // Inline-download consent cards the main process is showing: relay id → the save to run if the - // user allows. [consentTabs] scopes each id to its tab's session so a closing tab clears only its - // own; entries are removed when the client answers. - private val pendingDownloadConsents = mutableMapOf Unit>() - private val consentTabs = mutableMapOf() + // Download consent cards the main process is showing, by relay id, until the client answers or the + // tab closes. The offer holds the decoded bytes (or the URL to fetch) the card describes. + private class PendingDownload( + val sessionId: String, + val origin: String, + val offer: BrowserDownloads.DownloadOffer, + ) + + private val pendingDownloads = mutableMapOf() private var downloadSeq = 0L // The shim never changes; read+decode it once instead of per tab on the main thread. @@ -304,10 +314,10 @@ class NappletBrowserService : Service() { } NappletBrowserContract.MSG_DOWNLOAD_CONSENT_RESULT -> { val data = msg.data ?: return true - val id = data.getLong(NappletBrowserContract.KEY_DOWNLOAD_ID) - val answer = pendingDownloadConsents.remove(id) ?: return true - consentTabs.remove(id) - answer(data.getBoolean(NappletBrowserContract.KEY_DOWNLOAD_ALLOWED, false)) + val pending = pendingDownloads.remove(data.getLong(NappletBrowserContract.KEY_DOWNLOAD_ID)) ?: return true + val tab = tabs[pending.sessionId] ?: return true + tab.downloadCooldown.answered(pending.origin) + if (data.getBoolean(NappletBrowserContract.KEY_DOWNLOAD_ALLOWED, false)) pending.offer.save(this) } NappletBrowserContract.MSG_EXIT_FULLSCREEN -> tabFor(msg)?.let { exitFullscreen(it) } NappletBrowserContract.MSG_RELOAD -> tabFor(msg)?.webView?.reload() @@ -460,12 +470,8 @@ class NappletBrowserService : Service() { // Release a picker still waiting on this surface before its WebView goes away. tab.fileChooser.cancel() cancelPending(tab) - // A consent card parked behind this tab dies with it: an unanswered prompt saves nothing, and - // its decoded bytes are freed. Only this tab's entries are dropped (a sibling tab's is not). - pendingDownloadConsents.keys.filter { consentTabs[it] == sessionId }.forEach { id -> - pendingDownloadConsents.remove(id) - consentTabs.remove(id) - } + // An unanswered download card dies with its tab: nothing is saved, and its bytes are freed. + pendingDownloads.values.removeAll { it.sessionId == sessionId } tab.customViewCallback?.onCustomViewHidden() tab.customViewCallback = null tab.customView = null @@ -481,18 +487,14 @@ class NappletBrowserService : Service() { BrowserWebTools.applyBrowserSettings(wv) wv.webViewClient = BrowserClient(tab) wv.webChromeClient = BrowserChromeClient(tab) - wv.setDownloadListener { url, userAgent, contentDisposition, mimeType, _ -> + wv.setDownloadListener { url, userAgent, contentDisposition, mimeType, contentLength -> if (tab == null) return@setDownloadListener - BrowserDownloads.downloadWithConsent( - context = this, - url = url, - contentDisposition = contentDisposition, - mimeType = mimeType, - cookieHolder = { BrowserWebTools.cookieManager(wv).getCookie(url) }, - userAgent = userAgent, - proxyPort = if (tab.useTor) tab.proxyPort else -1, - ) { fileName, sizeBytes, risky, consent -> - offerListenerDownloadConsent(tab, fileName, sizeBytes, risky, consent) + val origin = wv.url?.let(BrowserChrome::originOf) ?: return@setDownloadListener + if (!canOfferDownload(tab, origin)) return@setDownloadListener + // Read on the main thread: the cookie jar is the tab's own per-account WebView profile. + val cookie = BrowserWebTools.cookieManager(wv).getCookie(url) + prepareDownloadOffer(tab, origin) { ready -> + BrowserDownloads.offerListenerDownload(url, userAgent, contentDisposition, mimeType, contentLength, cookie, if (tab.useTor) tab.proxyPort else -1, ready) } } } @@ -983,15 +985,12 @@ class NappletBrowserService : Service() { val raw = message.data ?: return val envelope = parseJsonObjectOrNull(raw) ?: return - // The origin the WebView REPORTS — the page cannot forge this — keys every consent decision, - // including browser.* ones. Page-supplied fields are never trusted for that. + // The origin the WebView reports, which the page can't forge, keys every decision below. val origin = trustedOrigin(sourceOrigin) ?: return - // Browser conveniences (share, blob downloads) are handled here, never forwarded to the broker's - // capability router; the theme colour only matters to a window with system bars, which an embedded - // tab doesn't own. That is NOT a consent exemption: browser.download writes into the shared - // Downloads collection, so it is gated below by [offerDownloadConsent] on this WebView-reported - // origin. + // Browser conveniences (share, blob downloads) are handled here, never brokered; a download still + // asks the user first ([offerInlineDownload]), since any top-frame script can post one. The theme + // colour only matters to a window with system bars, which an embedded tab doesn't own. when (envelope.stringOrNull("type")) { "browser.share" -> { BrowserWebTools.share(this, envelope.stringOrNull("title"), envelope.stringOrNull("text"), envelope.stringOrNull("url")) @@ -999,7 +998,7 @@ class NappletBrowserService : Service() { } "browser.themeColor" -> return "browser.download" -> { - offerDownloadConsent(tab, origin, envelope) + offerInlineDownload(tab, origin, envelope) return } } @@ -1043,72 +1042,62 @@ class NappletBrowserService : Service() { } /** - * The one-shot native consent card for a `browser.download` envelope, relayed to the main process - * (this provider has no window to show one on), then the save. Keyed on the WebView-reported - * [origin] only; nothing about the envelope can trigger the save alone. The byte payload is fully - * decoded here — before the prompt — so the dialog names the true size and malformed or oversized - * payloads are refused without ever reaching MediaStore. Exactly one card is live per tab at a time - * (a second request drops), so a page can't swap the name under the user's finger. + * A `browser.download` envelope: the bytes a page wants saved (a `blob:` download the extras script + * read, or one a script forged). Keyed on the WebView-reported [origin], never an envelope field. */ - private fun offerDownloadConsent( + private fun offerInlineDownload( tab: BrowserTab, origin: String, envelope: JsonObject, ) { - val data = envelope.stringOrNull("data")?.takeIf { it.startsWith("data:", ignoreCase = true) } ?: return - val save = BrowserDownloadGate.preludeInlineSave(data, envelope.stringOrNull("name")) ?: return - if (!BrowserDownloadGate.shouldPrompt(tab.sessionId, origin)) return - if (hasLiveDownloadConsent(tab)) return - val id = ++downloadSeq - val sent = - sendToClient(tab, NappletBrowserContract.MSG_DOWNLOAD_CONSENT) { - putLong(NappletBrowserContract.KEY_DOWNLOAD_ID, id) - putString(NappletBrowserContract.KEY_BROWSER_ORIGIN, origin) - putString(NappletBrowserContract.KEY_DOWNLOAD_NAME, save.fileName) - putLong(NappletBrowserContract.KEY_DOWNLOAD_SIZE, save.bytes.size.toLong()) - putBoolean(NappletBrowserContract.KEY_DOWNLOAD_RISKY, BrowserDownloadGate.isRisky(save.fileName)) - } - if (sent) { - consentTabs[id] = tab.sessionId - pendingDownloadConsents[id] = { allowed -> - if (allowed) BrowserDownloads.saveInlineBytes(this, save.fileName, save.mimeType, save.bytes) - } + if (!canOfferDownload(tab, origin)) return + val data = envelope.stringOrNull("data") ?: return + prepareDownloadOffer(tab, origin) { ready -> BrowserDownloads.offerInline(data, envelope.stringOrNull("name"), ready) } + } + + /** + * Whether [origin] may put a download card up in [tab] now: never over the tab's card already showing + * (so a page can't swap the name under the user's finger) or one still being prepared (so a page + * can't queue decodes), and not within the cooldown after its last card was answered. + */ + private fun canOfferDownload( + tab: BrowserTab, + origin: String, + ) = !tab.preparingDownload && pendingDownloads.values.none { it.sessionId == tab.sessionId } && tab.downloadCooldown.allows(origin) + + /** Runs [prepare] (which answers exactly once, on the main thread) and relays the offer it produces. */ + private fun prepareDownloadOffer( + tab: BrowserTab, + origin: String, + prepare: ((BrowserDownloads.DownloadOffer?) -> Unit) -> Unit, + ) { + tab.preparingDownload = true + prepare { offer -> + tab.preparingDownload = false + if (offer != null) showDownloadOffer(tab, origin, offer) } } - /** Whether this tab's consent card is still on screen (a second request for it is dropped — the anti-swap rule). */ - private fun hasLiveDownloadConsent(tab: BrowserTab): Boolean = pendingDownloadConsents.keys.any { consentTabs[it] == tab.sessionId } - /** - * The consent card for a WebView `DownloadListener` hit in this tab (a page-initiated `` - * or `Content-Disposition: attachment`), offered by [BrowserDownloads.downloadWithConsent] after it - * probed the size. Shares the exact same one-live-card-per-tab anti-swap rule as the bridge envelope. + * Asks the main process to show [offer]'s consent card (this provider has no window of its own); the + * file is fetched or written only if the user taps Save there. */ - private fun offerListenerDownloadConsent( + private fun showDownloadOffer( tab: BrowserTab, - fileName: String, - sizeBytes: Long, - risky: Boolean, - consent: BrowserDownloads.Consent, + origin: String, + offer: BrowserDownloads.DownloadOffer, ) { - val origin = tab.webView?.url?.let(BrowserChrome::originOf) ?: return - if (!BrowserDownloadGate.shouldPrompt(tab.sessionId, origin)) return - if (hasLiveDownloadConsent(tab)) return + if (tabs[tab.sessionId] !== tab || !canOfferDownload(tab, origin)) return val id = ++downloadSeq val sent = sendToClient(tab, NappletBrowserContract.MSG_DOWNLOAD_CONSENT) { putLong(NappletBrowserContract.KEY_DOWNLOAD_ID, id) putString(NappletBrowserContract.KEY_BROWSER_ORIGIN, origin) - putString(NappletBrowserContract.KEY_DOWNLOAD_NAME, fileName) - putLong(NappletBrowserContract.KEY_DOWNLOAD_SIZE, sizeBytes) - putBoolean(NappletBrowserContract.KEY_DOWNLOAD_RISKY, risky) + putString(NappletBrowserContract.KEY_DOWNLOAD_NAME, offer.fileName) + putLong(NappletBrowserContract.KEY_DOWNLOAD_SIZE, offer.sizeBytes) + putBoolean(NappletBrowserContract.KEY_DOWNLOAD_RISKY, offer.risky) } - if (sent) { - consentTabs[id] = tab.sessionId - pendingDownloadConsents[id] = { allowed -> - if (allowed) consent.run() - } - } + if (sent) pendingDownloads[id] = PendingDownload(tab.sessionId, origin, offer) } private fun requestBrowserToken(