mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E3cqjbY7FX2zrkGXCVXpFD
This commit is contained in:
+3
-3
@@ -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,
|
||||
|
||||
+1
-1
@@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
+1
-1
@@ -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").
|
||||
|
||||
+186
@@ -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 `<a download>`, 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:[<mime>][;param=v…][;base64],<payload>`, 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<String, TimeMark>()
|
||||
|
||||
/** 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()
|
||||
}
|
||||
}
|
||||
+99
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -2997,7 +2997,4 @@
|
||||
<string name="unable_to_create_a_lightning_invoice_before_sending_the_zap_the_receiver_s_lightning_wallet_sent_the_following_error_with_user">No se pudo crear una factura de Lightning. Mensaje de %1$s: %2$s.</string>
|
||||
<string name="browser_address_hint">Buscar o introducir dirección</string>
|
||||
<string name="napplet_untitled">NApplet sin título</string>
|
||||
<string name="browser_pill_download_title">¿Descargar este archivo?</string>
|
||||
<string name="browser_pill_download_risky">Este tipo de archivo puede ejecutar código. Comprueba que el nombre coincide con lo que querías obtener.</string>
|
||||
<string name="browser_pill_download_save">Guardar</string>
|
||||
</resources>
|
||||
|
||||
+10
-16
@@ -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"
|
||||
}
|
||||
|
||||
+3
-3
@@ -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?,
|
||||
|
||||
-226
@@ -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<String>()
|
||||
private val lastAnsweredAt = HashMap<String, HashMap<String, Long>>()
|
||||
}
|
||||
+111
-157
@@ -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 `<a download>` 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)
|
||||
|
||||
+3
-5
@@ -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 `<a download>` 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 =
|
||||
|
||||
+52
-68
@@ -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<String, String>()
|
||||
private val pendingByOrigin = mutableMapOf<String, MutableList<Message>>()
|
||||
private val mintInFlight = mutableSetOf<String>()
|
||||
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 `<a download>` 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). */
|
||||
|
||||
+7
-8
@@ -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, `<a download>`, 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). */
|
||||
|
||||
+71
-82
@@ -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<String, MutableList<Message>>()
|
||||
val mintInFlight = mutableSetOf<String>()
|
||||
|
||||
// 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<PermissionRequest, Long>()
|
||||
|
||||
// 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<Long, (Boolean) -> Unit>()
|
||||
private val consentTabs = mutableMapOf<Long, String>()
|
||||
// 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<Long, PendingDownload>()
|
||||
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 `<a download>`
|
||||
* 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(
|
||||
|
||||
Reference in New Issue
Block a user