From c922a0ea65a6c2c5699667cdb4eebae8fb649a93 Mon Sep 17 00:00:00 2001 From: Jameson Lopp Date: Wed, 30 Sep 2026 10:24:26 -0400 Subject: [PATCH 1/3] fix: harden download bridge --- .../loggedIn/browser/EmbeddedPageRequests.kt | 13 + .../browser/EmbeddedWebAppController.kt | 35 +++ .../screen/loggedIn/browser/WebAppScreen.kt | 25 +- .../composeResources/values-es/strings.xml | 3 + .../composeResources/values/strings.xml | 5 + .../browser/ui/pill/DownloadPromptCard.kt | 128 ++++++++++ .../commons/browser/ui/pill/PageSheets.kt | 2 +- .../amethyst/napplethost/BrowserChromeHost.kt | 38 +++ .../napplethost/BrowserDownloadGate.kt | 226 ++++++++++++++++++ .../amethyst/napplethost/BrowserDownloads.kt | 192 ++++++++++++--- .../napplethost/BrowserExtrasScript.kt | 8 +- .../napplethost/NappletBrowserActivity.kt | 134 +++++++++-- .../napplethost/NappletBrowserContract.kt | 29 +++ .../napplethost/NappletBrowserService.kt | 132 +++++++++- 14 files changed, 896 insertions(+), 74 deletions(-) create mode 100644 commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/DownloadPromptCard.kt create 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 65eedabf6d..dd1e18c379 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 @@ -40,3 +40,16 @@ data class EmbeddedPermissionRequest( val origin: String, val permissions: Set, ) + +/** + * 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. + */ +data class EmbeddedDownloadRequest( + val id: Long, + val origin: String, + val fileName: String, + val sizeBytes: Long, + val risky: Boolean, +) 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 8df5a2000f..44707deeaf 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 @@ -129,6 +129,9 @@ class EmbeddedWebAppController( /** The camera / microphone / location request the page is waiting on, if any. */ val pendingPermission = mutableStateOf(null) + /** The inline download awaiting the user's consent, if any. */ + val pendingDownload = mutableStateOf(null) + /** The certificate of the page on screen, once page info asked for it (null for none, or not yet). */ val pageCertificate = mutableStateOf(null) @@ -181,6 +184,7 @@ class EmbeddedWebAppController( consoleLogs.clear() pendingDialog.value = null pendingPermission.value = null + pendingDownload.value = null isFullscreen.value = false } @@ -367,6 +371,25 @@ class EmbeddedWebAppController( val id = msg.data?.getLong(NappletBrowserContract.KEY_PERMISSION_ID) if (pendingPermission.value?.id == id) pendingPermission.value = null } + NappletBrowserContract.MSG_DOWNLOAD_CONSENT -> { + val data = msg.data ?: return true + val id = data.getLong(NappletBrowserContract.KEY_DOWNLOAD_ID) + val origin = data.getString(NappletBrowserContract.KEY_BROWSER_ORIGIN) + val name = data.getString(NappletBrowserContract.KEY_DOWNLOAD_NAME).orEmpty() + // An unnamed/absent origin means the sandbox couldn't even state who is asking: refuse. + if (origin == null || name.isEmpty() || pendingDownload.value != null) { + answerDownload(id, allowed = false) + return true + } + pendingDownload.value = + EmbeddedDownloadRequest( + id = id, + origin = origin, + fileName = name, + sizeBytes = data.getLong(NappletBrowserContract.KEY_DOWNLOAD_SIZE, 0L), + risky = data.getBoolean(NappletBrowserContract.KEY_DOWNLOAD_RISKY, false), + ) + } NappletBrowserContract.MSG_FULLSCREEN -> isFullscreen.value = msg.data?.getBoolean(NappletBrowserContract.KEY_ENABLED, false) ?: false NappletBrowserContract.MSG_MAGNIFIER_FRAME -> { val data = msg.data ?: return true @@ -486,6 +509,18 @@ class EmbeddedWebAppController( } } + /** Answers the download-consent card for [id]: true saves the bytes the sandbox already holds. */ + fun answerDownload( + id: Long, + allowed: Boolean, + ) { + if (pendingDownload.value?.id == id) pendingDownload.value = null + send(NappletBrowserContract.MSG_DOWNLOAD_CONSENT_RESULT) { + putLong(NappletBrowserContract.KEY_DOWNLOAD_ID, id) + putBoolean(NappletBrowserContract.KEY_DOWNLOAD_ALLOWED, allowed) + } + } + fun setTor(useTor: Boolean) = send(NappletBrowserContract.MSG_SET_TOR) { putBoolean(NappletBrowserContract.KEY_USE_TOR, useTor) } override fun sendImeOp(json: String) = send(NappletBrowserContract.MSG_IME_OP) { putString(NappletBrowserContract.KEY_IME_PAYLOAD, json) } 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 d2947e9dfc..16b391755d 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 @@ -65,6 +65,7 @@ import com.vitorpamplona.amethyst.commons.browser.OmniboxSuggestions import com.vitorpamplona.amethyst.commons.browser.ui.pill.AddressSuggestion import com.vitorpamplona.amethyst.commons.browser.ui.pill.BrowserPillEvent import com.vitorpamplona.amethyst.commons.browser.ui.pill.BrowserPillUi +import com.vitorpamplona.amethyst.commons.browser.ui.pill.DownloadPromptCard import com.vitorpamplona.amethyst.commons.browser.ui.pill.PageDialogCard import com.vitorpamplona.amethyst.commons.browser.ui.pill.PageInfoSheet import com.vitorpamplona.amethyst.commons.browser.ui.pill.PermissionPromptCard @@ -324,8 +325,9 @@ private fun EmbeddedWebAppTab( /** * Everything an embedded page asks the user for, drawn by the main process because the provider has no * window: JS dialogs, camera / microphone / location prompts (remembered per origin in - * [WebSitePermissionRegistry], then Android's own runtime permission), and page info — the shared - * [PageDialogCard], [PermissionPromptCard] and [PageInfoSheet]. + * [WebSitePermissionRegistry], then Android's own runtime permission), inline-download consent, and + * page info — the shared [PageDialogCard], [PermissionPromptCard], [DownloadPromptCard] and + * [PageInfoSheet]. */ @RequiresApi(Build.VERSION_CODES.R) @Composable @@ -408,6 +410,25 @@ private fun EmbeddedPageUi( } } + // A page's inline download: nothing reaches the shared Downloads collection 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"). + val downloadRequest by controller.pendingDownload + downloadRequest?.let { request -> + Dialog(onDismissRequest = { controller.answerDownload(request.id, allowed = false) }) { + DownloadPromptCard( + host = hostLabel(request.origin), + security = ui.security, + fileName = request.fileName, + sizeBytes = request.sizeBytes, + risky = request.risky, + onAllow = { controller.answerDownload(request.id, allowed = true) }, + onDeny = { controller.answerDownload(request.id, allowed = false) }, + ) + } + } + if (showPageInfo) { val certificate by controller.pageCertificate val origin = browserOrigin(ui.chrome.url) diff --git a/commonsUI/src/commonMain/composeResources/values-es/strings.xml b/commonsUI/src/commonMain/composeResources/values-es/strings.xml index c278f19263..52d054cc83 100644 --- a/commonsUI/src/commonMain/composeResources/values-es/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-es/strings.xml @@ -2997,4 +2997,7 @@ 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/composeResources/values/strings.xml b/commonsUI/src/commonMain/composeResources/values/strings.xml index 5233867a0e..eb937376ff 100644 --- a/commonsUI/src/commonMain/composeResources/values/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values/strings.xml @@ -6355,4 +6355,9 @@ Ask Allowed Blocked + + + Download this file? + This file type can run code. Check that the name matches what you meant to get. + Save 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 new file mode 100644 index 0000000000..28b40df411 --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/DownloadPromptCard.kt @@ -0,0 +1,128 @@ +/* + * 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.ui.pill + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.browser.BrowserChrome +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.resources.Res +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.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. + */ +@Composable +fun DownloadPromptCard( + host: String?, + security: BrowserChrome.Security, + fileName: String, + sizeBytes: Long, + risky: Boolean, + onAllow: () -> Unit, + onDeny: () -> Unit, +) { + PageCard { + if (host != null) { + OriginBadge(host, security) + Spacer(Modifier.height(20.dp)) + } + Text( + stringRes(Res.string.browser_pill_download_title), + style = MaterialTheme.typography.titleLarge, + ) + Spacer(Modifier.height(16.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + Modifier.size(44.dp).clip(CircleShape).background(MaterialTheme.colorScheme.primaryContainer), + contentAlignment = Alignment.Center, + ) { + Icon(MaterialSymbols.Download, contentDescription = null, tint = MaterialTheme.colorScheme.onPrimaryContainer, filled = true) + } + 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) + } + } + if (risky) { + Spacer(Modifier.height(16.dp)) + Row( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.6f)) + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + PillActionIcon(BrowserChrome.Action.ACCESS_INFO, tint = MaterialTheme.colorScheme.onErrorContainer, size = 18.dp) + Spacer(Modifier.width(10.dp)) + Text(stringRes(Res.string.browser_pill_download_risky), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onErrorContainer, fontWeight = FontWeight.Medium) + } + } + Spacer(Modifier.height(24.dp)) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { + TextButton(onClick = onDeny) { Text(stringRes(Res.string.browser_pill_cancel)) } + Spacer(Modifier.width(8.dp)) + Button(onClick = onAllow) { Text(stringRes(Res.string.browser_pill_download_save)) } + } + } +} + +/** 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/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/PageSheets.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/PageSheets.kt index 8c28a74bc7..dff2b52fc3 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/PageSheets.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/PageSheets.kt @@ -137,7 +137,7 @@ fun OriginBadge( /** The card frame every page-initiated prompt shares. */ @Composable -private fun PageCard(content: @Composable () -> Unit) { +internal fun PageCard(content: @Composable () -> Unit) { Surface( shape = RoundedCornerShape(28.dp), color = MaterialTheme.colorScheme.surfaceContainerHigh, 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 a781eb4722..4a13007331 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserChromeHost.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserChromeHost.kt @@ -56,6 +56,7 @@ import com.vitorpamplona.amethyst.commons.browser.ui.pill.BrowserPillUi import com.vitorpamplona.amethyst.commons.browser.ui.pill.CertificateInfo import com.vitorpamplona.amethyst.commons.browser.ui.pill.ConsoleLine import com.vitorpamplona.amethyst.commons.browser.ui.pill.ConsoleSheet +import com.vitorpamplona.amethyst.commons.browser.ui.pill.DownloadPromptCard import com.vitorpamplona.amethyst.commons.browser.ui.pill.FindInPagePill import com.vitorpamplona.amethyst.commons.browser.ui.pill.PageDialogCard import com.vitorpamplona.amethyst.commons.browser.ui.pill.PageDialogType @@ -130,6 +131,20 @@ class BrowserChromeHost( val answer: (allow: Boolean, remember: Boolean) -> Unit, ) + /** + * 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. + */ + class PendingDownload( + val host: String?, + val security: BrowserChrome.Security, + val fileName: String, + val sizeBytes: Long, + val risky: Boolean, + val answer: (allow: Boolean) -> Unit, + ) + var ui by mutableStateOf(initial) var expanded by mutableStateOf(false) private set @@ -146,6 +161,7 @@ class BrowserChromeHost( var dialog by mutableStateOf(null) var permissionPrompt by mutableStateOf(null) + var downloadPrompt by mutableStateOf(null) private var pageInfoOpen by mutableStateOf(false) private var accessInfo by mutableStateOf(null) private var certificate by mutableStateOf(null) @@ -345,6 +361,28 @@ class BrowserChromeHost( ) } } + downloadPrompt?.let { pending -> + Dialog(onDismissRequest = { + downloadPrompt = null + pending.answer(false) + }) { + DownloadPromptCard( + host = pending.host, + security = pending.security, + fileName = pending.fileName, + sizeBytes = pending.sizeBytes, + risky = pending.risky, + onAllow = { + downloadPrompt = null + pending.answer(true) + }, + onDeny = { + downloadPrompt = null + pending.answer(false) + }, + ) + } + } accessInfo?.let { info -> Dialog(onDismissRequest = { accessInfo = null }, properties = DialogProperties(usePlatformDefaultWidth = false)) { Box(Modifier.fillMaxWidth().padding(16.dp), contentAlignment = Alignment.Center) { diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserDownloadGate.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserDownloadGate.kt new file mode 100644 index 0000000000..3ef91c013d --- /dev/null +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserDownloadGate.kt @@ -0,0 +1,226 @@ +/* + * 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 6e97344b45..fbfcef6a96 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserDownloads.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserDownloads.kt @@ -34,6 +34,7 @@ import android.widget.Toast 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 @@ -45,6 +46,10 @@ 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. + * * 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 * cookies from its own per-account storage profile and its user agent, so a logged-in download works. @@ -59,8 +64,9 @@ object BrowserDownloads { private val main = Handler(Looper.getMainLooper()) /** - * A WebView `DownloadListener` hit. [cookie] must be read on the main thread (from the tab's own - * profile) before calling; the transfer itself runs on a background thread. + * 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. */ fun download( context: Context, @@ -76,40 +82,136 @@ object BrowserDownloads { saveDataUrl(app, url, null) return } - if (!url.startsWith("https://", ignoreCase = true) && !url.startsWith("http://", ignoreCase = true)) return + if (!isHttp(url)) return val name = URLUtil.guessFileName(url, contentDisposition, mimeType) 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)) + runNetworkDownload(app, url, name, 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. + */ + fun downloadWithConsent( + context: Context, + url: String, + contentDisposition: String?, + mimeType: String?, + cookieHolder: () -> String?, + userAgent: String?, + proxyPort: Int, + showPrompt: (fileName: String, sizeBytes: Long, risky: Boolean, consent: Consent) -> 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) }) + 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) } + }, + ) + } + } + } + + /** + * 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. + */ + fun interface Consent { + fun run() + } + + 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( + app: Context, + url: String, + name: String, + userAgent: String?, + mimeType: String?, + 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() + } + } + + /** 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`). */ fun saveDataUrl( context: Context, @@ -147,6 +249,36 @@ object BrowserDownloads { } } + /** + * 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, + 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 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 acb3b06ec1..b53275020b 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserExtrasScript.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserExtrasScript.kt @@ -29,10 +29,14 @@ package com.vitorpamplona.amethyst.napplethost * - `` — reported (`browser.themeColor`, normalized to `rgb(r, g, b)`) whenever it * 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`). + * `.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. * * 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. + * 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). */ 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 4525300d1b..4ebed03439 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt @@ -362,7 +362,17 @@ class NappletBrowserActivity : ComponentActivity() { wv.webChromeClient = BrowserChromeClient() wv.setFindListener { active, total, _ -> chrome?.setFindResult(active, total) } wv.setDownloadListener { url, userAgent, contentDisposition, mimeType, _ -> - BrowserDownloads.download(this, url, userAgent, contentDisposition, mimeType, BrowserWebTools.cookieManager(wv).getCookie(url), if (useTor) proxyPort else -1) + 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.setBackgroundColor(resolveThemeColor(android.R.attr.colorBackground)) wv.dropSystemBarInsets() @@ -423,6 +433,7 @@ class NappletBrowserActivity : ComponentActivity() { // A dialog still up would leak its window and leave the page's JS blocked on an unanswered result. chrome?.dialog?.answer?.invoke(false, null, false) chrome?.permissionPrompt?.answer?.invoke(false, false) + chrome?.downloadPrompt?.answer?.invoke(false) customViewCallback?.onCustomViewHidden() destroyWebView() super.onDestroy() @@ -892,15 +903,30 @@ class NappletBrowserActivity : ComponentActivity() { val raw = message.data ?: return val envelope = parseJsonObjectOrNull(raw) ?: return - // Browser conveniences (share, theme colour, blob downloads) are handled here, never brokered. - if (envelope.stringOrNull("type").orEmpty().startsWith("browser.")) { - onBrowserMessage(envelope) - 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. + val origin = trustedOrigin(sourceOrigin) ?: return - val scheme = sourceOrigin.scheme ?: return - val host = sourceOrigin.host ?: return - val origin = "$scheme://$host" + if (sourceOrigin.port > 0) ":${sourceOrigin.port}" else "" + // 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. + when (envelope.stringOrNull("type")) { + "browser.share" -> { + if (resumed) BrowserWebTools.share(this, envelope.stringOrNull("title"), envelope.stringOrNull("text"), envelope.stringOrNull("url")) + return + } + "browser.themeColor" -> { + themeColor = BrowserChrome.parseCssRgb(envelope.stringOrNull("color")) + applyThemeColor(themeColor) + updateTaskDescription() + return + } + "browser.download" -> { + offerDownloadConsent(origin, envelope) + return + } + } val id = envelope.stringOrNull("id").orEmpty().ifEmpty { "fire-${fireSeq++}" } val msg = @@ -923,25 +949,75 @@ class NappletBrowserActivity : ComponentActivity() { } } - /** A `browser.*` message from [BrowserExtrasScript]. */ - private fun onBrowserMessage(envelope: JsonObject) { - when (envelope.stringOrNull("type")) { - "browser.share" -> - if (resumed) { - BrowserWebTools.share(this, envelope.stringOrNull("title"), envelope.stringOrNull("text"), envelope.stringOrNull("url")) - } - "browser.themeColor" -> { - themeColor = BrowserChrome.parseCssRgb(envelope.stringOrNull("color")) - applyThemeColor(themeColor) - updateTaskDescription() + /** `scheme://host[:port]` of the WebView-reported origin, or null when it has no usable one. */ + private fun trustedOrigin(sourceOrigin: Uri): String? { + val scheme = sourceOrigin.scheme ?: return null + val host = sourceOrigin.host ?: return null + return "$scheme://$host" + if (sourceOrigin.port > 0) ":${sourceOrigin.port}" else "" + } + + /** + * 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. + */ + private fun offerDownloadConsent( + 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) } - "browser.download" -> { - val data = envelope.stringOrNull("data") ?: return - if (data.startsWith("data:") && data.length <= BrowserDownloads.MAX_INLINE_BYTES / 3 * 4 + 256) { - BrowserDownloads.saveDataUrl(this, data, envelope.stringOrNull("name")) - } + } + + /** + * 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. + */ + private fun offerListenerDownloadConsent( + fileName: String, + sizeBytes: Long, + risky: Boolean, + consent: BrowserDownloads.Consent, + ) { + 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 + host.downloadPrompt = + BrowserChromeHost.PendingDownload( + host = BrowserChrome.displayHost(origin), + security = BrowserChrome.security(host.ui.chrome), + fileName = fileName, + sizeBytes = sizeBytes, + risky = risky, + ) { allowed -> + if (allowed) consent.run() } - } } private fun requestBrowserToken(origin: String) { @@ -1652,6 +1728,12 @@ 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 f814830628..f140673e0a 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserContract.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserContract.kt @@ -184,6 +184,20 @@ object NappletBrowserContract { /** Leave HTML fullscreen (the user pressed back). */ 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. + */ + const val MSG_DOWNLOAD_CONSENT = 34 + + /** Client → provider: the user's answer to [MSG_DOWNLOAD_CONSENT]: [KEY_DOWNLOAD_ID] + [KEY_DOWNLOAD_ALLOWED]. */ + const val MSG_DOWNLOAD_CONSENT_RESULT = 35 + const val KEY_CAN_GO_FORWARD = "canGoForward" const val KEY_FIND_QUERY = "findQuery" const val KEY_FIND_FORWARD = "findForward" @@ -206,6 +220,21 @@ object NappletBrowserContract { const val KEY_PERMISSIONS = "permissions" const val KEY_BROWSER_ORIGIN = "browserOrigin" + /** Correlates a [MSG_DOWNLOAD_CONSENT] prompt with its [MSG_DOWNLOAD_CONSENT_RESULT] answer. */ + const val KEY_DOWNLOAD_ID = "downloadId" + + /** 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. */ + const val KEY_DOWNLOAD_SIZE = "downloadSize" + + /** Whether [KEY_DOWNLOAD_NAME]'s extension is one the user should double-check (installer/script-like). */ + const val KEY_DOWNLOAD_RISKY = "downloadRisky" + + /** The user's answer in [MSG_DOWNLOAD_CONSENT_RESULT]: true = save, false = discard. */ + const val KEY_DOWNLOAD_ALLOWED = "downloadAllowed" + const val KEY_FILE_CHOOSER_ID = "fileChooserId" const val KEY_FILE_CHOOSER_ACCEPT = "fileChooserAccept" const val KEY_FILE_CHOOSER_MULTIPLE = "fileChooserMultiple" 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 e58e8a861e..00b5c5d95d 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt @@ -154,6 +154,13 @@ 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() + private var downloadSeq = 0L + // The shim never changes; read+decode it once instead of per tab on the main thread. private val shimJs: String by lazy { readContractAsset(NappletWebContract.SHIM_JS_PATH).decodeToString() } @@ -295,6 +302,13 @@ class NappletBrowserService : Service() { .toSet(), ) } + 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)) + } NappletBrowserContract.MSG_EXIT_FULLSCREEN -> tabFor(msg)?.let { exitFullscreen(it) } NappletBrowserContract.MSG_RELOAD -> tabFor(msg)?.webView?.reload() NappletBrowserContract.MSG_BACK -> tabFor(msg)?.webView?.let { if (it.canGoBack()) it.goBack() } @@ -446,6 +460,12 @@ 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) + } tab.customViewCallback?.onCustomViewHidden() tab.customViewCallback = null tab.customView = null @@ -462,8 +482,18 @@ class NappletBrowserService : Service() { wv.webViewClient = BrowserClient(tab) wv.webChromeClient = BrowserChromeClient(tab) wv.setDownloadListener { url, userAgent, contentDisposition, mimeType, _ -> - val route = if (tab != null && tab.useTor) tab.proxyPort else -1 - BrowserDownloads.download(this, url, userAgent, contentDisposition, mimeType, BrowserWebTools.cookieManager(wv).getCookie(url), route) + 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) + } } } @@ -953,21 +983,25 @@ class NappletBrowserService : Service() { val raw = message.data ?: return val envelope = parseJsonObjectOrNull(raw) ?: return - // Browser conveniences (share, blob downloads) are handled here, never brokered. The theme colour - // only matters to a window with system bars, which an embedded tab doesn't own. + // 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. + 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. when (envelope.stringOrNull("type")) { "browser.share" -> { BrowserWebTools.share(this, envelope.stringOrNull("title"), envelope.stringOrNull("text"), envelope.stringOrNull("url")) return } + "browser.themeColor" -> return "browser.download" -> { - val data = envelope.stringOrNull("data") ?: return - if (data.startsWith("data:") && data.length <= BrowserDownloads.MAX_INLINE_BYTES / 3 * 4 + 256) { - BrowserDownloads.saveDataUrl(this, data, envelope.stringOrNull("name")) - } + offerDownloadConsent(tab, origin, envelope) return } - "browser.themeColor" -> return } // IME events aren't brokered — the main app hosts the keyboard. Relay the envelope to the client. @@ -980,10 +1014,6 @@ class NappletBrowserService : Service() { return } - val scheme = sourceOrigin.scheme ?: return - val host = sourceOrigin.host ?: return - val origin = "$scheme://$host" + if (sourceOrigin.port > 0) ":${sourceOrigin.port}" else "" - val id = envelope.stringOrNull("id").orEmpty().ifEmpty { "fire-${tab.fireSeq++}" } val msg = Message.obtain(null, NappletIpc.MSG_REQUEST).apply { @@ -1005,6 +1035,82 @@ class NappletBrowserService : Service() { } } + /** `scheme://host[:port]` of the WebView-reported origin, or null when it has no usable one. */ + private fun trustedOrigin(sourceOrigin: Uri): String? { + val scheme = sourceOrigin.scheme ?: return null + val host = sourceOrigin.host ?: return null + return "$scheme://$host" + if (sourceOrigin.port > 0) ":${sourceOrigin.port}" else "" + } + + /** + * 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. + */ + private fun offerDownloadConsent( + 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) + } + } + } + + /** 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. + */ + private fun offerListenerDownloadConsent( + tab: BrowserTab, + fileName: String, + sizeBytes: Long, + risky: Boolean, + consent: BrowserDownloads.Consent, + ) { + val origin = tab.webView?.url?.let(BrowserChrome::originOf) ?: 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, fileName) + putLong(NappletBrowserContract.KEY_DOWNLOAD_SIZE, sizeBytes) + putBoolean(NappletBrowserContract.KEY_DOWNLOAD_RISKY, risky) + } + if (sent) { + consentTabs[id] = tab.sessionId + pendingDownloadConsents[id] = { allowed -> + if (allowed) consent.run() + } + } + } + private fun requestBrowserToken( tab: BrowserTab, origin: String, From a6b6b08f2980933bbf73ac4bd2ce95f8905b205e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 19:28:24 +0000 Subject: [PATCH 2/3] 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( From d51484d538c9927b50fecc43a4760d6bcc2bec83 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 19:54:22 +0000 Subject: [PATCH 3/3] fix(browser): close the gaps an audit found in download consent - The saved file's MIME type now comes from the approved name, so MediaStore can't append a different extension than the card showed ("invoice.pdf" typed as an APK would have become "invoice.pdf.apk"). - File names drop bidi/zero-width/C1 characters, keep their extension when shortened, and render on one middle-ellipsized line. - Refusals back off (1s, doubling to 1 min; Save resets), no card over another page dialog or permission prompt, and Save ignores taps for 500 ms after the card appears. - The card names the host a network file comes from when it isn't the page's (an ad frame, a CDN); a fresh about:blank popup falls back to it instead of silently dropping the download. - Offers save at most once (no double file on a double tap); a decode failure (even OOM) refuses the download instead of killing :napplet; percent-decoding counts bytes before allocating; ;base64 must be the last parameter; more risky extensions. - A closing embedded tab withdraws its card in the main process. - The long-press data: save decodes off the main thread. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E3cqjbY7FX2zrkGXCVXpFD --- .../loggedIn/browser/EmbeddedPageRequests.kt | 1 + .../browser/EmbeddedWebAppController.kt | 11 +- .../screen/loggedIn/browser/WebAppScreen.kt | 8 +- .../commons/browser/BrowserDownloadRules.kt | 171 +++++++++++++----- .../browser/BrowserDownloadRulesTest.kt | 66 ++++++- .../composeResources/values/strings.xml | 1 + .../browser/ui/pill/DownloadPromptCard.kt | 39 +++- .../amethyst/napplethost/BrowserChromeHost.kt | 2 + .../amethyst/napplethost/BrowserDownloads.kt | 86 +++++---- .../napplethost/NappletBrowserActivity.kt | 17 +- .../napplethost/NappletBrowserContract.kt | 8 +- .../napplethost/NappletBrowserService.kt | 30 ++- 12 files changed, 333 insertions(+), 107 deletions(-) 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 67119d18c3..b7ed7888e8 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 @@ -51,5 +51,6 @@ data class EmbeddedDownloadRequest( val origin: String, val fileName: String, val sizeBytes: Long, + val sourceHost: String?, val risky: Boolean, ) 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 df043eb826..2e56d97b8a 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 @@ -129,7 +129,7 @@ class EmbeddedWebAppController( /** The camera / microphone / location request the page is waiting on, if any. */ val pendingPermission = mutableStateOf(null) - /** The inline download awaiting the user's consent, if any. */ + /** The download the page started that awaits the user's consent, if any. */ val pendingDownload = mutableStateOf(null) /** The certificate of the page on screen, once page info asked for it (null for none, or not yet). */ @@ -377,7 +377,7 @@ class EmbeddedWebAppController( val origin = data.getString(NappletBrowserContract.KEY_BROWSER_ORIGIN) val name = data.getString(NappletBrowserContract.KEY_DOWNLOAD_NAME).orEmpty() // An unnamed/absent origin means the sandbox couldn't even state who is asking: refuse. - if (origin == null || name.isEmpty() || pendingDownload.value != null) { + if (origin == null || name.isEmpty() || pendingDownload.value != null || pendingDialog.value != null || pendingPermission.value != null) { answerDownload(id, allowed = false) return true } @@ -387,9 +387,14 @@ class EmbeddedWebAppController( origin = origin, fileName = name, sizeBytes = data.getLong(NappletBrowserContract.KEY_DOWNLOAD_SIZE, -1L), + sourceHost = data.getString(NappletBrowserContract.KEY_DOWNLOAD_SOURCE), risky = data.getBoolean(NappletBrowserContract.KEY_DOWNLOAD_RISKY, false), ) } + NappletBrowserContract.MSG_DOWNLOAD_CANCEL -> { + val id = msg.data?.getLong(NappletBrowserContract.KEY_DOWNLOAD_ID) + if (pendingDownload.value?.id == id) pendingDownload.value = null + } NappletBrowserContract.MSG_FULLSCREEN -> isFullscreen.value = msg.data?.getBoolean(NappletBrowserContract.KEY_ENABLED, false) ?: false NappletBrowserContract.MSG_MAGNIFIER_FRAME -> { val data = msg.data ?: return true @@ -509,7 +514,7 @@ class EmbeddedWebAppController( } } - /** Answers the download-consent card for [id]: true saves the bytes the sandbox already holds. */ + /** Answers the download-consent card for [id]: true lets the sandbox fetch or write the file it described. */ fun answerDownload( id: Long, allowed: Boolean, 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 36b516a1b7..b66cedceca 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,10 +410,9 @@ private fun EmbeddedPageUi( } } - // 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"). + // A download the page started: nothing is fetched or saved until this is answered. The card shows the + // file name that would be saved, its size and source host, headed by the WebView-reported origin (never + // a page-supplied field), with a warning for names that can be installed or run ("invoice.apk"). val downloadRequest by controller.pendingDownload downloadRequest?.let { request -> Dialog(onDismissRequest = { controller.answerDownload(request.id, allowed = false) }) { @@ -422,6 +421,7 @@ private fun EmbeddedPageUi( security = ui.security, fileName = request.fileName, sizeBytes = request.sizeBytes, + sourceHost = request.sourceHost, risky = request.risky, onAllow = { controller.answerDownload(request.id, allowed = true) }, onDeny = { controller.answerDownload(request.id, allowed = false) }, 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 index 6b85a01384..fe22abfa1c 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/BrowserDownloadRules.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/BrowserDownloadRules.kt @@ -22,14 +22,15 @@ package com.vitorpamplona.amethyst.commons.browser import kotlin.io.encoding.Base64 import kotlin.time.Duration +import kotlin.time.Duration.Companion.minutes 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. + * The platform-free rules behind the in-app browser's download consent: what a saved file may be named, + * which 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 @@ -39,12 +40,16 @@ 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 + /** Longest file name kept; longer ones are shortened in the middle so the extension survives. */ + const val MAX_NAME_LENGTH = 120 + /** - * 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. + * Extensions something can install, run, or open as a live page from. The consent card flags them; + * the name itself is never rewritten, so the user judges the real one. */ val RISKY_EXTENSIONS = setOf( + // Android "apk", "apks", "apkm", @@ -53,22 +58,34 @@ object BrowserDownloadRules { "jar", "dex", "so", + // Windows "exe", "msi", + "msix", + "appx", "bat", "cmd", "com", + "cpl", + "pif", + "reg", "scr", "hta", "ps1", + "js", + "jse", "vbs", + "vbe", "wsf", "lnk", "url", + // Apple "dmg", "pkg", "app", + "ipa", "command", + // Linux "deb", "rpm", "appimage", @@ -77,15 +94,48 @@ object BrowserDownloadRules { "zsh", "bash", "desktop", + // Pages that run script when opened "html", "htm", "xhtml", + "xht", + "mht", + "mhtml", "svg", + "svgz", ) /** 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 + // Path-reserved characters become "_"; C0/C1 controls, DEL, bidi controls and zero-width characters + // are dropped outright, since they can make "invoice[RLO]gpj.apk" render as "invoicekpa.jpg". + private val reservedNameChars = Regex("[:*?\"<>|]") + private val invisibleNameChars = Regex("[\\u0000-\\u001f\\u007f-\\u009f\\u061c\\u200b-\\u200f\\u202a-\\u202e\\u2060-\\u2069\\ufeff]") + + /** + * A plain file name from a page's suggestion: no path parts, no reserved or invisible characters, at + * most [MAX_NAME_LENGTH] long with its extension kept. Null when nothing usable is left. + */ + fun safeFileName(suggested: String?): String? { + val base = + suggested + ?.substringAfterLast('/') + ?.substringAfterLast('\\') + ?.replace(invisibleNameChars, "") + ?.replace(reservedNameChars, "_") + ?.trim() + ?.takeIf { it.isNotEmpty() && it != "." && it != ".." } + ?: return null + if (base.length <= MAX_NAME_LENGTH) return base + val ext = base.substringAfterLast('.', "") + return if (ext.isNotEmpty() && ext.length <= 16) { + base.take(MAX_NAME_LENGTH - ext.length - 1).trimEnd() + "." + ext + } else { + base.take(MAX_NAME_LENGTH) + } + } + /** A decoded `data:` URL: the declared MIME type (null when absent) and the payload bytes. */ class DataUrl( val mimeType: String?, @@ -106,58 +156,85 @@ object BrowserDownloadRules { 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 params = dataUrl.substring(5, comma).split(';') val mime = params .first() .trim() .lowercase() .takeIf { it.isNotEmpty() } - val isBase64 = params.drop(1).any { it.trim().equals("base64", ignoreCase = true) } + val isBase64 = params.size > 1 && params.last().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 + // 4 chars per 3 bytes, plus room for the CRLF a MIME encoder puts every 76 chars. + if (payloadLength.toLong() > maxBytes / 3 * 4L + maxBytes / 57 * 2L + 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 + // A percent escape is 3 chars per byte; the exact size is counted before allocating. + if (payloadLength.toLong() > maxBytes * 3L) return null + percentDecode(dataUrl, comma + 1, maxBytes) ?: 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. */ + /** + * RFC 3986 percent-decoding of [text] from [start] (a `+` stays a `+`, other characters are UTF-8). + * Null on a broken escape or when the result would exceed [maxBytes], checked before allocating it. + */ private fun percentDecode( text: String, start: Int, + maxBytes: 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 + var size = 0L + var i = start + while (i < text.length) { + val c = text[i] + if (c == '%') { + if (i + 2 >= text.length || hexValue(text[i + 1]) < 0 || hexValue(text[i + 2]) < 0) return null + size += 1 + i += 3 + } else if (c.isHighSurrogate() && i + 1 < text.length && text[i + 1].isLowSurrogate()) { + size += 4 + i += 2 } else { - out[written++] = b - read++ + // A lone surrogate encodes as U+FFFD, 3 bytes, like any other char from U+0800 up. + size += + when { + c.code < 0x80 -> 1 + c.code < 0x800 -> 2 + else -> 3 + } + i++ + } + if (size > maxBytes) return null + } + val out = ByteArray(size.toInt()) + var written = 0 + i = start + var runStart = start + while (i <= text.length) { + if (i == text.length || text[i] == '%') { + if (i > runStart) { + val run = text.encodeToByteArray(runStart, i) + run.copyInto(out, written) + written += run.size + } + if (i == text.length) break + out[written++] = ((hexValue(text[i + 1]) shl 4) or hexValue(text[i + 2])).toByte() + i += 3 + runStart = i + } else { + i++ } } - return out.copyOf(written) + return if (written == out.size) out else out.copyOf(written) } - private fun hexValue(b: Byte): Int = - when (val c = b.toInt().toChar()) { + private fun hexValue(c: Char): Int = + when (c) { in '0'..'9' -> c - '0' in 'a'..'f' -> c - 'a' + 10 in 'A'..'F' -> c - 'A' + 10 @@ -166,21 +243,33 @@ object BrowserDownloadRules { } /** - * 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. + * How often one browser surface (a window, or one embedded tab) lets a site show a download card. After + * the user refuses a site's card, it waits [base] before it may ask again, and each further refusal + * doubles that, up to [max]; a Save resets it. Without this a refused page could re-ask in a loop until + * the user gives in. Main-thread only. */ class DownloadCooldown( - private val window: Duration = 1.seconds, + private val base: Duration = 1.seconds, + private val max: Duration = 1.minutes, private val timeSource: TimeSource = TimeSource.Monotonic, ) { - private val answeredAt = HashMap() + private class Hold( + val since: TimeMark, + val window: Duration, + ) + + private val holds = HashMap() /** Whether [origin] may show a card now. */ - fun allows(origin: String): Boolean = answeredAt[origin]?.let { it.elapsedNow() >= window } ?: true + fun allows(origin: String): Boolean = holds[origin]?.let { it.since.elapsedNow() >= it.window } ?: true - /** Starts [origin]'s cooldown: the user just answered (or the surface dismissed) its card. */ - fun answered(origin: String) { - answeredAt[origin] = timeSource.markNow() + /** The user answered [origin]'s card: [allowed] resets its wait, a refusal (or dismissal) doubles it. */ + fun answered( + origin: String, + allowed: Boolean, + ) { + val previous = holds[origin]?.window + val window = if (allowed || previous == null) base else minOf(previous * 2, max) + holds[origin] = Hold(timeSource.markNow(), window) } } 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 index e186292c62..95b929c5b9 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/browser/BrowserDownloadRulesTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/browser/BrowserDownloadRulesTest.kt @@ -28,6 +28,7 @@ import kotlin.test.assertFalse import kotlin.test.assertNull import kotlin.test.assertTrue import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds import kotlin.time.TestTimeSource @@ -81,13 +82,48 @@ class BrowserDownloadRulesTest { assertNull(BrowserDownloadRules.decodeDataUrl("data:," + "a".repeat(11), maxBytes = 10)) } + @Test + fun base64MarkerMustBeTheLastParameter() { + assertEquals("aGk", BrowserDownloadRules.decodeDataUrl("data:text/plain;base64;x=y,aGk")!!.bytes.decodeToString()) + } + + @Test + fun percentDecodingCountsBytesBeforeAllocating() { + assertEquals(3, BrowserDownloadRules.decodeDataUrl("data:,%E4%B8%AD", maxBytes = 3)!!.bytes.size) + assertEquals("中", BrowserDownloadRules.decodeDataUrl("data:,中", maxBytes = 3)!!.bytes.decodeToString()) + assertNull(BrowserDownloadRules.decodeDataUrl("data:,中中", maxBytes = 5)) + assertEquals(4, BrowserDownloadRules.decodeDataUrl("data:,\uD83D\uDE00", maxBytes = 4)!!.bytes.size) + // A lone surrogate must not overflow the pre-counted buffer. + assertTrue(BrowserDownloadRules.decodeDataUrl("data:,a\uDE00b%41")!!.bytes.isNotEmpty()) + } + + @Test + fun safeFileNameStripsPathsAndInvisibleCharacters() { + assertEquals("evil.apk", BrowserDownloadRules.safeFileName("../../evil.apk")) + assertEquals("evil.apk", BrowserDownloadRules.safeFileName("C:\\x\\evil.apk")) + assertEquals("a_b_.pdf", BrowserDownloadRules.safeFileName("a:b?.pdf")) + assertEquals("invoicegpj.apk", BrowserDownloadRules.safeFileName("invoice\u202Egpj.apk")) + assertEquals("ab.txt", BrowserDownloadRules.safeFileName("a\u200Bb\u0085.txt")) + assertNull(BrowserDownloadRules.safeFileName("..")) + assertNull(BrowserDownloadRules.safeFileName(" \u200F ")) + assertNull(BrowserDownloadRules.safeFileName(null)) + } + + @Test + fun safeFileNameKeepsTheExtensionWhenShortening() { + val name = BrowserDownloadRules.safeFileName("photo.jpg" + "x".repeat(300) + ".apk")!! + assertEquals(BrowserDownloadRules.MAX_NAME_LENGTH, name.length) + assertTrue(name.endsWith(".apk")) + assertTrue(BrowserDownloadRules.isRisky(name)) + } + @Test fun cooldownHoldsOffOnlyTheAnsweredOrigin() { val clock = TestTimeSource() - val cooldown = DownloadCooldown(1.seconds, clock) + val cooldown = DownloadCooldown(1.seconds, 1.minutes, clock) assertTrue(cooldown.allows("https://a.example")) - cooldown.answered("https://a.example") + cooldown.answered("https://a.example", allowed = true) assertFalse(cooldown.allows("https://a.example")) assertTrue(cooldown.allows("https://b.example")) @@ -96,4 +132,30 @@ class BrowserDownloadRulesTest { clock += 1.milliseconds assertTrue(cooldown.allows("https://a.example")) } + + @Test + fun cooldownDoublesOnEachRefusalAndResetsOnSave() { + val clock = TestTimeSource() + val cooldown = DownloadCooldown(1.seconds, 4.seconds, clock) + val site = "https://a.example" + + cooldown.answered(site, allowed = false) // 1s + clock += 1.seconds + assertTrue(cooldown.allows(site)) + cooldown.answered(site, allowed = false) // 2s + clock += 1.seconds + assertFalse(cooldown.allows(site)) + clock += 1.seconds + assertTrue(cooldown.allows(site)) + cooldown.answered(site, allowed = false) // 4s + cooldown.answered(site, allowed = false) // capped at 4s + clock += 3.seconds + assertFalse(cooldown.allows(site)) + clock += 1.seconds + assertTrue(cooldown.allows(site)) + + cooldown.answered(site, allowed = true) // back to 1s + clock += 1.seconds + assertTrue(cooldown.allows(site)) + } } diff --git a/commonsUI/src/commonMain/composeResources/values/strings.xml b/commonsUI/src/commonMain/composeResources/values/strings.xml index eb937376ff..b7db85531f 100644 --- a/commonsUI/src/commonMain/composeResources/values/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values/strings.xml @@ -6360,4 +6360,5 @@ Download this file? This file type can run code. Check that the name matches what you meant to get. Save + From %1$s 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 09d52f47a1..22ea19d944 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 @@ -38,6 +38,11 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -49,17 +54,21 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.browser_pill_cancel +import com.vitorpamplona.amethyst.commons.resources.browser_pill_download_from 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 kotlinx.coroutines.delay +import kotlin.time.Duration.Companion.milliseconds /** * 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. + * WebView-reported origin, never a page-supplied field), the exact file name that would be saved, its + * size ([sizeBytes] is -1 when the server didn't say) and, for a network file, the host it comes from + * ([sourceHost]) when that isn't [host]. Nothing is saved until the user taps Save. */ @Composable fun DownloadPromptCard( @@ -67,10 +76,18 @@ fun DownloadPromptCard( security: BrowserChrome.Security, fileName: String, sizeBytes: Long, + sourceHost: String?, risky: Boolean, onAllow: () -> Unit, onDeny: () -> Unit, ) { + // Save ignores taps for a moment after the card appears, so a tap meant for whatever was under the + // finger (say, a page dialog's button the page just replaced with this card) can't land on it. + var armed by remember(fileName) { mutableStateOf(false) } + LaunchedEffect(fileName) { + delay(ARM_DELAY) + armed = true + } PageCard { if (host != null) { OriginBadge(host, security) @@ -90,10 +107,16 @@ fun DownloadPromptCard( } Spacer(Modifier.width(14.dp)) Column { - // 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) + // One line, cut in the middle: the extension is what tells "invoice.pdf" from "invoice.pdf.apk". + Text(fileName, style = MaterialTheme.typography.titleSmall, maxLines = 1, overflow = TextOverflow.MiddleEllipsis) + val details = + listOfNotNull( + sizeBytes.takeIf { it >= 0 }?.let { formatBytes(it) }, + // A network file can come from another host than the page asking (an ad frame, a CDN). + sourceHost?.takeUnless { it.equals(host, ignoreCase = true) }?.let { stringRes(Res.string.browser_pill_download_from, it) }, + ) + if (details.isNotEmpty()) { + Text(details.joinToString(" · "), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.MiddleEllipsis) } } } @@ -116,7 +139,9 @@ fun DownloadPromptCard( Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { TextButton(onClick = onDeny) { Text(stringRes(Res.string.browser_pill_cancel)) } Spacer(Modifier.width(8.dp)) - Button(onClick = onAllow) { Text(stringRes(Res.string.browser_pill_download_save)) } + Button(onClick = onAllow, enabled = armed) { Text(stringRes(Res.string.browser_pill_download_save)) } } } } + +private val ARM_DELAY = 500.milliseconds 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 ad595fd2fb..2950d2c9d4 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserChromeHost.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserChromeHost.kt @@ -141,6 +141,7 @@ class BrowserChromeHost( val security: BrowserChrome.Security, val fileName: String, val sizeBytes: Long, + val sourceHost: String?, val risky: Boolean, val answer: (allow: Boolean) -> Unit, ) @@ -371,6 +372,7 @@ class BrowserChromeHost( security = pending.security, fileName = pending.fileName, sizeBytes = pending.sizeBytes, + sourceHost = pending.sourceHost, risky = pending.risky, onAllow = { downloadPrompt = null 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 31a7d57ae1..1676f074d2 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserDownloads.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserDownloads.kt @@ -30,6 +30,7 @@ import android.provider.MediaStore import android.webkit.MimeTypeMap import android.webkit.URLUtil import android.widget.Toast +import androidx.core.net.toUri import com.vitorpamplona.amethyst.commons.browser.BrowserDownloadRules import com.vitorpamplona.quartz.utils.Log import okhttp3.Request @@ -37,6 +38,7 @@ import java.io.File import java.io.OutputStream import java.util.concurrent.Executors import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean import com.vitorpamplona.amethyst.commons.R as CommonsR /** @@ -67,22 +69,27 @@ object BrowserDownloads { 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:*?\"<>|]") - /** - * 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. + * A page-initiated download, resolved to what its consent card shows: [save] stores one file named + * [fileName], typed by that name's extension (so the system can't append a different one), and until + * it runs no byte has been fetched or written. [save] runs at most once, however often it is called. */ class DownloadOffer internal constructor( val fileName: String, - /** The exact size in bytes, or -1 when the server didn't say. */ + /** The size in bytes: exact for inline data, the server's advertised length otherwise; -1 when unknown. */ val sizeBytes: Long, + /** The host a network download is fetched from (it can differ from the page's), or null for inline data. */ + val sourceHost: String?, private val start: (Context) -> Unit, ) { + private val started = AtomicBoolean(false) + /** 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) + fun save(context: Context) { + if (started.compareAndSet(false, true)) start(context.applicationContext) + } } /** @@ -105,7 +112,7 @@ object BrowserDownloads { return } if (!isHttp(url)) return - startNetworkDownload(app, url, networkName(url, contentDisposition, mimeType), userAgent, mimeType, cookie, proxyPort) + startNetworkDownload(app, url, networkName(url, contentDisposition, mimeType), userAgent, cookie, proxyPort) } /** @@ -134,8 +141,8 @@ object BrowserDownloads { } val name = networkName(url, contentDisposition, mimeType) onReady( - DownloadOffer(name, contentLength.takeIf { it > 0 } ?: -1L) { app -> - startNetworkDownload(app, url, name, userAgent, mimeType, cookie, proxyPort) + DownloadOffer(name, contentLength.takeIf { it > 0 } ?: -1L, url.toUri().host) { app -> + startNetworkDownload(app, url, name, userAgent, cookie, proxyPort) }, ) } @@ -151,10 +158,17 @@ object BrowserDownloads { onReady: (DownloadOffer?) -> Unit, ) { decoder.execute { + // Throwable, not Exception: an OutOfMemoryError here must refuse the download, not kill the + // process (and every tab in it) or leave the caller waiting on a callback that never comes. 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) } + try { + BrowserDownloadRules.decodeDataUrl(dataUrl)?.let { data -> + val name = safeName(suggestedName, data.mimeType) + DownloadOffer(name, data.bytes.size.toLong(), null) { app -> writeInBackground(app, name, data.bytes) } + } + } catch (e: Throwable) { + Log.w(TAG, "Inline download refused", e) + null } main.post { onReady(offer) } } @@ -173,7 +187,6 @@ object BrowserDownloads { url: String, name: String, userAgent: String?, - mimeType: String?, cookie: String?, proxyPort: Int, ) { @@ -200,8 +213,7 @@ object BrowserDownloads { .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) } } + write(app, name) { out -> response.body.byteStream().use { it.copyTo(out) } } } }.onFailure { Log.w(TAG, "Download failed for $url", it) } .getOrDefault(false) @@ -209,26 +221,26 @@ object BrowserDownloads { } } - /** Saves a `data:` URL (`data:[mime][;base64],payload`) the user asked for directly. */ - fun saveDataUrl( - context: Context, + /** Saves a `data:` URL (`data:[mime][;base64],payload`) the user asked for directly, decoded off the main thread. */ + private fun saveDataUrl( + app: Context, dataUrl: String, suggestedName: String?, ) { - val data = BrowserDownloadRules.decodeDataUrl(dataUrl) ?: return - val mime = data.mimeType ?: "application/octet-stream" - writeInBackground(context.applicationContext, safeName(suggestedName, mime), mime, data.bytes) + decoder.execute { + val data = runCatching { BrowserDownloadRules.decodeDataUrl(dataUrl) }.getOrNull() ?: return@execute + writeInBackground(app, safeName(suggestedName, data.mimeType), data.bytes) + } } private fun writeInBackground( app: Context, name: String, - mimeType: String?, bytes: ByteArray, ) { if (bytes.size > MAX_INLINE_BYTES) return io.execute { - val ok = runCatching { write(app, name, mimeType) { it.write(bytes) } }.getOrDefault(false) + val ok = runCatching { write(app, name) { it.write(bytes) } }.getOrDefault(false) toast(app, app.getString(if (ok) CommonsR.string.browser_download_saved else CommonsR.string.browser_download_failed, name)) } } @@ -241,7 +253,6 @@ object BrowserDownloads { private fun write( context: Context, name: String, - mimeType: String?, body: (OutputStream) -> Unit, ): Boolean { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { @@ -249,7 +260,9 @@ object BrowserDownloads { val values = ContentValues().apply { put(MediaStore.Downloads.DISPLAY_NAME, name) - mimeType?.let { put(MediaStore.Downloads.MIME_TYPE, it) } + // Typed by the approved name alone: a page- or server-supplied type that disagrees with + // the extension would make MediaStore append its own ("invoice.pdf" -> "invoice.pdf.apk"). + put(MediaStore.Downloads.MIME_TYPE, mimeTypeOf(name)) put(MediaStore.Downloads.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS) put(MediaStore.Downloads.IS_PENDING, 1) } @@ -269,23 +282,28 @@ object BrowserDownloads { return true } - /** A plain filename: the page's suggestion without path parts, else "download" + the MIME's extension. */ + /** + * The file name to save under: the page's suggestion made safe ([BrowserDownloadRules.safeFileName]), + * else "download" + the MIME's extension. + */ private fun safeName( suggested: String?, mimeType: String?, ): String { - val base = - suggested - ?.substringAfterLast('/') - ?.substringAfterLast('\\') - ?.replace(unsafeNameChars, "_") - ?.trim() - ?.takeIf { it.isNotEmpty() && it != "." && it != ".." } - if (base != null) return base.take(120) + BrowserDownloadRules.safeFileName(suggested)?.let { return it } val ext = mimeType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } return if (ext != null) "download.$ext" else "download" } + /** The MIME type [name]'s extension implies, or octet-stream, which MediaStore stores under the name as given. */ + private fun mimeTypeOf(name: String): String = + name + .substringAfterLast('.', "") + .lowercase() + .takeIf { it.isNotEmpty() } + ?.let { MimeTypeMap.getSingleton().getMimeTypeFromExtension(it) } + ?: "application/octet-stream" + private fun toast( context: Context, text: 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 d724037235..38d2c5503b 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt @@ -365,12 +365,13 @@ class NappletBrowserActivity : ComponentActivity() { wv.webChromeClient = BrowserChromeClient() wv.setFindListener { active, total, _ -> chrome?.setFindResult(active, total) } wv.setDownloadListener { url, userAgent, contentDisposition, mimeType, contentLength -> + // The page's origin; a fresh popup still on about:blank has none, so name the file's own. val origin = chrome ?.ui ?.chrome ?.url - ?.let(BrowserChrome::originOf) ?: return@setDownloadListener + ?.let(BrowserChrome::originOf) ?: BrowserChrome.originOf(url) ?: 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) @@ -971,11 +972,14 @@ class NappletBrowserActivity : ComponentActivity() { } /** - * 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. + * Whether [origin] may put a download card up now: never over another page prompt (so a page can't swap + * the name under the user's finger, or pop the card where a dialog's button just was), never while an + * offer is still being prepared (so a page can't queue decodes), and not within its cooldown. */ - private fun canOfferDownload(origin: String) = chrome?.downloadPrompt == null && !preparingDownload && downloadCooldown.allows(origin) + private fun canOfferDownload(origin: String): Boolean { + val host = chrome ?: return false + return host.downloadPrompt == null && host.dialog == null && host.permissionPrompt == null && !preparingDownload && downloadCooldown.allows(origin) + } /** Runs [prepare] (which answers exactly once, on the main thread) and shows the offer it produces. */ private fun prepareDownloadOffer( @@ -1002,9 +1006,10 @@ class NappletBrowserActivity : ComponentActivity() { security = BrowserChrome.security(host.ui.chrome), fileName = offer.fileName, sizeBytes = offer.sizeBytes, + sourceHost = offer.sourceHost, risky = offer.risky, ) { allowed -> - downloadCooldown.answered(origin) + downloadCooldown.answered(origin, allowed) if (allowed) offer.save(this) } } 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 0cb87270e3..7b42f651d9 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserContract.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserContract.kt @@ -188,7 +188,7 @@ object NappletBrowserContract { * 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 + * page-supplied field), the sanitized [KEY_DOWNLOAD_NAME], [KEY_DOWNLOAD_SIZE], [KEY_DOWNLOAD_SOURCE], 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. */ @@ -197,6 +197,9 @@ object NappletBrowserContract { /** Client → provider: the user's answer to [MSG_DOWNLOAD_CONSENT]: [KEY_DOWNLOAD_ID] + [KEY_DOWNLOAD_ALLOWED]. */ const val MSG_DOWNLOAD_CONSENT_RESULT = 35 + /** Provider → client: withdraw the [MSG_DOWNLOAD_CONSENT] card [KEY_DOWNLOAD_ID] (its tab closed). */ + const val MSG_DOWNLOAD_CANCEL = 36 + const val KEY_CAN_GO_FORWARD = "canGoForward" const val KEY_FIND_QUERY = "findQuery" const val KEY_FIND_FORWARD = "findForward" @@ -228,6 +231,9 @@ object NappletBrowserContract { /** The size in bytes the consented download will write, or -1 when the server didn't say. */ const val KEY_DOWNLOAD_SIZE = "downloadSize" + /** The host a network download is fetched from (absent for inline data), shown when it isn't the page's. */ + const val KEY_DOWNLOAD_SOURCE = "downloadSource" + /** Whether [KEY_DOWNLOAD_NAME]'s extension is one the user should double-check (installer/script-like). */ const val KEY_DOWNLOAD_RISKY = "downloadRisky" 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 c8e0697818..dcd898d7df 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt @@ -316,8 +316,9 @@ class NappletBrowserService : Service() { val data = msg.data ?: return true 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) + val allowed = data.getBoolean(NappletBrowserContract.KEY_DOWNLOAD_ALLOWED, false) + tab.downloadCooldown.answered(pending.origin, allowed) + if (allowed) pending.offer.save(this) } NappletBrowserContract.MSG_EXIT_FULLSCREEN -> tabFor(msg)?.let { exitFullscreen(it) } NappletBrowserContract.MSG_RELOAD -> tabFor(msg)?.webView?.reload() @@ -470,8 +471,12 @@ class NappletBrowserService : Service() { // Release a picker still waiting on this surface before its WebView goes away. tab.fileChooser.cancel() cancelPending(tab) - // An unanswered download card dies with its tab: nothing is saved, and its bytes are freed. - pendingDownloads.values.removeAll { it.sessionId == sessionId } + // An unanswered download card dies with its tab: nothing is saved, its bytes are freed, and the + // client is told so its card doesn't linger with a Save that does nothing. + pendingDownloads.entries.filter { it.value.sessionId == sessionId }.forEach { (id, _) -> + pendingDownloads.remove(id) + sendToClient(tab, NappletBrowserContract.MSG_DOWNLOAD_CANCEL) { putLong(NappletBrowserContract.KEY_DOWNLOAD_ID, id) } + } tab.customViewCallback?.onCustomViewHidden() tab.customViewCallback = null tab.customView = null @@ -489,7 +494,8 @@ class NappletBrowserService : Service() { wv.webChromeClient = BrowserChromeClient(tab) wv.setDownloadListener { url, userAgent, contentDisposition, mimeType, contentLength -> if (tab == null) return@setDownloadListener - val origin = wv.url?.let(BrowserChrome::originOf) ?: return@setDownloadListener + // The page's origin; a fresh popup still on about:blank has none, so name the file's own. + val origin = wv.url?.let(BrowserChrome::originOf) ?: BrowserChrome.originOf(url) ?: 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) @@ -1056,14 +1062,19 @@ class NappletBrowserService : Service() { } /** - * 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. + * Whether [origin] may put a download card up in [tab] now: never over another of the tab's page prompts + * (so a page can't swap the name under the user's finger, or pop the card where a dialog's button just + * was), never while an offer is still being prepared (so a page can't queue decodes), and not within + * its cooldown. */ private fun canOfferDownload( tab: BrowserTab, origin: String, - ) = !tab.preparingDownload && pendingDownloads.values.none { it.sessionId == tab.sessionId } && tab.downloadCooldown.allows(origin) + ) = !tab.preparingDownload && + tab.jsDialogs.isEmpty() && + tab.permissionRequests.isEmpty() && + 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( @@ -1095,6 +1106,7 @@ class NappletBrowserService : Service() { putString(NappletBrowserContract.KEY_BROWSER_ORIGIN, origin) putString(NappletBrowserContract.KEY_DOWNLOAD_NAME, offer.fileName) putLong(NappletBrowserContract.KEY_DOWNLOAD_SIZE, offer.sizeBytes) + putString(NappletBrowserContract.KEY_DOWNLOAD_SOURCE, offer.sourceHost) putBoolean(NappletBrowserContract.KEY_DOWNLOAD_RISKY, offer.risky) } if (sent) pendingDownloads[id] = PendingDownload(tab.sessionId, origin, offer)