Philipp Lehmann @PhilippTheServer

ndots and the Accidental Search-Domain Leak in Pod DNS

How a legitimate search-domain addition quietly reroutes ordinary external lookups.

The problem

Every pod gets a /etc/resolv.conf kubelet builds for it. By default it looks something like this:

search default.svc.cluster.local svc.cluster.local cluster.local
options ndots:5

ndots:5 tells the resolver: if a name being looked up has fewer than five dots in it, don’t assume it’s already a complete, absolute name — try it with each entry in the search list appended first, in order, and only fall back to trying it as written if all of those fail. A name like redis (0 dots) obviously needs this to become redis.default.svc.cluster.local. A name like db.example.com has only two dots, so it gets exactly the same treatment: every search suffix is tried before the plain, intended name ever is.

Normally this costs nothing but a handful of wasted NXDOMAIN round trips before the real answer wins, and nobody notices. It stops being harmless the moment a pod’s search list contains a domain that is actually authoritative for something. This is what dnsConfig.searches lets you do — add your own domain to the list, usually to reach some other internal short name conveniently:

# Looks reasonable. Added to reach an internal service by a short name.
dnsPolicy: ClusterFirst
dnsConfig:
  searches:
    - internal.example.net

If internal.example.net happens to be authoritative for a wildcard record — a catch-all reverse proxy, or just a permissively configured internal zone — then an ordinary, unrelated external lookup like db.example.com (two dots, so it qualifies for the search-list treatment) can get answered by db.example.com.internal.example.net matching that wildcard, before the real, absolute db.example.com is ever queried. There is no error and no timeout. The application gets an answer, accepts it as correct, and connects to whatever the internal wildcard points at instead of the real external endpoint.

This is hard to catch because the search-domain addition is almost always made for a legitimate, unrelated reason — reaching one specific internal name conveniently — and the side effect only appears for names that happen to have fewer than five dots and were not already being queried as an absolute name. That covers most ordinary hostnames, which is exactly why the blast radius is larger than whoever added the search domain was thinking about.

Working through it

Lower ndots, but be precise about what it actually protects

It is tempting to read ndots as an on/off switch for the search list. It is not one. ndots only decides which candidate is tried first. With ndots:1, a name needs just one dot to be tried as the absolute name before anything from the search list is appended. With the default ndots:5, four dots or fewer means the search list is tried first instead. Either way, trying the first candidate is not the end of the story: if it does not come back with a usable, positive answer, the resolver falls through and tries the rest of the list. The absolute name still gets its turn under ndots:5 if every search-suffixed attempt fails; the search list still gets its turn under ndots:1 if the absolute attempt fails.

dnsConfig:
  options:
    - name: ndots
      value: "1"

That fallthrough is exactly why this fix only protects one of the two situations that look identical from the outside. For an external name that currently has a real, working answer, ndots:1 means the absolute query is tried first, it succeeds immediately, and the search list — wildcard included — is never consulted at all. That is a genuine, verifiable fix, and it is the case most people picture when they hear about this leak: an ordinary hostname getting silently rerouted. For a name that does not currently have a working answer — mistyped, not yet provisioned, or simply retired — the absolute attempt fails to produce anything usable, and the resolver falls through to the search list exactly as it would under the default. Lowering ndots does not protect that case at all, and the lab below demonstrates both sides of that split rather than asserting it.

ndots:1 also breaks the convenience that made ndots:5 the default in the first place: a bare service name like redis (0 dots) no longer gets the search suffix applied before being tried absolutely, and an absolute lookup for a bare redis will simply fail. This only makes sense either for workloads that talk mostly to the outside world and rarely if ever to other in-cluster services by short name, or combined with switching internal lookups to fully-qualified names too. It’s a workload-level decision, not something to apply cluster-wide without checking what depends on the default.

Use a trailing dot for names you control

A trailing dot marks a name as already absolute, and an absolute name skips the search list entirely, regardless of ndots:

db.example.com.

This is the cheapest and most surgical fix available, because it needs no cluster-wide configuration change at all — only a one-character edit wherever the hostname is configured or hardcoded. Its limit is exactly that specificity: it only protects the names you can actually edit. A name that arrives from a third-party library’s default configuration, or from a value nobody remembers to append the dot to, is not protected by this fix.

Treat every added search domain as blast radius

The most durable fix is upstream of both of the above: stop treating dnsConfig.searches as a free convenience. Every domain added there is tried for every non-absolute lookup a pod makes, not just the specific internal name you had in mind when you added it. If a workload genuinely needs to resolve names inside an internal domain, a narrower mechanism — an explicit resolver used only by the workloads that need it, or a dedicated CoreDNS zone matched by name rather than injected into every pod’s search path — keeps an ordinary external lookup from ever being able to match it by accident. The convenience of a short name is not worth making every other lookup that workload makes ambiguous.

The solution

Everything below runs on a laptop with kind, using its default CoreDNS. The zone internal.example.net and the wildcard inside it exist only in this lab cluster — nothing external is touched.

Add a self-contained internal zone with a wildcard record to CoreDNS:

kubectl get configmap coredns -n kube-system -o jsonpath='{.data.Corefile}' > Corefile.orig

kubectl patch configmap coredns -n kube-system --type merge -p '
data:
  Corefile: |
    .:53 {
        errors
        health
        ready
        kubernetes cluster.local in-addr.arpa ip6.arpa {
            pods insecure
            fallthrough in-addr.arpa ip6.arpa
        }
        prometheus :9153
        forward . /etc/resolv.conf
        cache 30
        loop
        reload
        loadbalance
    }
    internal.example.net:53 {
        template IN A {
            answer "{{ .Name }} 60 IN A 203.0.113.50"
        }
    }
'

kubectl rollout restart deployment coredns -n kube-system
kubectl rollout status deployment coredns -n kube-system

The broken pod, demonstrating the leak. This uses debian:12-slim rather than a smaller image deliberately: getent hosts is the command that actually exercises glibc’s resolver — search list, ndots, and all — the same machinery any normal application relies on through getaddrinfo(). BusyBox’s nslookup is a much more minimal tool that skips search-list expansion entirely, and BusyBox has no getent at all, so it cannot demonstrate this mechanism regardless of which command you reach for.

# pod-leak.yaml
apiVersion: v1
kind: Pod
metadata:
  name: dns-leak
spec:
  dnsPolicy: ClusterFirst
  dnsConfig:
    searches:
      - internal.example.net
  containers:
    - name: shell
      image: debian:12-slim
      command: ["sleep", "3600"]
kubectl apply -f pod-leak.yaml
kubectl wait --for=condition=Ready pod/dns-leak --timeout=60s
kubectl exec dns-leak -- getent hosts db.example.com
# 203.0.113.50   db.example.com.internal.example.net

db.example.com was never a real name anywhere — example.com itself is a real, IANA-reserved domain, but this subdomain has no record on it. Queried on its own it comes back with nothing usable. Here it instead resolved via internal.example.net’s wildcard, because with the default ndots:5 and only two dots in the name, every search-list suffix is tried before the absolute name ever gets a turn.

Fix one: lower ndots for this workload, and see exactly which of the two situations from the previous section it actually covers.

# pod-ndots1.yaml
apiVersion: v1
kind: Pod
metadata:
  name: dns-ndots1
spec:
  dnsPolicy: ClusterFirst
  dnsConfig:
    searches:
      - internal.example.net
    options:
      - name: ndots
        value: "1"
  containers:
    - name: shell
      image: debian:12-slim
      command: ["sleep", "3600"]
kubectl apply -f pod-ndots1.yaml
kubectl wait --for=condition=Ready pod/dns-ndots1 --timeout=60s

# A real external name with a genuine, currently-working answer: the absolute
# query is tried first, it succeeds outright, and the search list is never consulted.
kubectl exec dns-ndots1 -- getent ahostsv4 one.one.one.one
# 1.1.1.1         STREAM one.one.one.one
# 1.1.1.1         DGRAM
# 1.1.1.1         RAW
# (Cloudflare publishes this name against two addresses; which one lists first
# can vary between runs — what matters is that 203.0.113.50 never appears here.)

# The same fictional name as before: the absolute query is still tried first, but it
# comes back with nothing usable, so the resolver falls through to the search list
# anyway — and the wildcard is still there to answer it.
kubectl exec dns-ndots1 -- getent hosts db.example.com
# 203.0.113.50   db.example.com.internal.example.net

Lowering ndots protected the name that already worked, and did nothing at all for the name that did not — which is precisely the one this whole failure mode is about. A fix that only works when nothing was actually wrong is not the fix to reach for here.

Fix two: a trailing dot, without touching pod DNS config at all.

kubectl exec dns-leak -- getent hosts db.example.com.
# (no output, exit status 2 - not found, correctly not resolved)

Unlike lowering ndots, the trailing dot protects both cases, because it removes the search list from consideration entirely for that one lookup rather than merely reordering when it gets consulted — there is no fallthrough left for a missing answer to fall through to.

The two fixes are not equally reliable; which one is appropriate depends on whether you control the pod’s DNS configuration or only the application’s own hostname strings.

Conclusion

ndots:5 is a reasonable default for short cluster-internal names, and a liability the moment any domain with a wildcard sits anywhere in the search path. The default was not designed with that domain in mind; it just happens to apply to it too.

A trailing dot is a one-character fix for any fully-qualified name you control. It needs no cluster or pod configuration change, which makes it the right first thing to reach for whenever the name in question is something your own code or config sets.

Every domain added via dnsConfig.searches is blast radius, not just convenience. It is tried for every lookup that isn’t already absolute, not only the one internal name you added it to reach — and that gap between intent and effect is exactly what makes this leak easy to introduce and hard to notice.