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
3 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 −0
VERSION+1 −1
@@ -1 +1 @@1-2.14.931+2.14.94

view file

app/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 /**

view file

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 }

view file