From cbdba302ce5a5efe95b9006cbaf06e91ec2deeb7 Mon Sep 17 00:00:00 2001 From: Kayos Date: Mon, 25 May 2026 14:29:32 -0700 Subject: [PATCH] =?UTF-8?q?vc=3D38:=20round-3=20audit-fix=20sprint=20?= =?UTF-8?q?=E2=80=94=209=20HIGH=20+=207=20MED?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three round-3 Opus audits ran on vc=37. NO new CRITs (round-2 work held) but real new HIGHs — several were vc=37 own-goals. HIGH R3-1 recordAllWatches dropped import on capacity=0. Old: when watches store hit MAX_WATCHES (50), capacity=0, the whole import was discarded silently. New: build fresh import list capped at MAX_WATCHES, then combine + take(MAX_WATCHES) so imports always land (truncating oldest current entries). Also: skip SP write when next === before (no-op import on already-saturated store no longer thrashes disk). New recordAllSearches with same shape — round-3 CVE MED-6: importHistory was per-row recordSearch. R3-2 / CVE-2 SubscriptionsStore.addAll counter race. The vc=36 size-delta fix snapshot `cur = _subs.value` BEFORE updateAndGet, so a concurrent toggle inflated `added`. New: AtomicInteger reset at the start of each lambda re-run, counted by checking each ref against the pre-image inside the CAS. Exactly the additions THIS call made. R3-3 refresh() empty-channels didn't cancel inFlight. Cancel moved to the top of refresh() unconditionally so a refresh on the prior sub set is killed before the empty branch clears + wipes disk. clearInMemoryCache also cancels inFlight — without it, a cache-disable flip during a refresh could see fetchChannelInto re-populate the just-cleared map. R3-4 Non-atomic `_ui.value = it.copy(...)` at init hydrate path and clearInMemoryCache. Replaced with `_ui.update {}` for atomicity vs concurrent refresh writes. init's lastFetchedAt write now uses maxOf so it never regresses past a fresh refresh value. CVE-1 state.error rendered raw UniFFI/Rust error strings to UI — NetworkError::Recaptcha { url } embeds full signed googlevideo URL. User screenshots a "reCAPTCHA at " banner → leak. All four VMs (Channel/Detail/Feed/Search) now scrub via LogDump.scrubLine before storing. CVE-3 pruneCacheToSubs in init can clobber concurrent fetchChannelInto writes. init's putAll → putIfAbsent so a fresh entry from a parallel refresh isn't overwritten with disk-stale data. CVE-4 SIGNED_PARAM_RE over-redacted short tokens (`\bn=` matched `n=42` counters from any wrapped lib). Split into SIGNED_PARAM_LONG_RE (signature/sparams/lsig/cpn/expire/ pot/sig/key — match anywhere) and SIGNED_PARAM_SHORT_RE (n/mn/ms/mo/pl/ip/ei — require `[?&]` immediately before). Func-HIGH-1 refresh() swallowed CancellationException as a user-visible error. Spam-tapping Refresh produced a "refresh failed: StandaloneCoroutineCancelled" banner. Re-throw CancellationException; catch only real errors. MED R3-5 reactiveFilter did N `.lowercase()` allocations per keystroke. Switched to contains(ignoreCase = true) — zero allocations. CVE-MED-5 FileProvider cache-path was "." (whole cacheDir, including SettingsImport workdirs). Narrowed to "logs/"; LogDump.capture now writes to cacheDir/logs/ to match. CVE-MED-7 Downloader.Request.setTitle was the raw title (bidi-override / control chars possible). Switched to safeTitle. CVE-MED-8 Rust hello_from_rust value-log scrubbed to name_len. Func-LOW-4 recordAllWatches skip-write-on-no-change (`next !== before`). Deferred to a follow-up (not user-facing this ship): R3-MED-6 — Settings setMaxResolution/setThemeMode/setCacheEnabled not atomic via updateAndGet. Inconsistent with toggle() but the Switch UI throttles enough that no real race. R3-MED-8 — Minibar play-button reads live controller.isPlaying instead of listener-tracked. One-frame oscillation on super-fast double-tap. R3-LOW — collectAsState vs collectAsStateWithLifecycle drift. Func-LOW-6 — refreshIfStale isActive check is TOCTOU on a non-existent multi-threaded call surface (LaunchedEffect + button are both Main). --- buildSrc/src/main/kotlin/ProjectConfig.kt | 4 +- rust/strawcore/src/lib.rs | 5 +- .../com/sulkta/straw/data/HistoryStore.kt | 64 ++++++++++++++----- .../sulkta/straw/data/SubscriptionsStore.kt | 21 +++--- .../straw/feature/channel/ChannelViewModel.kt | 8 ++- .../feature/dataimport/SettingsImport.kt | 9 ++- .../feature/detail/VideoDetailViewModel.kt | 4 +- .../straw/feature/download/Downloader.kt | 6 +- .../feature/feed/SubscriptionFeedViewModel.kt | 55 ++++++++++++---- .../straw/feature/search/SearchViewModel.kt | 12 ++-- .../kotlin/com/sulkta/straw/util/LogDump.kt | 31 ++++++--- strawApp/src/main/res/xml/file_paths.xml | 9 ++- 12 files changed, 167 insertions(+), 61 deletions(-) diff --git a/buildSrc/src/main/kotlin/ProjectConfig.kt b/buildSrc/src/main/kotlin/ProjectConfig.kt index 4e4aea201..7b8d1dc1d 100644 --- a/buildSrc/src/main/kotlin/ProjectConfig.kt +++ b/buildSrc/src/main/kotlin/ProjectConfig.kt @@ -55,6 +55,6 @@ const val NEWPIPE_APPLICATION_ID_NEW = "net.newpipe.app" // vc=19 / 0.1.0-AE — rust pipeline cutover. Extraction via // strawcore-core (Sulkta-Coop/strawcore) via the UniFFI wrapper; no // NewPipeExtractor in the runtime path. -const val STRAW_VERSION_CODE = 37 -const val STRAW_VERSION_NAME = "0.1.0-AW" +const val STRAW_VERSION_CODE = 38 +const val STRAW_VERSION_NAME = "0.1.0-AX" const val STRAW_APPLICATION_ID = "com.sulkta.straw" diff --git a/rust/strawcore/src/lib.rs b/rust/strawcore/src/lib.rs index 1ca13d06c..2329d55ce 100644 --- a/rust/strawcore/src/lib.rs +++ b/rust/strawcore/src/lib.rs @@ -39,9 +39,12 @@ pub fn init_logging() { } /// Smoke-test entry point — round-trip a string through JNI. +/// Used during the initial UniFFI bring-up; kept for future smoke +/// debugging. Logs shape only — the `name` value never hits logcat +/// because a future caller might pass a real user-supplied string. #[uniffi::export] pub fn hello_from_rust(name: String) -> String { - log::info!("hello_from_rust called with name={}", name); + log::info!("hello_from_rust called name_len={}", name.len()); format!( "hello {} from rust 🦀 (strawcore v{})", name, diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/data/HistoryStore.kt b/strawApp/src/main/kotlin/com/sulkta/straw/data/HistoryStore.kt index 8e944d7c6..30aca3d0c 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/data/HistoryStore.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/data/HistoryStore.kt @@ -61,36 +61,70 @@ class HistoryStore(context: Context) { * oldest→newest. Single SP write — vc=34 audit flagged the * per-row recordWatch in importHistory as a write-storm vector. * - * O(N) on input size, not O(N²). The vc=35 first cut had an - * `add(0, item)` inside a loop walking up to MAX_HISTORY_IMPORT - * (~50k) entries — ArrayList shift over `merged.size` each step, - * a billion+ shifts in the worst case for a final `take(50)` that - * discards 99.9% of the work. Round-2 audit CRIT-R2. + * Walks input newest-first (input is fed oldest-first), filters + * blanks + already-seen videoIds, prepends to current, then takes + * MAX_WATCHES. Imports WIN over older current entries when the + * store is at the cap — the vc=37 first cut silently discarded + * the whole import in that case (round-3 audit HIGH-1). * - * New shape: walk input newest-first (reversed; SettingsImport - * fed oldest-first), filter blanks + already-seen videoIds, take - * up to MAX_WATCHES, prepend to current. Done in one pass with - * the capped output never exceeding MAX_WATCHES. + * Skips the SP write when the resulting list is identical (by + * reference equality after updateAndGet's no-op return) so a + * spam-import on an already-up-to-date store doesn't thrash disk. */ fun recordAllWatches(items: List) { if (items.isEmpty()) return + val before = _watches.value val next = _watches.updateAndGet { current -> val seen = HashSet(current.size + items.size) current.forEach { seen.add(it.videoId) } - val capacity = (MAX_WATCHES - current.size).coerceAtLeast(0) - if (capacity == 0) return@updateAndGet current - val fresh = ArrayList(capacity) - // Walk newest-first; stop as soon as we have capacity. + // Build the import list newest-first. Capped at + // MAX_WATCHES on its own so we don't over-allocate + // even on a 50k-row hostile export. + val fresh = ArrayList(MAX_WATCHES) val it = items.listIterator(items.size) - while (it.hasPrevious() && fresh.size < capacity) { + while (it.hasPrevious() && fresh.size < MAX_WATCHES) { val item = it.previous() if (item.videoId.isBlank()) continue if (!seen.add(item.videoId)) continue fresh.add(item) } + if (fresh.isEmpty()) return@updateAndGet current + // Combine + cap. take() truncates older `current` entries + // when we'd exceed MAX_WATCHES, so imports always land. (fresh + current).take(MAX_WATCHES) } - sp.edit().putString(KEY_WATCHES, json.encodeToString(next)).apply() + if (next !== before) { + sp.edit().putString(KEY_WATCHES, json.encodeToString(next)).apply() + } + } + + /** + * Bulk import for search history. Same pattern as + * recordAllWatches — single SP write regardless of input size. + * vc=37 round-3 audit CVE-MED-6: SettingsImport.importHistory was + * calling recordSearch per row, producing N SP writes on a + * potentially-100k-row import. + */ + fun recordAllSearches(queries: List) { + if (queries.isEmpty()) return + val before = _searches.value + val next = _searches.updateAndGet { current -> + val seen = HashSet(current.size + queries.size) + current.forEach { seen.add(it.lowercase()) } + val fresh = ArrayList(MAX_SEARCHES) + val it = queries.listIterator(queries.size) + while (it.hasPrevious() && fresh.size < MAX_SEARCHES) { + val q = it.previous().trim() + if (q.isEmpty()) continue + if (!seen.add(q.lowercase())) continue + fresh.add(q) + } + if (fresh.isEmpty()) return@updateAndGet current + (fresh + current).take(MAX_SEARCHES) + } + if (next !== before) { + sp.edit().putString(KEY_SEARCHES, json.encodeToString(next)).apply() + } } fun recordSearch(query: String) { diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/data/SubscriptionsStore.kt b/strawApp/src/main/kotlin/com/sulkta/straw/data/SubscriptionsStore.kt index 9675b005e..97a3bf994 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/data/SubscriptionsStore.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/data/SubscriptionsStore.kt @@ -73,22 +73,27 @@ class SubscriptionsStore(context: Context) { * caller can report an "added X" stat. */ fun addAll(refs: List): Int { - // Derive `added` from the size delta INSTEAD of incrementing a - // var inside updateAndGet's lambda — that lambda can re-run - // under CAS contention (a concurrent toggle from the channel - // screen during a 500-row import), and a var-outside-lambda - // accumulates across retries. vc=36 audit CVE HIGH-4. - val cur = _subs.value + // Count NEW refs by checking each input URL against the + // current state's pre-image inside the CAS lambda. Captures + // exactly the additions this call made — concurrent + // toggle()s that race the CAS don't inflate the count (vc=37 + // round-3 audit HIGH-2/CVE-2). The counter lives in an + // AtomicInteger so each lambda re-run resets it correctly. + val counter = java.util.concurrent.atomic.AtomicInteger(0) val next = _subs.updateAndGet { state -> + counter.set(0) val byUrl = state.associateBy { it.url }.toMutableMap() for (r in refs) { if (r.url.isBlank()) continue - if (r.url !in byUrl) byUrl[r.url] = r + if (r.url !in byUrl) { + byUrl[r.url] = r + counter.incrementAndGet() + } } byUrl.values.toList() } persist(next) - return next.size - cur.size + return counter.get() } fun clear() { diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelViewModel.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelViewModel.kt index 82a3d4245..9bec03cb2 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelViewModel.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelViewModel.kt @@ -59,7 +59,13 @@ class ChannelViewModel : ViewModel() { } catch (t: Throwable) { _ui.value = ChannelUiState( loading = false, - error = t.message ?: t.javaClass.simpleName, + // Scrub before storing — UniFFI/Rust exceptions + // can embed full signed googlevideo URLs in the + // message (NetworkError::Recaptcha { url }). vc=37 + // round-3 audit CVE-1. + error = com.sulkta.straw.util.LogDump.scrubLine( + t.message ?: t.javaClass.simpleName, + ), ) } } diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/dataimport/SettingsImport.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/dataimport/SettingsImport.kt index b35185009..8ff1c3aea 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/dataimport/SettingsImport.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/dataimport/SettingsImport.kt @@ -378,16 +378,21 @@ object SettingsImport { openDb(dbFile).use { db -> // Search history — feed oldest first so the store ends up with // the most-recent on top after its own dedup + take(MAX). + // Stage + bulk-write — vc=37 round-3 audit CVE MED-6: + // per-row recordSearch was N SP writes on potentially + // 100k+ rows. The SELECT also lacked a LIMIT; added now. + val stagedSearches = mutableListOf() db.rawQuery( - "SELECT search FROM search_history WHERE service_id=? ORDER BY creation_date ASC", + "SELECT search FROM search_history WHERE service_id=? ORDER BY creation_date ASC LIMIT 50000", arrayOf(YT_SERVICE_ID.toString()), ).use { c -> while (c.moveToNext()) { val q = c.getString(0) ?: continue - historyStore.recordSearch(q) + stagedSearches += q searchesSeen++ } } + historyStore.recordAllSearches(stagedSearches) // Watch history — newest first via stream_history.access_date, // joined to streams for the metadata we need. diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailViewModel.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailViewModel.kt index 61e8744b8..602a3fab4 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailViewModel.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailViewModel.kt @@ -189,7 +189,9 @@ class VideoDetailViewModel : ViewModel() { } catch (t: Throwable) { _ui.value = VideoDetailUiState( loading = false, - error = t.message ?: t.javaClass.simpleName, + error = com.sulkta.straw.util.LogDump.scrubLine( + t.message ?: t.javaClass.simpleName, + ), ) } } diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/download/Downloader.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/download/Downloader.kt index 6ce34060e..b7ac792c2 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/download/Downloader.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/download/Downloader.kt @@ -65,7 +65,11 @@ object Downloader { // returned below, so user-facing UX is unaffected. val req = runCatching { DownloadManager.Request(Uri.parse(url)) - .setTitle(title) + // Sanitized title — bidi-overrides and control chars + // in extractor output would otherwise render in + // DownloadsScreen's row title. vc=37 round-3 audit + // CVE MED-7. + .setTitle(safeTitle) .setDescription("Straw — ${kind.name.lowercase()}") .setNotificationVisibility(DownloadManager.Request.VISIBILITY_HIDDEN) .setVisibleInDownloadsUi(false) diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/SubscriptionFeedViewModel.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/SubscriptionFeedViewModel.kt index 2dffb22e9..5a87e73dd 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/SubscriptionFeedViewModel.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/SubscriptionFeedViewModel.kt @@ -25,6 +25,7 @@ import com.sulkta.straw.data.Settings import com.sulkta.straw.data.Subscriptions import com.sulkta.straw.feature.search.StreamItem import com.sulkta.straw.util.strawLogW +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.async @@ -78,14 +79,24 @@ class SubscriptionFeedViewModel : ViewModel() { if (!Settings.get().cacheEnabled.value) return@launch val saved = withContext(Dispatchers.IO) { FeedCache.get().load() } if (saved.isEmpty()) return@launch - channelCache.putAll(saved) + // putIfAbsent (not putAll) — refresh() may have started + // populating fresh entries during our IO suspension; we + // must not overwrite those with disk-stale values. + // vc=37 round-3 audit CVE-3. + saved.forEach { (url, entry) -> channelCache.putIfAbsent(url, entry) } val channels = Subscriptions.get().subs.value if (channels.isNotEmpty()) { pruneCacheToSubs(channels) - _ui.value = _ui.value.copy( - items = mergeFromCache(channels), - lastFetchedAt = saved.values.maxOfOrNull { it.fetchedAt } ?: 0L, - ) + val savedTs = saved.values.maxOfOrNull { it.fetchedAt } ?: 0L + // _ui.update so a concurrent refresh()'s state write + // doesn't race with this copy. vc=37 round-3 audit + // HIGH-4. Only advance lastFetchedAt — never regress. + _ui.update { + it.copy( + items = mergeFromCache(channels), + lastFetchedAt = maxOf(it.lastFetchedAt, savedTs), + ) + } } } } @@ -134,21 +145,23 @@ class SubscriptionFeedViewModel : ViewModel() { } fun refresh() { + // Cancel any in-flight refresh at the TOP — including before + // the empty-channels branch. Without this, a refresh that + // ran on a non-empty sub set could still be writing to + // channelCache when the user unsubscribes from the last + // channel; we'd clear() then immediately repopulate with + // phantom entries when the prior fetchChannelInto resolved. + // vc=37 round-3 audit HIGH-3. + inFlight?.cancel() val channels = Subscriptions.get().subs.value if (channels.isEmpty()) { - _ui.update { SubscriptionFeedUiState(loading = false, items = emptyList()) } + _ui.update { it.copy(loading = false, items = emptyList(), error = null) } channelCache.clear() - // Wipe disk too. vc=36 audit B1: previously the disk - // cache kept stale entries indefinitely after the user - // unsubscribed from everything. mergeFromCache eventually - // prunes them on the next merge, but they sat as orphans - // through cold starts in the meantime. viewModelScope.launch(Dispatchers.IO) { runCatching { FeedCache.get().clear() } } return } - inFlight?.cancel() _ui.update { it.copy(loading = true, error = null) } inFlight = viewModelScope.launch { try { @@ -181,10 +194,18 @@ class SubscriptionFeedViewModel : ViewModel() { } } } catch (t: Throwable) { + // Re-throw cancellation so spam-tapping Refresh (or + // toggling cache OFF→ON during a refresh) doesn't + // surface a "refresh failed: StandaloneCoroutineCancelled" + // banner above the cached items. vc=37 round-3 audit + // function-correctness HIGH-1. + if (t is CancellationException) throw t _ui.update { it.copy( loading = false, - error = t.message ?: t.javaClass.simpleName, + error = com.sulkta.straw.util.LogDump.scrubLine( + t.message ?: t.javaClass.simpleName, + ), ) } } @@ -264,8 +285,14 @@ class SubscriptionFeedViewModel : ViewModel() { * stayed visible until process death. vc=35 audit MED-C13. */ fun clearInMemoryCache() { + // Cancel any in-flight refresh — without this, fetchChannelInto + // coroutines mid-execution would re-populate the cache after + // the clear. Round-3 audit function MED-3. + inFlight?.cancel() channelCache.clear() - _ui.value = _ui.value.copy(items = emptyList(), lastFetchedAt = 0L) + // Use _ui.update for atomicity vs concurrent refresh writes + // (round-3 audit HIGH-4). + _ui.update { it.copy(items = emptyList(), lastFetchedAt = 0L) } } } diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchViewModel.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchViewModel.kt index 082d61fdb..bcef3a3a4 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchViewModel.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchViewModel.kt @@ -180,7 +180,9 @@ class SearchViewModel : ViewModel() { // the user still has something to look at while offline. _ui.value = _ui.value.copy( loading = false, - error = t.message ?: t.javaClass.simpleName, + error = com.sulkta.straw.util.LogDump.scrubLine( + t.message ?: t.javaClass.simpleName, + ), ) } } @@ -193,11 +195,13 @@ class SearchViewModel : ViewModel() { * after each successful submit and at VM construction. */ private fun reactiveFilter(q: String): List { - val needle = q.lowercase() + // contains(ignoreCase=true) on the raw fields avoids the + // 3N+ String allocations per keystroke that `.lowercase()` + // copy-and-compare produced. Round-3 audit MED-5. return pool.asSequence() .filter { item -> - item.title.lowercase().contains(needle) - || item.uploader.lowercase().contains(needle) + item.title.contains(q, ignoreCase = true) + || item.uploader.contains(q, ignoreCase = true) } .take(60) .toList() diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/util/LogDump.kt b/strawApp/src/main/kotlin/com/sulkta/straw/util/LogDump.kt index d5279a144..af728aae2 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/util/LogDump.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/util/LogDump.kt @@ -43,12 +43,16 @@ object LogDump { runCatching { val pid = Process.myPid() val timestamp = SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US).format(Date()) - val outFile = File(context.cacheDir, "straw-logs-$timestamp.txt") - val tmpFile = File(context.cacheDir, "straw-logs-$timestamp.txt.tmp") + // Write to cacheDir/logs/ — vc=37 round-3 audit CVE MED-5 + // narrowed the FileProvider scope from the whole cacheDir + // to just this subdir, so dumps must land here. + val logsDir = File(context.cacheDir, "logs").apply { mkdirs() } + val outFile = File(logsDir, "straw-logs-$timestamp.txt") + val tmpFile = File(logsDir, "straw-logs-$timestamp.txt.tmp") // Sweep old dumps before writing the new one so cacheDir // doesn't grow per export. - context.cacheDir.listFiles { _, name -> + logsDir.listFiles { _, name -> name.startsWith("straw-logs-") && (name.endsWith(".txt") || name.endsWith(".tmp")) }?.forEach { it.delete() } @@ -107,18 +111,27 @@ object LogDump { var s = line // Pre-signed googlevideo URLs: keep host visible, drop path+query. s = GOOGLEVIDEO_URL_RE.replace(s, "https://.googlevideo.com/") - // Any remaining signed-param shapes that snuck through other URLs. - // Expanded set vc=36 audit CVE MED-2: + n (JS-deobfuscated n-sig), - // lsig (link signature), ei (encrypted event-id), key, sparams. - s = SIGNED_PARAM_RE.replace(s, "$1=") + // Long, distinctive token names — match anywhere. + s = SIGNED_PARAM_LONG_RE.replace(s, "$1=") + // Short single-letter / two-letter tokens — require `[?&]` + // immediately before to avoid eating innocent counters. + s = SIGNED_PARAM_SHORT_RE.replace(s, "$1$2=") return s } private val GOOGLEVIDEO_URL_RE = Regex( """https?://[a-zA-Z0-9.-]*googlevideo\.com/\S+""", ) - private val SIGNED_PARAM_RE = Regex( - """\b(signature|sig|pot|cpn|expire|ip|mn|ms|mo|pl|n|lsig|ei|key|sparams)=([^&\s"']+)""", + // Long tokens are unique enough to match anywhere. Short tokens + // (n, mn, ms, mo, pl, ip, ei) require `[?&]` immediately before + // so we don't redact innocuous `n=42` counters from other libs. + // vc=37 round-3 audit CVE-4. + private val SIGNED_PARAM_LONG_RE = Regex( + """\b(signature|sparams|lsig|cpn|expire|pot|sig|key)=([^&\s"']+)""", + RegexOption.IGNORE_CASE, + ) + private val SIGNED_PARAM_SHORT_RE = Regex( + """([?&])(n|mn|ms|mo|pl|ip|ei)=([^&\s"']+)""", RegexOption.IGNORE_CASE, ) } diff --git a/strawApp/src/main/res/xml/file_paths.xml b/strawApp/src/main/res/xml/file_paths.xml index 2cd9f5c6f..57446c2ce 100644 --- a/strawApp/src/main/res/xml/file_paths.xml +++ b/strawApp/src/main/res/xml/file_paths.xml @@ -1,8 +1,11 @@ - - + +