Files
fancy-dns/internal/server/identity_test.go
T
developer e8c5b414d9
build / build-push (push) Failing after 23s
half broken
2026-09-27 17:17:53 +00:00

233 lines
8.7 KiB
Go

package server
import (
"net"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"github.com/miekg/dns"
)
// TestSetIdentityReachesTheEngineRequest: the server resolves a client IP to
// reverse names and ACL tags before handing the query on, so the engine never
// has to do a PTR lookup on the hot path. Both the names and the tags must
// arrive, and the key they are resolved from must be the client's own address
// — a resolver that keys on anything else silently puts every client in one
// ACL group.
func TestSetIdentityReachesTheEngineRequest(t *testing.T) {
h := newHarness(t, nil)
var mu sync.Mutex
var keys []string
h.srv.SetIdentity(
func(ip string) []string {
mu.Lock()
keys = append(keys, ip)
mu.Unlock()
return []string{"host." + strings.ReplaceAll(ip, ".", "-") + ".test."}
},
func(ip string) []string { return []string{"tag:" + ip} },
)
h.ask("udp", "identity.test.", dns.TypeA)
got := h.fake.only(t)
if len(got.names) != 1 || got.names[0] != "host.127-0-0-1.test." {
t.Errorf("ClientNames = %v, want [host.127-0-0-1.test.]", got.names)
}
if len(got.tags) != 1 || got.tags[0] != "tag:127.0.0.1" {
t.Errorf("ClientTags = %v, want [tag:127.0.0.1]", got.tags)
}
mu.Lock()
defer mu.Unlock()
if len(keys) != 1 || keys[0] != "127.0.0.1" {
t.Errorf("identity was resolved for %v, want [127.0.0.1]", keys)
}
// And it is consulted per request, not cached at Start: a second query
// after a change has to see the new answer.
h.srv.SetIdentity(
func(string) []string { return []string{"second.test."} },
func(string) []string { return []string{"tag:second"} },
)
h.ask("tcp", "identity2.test.", dns.TypeA)
got = h.fake.last()
if len(got.names) != 1 || got.names[0] != "second.test." {
t.Errorf("ClientNames after the second SetIdentity = %v, want [second.test.]", got.names)
}
if len(got.tags) != 1 || got.tags[0] != "tag:second" {
t.Errorf("ClientTags after the second SetIdentity = %v, want [tag:second]", got.tags)
}
}
// TestRequestWithoutIdentityIsStillAnswered: SetIdentity is optional. With the
// resolvers unset the atomic holders are nil, and the query path must not
// dereference them — a server wired before the app installed its resolvers
// would otherwise panic on the first packet.
func TestRequestWithoutIdentityIsStillAnswered(t *testing.T) {
h := newHarness(t, nil)
res := h.ask("udp", "noidentity.test.", dns.TypeA)
if answerAddr(res) != "203.0.113.9" {
t.Errorf("A = %q, want 203.0.113.9", answerAddr(res))
}
got := h.fake.only(t)
if got.names != nil || got.tags != nil {
t.Errorf("ClientNames/ClientTags = %v/%v, want nil", got.names, got.tags)
}
}
// TestAddrToAddr covers the address normalisation every client identity, ACL
// and rate-limit decision depends on. An IPv4 client must arrive as an IPv4
// address even when the kernel hands us a v4-mapped v6 one, or every CIDR in
// every ACL group stops matching.
func TestAddrToAddr(t *testing.T) {
cases := []struct {
name string
addr net.Addr
want string
}{
{"udp IPv4", &net.UDPAddr{IP: net.ParseIP("192.0.2.7"), Port: 5353}, "192.0.2.7"},
{"tcp IPv4", &net.TCPAddr{IP: net.ParseIP("192.0.2.7"), Port: 5353}, "192.0.2.7"},
// A dual-stack listener reports IPv4 clients as ::ffff:a.b.c.d; without
// Unmap the ACL lookup would be handed "::ffff:192.0.2.7" and match no
// IPv4 group.
{"udp IPv4-mapped IPv6", &net.UDPAddr{IP: net.ParseIP("::ffff:192.0.2.7"), Port: 53}, "192.0.2.7"},
{"tcp IPv4-mapped IPv6", &net.TCPAddr{IP: net.ParseIP("::ffff:192.0.2.7"), Port: 53}, "192.0.2.7"},
{"udp IPv6", &net.UDPAddr{IP: net.ParseIP("2001:db8::1"), Port: 53}, "2001:db8::1"},
// A scoped link-local address keeps its zone: dropping it would change
// which interface the address names.
{"udp IPv6 with a zone", &net.UDPAddr{IP: net.ParseIP("fe80::1"), Port: 53, Zone: "eth0"}, "fe80::1%eth0"},
{"custom addr with host:port", stringAddr("198.51.100.4:34567"), "198.51.100.4"},
{"custom addr with a bare IP", stringAddr("198.51.100.5"), "198.51.100.5"},
// Unparseable: the zero Addr. A client identity lookup on it must not
// panic, and the caller sees the invalid address.
{"custom addr with garbage", stringAddr("not-an-address"), "invalid IP"},
{"custom addr with a bare hostname", stringAddr("localhost"), "invalid IP"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := addrToAddr(tc.addr)
if got.String() != tc.want {
t.Errorf("addrToAddr(%s) = %q, want %q", tc.addr, got, tc.want)
}
if tc.want == "invalid IP" {
if got.IsValid() {
t.Errorf("addrToAddr returned a valid address %v for unparseable input", got)
}
return
}
if !got.IsValid() {
t.Errorf("addrToAddr returned the invalid address for %s", tc.addr)
}
})
}
}
// TestHTTPRemoteAddr covers the DoH adapter that turns an http.Request's
// RemoteAddr into the net.Addr the shared request builder wants.
func TestHTTPRemoteAddr(t *testing.T) {
cases := []struct {
name string
remoteAddr string
want string
}{
{"IPv4 with a port", "192.0.2.9:54321", "192.0.2.9"},
{"IPv6 with a port", "[2001:db8::1]:54321", "2001:db8::1"},
{"IPv4-mapped IPv6", "[::ffff:192.0.2.9]:54321", "192.0.2.9"},
// An empty or unparseable RemoteAddr cannot become a client identity;
// the zero TCPAddr degrades to the invalid address rather than
// inventing one.
{"empty", "", "invalid IP"},
{"garbage", "not-an-address", "invalid IP"},
{"no port", "192.0.2.9", "invalid IP"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "https://example.test/dns-query", nil)
r.RemoteAddr = tc.remoteAddr
got := addrToAddr(httpRemoteAddr(r))
if got.String() != tc.want {
t.Errorf("httpRemoteAddr(%q) -> %q, want %q", tc.remoteAddr, got, tc.want)
}
})
}
}
// TestDoHClientIdentityIsTheProxyNotTheForwardedForHeader pins a known
// limitation, deliberately. httpRemoteAddr (server.go) uses the raw
// RemoteAddr and never consults X-Forwarded-For, even though the admin API in
// the same binary trusts that header from trusted_proxies. So a DoH query that
// arrives through the Ingress sees the Ingress's address as its client.
//
// Consequence, and the reason this needs a test before anyone "fixes" it: the
// DoH ACL group is the proxy's group, not the client's. Behind the shipped
// ClusterIP Ingress every DoH client lands in the same group — fail-CLOSED if
// that group is restrictive (a legitimate DoH client is refused) and fail-OPEN
// if it is permissive (a client outside its intended group is allowed). This
// is a security decision, not a bug to be quietly patched: turning on
// X-Forwarded-For without consulting trusted_proxies would let any client
// spoof its identity past the ACL and the rate limiter, which is exactly what
// the AGENTS.md invariant forbids. The fix, if it is ever wanted, is a trusted
// proxy list for the DoH listener and a rewrite of this test.
func TestDoHClientIdentityIsTheProxyNotTheForwardedForHeader(t *testing.T) {
h := newHarness(t, nil)
var mu sync.Mutex
var keys []string
h.srv.SetIdentity(
func(ip string) []string {
mu.Lock()
keys = append(keys, ip)
mu.Unlock()
return []string{"client.test."}
},
func(ip string) []string { return nil },
)
wire, err := question("xff.test.", dns.TypeA).Pack()
if err != nil {
t.Fatalf("pack: %v", err)
}
req, err := http.NewRequest(http.MethodPost, h.dohURL(h.base()), strings.NewReader(string(wire)))
if err != nil {
t.Fatalf("request: %v", err)
}
req.Header.Set("Content-Type", contentTypeDNS)
// Both headers an operator would expect to be honoured. Neither is.
req.Header.Set("X-Forwarded-For", "203.0.113.66")
req.Header.Set("X-Real-IP", "203.0.113.66")
req.Header.Set("Forwarded", "for=203.0.113.66")
res, err := h.dohClient(false).Do(req)
if err != nil {
t.Fatalf("POST: %v", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", res.StatusCode)
}
got := h.fake.only(t)
// The client connected from loopback, so the proxy IS loopback here.
if got.remote.String() != "127.0.0.1" {
t.Errorf("Request.Remote = %v, want the socket peer 127.0.0.1", got.remote)
}
mu.Lock()
defer mu.Unlock()
for _, k := range keys {
if k == "203.0.113.66" {
t.Fatalf("identity was resolved for the X-Forwarded-For address %q; httpRemoteAddr must ignore it", k)
}
}
if len(keys) == 0 || keys[0] != "127.0.0.1" {
t.Errorf("identity keys = %v, want [127.0.0.1]", keys)
}
}
// stringAddr is a net.Addr that is neither TCP nor UDP, so addrToAddr has to
// fall back to parsing its string — which is the path a wrapped or proxied
// connection takes.
type stringAddr string
func (s stringAddr) Network() string { return "test" }
func (s stringAddr) String() string { return string(s) }