Skip to content

Fix blocklist load integrity: short reads and premature hosts mtime stamp - #773

Merged
kasnder merged 1 commit into
masterfrom
fix/blocklist-load-integrity
Aug 22, 2026
Merged

Fix blocklist load integrity: short reads and premature hosts mtime stamp#773
kasnder merged 1 commit into
masterfrom
fix/blocklist-load-integrity

Conversation

@kasnder

@kasnder kasnder commented Aug 22, 2026

Copy link
Copy Markdown
Member

Two confirmed silent-degradation defects in the Java-side blocklist loading path.

Defect 1 — asset loads assume a single read(byte[]) fills the buffer

  • app/src/main/java/net/kollnig/missioncontrol/data/TrackerList.java:585-588 (loadDisconnectTrackers)
  • app/src/main/java/net/kollnig/missioncontrol/data/BlockingMode.java:168-172 (loadExcludedApps)
  • app/src/main/java/net/kollnig/missioncontrol/data/BlockingMode.java:187-191 (loadBrowserApps)

Each sizes a buffer from is.available() and then calls is.read(buffer) once, treating any positive return as a full read. InputStream.read(byte[]) may legally return fewer bytes. These assets are deflate-compressed — there is no noCompress entry for them in app/build.gradle — so the contract violation is real; it does not bite today only because Android's StreamingZipInflater happens to loop internally. A short read would feed truncated bytes straight into JSON parsing: a partial Disconnect list (detection quietly degrades) or empty minimal-mode exclusions / browser classification, with only a log line.

Fixed by wrapping the asset stream in DataInputStream and using readFully(). EOFException is an IOException, so the existing catch/log behaviour is unchanged.

Defect 2 — hosts-file mtime stamped before the parse succeeds

app/src/main/java/eu/faircode/netguard/ServiceSinkhole.java:2151 (prepareHostsBlocked) set last_hosts_modified = hosts.lastModified() before the parse loop, and an IOException mid-parse is swallowed by the catch at :2179. The early return at :2146-2149 (!changed && mapHostsBlocked.size() > 0) then short-circuits every later call.

Accurate impact — narrower than issue #762 states: the other early return at :2140 is guarded by mapHostsBlocked.size() > 0, so a total failure (map ends up empty) does still retry. Only a partial parse gets pinned: the map keeps whatever prefix was read until the file's mtime changes again.

Fixed by reading the mtime once before opening the reader and committing it to last_hosts_modified only after the parse loop completes without throwing. On failure the field keeps its previous value, so the next call re-reads.

The parse loop and the reload/commit state machine move into a small pure helper, eu.faircode.netguard.HostsBlocklistLogic, following the existing NetworkReloadPolicy / VpnRevokePolicy / BlockingModeLogic pattern in this repo, so the retry behaviour is unit-testable. Parsing behaviour is byte-for-byte identical: # comment stripping, 2-word line validation, Locale.ROOT lowercase keying, the test.netguard.me entry, and the same log lines.

Deliberately excluded

Issue #762 parts (b) (a deleted hosts.txt keeps stale entries) and (c) (a missing file silently restores the bundled ~90k-domain StevenBlack asset) are not addressed here. Both change fresh-install and post-deletion defaults, which is a product decision that has not been made — whether a missing file should be treated as authoritative-empty, and whether the bundled fallback should be surfaced to the user. They need their own PR once that call is made.

Test evidence

New app/src/test/java/eu/faircode/netguard/HostsBlocklistLogicTest.java drives a Reader that throws IOException after the first line and asserts:

  1. after the mid-parse failure the recorded mtime is unchanged, and shouldReload(sameMtime) is still true — the partial map is not pinned;
  2. a following successful parse at the same mtime repopulates the map fully and then commits the mtime;
  3. a successful parse followed by an unchanged mtime still short-circuits — no regression of the existing skip.

Assertion (1) fails against the pre-fix ordering.

$ ./gradlew :app:compileGithubDebugJavaWithJavac -q
Note: [1] Wrote GeneratedAppGlideModule with: []
Note: Some input files use or override a deprecated API.
Note: Recompile with -Xlint:deprecation for details.
Note: Some input files use unchecked or unsafe operations.
Note: Recompile with -Xlint:unchecked for details.
# exit 0

$ ./gradlew :app:testGithubDebugUnitTest
BUILD SUCCESSFUL in 36s
# exit 0

TEST-eu.faircode.netguard.HostsBlocklistLogicTest.xml: tests="1" skipped="0" failures="0" errors="0".

No short-read regression test: the three call sites read directly from AssetManager with no injectable seam, and adding one purely for this would mean widening the API surface for a test.

Merge notes

ServiceSinkhole.java is also touched by open PRs #752 and #768, in getBuilder (:1762), LogHandler (:945) and onCreate/onDestroy (:3209/:3691). This change stays inside prepareHostsBlocked (:2132-2170), plus dropping the now-unused java.util.Locale import.

Fixes #758
Partially addresses #762 (part (a) only).

Two silent-degradation defects in the Java-side blocklist loading path.

1. Blocklist asset loads assumed a single read(byte[]) filled the buffer
   (TrackerList.loadDisconnectTrackers, BlockingMode.loadExcludedApps,
   BlockingMode.loadBrowserApps). The assets are deflate-compressed, so
   the contract violation is real. Use DataInputStream.readFully().

2. ServiceSinkhole.prepareHostsBlocked stamped last_hosts_modified before
   the parse loop ran, so an IOException mid-parse left a partially
   populated map pinned behind the "Hosts file unchanged" early return.
   The mtime is now committed only after a fully successful parse.

The hosts parse plus the reload/commit state machine move into a small
pure helper (HostsBlocklistLogic) so the retry behaviour can be unit
tested; behaviour is otherwise unchanged.

Fixes #758
Partially addresses #762 (part a only)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@kasnder
kasnder merged commit a97fb93 into master Aug 22, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Blocklist asset loads tolerate short reads; hosts-file mtime stamped before successful parse

1 participant