c1bbf89689ab
fix: Contain favicon cache paths (PB-26-033, v2.14.94)
- time
- 2026-09-02 11:38 UTC · 1 month ago
- machine
- koh2
- parent
- a8190173b084
↓ peach-android-c1bbf896.tar.gzatlas at this save
files changed3 files
VERSION+1 −1app/src/main/java/com/peachpasswords/ui/components/Favicons.kt+36 −9app/src/test/java/com/peachpasswords/ui/components/FaviconsTest.kt+31 −0app/src/main/java/com/peachpasswords/ui/components/Favicons.kt+36 −9
@@ -3,9 +3,12 @@33 import android.content.Context44 import android.graphics.Bitmap55 import android.graphics.BitmapFactory6+import com.peachpasswords.shared.extractDomain67 import dagger.hilt.android.qualifiers.ApplicationContext78 import java.io.File9+import java.net.IDN810 import java.util.Collections11+import java.util.Locale912 import java.util.concurrent.ConcurrentHashMap1013 import java.util.concurrent.TimeUnit1114 import javax.inject.Inject@@ -48,11 +51,11 @@4851 private val _revision = MutableStateFlow(0)4952 val revision: StateFlow<Int> = _revision5053 51- fun fileFor(domain: String): File = File(dir, "$domain.png")52- private fun missFileFor(domain: String): File = File(dir, "$domain.miss")54+ private fun fileFor(domain: String): File? = faviconCacheFile(dir, domain, ".png")55+ private fun missFileFor(domain: String): File? = faviconCacheFile(dir, domain, ".miss")5356 5457 fun bitmap(domain: String, sizePx: Int): Bitmap? = runCatching {55- val file = fileFor(domain)58+ val file = fileFor(domain) ?: return null5659 if (!file.exists()) return null5760 val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }5861 BitmapFactory.decodeFile(file.absolutePath, bounds)@@ -64,7 +67,7 @@6467 6568 /** Fire-and-forget fetches for every domain missing from the cache. */6669 fun prefetch(domains: List<String>) {67- for (domain in domains.distinct()) {70+ for (domain in domains.mapNotNull(::canonicalFaviconDomain).distinct()) {6871 if (!needsFetch(domain) || !inFlight.add(domain)) continue6972 scope.launch {7073 try {@@ -77,19 +80,21 @@7780 }7881 7982 private fun needsFetch(domain: String): Boolean {80- if (fileFor(domain).exists()) return false81- val miss = missFileFor(domain)83+ if (fileFor(domain)?.exists() == true) return false84+ val miss = missFileFor(domain) ?: return false8285 return !miss.exists() || System.currentTimeMillis() - miss.lastModified() >= MISS_RETRY_MS8386 }8487 8588 internal suspend fun fetch(domain: String) = withContext(Dispatchers.IO) {86- val bytes = fetchUrl("https://icons.duckduckgo.com/ip3/$domain.ico")89+ val canonicalDomain = canonicalFaviconDomain(domain) ?: return@withContext90+ val bytes = fetchUrl("https://icons.duckduckgo.com/ip3/$canonicalDomain.ico")8791 ?.let(::decodeOrUnwrapIco)88- val saved = bytes != null && saveScaledPng(bytes, fileFor(domain))92+ val target = fileFor(canonicalDomain) ?: return@withContext93+ val saved = bytes != null && saveScaledPng(bytes, target)8994 if (saved) {9095 _revision.value++9196 } else {92- runCatching { missFileFor(domain).createNewFile() }97+ runCatching { missFileFor(canonicalDomain)?.createNewFile() }9398 }9499 }95100 @@ -119,6 +124,28 @@119124 const val MAX_ICON_BYTES = 256L * 1024L120125 const val MISS_RETRY_MS = 7L * 24 * 60 * 60 * 1000121126 }127+}128+129+/** Canonical registrable DNS host accepted by the favicon network/cache boundary. */130+internal fun canonicalFaviconDomain(raw: String): String? {131+ if (raw.isEmpty() || raw != raw.trim()) return null132+ if (raw.any { it <= '\u001F' || it == '\u007F' || it in "/\\@?#:" }) return null133+ if (raw.endsWith('.')) return null134+ val ascii = runCatching { IDN.toASCII(raw, IDN.USE_STD3_ASCII_RULES) }135+ .getOrNull()136+ ?.lowercase(Locale.ROOT)137+ ?: return null138+ if (ascii.length > 253 || ascii.split('.').any { it.isEmpty() || it.length > 63 }) return null139+ return ascii.takeIf { extractDomain(it) == it }140+}141+142+/** Builds a contained direct-child cache path only after host validation. */143+internal fun faviconCacheFile(directory: File, rawDomain: String, suffix: String): File? {144+ if (suffix != ".png" && suffix != ".miss") return null145+ val domain = canonicalFaviconDomain(rawDomain) ?: return null146+ val base = runCatching { directory.canonicalFile }.getOrNull() ?: return null147+ val target = runCatching { File(base, "$domain$suffix").canonicalFile }.getOrNull() ?: return null148+ return target.takeIf { it.parentFile == base }122149 }123150 124151 /**app/src/test/java/com/peachpasswords/ui/components/FaviconsTest.kt+31 −0
@@ -1,6 +1,8 @@11 package com.peachpasswords.ui.components22 3+import java.io.File34 import org.junit.Assert.assertArrayEquals5+import org.junit.Assert.assertEquals46 import org.junit.Assert.assertNull57 import org.junit.Test68 @@ -23,5 +25,34 @@2325 icoBmp[2] = 12426 icoBmp[18] = 222527 assertNull(decodeOrUnwrapIco(icoBmp))28+ }29+30+ @Test fun canonical_cache_file_stays_inside_favicon_directory() {31+ val dir = File("/tmp/peach-favicon-test")32+33+ assertEquals(34+ File(dir, "example.com.png").canonicalPath,35+ faviconCacheFile(dir, "EXAMPLE.com", ".png")?.path,36+ )37+ assertEquals(38+ File(dir, "xn--bcher-kva.de.miss").canonicalPath,39+ faviconCacheFile(dir, "bücher.de", ".miss")?.path,40+ )41+ }42+43+ @Test fun invalid_host_material_never_becomes_a_cache_path() {44+ listOf(45+ "../vault",46+ "example.com/../../vault",47+ "example.com\\..\\vault",48+ "/absolute.example",49+ "example.com?x=y",50+ "[email protected]",51+ "example.com:443",52+ "localhost",53+ "127.0.0.1",54+ "exa\u0000mple.com",55+ "evil.com.",56+ ).forEach { assertNull(it, faviconCacheFile(File("/tmp/cache"), it, ".png")) }2657 }2758 }