Skip to content

fix(ios): exempt captive-portal login domains from the DoH profile - #1275

Draft
kar wants to merge 2 commits into
mainfrom
fix/ios-captive-probe-doh-exempt
Draft

kar wants to merge 2 commits into
mainfrom
fix/ios-captive-probe-doh-exempt

Conversation

@kar

@kar kar commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

Addresses blokadaorg/issue-tracker#283 (updated spec). Draft until the captive-portal spike passes.

On captive Wi-Fi with the Blokada Cloud/Family DNS profile active, the portal's login page never loads in Safari. Portal hosts often exist only in the network's own DNS (e.g. wifi.finnair.com is NXDOMAIN publicly), so DoH can't answer them. iOS already exempts its own captive probe, so the sign-in sheet isn't the problem.

Change (PrivateDnsService.swift): NEDNSSettingsManager.onDemandRules holds an NEOnDemandRuleEvaluateConnection with NEEvaluateConnectionRule(.neverConnect) for known portal hosts. Those names resolve via the network's DNS. A trailing NEOnDemandRuleConnect keeps DoH for everything else.

  • The list is the spec's 36 in-flight, rail and hotel-platform hosts, plus Apple's probe hosts as a hedge.
  • The API needs iOS 14+; our deployment target is 15.0.
  • No Dart change. Cloud (syncPermsAfterTagChange) and Family (_checkDns) re-save the profile on every launch, so users pick it up on their next app start.

Verified (iPhone 16e, Six, installed in place without uninstalling):

  • Updating over main keeps the profile enabled: no re-activation prompt (currentDns = enabled after the save that adds the rules)
  • Updating from the probe-only rules to the full list keeps it enabled
  • Relaunching (no-op save) keeps it enabled
  • Note: make -C ios run-six uninstalls first. That deletes the DNS profile, so after it the profile always reads disabled. Don't use it to judge upgrade behaviour.

Still needed: the spike on the captive rig (instructions in the comment below)

  • Stage A: listed portal hosts resolve via the network's DNS on a normal network, and suffix entries cover subdomains
  • Stage B: the same while the network is actually captive (the question that decides the spike)
  • Ad domains stay blocked with the rules active

If Stage B fails, a domain list can't work: close this PR and rethink the mechanism.

Out of scope: Android (no API for this), third-party hosts loaded inside portal pages (e.g. payment providers), and the macOS mobileconfig.

🤖 Generated with Claude Code

https://claude.ai/code/session_01PHms5UiGbdr44cLAS922fK

The Cloud/Family DoH profile also caught iOS's captive-portal probe
(captive.apple.com), so walled-garden wifi never showed the sign-in sheet
and looked like no internet. Add NEDNSSettingsManager.onDemandRules that
send Apple's probe hosts to system DNS, with a trailing Connect rule so
DoH keeps filtering everything else. Existing users pick this up on the
next launch, since the profile is re-saved on every start.

Refs blokadaorg/issue-tracker#283

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@kar

kar commented Sep 24, 2026

Copy link
Copy Markdown
Contributor Author

Confirmed most points. I'll skip the native sign-in sheet check because it's hard to reproduce, the change is small enough that it just has to work. :D If ok I'll merge

@kar
kar requested review from balboah and a lite review from Copilot September 24, 2026 13:54

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🔵 Needs a closer look

Real-device captive-portal verification remains incomplete.

Review effort: Lite
Findings: None

What changed in this PR

Updates iOS DoH profiles to bypass Apple captive-portal probes while preserving DoH for other traffic.

Changes:

  • Adds captive-portal probe exceptions.
  • Adds a trailing DoH connection rule.
  • Consolidates profile URL and rule configuration.
File Summary
ios/​App/​Service/​PrivateDnsService.swift Configures captive-portal bypass rules for iOS DNS profiles.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@balboah

balboah commented Sep 25, 2026

Copy link
Copy Markdown
Contributor

I tested this on a captive-portal setup, and it turns out the spec described the wrong failure. Sorry about that.

What the test showed

  • iOS exempts its own captive detection from DNS profiles.
  • With the Blokada profile active, the current App Store build already shows the sign-in sheet, and the captive.apple.com lookups go to the network's DNS, not DoH.

What actually breaks

  • The portal's own login page, opened in Safari (e.g. wifi.finnair.com). On the flight where this was hit, no sign-in sheet appeared at all, so Safari was the only way in.
  • These hosts often exist only in the network's own DNS, so they never resolve through the profile.

I've updated the spec accordingly.

Testing showed iOS already exempts its own captive probe from DNS
profiles; what breaks is the portal's login page opened in Safari, whose
host often exists only in the portal network's DNS (e.g.
wifi.finnair.com is NXDOMAIN publicly). Extend the neverConnect list
with known in-flight, rail and hotel-platform portal hosts, keeping
Apple's probe hosts as a hedge for networks where no sheet appears.

Refs blokadaorg/issue-tracker#283

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PHms5UiGbdr44cLAS922fK
@kar kar changed the title fix(ios): exempt captive-portal probes from the DoH profile fix(ios): exempt captive-portal login domains from the DoH profile Sep 28, 2026
@kar
kar marked this pull request as draft September 28, 2026 06:30
@kar

kar commented Sep 28, 2026

Copy link
Copy Markdown
Contributor Author

@balboah Thanks for re-testing and updating the spec. I've retargeted this PR: it now carries the spec's portal list (plus the Apple probe hosts as the hedge) and is a draft until the spike passes. I don't have a wired Mac for the rig, so could you run it? It should be one session on your setup. I've already verified upgrade safety (see the PR description), so this is only about whether the exemptions work.

Build: check out this branch and build Six on your iPhone. Note that make -C ios run-six uninstalls the app first, which wipes app data and the DNS profile. Re-onboard and enable Blokada in Settings → General → VPN & Device Management → DNS before testing.

The rig is your spec's, with two changed scripts. Neither needs a test-only domain in the app: the portal uses names that are in the real list, plus one control name that isn't.

  • wifi.finnair.com: an exact list entry
  • login.inflightinternet.com: covered only by the suffix entry inflightinternet.com, so it tests suffix matching
  • portal.notlisted.test: not in the list (control: this one should fail)
portal.py (replaces step 2)
import http.server as h, socket
# Hosts that serve the login page; everything else redirects to the first one.
LOGIN = ["wifi.finnair.com", "login.inflightinternet.com", "portal.notlisted.test"]
class P(h.BaseHTTPRequestHandler):
    def do_GET(s):
        host = (s.headers.get("Host") or "").split(":")[0].lower()
        if host not in LOGIN:
            s.send_response(302); s.send_header("Location", f"http://{LOGIN[0]}/login"); s.end_headers(); return
        s.send_response(200); s.send_header("Content-Type", "text/html"); s.end_headers()
        s.wfile.write(f"<h1>Test portal (login page) on {host}</h1>".encode())
class S(h.ThreadingHTTPServer):
    address_family = socket.AF_INET6
    def server_bind(self):
        self.socket.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 0)
        super().server_bind()
S(("::", 8080), P).serve_forever()
dns.py (replaces step 3; MODE=open hijacks only the three portal names, MODE=captive hijacks everything except cloud.blokada.org)
import os, socket, struct
V4 = socket.inet_aton(os.environ["V4"]); V6 = socket.inet_pton(socket.AF_INET6, os.environ["V6"])
MODE = os.environ.get("MODE", "captive")  # open = Stage A, captive = Stage B
PORTAL = {"wifi.finnair.com", "login.inflightinternet.com", "portal.notlisted.test"}
PASS = {"cloud.blokada.org"}
def forward(d):
    u = socket.socket(socket.AF_INET, socket.SOCK_DGRAM); u.settimeout(3)
    u.sendto(d, ("1.1.1.1", 53)); return u.recv(4096)
def answer(d):
    o, labels = 12, []
    while d[o]: labels.append(d[o+1:o+1+d[o]].decode()); o += d[o] + 1
    o += 1; name = ".".join(labels).lower(); qtype = struct.unpack(">H", d[o:o+2])[0]
    if name in PASS or (MODE == "open" and name not in PORTAL):
        return forward(d)
    print("hijacked", name, qtype, flush=True)
    rr = b""
    if qtype == 1: rr = b"\xc0\x0c" + struct.pack(">HHIH", 1, 1, 30, 4) + V4
    elif qtype == 28: rr = b"\xc0\x0c" + struct.pack(">HHIH", 28, 1, 30, 16) + V6
    return d[:2] + b"\x81\x80" + d[4:6] + struct.pack(">HHH", 1 if rr else 0, 0, 0) + d[12:o+4] + rr
s = socket.socket(socket.AF_INET6, socket.SOCK_DGRAM)
s.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 0); s.bind(("::", 5300))
while True:
    d, a = s.recvfrom(4096)
    try: s.sendto(answer(d), a)
    except OSError as e: print("forward failed:", e, flush=True)

Run it as sudo env V4=$V4 V6=$V6 MODE=open python3 dns.py. Each hijacked name prints a line, so you can see which lookups reached the network's DNS.

Stage A: non-captive (checks the rule mechanics)

The network is not captive here: Apple's probe resolves normally. Only the three portal names are answered locally.

  1. python3 portal.py, then sudo env V4=… V6=… MODE=open python3 dns.py
  2. pf: load only the four rdr lines from step 4 (no pass/block lines), then flush states
  3. iPhone with the Blokada profile active, joined to the shared SSID. In Safari:
    • http://wifi.finnair.com/login → should show "Test portal (login page) on wifi.finnair.com"
    • http://login.inflightinternet.com/login → should show the test portal. If it doesn't, suffix entries don't cover subdomains and we need exact hosts.
    • http://portal.notlisted.test/login → should fail (this goes through DoH and gets NXDOMAIN)
    • an ad domain on your blocklist → should be blocked (DoH is still filtering)
  4. The dns.py log should show hijacked for the two listed names only. The control name should never reach it.

If Stage A fails for wifi.finnair.com, the rules don't work at all, whether captive or not. Stop there and report.

Stage B: captive (the deciding spike, issue open question 1)

  1. Restart dns.py with MODE=captive, load the full pf ruleset from step 4, then flush states
  2. Rename the SSID (iOS only probes on a fresh join). Keep cellular and Wi-Fi Assist off.
  3. Blokada profile active, join. The sheet should appear. Tap Cancel → Continue without internet, then in Safari:
    • http://wifi.finnair.com/login → test portal loads: the fix works while captive
    • http://login.inflightinternet.com/login → test portal loads (suffix match while captive)
    • http://portal.notlisted.test/login → fails: the original bug, reproduced in the same run
  4. Teardown as in step 7.

A pass on both stages plus a working control means the approach works, and I'll take the PR out of draft. If Stage A passes but Stage B fails, rules aren't evaluated while the network is captive; I'll close this PR and we need a different mechanism. The raw results for each URL (loads / fails) are enough.

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.

3 participants