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) }