From ae3cfa7af4c08828765e0e0cbea49d4a725baa54 Mon Sep 17 00:00:00 2001 From: shejnowicz Date: Thu, 10 Sep 2026 09:56:24 +0200 Subject: [PATCH] fix(wg-client): route unqualified names to Docker DNS, dotted names upstream MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #113. Implements the dnsmasq configuration proposed by @jiramares. The old config routed each search domain to Docker's embedded resolver and dropped dotless queries (domain-needed). That assumed search domains are the compose network domain — but Docker copies the HOST's search domains into containers, so in corporate setups they are intranet zones. Consequences: sibling-container lookups failed (libc's search expansion produced name. → NXDOMAIN, and the final bare-name attempt was dropped by domain-needed), and intranet FQDNs were black-holed (routed to Docker's resolver, which forwards unknown names to the compose `dns:` exfiltration sink). New config: `server=//127.0.0.11` sends ONLY unqualified single-label names (sibling containers) to Docker's resolver; every dotted name — including hosts under the search domains — goes to the upstream from dns.conf through wg0 → mitmproxy, so intranet zones resolve via `dns_servers` and stay subject to the DNS policy. Search domains in resolv.conf now serve their real purpose: short intranet names. The exfiltration posture is unchanged: unknown single-label names are forwarded by Docker's resolver to the unroutable `dns:` sink and fail without leaving the host; dotted names were already policy-gated. Tests updated with #113 regression guards (no per-search-domain routes, no domain-needed); docs (Container-to-container DNS) rewritten to match. Co-Authored-By: Claude Opus 5 (1M context) --- .../sandcat/scripts/wg-client-init.sh | 68 +++++++++++-------- cli/test/wg-client/write_dnsmasq_conf.bats | 22 +++--- docs/configuration/dns.md | 20 +++--- 3 files changed, 65 insertions(+), 45 deletions(-) diff --git a/cli/templates/devcontainer/sandcat/scripts/wg-client-init.sh b/cli/templates/devcontainer/sandcat/scripts/wg-client-init.sh index 820b6036..682f0a75 100644 --- a/cli/templates/devcontainer/sandcat/scripts/wg-client-init.sh +++ b/cli/templates/devcontainer/sandcat/scripts/wg-client-init.sh @@ -69,15 +69,31 @@ extract_search_domains() { "$resolv_conf" } -# Write a dnsmasq config that splits DNS: -# - queries under any of the given search domains (the Docker compose project -# network) are forwarded to Docker's embedded DNS at 127.0.0.11, -# - everything else is forwarded to the upstream from dns.conf or defaults. +# Write a dnsmasq config that splits DNS (#113): +# - unqualified single-label names (sibling containers) go to Docker's +# embedded DNS at 127.0.0.11 via the empty-domain rule `server=//...`, +# - every dotted name — including hosts under the container's search +# domains — is forwarded to the upstream from dns.conf or defaults, +# which routes through wg0 → mitmproxy for inspection and policy. +# +# Search domains are deliberately NOT routed to 127.0.0.11: Docker copies +# the HOST's search domains into the container, so in corporate setups they +# are intranet domains that only the custom upstream (dns_servers) can +# resolve — routing them to Docker's resolver black-holed them via the +# compose `dns:` exfiltration sink AND broke sibling lookups (libc's search +# expansion never reached the bare name because `domain-needed` dropped it). +# With the empty-domain rule, bare names resolve directly and search +# expansion serves what it is for — short intranet names. +# +# Unknown single-label names still cannot leak: Docker's embedded resolver +# forwards non-container names to the compose-configured `dns:` sink +# (192.0.2.1), which is unroutable, so they fail without leaving the host. # # Args: # $1 - path to dns.conf (sidecar from mitmproxy) # $2 - path to dnsmasq config to write -# $3..$N - search domains routed to 127.0.0.11 (may be empty) +# $3..$N - search domains (unused here; kept for resolv.conf callers' +# symmetry and future use) write_dnsmasq_conf() { local dns_conf="$1" local out="$2" @@ -91,16 +107,9 @@ write_dnsmasq_conf() { echo "bind-interfaces" echo "log-facility=-" echo "cache-size=1000" - # Drop dotless and RFC1918-reverse queries instead of forwarding them. - # Sibling-container resolution relies on libc expanding bare names via - # the search domain into FQDNs; any single-label query that still - # reaches dnsmasq has bypassed that path and shouldn't leak upstream. - echo "domain-needed" echo "bogus-priv" - local d - for d in "$@"; do - printf 'server=/%s/127.0.0.11\n' "$d" - done + # Empty-domain rule: applies ONLY to unqualified names. + echo "server=//127.0.0.11" local ns while IFS= read -r ns; do printf 'server=%s\n' "$ns" @@ -109,13 +118,14 @@ write_dnsmasq_conf() { } # Write a resolv.conf that routes all queries through the local dnsmasq. -# Search domains are preserved so that bare hostnames (e.g. another compose -# service) get expanded by the libc resolver and routed to 127.0.0.11 by -# dnsmasq's `server=//127.0.0.11` rules. +# Search domains are preserved so that short intranet names (e.g. `foo` with +# a corporate search domain `corp.example`) get expanded by the libc resolver +# into FQDNs the upstream DNS can answer. Sibling containers do not need the +# expansion: bare names are matched by dnsmasq's empty-domain rule +# (`server=//127.0.0.11`) and answered by Docker's embedded resolver (#113). # # `options ndots:0` from the Docker-supplied resolv.conf is intentionally NOT -# preserved — it would defeat the search-domain expansion path that lets -# dnsmasq distinguish sibling-container queries from external ones. +# preserved — search expansion for intranet short names should run first. # # Args: # $1 - path to resolv.conf to overwrite @@ -143,9 +153,10 @@ main() { test -f "$WG_JSON" # ── Snapshot search domains BEFORE we overwrite resolv.conf ──────────────── - # Docker populates the container's resolv.conf with the compose project's - # default network as a search domain. We preserve it so single-label - # sibling-container lookups still work after dnsmasq takes over. + # Docker copies the host's search domains (and/or the compose network + # domain) into the container's resolv.conf. We preserve them so short + # intranet names expand to FQDNs the upstream DNS can answer; sibling + # containers resolve via the empty-domain dnsmasq rule instead (#113). local search_domains=() mapfile -t search_domains < <(extract_search_domains /etc/resolv.conf) @@ -249,12 +260,13 @@ main() { ip6tables -A OUTPUT -o eth0 -j DROP # ── Local DNS forwarder ──────────────────────────────────────────────────── - # Run dnsmasq on 127.0.0.1 so: - # - sibling-container queries (matched via the compose project's search - # domain) go to Docker's embedded resolver at 127.0.0.11 (loopback, - # NAT'd to the daemon — never enters wg0), - # - everything else goes to the upstream from dns.conf (or defaults), - # which routes through wg0 → mitmproxy for inspection and policy. + # Run dnsmasq on 127.0.0.1 so (#113): + # - unqualified single-label queries (sibling containers) go to Docker's + # embedded resolver at 127.0.0.11 (loopback, NAT'd to the daemon — + # never enters wg0), + # - every dotted name — including hosts under the container's search + # domains — goes to the upstream from dns.conf (or defaults), which + # routes through wg0 → mitmproxy for inspection and policy. # # Outbound from dnsmasq stays inside the kill-switch installed above: # - 127.0.0.11 traffic uses the lo interface (ACCEPT-ed), diff --git a/cli/test/wg-client/write_dnsmasq_conf.bats b/cli/test/wg-client/write_dnsmasq_conf.bats index cdbd140c..eb43966e 100644 --- a/cli/test/wg-client/write_dnsmasq_conf.bats +++ b/cli/test/wg-client/write_dnsmasq_conf.bats @@ -6,7 +6,7 @@ setup() { DNSMASQ_OUT="$BATS_TEST_TMPDIR/dnsmasq.conf" } -@test "write_dnsmasq_conf uses defaults and routes the search domain to Docker DNS" { +@test "write_dnsmasq_conf routes unqualified names to Docker DNS and the rest upstream" { write_dnsmasq_conf "$DNS_CONF_FIXTURE" "$DNSMASQ_OUT" myproj_default run cat "$DNSMASQ_OUT" @@ -15,11 +15,16 @@ setup() { assert_line "no-hosts" assert_line "listen-address=127.0.0.1" assert_line "bind-interfaces" - assert_line "domain-needed" assert_line "bogus-priv" - assert_line "server=/myproj_default/127.0.0.11" + # Empty-domain rule: single-label (sibling container) names → Docker DNS. + assert_line "server=//127.0.0.11" assert_line "server=1.1.1.1" assert_line "server=8.8.8.8" + # #113 regression guards: search domains must NOT be routed to Docker + # (corporate search domains are intranet zones only the upstream knows), + # and dotless queries must NOT be dropped (they carry sibling lookups). + refute_output --partial "server=/myproj_default/" + refute_output --partial "domain-needed" } @test "write_dnsmasq_conf uses custom upstream from dns.conf" { @@ -35,22 +40,23 @@ setup() { refute_line "server=8.8.8.8" } -@test "write_dnsmasq_conf with no search domains emits no Docker DNS routes" { +@test "write_dnsmasq_conf emits the same config with no search domains" { write_dnsmasq_conf "$DNS_CONF_FIXTURE" "$DNSMASQ_OUT" run cat "$DNSMASQ_OUT" assert_success - refute_output --partial "127.0.0.11" + assert_line "server=//127.0.0.11" assert_line "server=1.1.1.1" } -@test "write_dnsmasq_conf supports multiple search domains" { +@test "write_dnsmasq_conf ignores search domains for routing (multiple given)" { write_dnsmasq_conf "$DNS_CONF_FIXTURE" "$DNSMASQ_OUT" myproj_default cluster.local run cat "$DNSMASQ_OUT" assert_success - assert_line "server=/myproj_default/127.0.0.11" - assert_line "server=/cluster.local/127.0.0.11" + assert_line "server=//127.0.0.11" + refute_output --partial "server=/myproj_default/" + refute_output --partial "server=/cluster.local/" } @test "write_dnsmasq_conf falls back to defaults when dns.conf is empty" { diff --git a/docs/configuration/dns.md b/docs/configuration/dns.md index 5d1f60ef..3e8777a5 100644 --- a/docs/configuration/dns.md +++ b/docs/configuration/dns.md @@ -28,9 +28,11 @@ what a lower layer set. Run `sandcat restart` after editing. ## Container-to-container DNS The agent can resolve sibling containers on the same Docker compose network by -name (e.g. a `db:` service in `compose.yml` is reachable as `db`). Queries -under the compose project's network (the `search` domain Docker assigns to -the container) go to Docker's embedded resolver at `127.0.0.11`. No +name (e.g. a `db:` service in `compose.yml` is reachable as `db`). +Unqualified single-label queries go to Docker's embedded resolver at +`127.0.0.11` (dnsmasq's empty-domain rule); every dotted name — including +hosts under the container's search domains, which in corporate setups are +intranet zones — goes to the configured upstream through the tunnel. No configuration is required. The agent shares wg-client's network namespace via `network_mode` but Docker @@ -40,12 +42,12 @@ volume mounted read-only at `/run/sandcat` in the agent, and `app-init.sh` copies it into `/etc/resolv.conf` on startup so the agent's lookups also go through the local dnsmasq. -To prevent the search-domain carve-out from becoming a DNS exfiltration -channel — where an attacker-crafted name like `._default` -would otherwise be forwarded by Docker's embedded resolver to the host's -upstream DNS, bypassing mitmproxy — wg-client is launched with a `dns:` sink -(RFC 5737 `192.0.2.1`). Sibling names still resolve locally; anything else -under the search domain fails fast without leaving the host. +To prevent the unqualified-name carve-out from becoming a DNS exfiltration +channel, wg-client is launched with a `dns:` sink (RFC 5737 `192.0.2.1`): +Docker's embedded resolver answers sibling-container names locally and +forwards anything it does not know to the sink, which is unroutable — so +unknown single-label lookups fail fast without ever leaving the host, and +every dotted name is subject to mitmproxy's DNS policy on its way upstream. ## Resolving internal hostnames — `extra_hosts`