DNS and DNSSEC

Split-horizon DNS for private services with CoreDNS, the secure way

Split-horizon DNS is two phone books with one front desk. Hand the internal book to a visitor and they learn where the database lives; hand the public book to staff and nothing works. CoreDNS decides by block order, and it will not warn you.

The short answer

Keep two zone files: internal names only in the internal one. In CoreDNS, declare the block with a view matching your internal client ranges first, and the public catch-all block last. Test the same names from an internal and an external address. Bind listeners and firewall port 53 so outsiders never reach the internal side.

Updated Houssam Hammoudi, CTOTested with CoreDNS 1.14.7

On this page
  1. What goes wrong
  2. What the docs say
  3. The secure configuration
  4. Prove it
  5. Mistakes people make
  6. Checklist

What goes wrong

Split-horizon DNS answers the same name differently depending on who asks. Staff on the internal network get www.example.com at a private address and can resolve db.example.com. Everyone else gets the public edge address and learns nothing about db.

Two mistakes are common. The first is serving one zone to everyone, often because the DNS server started internal-only and later got a public address. Every private name and address is then public. Attackers use those names to plan: which hosts exist, what they are called, which ranges are inside.

The second is specific to CoreDNS. The view plugin picks a server block by an expression such as the client address. Blocks for the same zone are tried from top to bottom. A block with no view matches everything. Put it first, and the internal view is never used: internal clients get public answers and private names stop resolving.

What the docs say

view defines an expression that must evaluate to true for a DNS request to be routed to the server block.

Source: CoreDNS docs, view plugin

An unfiltered catch-all block declared before a filtered block will shadow it, because the catch-all matches every query.

Source: CoreDNS docs, view plugin, Server Block Ordering

To get the expected split-DNS behavior, declare all filtered (view) blocks first and the unfiltered catch-all block last.

Source: CoreDNS docs, view plugin, Server Block Ordering

The CoreDNS docs describe the routing well. They leave out two things. First, client_ip() is the address the query came from. If internal clients use a central resolver, the view sees the resolver, not the laptop. Second, a view decides which answer to give, not who may connect. The server still answers the public view to anyone who can reach it, so network reachability needs its own control.

The secure configuration

Two zone files. The internal file has every name; the public file has only what the internet should see:

text
; db.example.com.internal
$ORIGIN example.com.
$TTL 300
@     SOA ns1 hostmaster 2026092401 3600 600 302400 300
@     NS  ns1
ns1   A   10.10.0.53
www   A   10.10.0.80      ; internal clients reach the app directly
db    A   10.10.0.5       ; private service: never in the public file
vault A   10.10.0.6
text
; db.example.com.public
$ORIGIN example.com.
$TTL 300
@     SOA ns1 hostmaster 2026092401 3600 600 302400 300
@     NS  ns1
ns1   A   198.51.100.53
www   A   203.0.113.80    ; public clients go through the edge

Corefile. The view block comes first; the catch-all comes last:

conf
example.com {
    bind 10.10.0.53 198.51.100.53        # listen only on the addresses you mean
    view internal {
        # the internal client ranges, or your internal resolvers' addresses
        expr incidr(client_ip(), '10.10.0.0/24')
    }
    file /etc/coredns/db.example.com.internal
    log
}

example.com {
    bind 10.10.0.53 198.51.100.53
    file /etc/coredns/db.example.com.public   # no private names in this file
    log
}

Notes on the configuration:

text
- Use single quotes inside expr. CoreDNS strips double quotes, and the
  expression then fails to parse.
- Do not add a forward or recursion to the public block for example.com.
  Unknown names there must be NXDOMAIN, not a lookup elsewhere.
- If internal clients reach CoreDNS through a resolver, match the resolver's
  addresses in the view, and make sure that resolver only serves internal
  clients.
- Keep the public file generated from, or checked against, the internal one,
  so a public name never exists only by accident.

Reachability, on the host running CoreDNS:

bash
# Internal listener: only internal ranges may reach it.
nft add rule inet filter input ip daddr 10.10.0.53 ip saddr != 10.10.0.0/24 udp dport 53 drop
nft add rule inet filter input ip daddr 10.10.0.53 ip saddr != 10.10.0.0/24 tcp dport 53 drop

Prove it

The test (secure-tests/split-horizon-dns-private-services-coredns/run.sh) attaches CoreDNS 1.14.7 to an internal network (10.10.0.53) and a public one (198.51.100.53). A client at 10.10.0.10 and a client at 198.51.100.99 ask for www and db.

Before 1: one zone for everyone

conf
example.com {
    file /etc/coredns/db.example.com.internal
}
text
$ kdig @10.10.0.53 www.example.com A          (internal client 10.10.0.10)
status: NOERROR
www.example.com.    	300	IN	A	10.10.0.80
$ kdig @198.51.100.53 www.example.com A       (public client 198.51.100.99)
status: NOERROR
www.example.com.    	300	IN	A	10.10.0.80

$ kdig @10.10.0.53 db.example.com A          (internal client 10.10.0.10)
status: NOERROR
db.example.com.     	300	IN	A	10.10.0.5
$ kdig @198.51.100.53 db.example.com A       (public client 198.51.100.99)
status: NOERROR
db.example.com.     	300	IN	A	10.10.0.5

The public client learns the private database name and address.

Before 2: views in the wrong order

The public catch-all block is declared first, the view block second:

text
$ kdig @10.10.0.53 www.example.com A          (internal client 10.10.0.10)
status: NOERROR
www.example.com.    	300	IN	A	203.0.113.80
$ kdig @198.51.100.53 www.example.com A       (public client 198.51.100.99)
status: NOERROR
www.example.com.    	300	IN	A	203.0.113.80

$ kdig @10.10.0.53 db.example.com A          (internal client 10.10.0.10)
status: NXDOMAIN
$ kdig @198.51.100.53 db.example.com A       (public client 198.51.100.99)
status: NXDOMAIN

Nothing leaks, but the internal view is dead: staff get public answers and cannot resolve db. The usual "fix" under pressure is to put the private records in the public file.

After: view first, catch-all last, bind on both blocks

text
$ kdig @10.10.0.53 www.example.com A          (internal client 10.10.0.10)
status: NOERROR
www.example.com.    	300	IN	A	10.10.0.80
$ kdig @198.51.100.53 www.example.com A       (public client 198.51.100.99)
status: NOERROR
www.example.com.    	300	IN	A	203.0.113.80

$ kdig @10.10.0.53 db.example.com A          (internal client 10.10.0.10)
status: NOERROR
db.example.com.     	300	IN	A	10.10.0.5
$ kdig @198.51.100.53 db.example.com A       (public client 198.51.100.99)
status: NXDOMAIN

The query log shows which client got which answer:

text
10.10.0.10:48039 - 49644 "A IN db.example.com. udp 43 false 1232" NOERROR qr,aa 102 0.000116642s
198.51.100.99:45427 - 9353 "A IN db.example.com. udp 43 false 1232" NXDOMAIN qr,aa 116 0.000194521s

Mistakes people make

The catch-all block first

CoreDNS routes to the first matching block. A block without view matches everything, so it must be last. The test shows the result of the wrong order: internal names vanish for internal users.

Private names in the public file "just in case"

Once a private name is in the public zone, it is in every resolver cache and in passive DNS databases that record answers over time. Keep private names only in the internal file, and diff the two files in CI.

Matching laptops when queries come from a resolver

If staff use a central resolver, CoreDNS sees the resolver's address. A view on the office range then never matches. Match the resolver, and lock that resolver to internal clients.

Treating the view as access control

A view chooses an answer. It does not refuse connections. Bind the internal listener to an internal address and firewall it, so outsiders cannot even send it a query.

Double quotes in the expression

The Corefile parser removes double quotes, and the expression fails to parse at startup. Use single quotes, as in the CoreDNS examples.

Checklist

  • Keep an internal zone file and a public zone file; private names only in the internal one.
  • Declare every view block before the catch-all block for the same zone.
  • Match the addresses that actually send queries: clients or internal resolvers.
  • Use single quotes inside expr.
  • Use bind so CoreDNS listens only on the intended addresses.
  • Firewall the internal listener to internal ranges.
  • Do not forward unknown names from the public block.
  • Test the same names from one internal and one external address after every change.
  • Diff the two zone files in CI and review every public addition.

Split-horizon DNS works when each side sees exactly its own book. Test from both sides, every time, because the server will happily answer either way.

H2-CSPE

Learn it on a live range

Cluster networking and policy, in Secure Platform Engineering: a real host in your browser, and every objective checked on the machine.

Start free

The Dome

Want it run for you?

The Dome puts post-quantum TLS, a WAF that blocks, signed DNS and a zero-trust mesh in front of your application. Tell us what you run.

See the Dome