Skip to content

Normalise DNS qnames to lowercase on the database write path - #772

Merged
kasnder merged 1 commit into
masterfrom
fix/dns-qname-case
Aug 22, 2026
Merged

Normalise DNS qnames to lowercase on the database write path#772
kasnder merged 1 commit into
masterfrom
fix/dns-qname-case

Conversation

@kasnder

@kasnder kasnder commented Aug 22, 2026

Copy link
Copy Markdown
Member

The defect

DNS names are case-insensitive, but the two halves of the DNS store disagreed about it.

  • Write side did not normalise. DatabaseHelper.insertDns() (app/src/main/java/eu/faircode/netguard/DatabaseHelper.java:1072-1078 on master) UPDATEd on the exact (qname, aname, resource) string triple and INSERTed rr.QName/rr.AName verbatim. The schema's CREATE UNIQUE INDEX idx_dns ON dns(qname, aname, resource) (DatabaseHelper.java:294, recreated in the v18 migration at :429) is BINARY-collated, so case variants of one domain became distinct rows.
  • Read side already did. TrackerList.findTracker lowercases (app/src/main/java/net/kollnig/missioncontrol/data/TrackerList.java:121) and ServiceSinkhole.prepareHostsBlocked lowercases hosts.txt keys (ServiceSinkhole.java:2167).
  • getQAName does GROUP BY d.qname (DatabaseHelper.java:1185-1189), so those variants counted as several distinct qnames for one IP.

User-visible symptom (default configuration)

ServiceSinkhole.log() computes uncertain = getQAName(...).getCount() > 1 ? ACCESS_UNCERTAIN_SHARED_IP : NONE (ServiceSinkhole.java:960-968), and log_app defaults to true, so dh.updateAccess(packet, dname, -1, uncertain) runs (:1035). A perfectly unambiguous domain therefore picked up the spurious "shared IP / uncertain" marker in the UI, and fed the mixed-evidence classification that decides whether an IP counts as an ambiguous shared host in Standard mode. The realistic trigger is server-chosen CNAME case in aname, rather than DNS 0x20 query-case randomisation.

The issue text points at get_qname in app/src/main/jni/netguard/dns.c; that reference is stale — DNS parsing now lives in Rust (wgbridge-rs/tc-dns/src/message.rs). Preserving wire case in the parser is correct and is unchanged here. The fix is purely on the Java write path.

What changed

app/src/main/java/eu/faircode/netguard/DatabaseHelper.java:

  • insertDns() lowercases qname/aname with Locale.ROOT before both the UPDATE match and the INSERT, via a null-safe lower() helper (the columns are NOT NULL, so a null still fails as a caught SQLite error rather than an NPE thrown out of the native dnsResolved callback). resource is an IP literal and is left alone.
  • The qname parameters of getAName(), getAlternateQNames() and getAccessDns() are lowercased to match the now-lowercase column — dnsResolved() calls prepareUidIPFilters(rr.QName) with the wire-case name.
  • The two joins that match dns.qname against access.daddr (getAccess's shared-IP count subquery and getAccessDns's LEFT JOIN) compare against lower(a.daddr), so access rows written before this change still resolve. The WHERE clause deliberately keeps the plain a.daddr = ? comparison so idx_access_daddr is still usable — prepareUidIPFilters runs on every new DNS record, and lower() there would have turned that into a full scan of a table that is never pruned. Battery is a first-class constraint in this repo.

Migration: yes, DB_VERSION 22 -> 23. The dns table is TTL-expiring, so duplicates would eventually age out on their own — but ttl defaults to a 259200 s (3 day) floor and users refresh the same domains constantly, so "eventually" can mean the false uncertainty marker sticks around for days on exactly the domains a user looks at most. The migration deletes case-variant duplicates first (keeping the freshest row per (lower(qname), lower(aname), resource), ties broken by MAX(ID)) and only then runs UPDATE dns SET qname = lower(qname), aname = lower(aname); the dedup must precede the update because idx_dns is UNIQUE and a naive lowercase would hit a constraint violation where a lowercase twin already exists. SQLite's lower() is ASCII-only, which is exactly right for DNS names. It is a one-off, two-statement pass over a small table.

Test evidence

  • DatabaseHelperDnsAttributionTest.caseVariantQnamesShareOneRowAndDoNotLookUncertain — inserts Graph.Facebook.Com/Alias.Example.Com and graph.facebook.com/alias.example.com for the same IP, then asserts getQAName returns one row (not two, which is what drove the false ACCESS_UNCERTAIN_SHARED_IP), with the lowercase names and the freshest timestamp.
  • DatabaseHelperMigrationTest.upgradeFrom22NormalizesAndDeduplicatesDns — seeds a v22 dns table with the same two case variants, runs onUpgrade(22, 23), asserts a single lowercase row survives carrying the newer time. The two existing migration tests were extended to target 23.

Reverting only DatabaseHelper.java and re-running these tests fails with case variants must share one stored qname expected:<1> but was:<2>, confirming the test reproduces the reported defect.

$ ./gradlew :app:compileGithubDebugJavaWithJavac -q
Note: [1] Wrote GeneratedAppGlideModule with: []
Note: Some input files use or override a deprecated API.
Note: Some input files use unchecked or unsafe operations.
(exit 0)

$ ./gradlew :app:testGithubDebugUnitTest
(exit 0) — 258 tests, 0 skipped, 0 failures, 0 errors

Deliberately out of scope

  • No change to the Rust/C DNS parsers: keeping wire case there is correct.
  • access.daddr is not normalised or deduplicated. A domain observed in mixed case before this change can leave a stale access row alongside the new lowercase one; the joins above still resolve it, and normalising access means a second dedup against UNIQUE INDEX idx_access(uid, version, protocol, daddr, dport) for a cosmetic legacy artefact.
  • Per-app DNS attribution remains global; unrelated to this fix.

Fixes #756

DNS names are case-insensitive, but insertDns() stored qname/aname with
their on-the-wire case while matching on the exact (qname, aname,
resource) triple. Since idx_dns is BINARY-collated, a server-chosen CNAME
case — or a resolver using 0x20 randomisation — produced several distinct
rows for one domain. getQAName() groups by qname, so ServiceSinkhole.log()
then read count > 1 and marked the access row ACCESS_UNCERTAIN_SHARED_IP,
showing the user a spurious shared-IP marker on an unambiguous domain.

The lookup side already normalised (TrackerList.findTracker, hosts keys),
so only the write side disagreed. Lowercase qname/aname in insertDns() and
in the qname parameters of getAName/getAlternateQNames/getAccessDns, and
compare dns.qname against lower(access.daddr) in the joins so pre-existing
mixed-case access rows still match. The WHERE clause keeps the plain
a.daddr = ? comparison so idx_access_daddr is still used — prepareUidIPFilters
runs on every new DNS record.

DB_VERSION 23 deduplicates and lowercases existing dns rows, keeping the
freshest row per (lower(qname), lower(aname), resource); the dedup has to
run before the UPDATE because idx_dns is UNIQUE.

Fixes #756

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@kasnder
kasnder merged commit 912fa1d 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.

DNS qnames stored with wire case fragment the dns table and inflate shared-IP uncertainty

1 participant