diff --git a/buildSrc/src/main/kotlin/ProjectConfig.kt b/buildSrc/src/main/kotlin/ProjectConfig.kt index 1b4ad26a7..8889f44a1 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 = 68 -const val STRAW_VERSION_NAME = "0.1.0-CB" +const val STRAW_VERSION_CODE = 69 +const val STRAW_VERSION_NAME = "0.1.0-CC" const val STRAW_APPLICATION_ID = "com.sulkta.straw" diff --git a/rust/strawcore/src/feed.rs b/rust/strawcore/src/feed.rs index 395b66840..54a372df5 100644 --- a/rust/strawcore/src/feed.rs +++ b/rust/strawcore/src/feed.rs @@ -164,6 +164,16 @@ async fn read_capped_body(resp: reqwest::Response) -> Option { let mut stream = resp.bytes_stream(); while let Some(chunk_result) = stream.next().await { let chunk = chunk_result.ok()?; + // Defense-in-depth: a single hostile chunk can be arbitrarily + // large (HTTP allows multi-GiB chunks). Reject any one chunk + // bigger than the whole body cap before we even add it to the + // running total — protects against hyper having already + // allocated the chunk on our behalf. Round-68 audit + // rust-HIGH-1. + if chunk.len() > RSS_MAX_BYTES { + log::warn!("strawcore::rss single chunk {} exceeds cap; aborting", chunk.len()); + return None; + } total = total.saturating_add(chunk.len()); if total > RSS_MAX_BYTES { log::warn!("strawcore::rss body exceeded {RSS_MAX_BYTES} bytes; aborting"); @@ -171,7 +181,12 @@ async fn read_capped_body(resp: reqwest::Response) -> Option { } buf.extend_from_slice(&chunk); } - String::from_utf8(buf).ok() + // Lossy decode — round-68 audit rust-HIGH-2. A strict from_utf8 + // returns None on any invalid byte, so a single mojibake title + // would silently drop the entire channel from the feed. quick-xml + // tolerates U+FFFD replacement chars and the per-entry skip-on- + // empty handles broken entries downstream. + Some(String::from_utf8_lossy(&buf).into_owned()) } /// Extract the `UCxxx` channel ID from a channel URL. Accepts the @@ -194,7 +209,11 @@ fn extract_channel_id(input: &str) -> Option { let trimmed_lower = trimmed.to_lowercase(); // Match the ":///channel/" prefix in a single sweep // so we accept http/https + www./m. variants without four-way - // string-strip ladders. + // string-strip ladders. ANCHORED at the start of the string — + // round-68 audit rust-HIGH-3: prior `find()` accepted any input + // containing the prefix as a substring, so a pasted + // `evil.com/?redir=https://www.youtube.com/channel/UCxxx` would + // silently rewrite to the wrong channel. const PREFIXES: &[&str] = &[ "https://www.youtube.com/channel/", "https://youtube.com/channel/", @@ -204,9 +223,13 @@ fn extract_channel_id(input: &str) -> Option { "http://m.youtube.com/channel/", ]; for p in PREFIXES { - if let Some(idx) = trimmed_lower.find(p) { - let rest = &trimmed[idx + p.len()..]; - let id = rest.split(|c: char| c == '/' || c == '?' || c == '#').next()?; + if let Some(rest) = trimmed_lower.strip_prefix(p) { + // Bytes match 1:1 with `trimmed` since the prefix is ASCII + // and case-folding ASCII doesn't change byte length. + let rest_in_original = &trimmed[p.len()..p.len() + rest.len()]; + let id = rest_in_original + .split(|c: char| c == '/' || c == '?' || c == '#') + .next()?; return validate_channel_id(id); } } 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 4860ef21c..fa2dbd404 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 @@ -29,6 +29,16 @@ data class ChannelUiState( val avatar: String? = null, val videos: List = emptyList(), val error: String? = null, + /** + * Tracks which channel URL the current state belongs to. Same + * activity-scoped-VM hazard as VideoDetail: a fresh nav to + * channel B sees the PREVIOUS channel's state for one composition + * frame before vm.load(B) clears it. Without this field, any + * caller that derives "this is the channel we want" from + * `state.name` (or other display fields) is reading channel A's + * data while believing it's B. Round-68 audit MED-4. + */ + val loadedUrl: String? = null, ) class ChannelViewModel : ViewModel() { @@ -40,10 +50,11 @@ class ChannelViewModel : ViewModel() { // the late-arriving older fetch is cancelled. Round-4 audit // HIGH-2 / MED-1. private var inFlight: Job? = null - private var loadedUrl: String? = null fun load(channelUrl: String) { - if (loadedUrl == channelUrl && _ui.value.videos.isNotEmpty()) return + // Snapshot _ui once so the two reads agree. Round-68 audit MED-4. + val snap = _ui.value + if (snap.loadedUrl == channelUrl && snap.videos.isNotEmpty()) return // Round-5 audit MED-3: extractor-emitted uploaderUrl can be // attacker-controlled if the YT response is poisoned upstream. // Refuse non-YT hosts at the entry point so we don't even @@ -53,12 +64,17 @@ class ChannelViewModel : ViewModel() { if (!isAllowedYtUrl(channelUrl)) { inFlight?.cancel() inFlight = null - _ui.update { ChannelUiState(loading = false, error = "Unsupported URL") } + _ui.update { + ChannelUiState( + loading = false, + error = "Unsupported URL", + loadedUrl = channelUrl, + ) + } return } inFlight?.cancel() - loadedUrl = channelUrl - _ui.update { ChannelUiState(loading = true) } + _ui.update { ChannelUiState(loading = true, loadedUrl = channelUrl) } inFlight = viewModelScope.launch { try { val ch = uniffi.strawcore.channelInfo(channelUrl) @@ -74,7 +90,7 @@ class ChannelViewModel : ViewModel() { uploadDateRelative = v.uploadDateRelative, ) } - if (loadedUrl != channelUrl) return@launch + if (_ui.value.loadedUrl != channelUrl) return@launch _ui.update { ChannelUiState( loading = false, @@ -83,11 +99,12 @@ class ChannelViewModel : ViewModel() { banner = ch.banner, avatar = ch.avatar, videos = videos, + loadedUrl = channelUrl, ) } } catch (t: Throwable) { if (t is CancellationException) throw t - if (loadedUrl != channelUrl) return@launch + if (_ui.value.loadedUrl != channelUrl) return@launch _ui.update { ChannelUiState( loading = false, @@ -98,6 +115,7 @@ class ChannelViewModel : ViewModel() { error = com.sulkta.straw.util.LogDump.scrubLine( t.message ?: t.javaClass.simpleName, ), + loadedUrl = channelUrl, ) } } 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 665e57afe..6f00358a3 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 @@ -232,15 +232,25 @@ class VideoDetailViewModel : ViewModel() { // subscribed and our stored avatar is stale or // missing, push the fresh one back to the store // so the subs feed picks it up too. + // + // Validate the scheme before persisting — the + // extractor surfaces the URL string verbatim + // and a poisoned channel page could ship + // `data:image/svg+xml,...