From 0b70e5cfd2838325d53e9d8ac9ee69248eb1fb1a Mon Sep 17 00:00:00 2001 From: Dimitri Herzog Date: Fri, 12 Jun 2026 18:02:04 +0200 Subject: [PATCH] feat(resolver): add DNS rebinding protection (#2111) --- config/config.go | 6 + config/rebinding.go | 88 ++++ config/rebinding_test.go | 114 +++++ docs/config.schema.json | 18 + docs/config.yml | 8 + docs/configuration.md | 72 +++ e2e/rebinding_test.go | 148 ++++++ resolver/blocking_resolver_test.go | 23 + resolver/caching_resolver.go | 21 +- resolver/caching_resolver_test.go | 61 +++ resolver/conditional_upstream_resolver.go | 17 +- resolver/domain_search.go | 22 + resolver/rebinding_resolver.go | 183 ++++++++ resolver/rebinding_resolver_test.go | 539 ++++++++++++++++++++++ resolver/sudn_resolver.go | 18 +- resolver/sudn_resolver_test.go | 5 + server/server.go | 8 + 17 files changed, 1320 insertions(+), 31 deletions(-) create mode 100644 config/rebinding.go create mode 100644 config/rebinding_test.go create mode 100644 e2e/rebinding_test.go create mode 100644 resolver/domain_search.go create mode 100644 resolver/rebinding_resolver.go create mode 100644 resolver/rebinding_resolver_test.go diff --git a/config/config.go b/config/config.go index 68d8afb7..2ac78905 100644 --- a/config/config.go +++ b/config/config.go @@ -315,6 +315,8 @@ type Config struct { HTTP3 HTTP3 `yaml:"http3"` // Per-client DNS query rate limiting configuration. RateLimit RateLimit `yaml:"rateLimit"` + // DNS rebinding protection configuration. + RebindingProtection RebindingProtection `yaml:"rebindingProtection"` // Deprecated options Deprecated struct { @@ -873,6 +875,10 @@ func (cfg *Config) validate(logger *logrus.Entry) { if err := cfg.RateLimit.validate(); err != nil { logger.Fatal(err) } + + if err := cfg.RebindingProtection.validate(); err != nil { + logger.Fatal(err) + } } // ConvertPort converts string representation into a valid port (0 - 65535) diff --git a/config/rebinding.go b/config/rebinding.go new file mode 100644 index 00000000..685be5b7 --- /dev/null +++ b/config/rebinding.go @@ -0,0 +1,88 @@ +package config + +import ( + "fmt" + "strings" + "unicode" + + "github.com/0xERR0R/blocky/util" + + "github.com/sirupsen/logrus" +) + +// RebindingProtection drops answers from the general upstream resolvers that contain +// private, loopback, link-local or unspecified IPs (DNS rebinding protection). +type RebindingProtection struct { + // Enable DNS rebinding protection for answers from the general upstream resolvers. + Enable bool `yaml:"enable"` + // Domains (including their subdomains) that may resolve to non-public IPs (split-horizon DNS). + AllowedDomains []string `yaml:"allowedDomains"` + + normalizedAllowedDomains []string +} + +// IsEnabled implements `config.Configurable`. +func (c *RebindingProtection) IsEnabled() bool { + return c.Enable +} + +// LogConfig implements `config.Configurable`. +func (c *RebindingProtection) LogConfig(logger *logrus.Entry) { + logger.Infof("allowed domains (%d):", len(c.AllowedDomains)) + + for _, domain := range c.AllowedDomains { + logger.Infof(" - %s", domain) + } +} + +// validate returns an error if the allowlist contains an empty or non-plain-domain +// entry. It runs even when the protection is disabled, so config errors surface +// before the user enables it. +func (c *RebindingProtection) validate() error { + for i, domain := range c.AllowedDomains { + if strings.TrimSpace(domain) == "" { + return fmt.Errorf("rebindingProtection.allowedDomains[%d] must not be empty", i) + } + + // queryLog.ignore.domains supports wildcard/regex syntax; this list does not — + // reject such entries (and other never-matching forms like padded strings or + // degenerate dots) instead of silently ignoring them; whitespace is rejected + // via unicode.IsSpace so the rule covers everything trimming would touch + if strings.ContainsAny(domain, "*/") || strings.ContainsFunc(domain, unicode.IsSpace) || + strings.HasPrefix(domain, ".") || strings.Contains(domain, "..") { + return fmt.Errorf( + "rebindingProtection.allowedDomains[%d] (%q) must be a plain domain"+ + " (no wildcards, regexes or whitespace); subdomains match automatically", + i, domain) + } + } + + c.normalizedAllowedDomains = normalizeDomains(c.AllowedDomains) + + return nil +} + +// NormalizedAllowedDomains returns the allowlist entries in canonical form +// (lowercase, no trailing dot) — the form resolvers must match against. +// validate caches the result; configs that were never validated (e.g. +// hand-built in tests) are normalized on the fly. +func (c *RebindingProtection) NormalizedAllowedDomains() []string { + if c.normalizedAllowedDomains == nil { + return normalizeDomains(c.AllowedDomains) + } + + return c.normalizedAllowedDomains +} + +func normalizeDomains(domains []string) []string { + if domains == nil { + return nil + } + + normalized := make([]string, len(domains)) + for i, domain := range domains { + normalized[i] = util.ExtractDomainOnly(domain) + } + + return normalized +} diff --git a/config/rebinding_test.go b/config/rebinding_test.go new file mode 100644 index 00000000..86932c11 --- /dev/null +++ b/config/rebinding_test.go @@ -0,0 +1,114 @@ +package config + +import ( + "github.com/0xERR0R/blocky/log" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("RebindingProtection", func() { + var cfg RebindingProtection + + BeforeEach(func() { + cfg = RebindingProtection{ + Enable: true, + AllowedDomains: []string{"intranet.example.com"}, + } + }) + + Describe("IsEnabled", func() { + It("is false for the zero value (opt-in)", func() { + var zero RebindingProtection + + Expect(zero.IsEnabled()).Should(BeFalse()) + }) + + It("is true when enabled", func() { + Expect(cfg.IsEnabled()).Should(BeTrue()) + }) + }) + + Describe("LogConfig", func() { + It("should log something", func() { + logger, hook := log.NewMockEntry() + + cfg.LogConfig(logger) + + Expect(hook.Calls).ShouldNot(BeEmpty()) + }) + }) + + Describe("validate", func() { + It("accepts a valid allowlist", func() { + Expect(cfg.validate()).Should(Succeed()) + }) + + It("accepts an empty allowlist", func() { + cfg.AllowedDomains = nil + + Expect(cfg.validate()).Should(Succeed()) + }) + + It("rejects empty entries", func() { + cfg.AllowedDomains = []string{"intranet.example.com", " "} + + Expect(cfg.validate()).Should(MatchError(ContainSubstring("allowedDomains[1] must not be empty"))) + }) + + It("rejects wildcard entries", func() { + cfg.AllowedDomains = []string{"*.example.com"} + + Expect(cfg.validate()).Should(MatchError(ContainSubstring("plain domain"))) + }) + + It("rejects regex entries", func() { + cfg.AllowedDomains = []string{"/example/"} + + Expect(cfg.validate()).Should(MatchError(ContainSubstring("plain domain"))) + }) + + It("rejects padded entries", func() { + cfg.AllowedDomains = []string{" intranet.example.com"} + + Expect(cfg.validate()).Should(MatchError(ContainSubstring("plain domain"))) + }) + + It("rejects entries with non-ASCII whitespace", func() { + // the rejected set must match the normalization's definition of + // whitespace (unicode.IsSpace), not just the ASCII subset + cfg.AllowedDomains = []string{"\u00a0intranet.example.com"} + + Expect(cfg.validate()).Should(MatchError(ContainSubstring("plain domain"))) + }) + + It("rejects dot-degenerate entries", func() { + cfg.AllowedDomains = []string{"."} + + Expect(cfg.validate()).Should(MatchError(ContainSubstring("plain domain"))) + }) + + It("accepts underscore, punycode and digit/hyphen entries", func() { + cfg.AllowedDomains = []string{"_dmarc.example.com", "xn--br-via.example.com", "host-1.example.com"} + + Expect(cfg.validate()).Should(Succeed()) + }) + }) + + Describe("NormalizedAllowedDomains", func() { + It("returns entries lowercased and without trailing dot after validate", func() { + cfg.AllowedDomains = []string{"Intranet.Example.COM."} + + Expect(cfg.validate()).Should(Succeed()) + Expect(cfg.NormalizedAllowedDomains()).Should(ConsistOf("intranet.example.com")) + }) + + It("normalizes on the fly when validate has not run", func() { + // configs hand-built in tests skip validate; canonicalization must + // not depend on it + cfg.AllowedDomains = []string{"Router.LAN."} + + Expect(cfg.NormalizedAllowedDomains()).Should(ConsistOf("router.lan")) + }) + }) +}) diff --git a/docs/config.schema.json b/docs/config.schema.json index 0d3a0d85..6f1bc514 100644 --- a/docs/config.schema.json +++ b/docs/config.schema.json @@ -1626,6 +1626,24 @@ "type": "object", "description": "Per-client DNS query rate limiting configuration." }, + "rebindingProtection": { + "properties": { + "enable": { + "type": "boolean", + "description": "Enable DNS rebinding protection for answers from the general upstream resolvers." + }, + "allowedDomains": { + "items": { + "type": "string" + }, + "type": "array", + "description": "Domains (including their subdomains) that may resolve to non-public IPs (split-horizon DNS)." + } + }, + "additionalProperties": false, + "type": "object", + "description": "DNS rebinding protection configuration." + }, "upstream": { "additionalProperties": { "items": { diff --git a/docs/config.yml b/docs/config.yml index 24bc49f1..6200a44c 100644 --- a/docs/config.yml +++ b/docs/config.yml @@ -314,6 +314,14 @@ filtering: queryTypes: - AAAA +# optional: DNS rebinding protection. When enabled, answers from the general +# upstreams containing private, loopback, link-local or unspecified IPs are +# dropped. allowedDomains exempts split-horizon domains (incl. subdomains). +rebindingProtection: + enable: true + allowedDomains: + - intranet.example.com + # optional: per-client DNS query rate limiting at the head of the resolver chain. # Intended for misbehaving clients, runaway scripts, and low-volume amplification # abuse — NOT a DDoS defense (run fail2ban or crowdsec at the firewall for that). diff --git a/docs/configuration.md b/docs/configuration.md index 9bb02647..7b9ef771 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -339,6 +339,74 @@ are left untouched. Because stripping a hint changes the record, any DNSSEC sign affected `HTTPS`/`SVCB` record is removed and the `AD` (authenticated data) flag is cleared for that response. +## DNS rebinding protection + +In a DNS rebinding attack, an attacker-controlled domain first resolves to a public IP, then +re-resolves to a private address, letting a victim's browser reach devices on the local network +under the attacker's origin. When this protection is enabled, blocky drops any answer from the +general upstream resolvers that contains a non-public IP address — in `A`/`AAAA` records or in +`ipv4hint`/`ipv6hint` SvcParams of `HTTPS`/`SVCB` records, in any section of the response +(answer, authority or additional) — and returns an empty `NOERROR` response instead (visible as +response type `FILTERED` with reason `FILTERED (rebinding protection)` in query logs and metrics; +the offending IP is logged at debug level). **Disabled by default.** + +The following ranges are considered non-public: + +| Range | Description | +| ----------------------------------------------- | ----------------------- | +| `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16` | IPv4 private (RFC 1918) | +| `fc00::/7` | IPv6 unique local | +| `127.0.0.0/8`, `::1` | loopback | +| `169.254.0.0/16`, `fe80::/10` | link-local | +| `0.0.0.0`, `::` | unspecified | + +Answers from [conditional upstreams](#conditional-dns-resolution), custom DNS and the hosts file +are never inspected, so internal zones served by trusted internal resolvers keep working without +any extra configuration. One exception: a `customDNS` entry of type `CNAME` resolves its target +through the regular chain (see [CNAME resolution](#cname-resolution)), so if the target resolves +to a private IP via the general upstreams (e.g. a DDNS name pointing into the LAN), that lookup +is inspected and the entry silently loses its address records — add the CNAME **target** name to +the allowlist to keep it working. Note that all `upstreams.groups` — **including client-specific +groups** — count as general upstream resolvers and are inspected; to serve an internal zone from +an internal resolver, use a conditional mapping instead of a client-keyed group. Lookups blocky +performs for its own operation (e.g. resolving `blocking.clientGroupsBlock` FQDN client +identifiers) are exempt from the protection. + +For split-horizon domains that legitimately resolve to private IPs via the public upstreams, add +them to the allowlist. Entries match the domain itself and all of its subdomains; matching is +done on the name of the inspected query, so a `CNAME` inside an upstream answer pointing at an +allowlisted name does not bypass the protection (for `customDNS` `CNAME` entries the inspected +query is the lookup of the CNAME target, so allowlist the **target** name, not the entry's +name). If `customDNS.rewrite` rules apply to the query, matching uses the rewritten name — +allowlist the rewritten form (`conditional.rewrite` rules do not affect matching). Entries must +be plain domain names: wildcards (`*.example.com`), regexes and whitespace are rejected at +startup, and internationalized domains must be given in punycode (`xn--…`) form. + +!!! example + + ```yaml + rebindingProtection: + enable: true + allowedDomains: + - intranet.example.com + ``` + +!!! note + + The protection runs above the cache: the upstream answer is cached unchanged for its + regular TTL, and every cache hit is re-inspected, so repeat queries for a filtered domain + keep showing `FILTERED`. This also covers cache entries synchronized from other instances + through [redis](#redis). Answers from trusted local sources (conditional upstreams, + special-use domains) are never written to the cache, so they cannot resurface as cached + upstream answers and become subject to inspection. + + The protection does not cover NAT64/DNS64 setups: an IPv6 answer embedding a private IPv4 + address under a NAT64 prefix (e.g. `64:ff9b::192.168.1.1`) is not detected, and answers + synthesized by blocky's own [DNS64](#dns64) resolver are not inspected at all (the internal + `A` lookup they are derived from enters the resolver chain below the protection). On + networks with a NAT64 gateway, DNS rebinding via synthesized IPv6 answers is therefore not + prevented. + ## Rate limiting per client IP Blocky can enforce a per-client query rate limit at the head of the resolver chain. **Disabled by default.** This is _not_ a DDoS defense — for that, run [fail2ban](https://www.fail2ban.org/) or [crowdsec](https://www.crowdsec.net/) at the firewall, which can drop traffic before it reaches Blocky. The limiter is intended for misbehaving clients, runaway scripts, and the kind of low-volume amplification that can enroll a public Blocky instance in someone else's DNS reflection attack ([issue #1135](https://github.com/0xERR0R/blocky/issues/1135)). @@ -498,6 +566,10 @@ When a CNAME record is defined and a query matches that record, blocky will: 2. Additionally resolve the target of the CNAME and include those records in the answer 3. Protect against CNAME loops (where CNAMEs point to each other in a loop) +The target resolution in step 2 goes through the regular resolver chain. If +[rebinding protection](#dns-rebinding-protection) is enabled and the target resolves to a +private IP via the general upstreams, add the target name to its allowlist. + ### Reverse DNS Blocky automatically creates reverse DNS (PTR) records for all defined A and AAAA records. This allows reverse lookups from IP addresses to domain names. diff --git a/e2e/rebinding_test.go b/e2e/rebinding_test.go new file mode 100644 index 00000000..4e3dc1af --- /dev/null +++ b/e2e/rebinding_test.go @@ -0,0 +1,148 @@ +package e2e + +import ( + "context" + + . "github.com/0xERR0R/blocky/helpertest" + "github.com/0xERR0R/blocky/util" + "github.com/miekg/dns" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/testcontainers/testcontainers-go" +) + +var _ = Describe("DNS rebinding protection", func() { + var ( + e2eNet *testcontainers.DockerNetwork + blocky testcontainers.Container + err error + ) + + BeforeEach(func(ctx context.Context) { + e2eNet = getRandomNetwork(ctx) + + _, err = createDNSMokkaContainer(ctx, "moka", e2eNet, + `A public.example.com/NOERROR("A 1.2.3.4 123")`, + `A rebind.example.com/NOERROR("A 192.168.1.100 123")`, + `AAAA rebind6.example.com/NOERROR("AAAA fd00::1 123")`, + `A intranet.example.com/NOERROR("A 192.168.1.50 123")`, + ) + Expect(err).Should(Succeed()) + }) + + When("rebinding protection is enabled with an allowlist", func() { + BeforeEach(func(ctx context.Context) { + blocky, err = createBlockyContainerFromString(ctx, e2eNet, dedent(` + upstreams: + groups: + default: + - moka + rebindingProtection: + enable: true + allowedDomains: + - intranet.example.com + `)) + Expect(err).Should(Succeed()) + }) + + It("should resolve public answers normally", func(ctx context.Context) { + msg := util.NewMsgWithQuestion("public.example.com.", A) + Expect(doDNSRequest(ctx, blocky, msg)). + Should( + SatisfyAll( + BeDNSRecord("public.example.com.", A, "1.2.3.4"), + HaveTTL(BeNumerically("==", 123)), + )) + }) + + It("should filter private IPv4 answers, also on repeat (cached) queries", func(ctx context.Context) { + msg := util.NewMsgWithQuestion("rebind.example.com.", A) + + By("first query filtered", func() { + resp, err := doDNSRequest(ctx, blocky, msg) + Expect(err).Should(Succeed()) + Expect(resp.Rcode).Should(Equal(dns.RcodeSuccess)) + Expect(resp.Answer).Should(BeEmpty()) + }) + + // the cache-path proof (one upstream call, re-filtered per hit) lives in the unit spec "chained above a caching resolver" + By("repeat query still filtered", func() { + resp, err := doDNSRequest(ctx, blocky, msg) + Expect(err).Should(Succeed()) + Expect(resp.Rcode).Should(Equal(dns.RcodeSuccess)) + Expect(resp.Answer).Should(BeEmpty()) + }) + }) + + It("should filter ULA IPv6 answers", func(ctx context.Context) { + msg := util.NewMsgWithQuestion("rebind6.example.com.", AAAA) + resp, err := doDNSRequest(ctx, blocky, msg) + Expect(err).Should(Succeed()) + Expect(resp.Rcode).Should(Equal(dns.RcodeSuccess)) + Expect(resp.Answer).Should(BeEmpty()) + }) + + It("should pass through allowlisted domains with private IPs", func(ctx context.Context) { + msg := util.NewMsgWithQuestion("intranet.example.com.", A) + Expect(doDNSRequest(ctx, blocky, msg)). + Should(BeDNSRecord("intranet.example.com.", A, "192.168.1.50")) + }) + }) + + When("rebinding protection is not configured", func() { + BeforeEach(func(ctx context.Context) { + blocky, err = createBlockyContainerFromString(ctx, e2eNet, dedent(` + upstreams: + groups: + default: + - moka + `)) + Expect(err).Should(Succeed()) + }) + + It("should pass through private answers (opt-in guard)", func(ctx context.Context) { + msg := util.NewMsgWithQuestion("rebind.example.com.", A) + Expect(doDNSRequest(ctx, blocky, msg)). + Should(BeDNSRecord("rebind.example.com.", A, "192.168.1.100")) + }) + }) + + When("a conditional upstream serves an internal zone", func() { + BeforeEach(func(ctx context.Context) { + _, err = createDNSMokkaContainer(ctx, "moka-cond", e2eNet, + `A router.home.lab/NOERROR("A 192.168.2.1 123")`) + Expect(err).Should(Succeed()) + + blocky, err = createBlockyContainerFromString(ctx, e2eNet, dedent(` + upstreams: + groups: + default: + - moka + conditional: + mapping: + home.lab: moka-cond + rebindingProtection: + enable: true + `)) + Expect(err).Should(Succeed()) + }) + + It("should resolve conditional answers with private IPs despite protection", func(ctx context.Context) { + // end-to-end proof that conditional upstream answers bypass rebinding + // protection (recognized by response type), no allowlist needed + msg := util.NewMsgWithQuestion("router.home.lab.", A) + + By("first query", func() { + Expect(doDNSRequest(ctx, blocky, msg)). + Should(BeDNSRecord("router.home.lab.", A, "192.168.2.1")) + }) + + // regression: conditional answers used to be cached and re-served as + // CACHED, where the protection filtered them from the 2nd query onward + By("repeat query", func() { + Expect(doDNSRequest(ctx, blocky, msg)). + Should(BeDNSRecord("router.home.lab.", A, "192.168.2.1")) + }) + }) + }) +}) diff --git a/resolver/blocking_resolver_test.go b/resolver/blocking_resolver_test.go index fe64b04d..e0f281ee 100644 --- a/resolver/blocking_resolver_test.go +++ b/resolver/blocking_resolver_test.go @@ -117,6 +117,29 @@ var _ = Describe("BlockingResolver", Label("blockingResolver"), func() { }) }) + Describe("Resolving FQDN client identifiers", func() { + When("rebinding protection is enabled", func() { + It("exempts identifier lookups by chain position while client queries stay filtered", func() { + // DDNS-to-LAN identifiers in clientGroupsBlock resolve to private + // IPs via the general upstreams; the rebinding resolver sits ABOVE + // blocking in the server chain (see createQueryResolver), so the + // internal lookups enter the chain below it and bypass it + mockAnswer.Answer = []dns.RR{rebindTestA("nas.ddns.example.com.", "192.168.1.5")} + rebinding := NewRebindingProtectionResolver(config.RebindingProtection{Enable: true}) + chain := Chain(rebinding, sut, m) + + ips, _ := sut.queryForFQIdentifierIPs(ctx, "nas.ddns.example.com") + Expect(ips).ShouldNot(BeNil()) + Expect(*ips).ShouldNot(BeEmpty()) + Expect((*ips)[0].String()).Should(Equal("192.168.1.5")) + + // the same name queried by a client passes the full chain and is filtered + Expect(chain.Resolve(ctx, newRequestWithClient("nas.ddns.example.com.", A, "192.168.1.5"))). + Should(HaveResponseType(ResponseTypeFILTERED)) + }) + }) + }) + Describe("Blocking with full-qualified client name", func() { BeforeEach(func() { sutConfig = config.Blocking{ diff --git a/resolver/caching_resolver.go b/resolver/caching_resolver.go index 4a81233d..651c21df 100644 --- a/resolver/caching_resolver.go +++ b/resolver/caching_resolver.go @@ -164,9 +164,10 @@ func (r *CachingResolver) reloadCacheEntry(ctx context.Context, cacheKey string) return nil, 0 } - // only refresh entries the normal put path would cache: successful, not - // truncated and without the CD flag (mirrors putInCache). - if response.Res.Rcode != dns.RcodeSuccess || !isResponseCacheable(response.Res) { + // only refresh entries the normal put path would cache: upstream-derived, + // successful, not truncated and without the CD flag (mirrors putInCache). + if !isCacheableResponseType(response.RType) || + response.Res.Rcode != dns.RcodeSuccess || !isResponseCacheable(response.Res) { return nil, 0 } @@ -323,9 +324,23 @@ func packForCache(ctx context.Context, msg *dns.Msg) ([]byte, error) { return packed, err } +// isCacheableResponseType reports whether the response originates from the general +// upstreams, directly (RESOLVED) or via DNS64 synthesis (SYNTHESIZED). Only such +// answers may be cached: the rebinding protection resolver sits above the cache and +// inspects CACHED responses, so caching answers from trusted local sources +// (conditional upstream, special-use domains) would re-label them as CACHED on hits +// and subject them to inspection. +func isCacheableResponseType(rType model.ResponseType) bool { + return rType == model.ResponseTypeRESOLVED || rType == model.ResponseTypeSYNTHESIZED +} + func (r *CachingResolver) putInCache( ctx context.Context, cacheKey string, response *model.Response, ttl time.Duration, ) { + if !isCacheableResponseType(response.RType) { + return + } + packed, err := packForCache(ctx, response.Res) if err != nil { return diff --git a/resolver/caching_resolver_test.go b/resolver/caching_resolver_test.go index d59cc99c..b44549c6 100644 --- a/resolver/caching_resolver_test.go +++ b/resolver/caching_resolver_test.go @@ -446,6 +446,67 @@ var _ = Describe("CachingResolver", func() { }) }) + Describe("Responses from trusted local sources", func() { + BeforeEach(func() { + mockAnswer, _ = util.NewMsgWithAnswer("router.home.lab.", 600, A, "192.168.2.1") + }) + + // the rebinding protection resolver sits above the cache and inspects + // CACHED responses; storing answers from trusted local sources would + // re-serve them re-labeled as CACHED and subject them to inspection + DescribeTable("does not cache them, keeping their response type on repeat queries", + func(rType ResponseType) { + m = &mockResolver{} + m.On("Resolve", mock.Anything).Return(&Response{Res: mockAnswer, RType: rType}, nil) + sut.Next(m) + + By("first request", func() { + Expect(sut.Resolve(ctx, newRequest("router.home.lab.", A))). + Should(SatisfyAll( + HaveResponseType(rType), + BeDNSRecord("router.home.lab.", A, "192.168.2.1"), + )) + + Expect(m.Calls).Should(HaveLen(1)) + }) + + By("second request", func() { + Expect(sut.Resolve(ctx, newRequest("router.home.lab.", A))). + Should(SatisfyAll( + HaveResponseType(rType), + BeDNSRecord("router.home.lab.", A, "192.168.2.1"), + )) + + // served fresh, not from the cache + Expect(m.Calls).Should(HaveLen(2)) + }) + }, + Entry("conditional upstream", ResponseTypeCONDITIONAL), + Entry("special-use domain", ResponseTypeSPECIAL), + ) + + It("still caches DNS64-synthesized answers (upstream-derived)", func() { + m = &mockResolver{} + m.On("Resolve", mock.Anything).Return(&Response{Res: mockAnswer, RType: ResponseTypeSYNTHESIZED}, nil) + sut.Next(m) + + By("first request", func() { + Expect(sut.Resolve(ctx, newRequest("router.home.lab.", A))). + Should(HaveResponseType(ResponseTypeSYNTHESIZED)) + + Expect(m.Calls).Should(HaveLen(1)) + }) + + By("second request", func() { + Expect(sut.Resolve(ctx, newRequest("router.home.lab.", A))). + Should(HaveResponseType(ResponseTypeCACHED)) + + // still one call to the next resolver + Expect(m.Calls).Should(HaveLen(1)) + }) + }) + }) + Describe("Negative cache (caching if upstream resolver returns NXDOMAIN)", func() { Context("Caching if upstream resolver returns NXDOMAIN", func() { When("Upstream resolver returns NXDOMAIN with caching", func() { diff --git a/resolver/conditional_upstream_resolver.go b/resolver/conditional_upstream_resolver.go index 128d97dc..46ecd1c9 100644 --- a/resolver/conditional_upstream_resolver.go +++ b/resolver/conditional_upstream_resolver.go @@ -60,25 +60,16 @@ func (r *ConditionalUpstreamResolver) processRequest( ctx context.Context, request *model.Request, ) (bool, *model.Response, error) { domainFromQuestion := util.ExtractDomain(request.Req.Question[0]) - domain := domainFromQuestion if strings.Contains(domainFromQuestion, ".") { // try with domain with and without sub-domains - for len(domain) > 0 { - if resolver, found := r.mapping[domain]; found { - resp, err := r.internalResolve(ctx, resolver, domainFromQuestion, domain, request) + if domain, resolver, found := searchDomainOrParent(r.mapping, domainFromQuestion); found { + resp, err := r.internalResolve(ctx, resolver, domainFromQuestion, domain, request) - return true, resp, err - } - - if i := strings.Index(domain, "."); i >= 0 { - domain = domain[i+1:] - } else { - break - } + return true, resp, err } } else if resolver, found := r.mapping["."]; found { - resp, err := r.internalResolve(ctx, resolver, domainFromQuestion, domain, request) + resp, err := r.internalResolve(ctx, resolver, domainFromQuestion, domainFromQuestion, request) return true, resp, err } diff --git a/resolver/domain_search.go b/resolver/domain_search.go new file mode 100644 index 00000000..e90672be --- /dev/null +++ b/resolver/domain_search.go @@ -0,0 +1,22 @@ +package resolver + +import "github.com/miekg/dns" + +// searchDomainOrParent returns the entry of m whose key equals domain or is its +// closest parent domain. Label boundaries are walked with dns.NextLabel, so +// escaped dots (`\.`) inside a label are not treated as separators. Keys and +// domain must use the same canonical form (case, trailing dot). +func searchDomainOrParent[T any](m map[string]T, domain string) (match string, value T, found bool) { + if domain == "" || len(m) == 0 { + return "", value, false + } + + for offset, end := 0, false; !end; offset, end = dns.NextLabel(domain, offset) { + match = domain[offset:] + if value, found = m[match]; found { + return match, value, true + } + } + + return "", value, false +} diff --git a/resolver/rebinding_resolver.go b/resolver/rebinding_resolver.go new file mode 100644 index 00000000..b327e8b7 --- /dev/null +++ b/resolver/rebinding_resolver.go @@ -0,0 +1,183 @@ +package resolver + +import ( + "context" + "net" + + "github.com/0xERR0R/blocky/config" + "github.com/0xERR0R/blocky/model" + "github.com/0xERR0R/blocky/util" + "github.com/miekg/dns" +) + +// RebindingProtectionResolver drops answers that contain private, loopback, link-local +// or unspecified IPs for non-allowlisted domains (DNS rebinding protection). +// +// It sits above the blocking resolver and the cache in the chain. Only RESOLVED and +// CACHED responses are inspected: answers from conditional upstreams, custom DNS, the +// hosts file, special-use domain handling and blocking are recognized by response +// type and pass through untouched. Sitting above the cache means cached answers — +// including entries synced from redis — are re-inspected on every hit; the cache in +// turn stores only upstream-derived answers (see isCacheableResponseType), so CACHED +// implies upstream origin. Internal lookups (e.g. blocking's FQDN client identifiers) +// enter the chain below this resolver and bypass it entirely. +// +// Known gap: DNS64-synthesized answers (SYNTHESIZED) are upstream-derived but pass +// through uninspected — see the NAT64/DNS64 note in the rebinding documentation. +type RebindingProtectionResolver struct { + configurable[*config.RebindingProtection] + NextResolver + typed + + allowedDomains map[string]struct{} +} + +// NewRebindingProtectionResolver returns a new resolver instance +func NewRebindingProtectionResolver(cfg config.RebindingProtection) *RebindingProtectionResolver { + domains := cfg.NormalizedAllowedDomains() + + allowed := make(map[string]struct{}, len(domains)) + for _, domain := range domains { + allowed[domain] = struct{}{} + } + + return &RebindingProtectionResolver{ + configurable: withConfig(&cfg), + typed: withType("rebinding_protection"), + allowedDomains: allowed, + } +} + +// Resolve inspects the next resolver's answer and replaces it with an empty FILTERED +// response if it contains a non-public IP. +func (r *RebindingProtectionResolver) Resolve(ctx context.Context, request *model.Request) (*model.Response, error) { + if !r.IsEnabled() { + return r.next.Resolve(ctx, request) + } + + response, err := r.next.Resolve(ctx, request) + if err != nil { + return nil, err + } + + if response == nil || response.Res == nil { + return response, nil + } + + // only answers originating from the general upstreams are inspected — directly + // (RESOLVED) or served from the cache (CACHED, incl. entries synced via redis; + // the cache stores only upstream-derived answers). Anything else (blocked, + // conditional, custom DNS, hosts file, SUDN, ...) is trusted local/internal + // data and passes through untouched — except DNS64-synthesized answers, which + // are upstream-derived but currently uninspected (documented NAT64/DNS64 gap). + if response.RType != model.ResponseTypeRESOLVED && response.RType != model.ResponseTypeCACHED { + return response, nil + } + + // scan first: most answers are public, so the common path skips question-name + // extraction and the allowlist walk entirely; the outcome is order-independent + ip := findBlockedIPInMsg(response.Res) + if ip == nil { + return response, nil + } + + // the question name decides the allowlist match; without exactly one + // question the answers cannot be attributed to a single name, so the + // allowlist never applies (fail closed) + var domain string + if len(request.Req.Question) == 1 { + domain = util.ExtractDomain(request.Req.Question[0]) + if r.isAllowed(domain) { + return response, nil + } + } + + _, logger := r.log(ctx) + logger.WithField(logFieldDomain, util.Obfuscate(domain)). + Debugf("dropped answer with non-public IP %s", util.Obfuscate(ip.String())) + + // fixed reason: it becomes a Prometheus label via MetricsResolver, and the IP is + // attacker-chosen — embedding it would allow unbounded cardinality growth + return model.NewResponseWithRcode(request, dns.RcodeSuccess, model.ResponseTypeFILTERED, + "FILTERED (rebinding protection)"), nil +} + +// isAllowed reports whether the queried domain matches an allowlist entry exactly +// or is a subdomain of one. +func (r *RebindingProtectionResolver) isAllowed(domain string) bool { + _, _, found := searchDomainOrParent(r.allowedDomains, domain) + + return found +} + +// findBlockedIPInMsg returns the first non-public IP found in any section of the +// message: upstreams may place address records not only in the answer but also in +// the additional section (e.g. HTTPS/SVCB target addresses, RFC 9460 §5) or the +// authority section, and clients may consume them from there. +func findBlockedIPInMsg(msg *dns.Msg) net.IP { + for _, section := range [][]dns.RR{msg.Answer, msg.Extra, msg.Ns} { + if ip := findBlockedIP(section); ip != nil { + return ip + } + } + + return nil +} + +// findBlockedIP returns the first non-public IP found in the A/AAAA records or HTTPS/SVCB ip hints of the +// given record section, or nil if there is none. +func findBlockedIP(answers []dns.RR) net.IP { + for _, rr := range answers { + switch v := rr.(type) { + case *dns.A: + if isBlockedIP(v.A) { + return v.A + } + case *dns.AAAA: + if isBlockedIP(v.AAAA) { + return v.AAAA + } + case *dns.HTTPS: + if ip := findBlockedHintIP(v.Value); ip != nil { + return ip + } + case *dns.SVCB: + if ip := findBlockedHintIP(v.Value); ip != nil { + return ip + } + } + } + + return nil +} + +// findBlockedHintIP returns the first non-public IP in the ipv4hint/ipv6hint +// SvcParams of an HTTPS/SVCB record, or nil if there is none. +func findBlockedHintIP(values []dns.SVCBKeyValue) net.IP { + for _, kv := range values { + switch hint := kv.(type) { + case *dns.SVCBIPv4Hint: + for _, ip := range hint.Hint { + if isBlockedIP(ip) { + return ip + } + } + case *dns.SVCBIPv6Hint: + for _, ip := range hint.Hint { + if isBlockedIP(ip) { + return ip + } + } + } + } + + return nil +} + +// isBlockedIP reports whether ip belongs to one of the fixed non-public ranges: +// RFC1918/ULA, loopback, link-local or unspecified. IPv4-mapped IPv6 addresses are +// evaluated as their 4-byte form by these predicates. A nil IP (address-less record) +// returns false by design. +func isBlockedIP(ip net.IP) bool { + return ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsUnspecified() +} diff --git a/resolver/rebinding_resolver_test.go b/resolver/rebinding_resolver_test.go new file mode 100644 index 00000000..1a1840da --- /dev/null +++ b/resolver/rebinding_resolver_test.go @@ -0,0 +1,539 @@ +package resolver + +import ( + "context" + "errors" + "net" + + "github.com/0xERR0R/blocky/config" + . "github.com/0xERR0R/blocky/helpertest" + "github.com/0xERR0R/blocky/log" + . "github.com/0xERR0R/blocky/model" + "github.com/0xERR0R/blocky/util" + + "github.com/miekg/dns" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/stretchr/testify/mock" +) + +// rebindTestA builds an A RR with the given owner name and address. It panics on +// invalid literals: a nil IP would silently pass through the resolver (nil is +// never blocked by design), turning a typo'd spec into a vacuous pass. +func rebindTestA(name, ip string) *dns.A { + parsed := net.ParseIP(ip) + if parsed == nil { + panic("rebindTestA: invalid IP literal " + ip) + } + + return &dns.A{ + Hdr: dns.RR_Header{Name: name, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 300}, + A: parsed, + } +} + +// rebindTestAAAA builds an AAAA RR with the given owner name and address. +// See rebindTestA for why it panics on invalid literals. +func rebindTestAAAA(name, ip string) *dns.AAAA { + parsed := net.ParseIP(ip) + if parsed == nil { + panic("rebindTestAAAA: invalid IP literal " + ip) + } + + return &dns.AAAA{ + Hdr: dns.RR_Header{Name: name, Rrtype: dns.TypeAAAA, Class: dns.ClassINET, Ttl: 300}, + AAAA: parsed, + } +} + +var _ = Describe("RebindingProtectionResolver", func() { + var ( + sut *RebindingProtectionResolver + sutConfig config.RebindingProtection + m *mockResolver + mockAnswer *dns.Msg + + ctx context.Context + cancelFn context.CancelFunc + ) + + Describe("Type", func() { + It("follows conventions", func() { + expectValidResolverType(sut) + }) + }) + + BeforeEach(func() { + ctx, cancelFn = context.WithCancel(context.Background()) + DeferCleanup(cancelFn) + + sutConfig = config.RebindingProtection{Enable: true} + mockAnswer = new(dns.Msg) + }) + + JustBeforeEach(func() { + sut = NewRebindingProtectionResolver(sutConfig) + m = &mockResolver{} + m.On("Resolve", mock.Anything).Return(&Response{Res: mockAnswer}, nil) + sut.Next(m) + }) + + Describe("IsEnabled", func() { + It("is true when enabled", func() { + Expect(sut.IsEnabled()).Should(BeTrue()) + }) + }) + + Describe("LogConfig", func() { + It("should log something", func() { + logger, hook := log.NewMockEntry() + + sut.LogConfig(logger) + + Expect(hook.Calls).ShouldNot(BeEmpty()) + }) + }) + + When("protection is disabled", func() { + BeforeEach(func() { + sutConfig = config.RebindingProtection{} + }) + + It("passes private answers through", func() { + a := rebindTestA("rebind.example.com.", "192.168.1.100") + mockAnswer.Answer = []dns.RR{a} + + resp, err := sut.Resolve(ctx, newRequest("rebind.example.com.", A)) + Expect(err).Should(Succeed()) + Expect(resp.Res.Answer).Should(ConsistOf(a)) + + // delegated to next resolver + Expect(m.Calls).Should(HaveLen(1)) + }) + }) + + When("the answer does not come from the general upstreams", func() { + // the resolver sits above blocking and the cache; answers produced by + // trusted local sources are recognized by response type and never filtered + DescribeTable("passes private answers through untouched", + func(rType ResponseType) { + m = &mockResolver{} + m.On("Resolve", mock.Anything).Return(&Response{Res: mockAnswer, RType: rType}, nil) + sut.Next(m) + + a := rebindTestA("router.home.lab.", "192.168.2.1") + mockAnswer.Answer = []dns.RR{a} + + resp, err := sut.Resolve(ctx, newRequest("router.home.lab.", A)) + Expect(err).Should(Succeed()) + Expect(resp.RType).Should(Equal(rType)) + Expect(resp.Res.Answer).Should(ConsistOf(a)) + }, + Entry("blocked", ResponseTypeBLOCKED), + Entry("conditional", ResponseTypeCONDITIONAL), + Entry("custom DNS", ResponseTypeCUSTOMDNS), + Entry("hosts file", ResponseTypeHOSTSFILE), + Entry("special-use domain", ResponseTypeSPECIAL), + ) + + It("still filters cached upstream answers (incl. redis-synced entries)", func() { + m = &mockResolver{} + m.On("Resolve", mock.Anything).Return(&Response{Res: mockAnswer, RType: ResponseTypeCACHED, Reason: "CACHED"}, nil) + sut.Next(m) + + mockAnswer.Answer = []dns.RR{rebindTestA("rebind.example.com.", "192.168.1.100")} + + Expect(sut.Resolve(ctx, newRequest("rebind.example.com.", A))). + Should(SatisfyAll( + HaveNoAnswer(), + HaveResponseType(ResponseTypeFILTERED), + )) + }) + }) + + When("protection is enabled", func() { + DescribeTable("filters answers containing non-public IPs", + func(rr dns.RR) { + mockAnswer.Answer = []dns.RR{rr} + + Expect(sut.Resolve(ctx, newRequest("rebind.example.com.", A))). + Should(SatisfyAll( + HaveNoAnswer(), + HaveResponseType(ResponseTypeFILTERED), + HaveReturnCode(dns.RcodeSuccess), + )) + }, + Entry("RFC1918 10/8", rebindTestA("rebind.example.com.", "10.1.2.3")), + Entry("RFC1918 172.16/12", rebindTestA("rebind.example.com.", "172.16.5.5")), + Entry("RFC1918 192.168/16", rebindTestA("rebind.example.com.", "192.168.1.100")), + Entry("IPv4 loopback", rebindTestA("rebind.example.com.", "127.0.0.1")), + Entry("IPv4 link-local", rebindTestA("rebind.example.com.", "169.254.10.10")), + Entry("IPv4 unspecified", rebindTestA("rebind.example.com.", "0.0.0.0")), + Entry("IPv6 ULA", rebindTestAAAA("rebind.example.com.", "fd00::1")), + Entry("IPv6 loopback", rebindTestAAAA("rebind.example.com.", "::1")), + Entry("IPv6 link-local", rebindTestAAAA("rebind.example.com.", "fe80::1")), + Entry("IPv6 unspecified", rebindTestAAAA("rebind.example.com.", "::")), + Entry("IPv4-mapped IPv6 private", rebindTestAAAA("rebind.example.com.", "::ffff:192.168.1.1")), + ) + + It("filters HTTPS answers with a private ipv4hint", func() { + https := &dns.HTTPS{SVCB: dns.SVCB{ + Hdr: dns.RR_Header{Name: "rebind.example.com.", Rrtype: dns.TypeHTTPS, Class: dns.ClassINET, Ttl: 300}, + Priority: 1, + Target: ".", + Value: []dns.SVCBKeyValue{ + &dns.SVCBIPv4Hint{Hint: []net.IP{net.ParseIP("192.168.1.100")}}, + }, + }} + mockAnswer.Answer = []dns.RR{https} + + Expect(sut.Resolve(ctx, newRequest("rebind.example.com.", HTTPS))). + Should(SatisfyAll( + HaveNoAnswer(), + HaveResponseType(ResponseTypeFILTERED), + )) + }) + + It("filters SVCB answers with a ULA ipv6hint", func() { + svcb := &dns.SVCB{ + Hdr: dns.RR_Header{Name: "rebind.example.com.", Rrtype: dns.TypeSVCB, Class: dns.ClassINET, Ttl: 300}, + Priority: 1, + Target: ".", + Value: []dns.SVCBKeyValue{ + &dns.SVCBIPv6Hint{Hint: []net.IP{net.ParseIP("fd00::1")}}, + }, + } + mockAnswer.Answer = []dns.RR{svcb} + + Expect(sut.Resolve(ctx, newRequest("rebind.example.com.", dns.Type(dns.TypeSVCB)))). + Should(SatisfyAll( + HaveNoAnswer(), + HaveResponseType(ResponseTypeFILTERED), + )) + }) + + It("filters HTTPS answers where only a later hint is private", func() { + https := &dns.HTTPS{SVCB: dns.SVCB{ + Hdr: dns.RR_Header{Name: "rebind.example.com.", Rrtype: dns.TypeHTTPS, Class: dns.ClassINET, Ttl: 300}, + Priority: 1, + Target: ".", + Value: []dns.SVCBKeyValue{ + &dns.SVCBIPv4Hint{Hint: []net.IP{net.ParseIP("1.2.3.4"), net.ParseIP("192.168.1.100")}}, + &dns.SVCBIPv6Hint{Hint: []net.IP{net.ParseIP("fd00::1")}}, + }, + }} + mockAnswer.Answer = []dns.RR{https} + + Expect(sut.Resolve(ctx, newRequest("rebind.example.com.", HTTPS))). + Should(SatisfyAll( + HaveNoAnswer(), + HaveResponseType(ResponseTypeFILTERED), + )) + }) + + DescribeTable("passes through answers without non-public IPs", + func(qType dns.Type, rrs ...dns.RR) { + mockAnswer.Answer = rrs + + resp, err := sut.Resolve(ctx, newRequest("example.com.", qType)) + Expect(err).Should(Succeed()) + Expect(resp.Res.Answer).Should(ConsistOf(rrs)) + Expect(resp.RType).Should(Equal(ResponseTypeRESOLVED)) + }, + Entry("public IPv4", A, rebindTestA("example.com.", "1.2.3.4")), + Entry("public IPv6", AAAA, rebindTestAAAA("example.com.", "2001:db8::1")), + Entry("HTTPS with public hints", HTTPS, newHTTPSRecord()), + Entry("record without an address (nil IP)", A, + &dns.A{Hdr: dns.RR_Header{Name: "example.com.", Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 300}}), + Entry("answer without address records", TXT, &dns.TXT{ + Hdr: dns.RR_Header{Name: "example.com.", Rrtype: dns.TypeTXT, Class: dns.ClassINET, Ttl: 300}, + Txt: []string{"hello"}, + }), + Entry("empty response", A), + ) + + It("uses a fixed reason (no attacker-controlled IP in metrics labels)", func() { + mockAnswer.Answer = []dns.RR{rebindTestA("rebind.example.com.", "192.168.1.100")} + + resp, err := sut.Resolve(ctx, newRequest("rebind.example.com.", A)) + Expect(err).Should(Succeed()) + Expect(resp.Reason).Should(Equal("FILTERED (rebinding protection)")) + }) + + It("filters answers mixing public and private records", func() { + mockAnswer.Answer = []dns.RR{ + rebindTestA("rebind.example.com.", "1.2.3.4"), + rebindTestA("rebind.example.com.", "192.168.1.100"), + } + + Expect(sut.Resolve(ctx, newRequest("rebind.example.com.", A))). + Should(SatisfyAll( + HaveNoAnswer(), + HaveResponseType(ResponseTypeFILTERED), + )) + }) + + It("filters responses whose additional section carries a private IP", func() { + // per RFC 9460 §5 an upstream may attach the HTTPS/SVCB TargetName's + // address records in the additional section; they must be inspected + // like answer records + https := &dns.HTTPS{SVCB: dns.SVCB{ + Hdr: dns.RR_Header{Name: "rebind.example.com.", Rrtype: dns.TypeHTTPS, Class: dns.ClassINET, Ttl: 300}, + Priority: 1, + Target: "target.example.com.", + }} + mockAnswer.Answer = []dns.RR{https} + mockAnswer.Extra = []dns.RR{rebindTestA("target.example.com.", "192.168.1.1")} + + Expect(sut.Resolve(ctx, newRequest("rebind.example.com.", HTTPS))). + Should(SatisfyAll( + HaveNoAnswer(), + HaveResponseType(ResponseTypeFILTERED), + )) + }) + + It("filters responses whose authority section carries a private IP", func() { + mockAnswer.Ns = []dns.RR{rebindTestA("rebind.example.com.", "192.168.1.1")} + + Expect(sut.Resolve(ctx, newRequest("rebind.example.com.", A))). + Should(HaveResponseType(ResponseTypeFILTERED)) + }) + + It("filters private answers for requests without a question section", func() { + req := newRequest("rebind.example.com.", A) + req.Req.Question = nil + mockAnswer.Answer = []dns.RR{rebindTestA("rebind.example.com.", "192.168.1.100")} + + Expect(sut.Resolve(ctx, req)).Should(HaveResponseType(ResponseTypeFILTERED)) + }) + + It("obfuscates the dropped IP in the debug log when log privacy is enabled", func() { + logger, hook := log.NewMockEntry() + loggedCtx, _ := log.NewCtx(ctx, logger) + + util.LogPrivacy.Store(true) + DeferCleanup(func() { util.LogPrivacy.Store(false) }) + + mockAnswer.Answer = []dns.RR{rebindTestA("rebind.example.com.", "192.168.1.100")} + + _, err := sut.Resolve(loggedCtx, newRequest("rebind.example.com.", A)) + Expect(err).Should(Succeed()) + + // the IP is answer content; like the domain, it must not appear + // in clear text when log privacy is on + Expect(hook.Messages).Should(ContainElement(ContainSubstring("dropped answer"))) + Expect(hook.Messages).ShouldNot(ContainElement(ContainSubstring("192.168.1.100"))) + }) + + It("filters CNAME chains ending in a private IP", func() { + cname := &dns.CNAME{ + Hdr: dns.RR_Header{Name: "evil.example.org.", Rrtype: dns.TypeCNAME, Class: dns.ClassINET, Ttl: 300}, + Target: "target.example.org.", + } + mockAnswer.Answer = []dns.RR{cname, rebindTestA("target.example.org.", "10.0.0.5")} + + Expect(sut.Resolve(ctx, newRequest("evil.example.org.", A))). + Should(HaveResponseType(ResponseTypeFILTERED)) + }) + }) + + When("a domain is allowlisted", func() { + BeforeEach(func() { + sutConfig = config.RebindingProtection{ + Enable: true, + // mixed case + trailing dot: exercises normalization + AllowedDomains: []string{"Intranet.Example.COM."}, + } + }) + + It("passes through private answers for the exact domain", func() { + a := rebindTestA("intranet.example.com.", "192.168.1.50") + mockAnswer.Answer = []dns.RR{a} + + resp, err := sut.Resolve(ctx, newRequest("intranet.example.com.", A)) + Expect(err).Should(Succeed()) + Expect(resp.Res.Answer).Should(ConsistOf(a)) + }) + + It("passes through private answers for subdomains", func() { + a := rebindTestA("nas.intranet.example.com.", "192.168.1.51") + mockAnswer.Answer = []dns.RR{a} + + resp, err := sut.Resolve(ctx, newRequest("nas.intranet.example.com.", A)) + Expect(err).Should(Succeed()) + Expect(resp.Res.Answer).Should(ConsistOf(a)) + }) + + It("still filters sibling domains", func() { + mockAnswer.Answer = []dns.RR{rebindTestA("notintranet.example.com.", "192.168.1.52")} + + Expect(sut.Resolve(ctx, newRequest("notintranet.example.com.", A))). + Should(HaveResponseType(ResponseTypeFILTERED)) + }) + + It("still filters CNAMEs pointing at an allowlisted name", func() { + // the question name decides; an attacker CNAME-ing to an allowlisted + // name must not bypass protection + cname := &dns.CNAME{ + Hdr: dns.RR_Header{Name: "evil.example.org.", Rrtype: dns.TypeCNAME, Class: dns.ClassINET, Ttl: 300}, + Target: "intranet.example.com.", + } + mockAnswer.Answer = []dns.RR{cname, rebindTestA("intranet.example.com.", "192.168.1.50")} + + Expect(sut.Resolve(ctx, newRequest("evil.example.org.", A))). + Should(HaveResponseType(ResponseTypeFILTERED)) + }) + + It("does not treat escaped dots as label boundaries", func() { + // `evil\.intranet` is a single label directly under example.com — + // NOT a subdomain of the allowlisted intranet.example.com + mockAnswer.Answer = []dns.RR{rebindTestA(`evil\.intranet.example.com.`, "192.168.1.66")} + + Expect(sut.Resolve(ctx, newRequest(`evil\.intranet.example.com.`, A))). + Should(HaveResponseType(ResponseTypeFILTERED)) + }) + + It("never applies the allowlist to multi-question requests", func() { + // with more than one question the answers cannot be attributed to a + // single name, so the allowlist must not exempt them (fail closed) + req := newRequest("intranet.example.com.", A) + req.Req.Question = append(req.Req.Question, + dns.Question{Name: "evil.example.org.", Qtype: dns.TypeA, Qclass: dns.ClassINET}) + mockAnswer.Answer = []dns.RR{rebindTestA("intranet.example.com.", "192.168.1.50")} + + Expect(sut.Resolve(ctx, req)).Should(HaveResponseType(ResponseTypeFILTERED)) + }) + }) + + When("a single-label domain is allowlisted", func() { + BeforeEach(func() { + sutConfig = config.RebindingProtection{ + Enable: true, + AllowedDomains: []string{"lan"}, + } + }) + + It("passes through private answers for names under it", func() { + a := rebindTestA("router.lan.", "192.168.2.1") + mockAnswer.Answer = []dns.RR{a} + + resp, err := sut.Resolve(ctx, newRequest("router.lan.", A)) + Expect(err).Should(Succeed()) + Expect(resp.Res.Answer).Should(ConsistOf(a)) + }) + }) + + When("the next resolver returns an error", func() { + JustBeforeEach(func() { + m = &mockResolver{} + m.On("Resolve", mock.Anything).Return(nil, errors.New("upstream error")) + sut.Next(m) + }) + + It("propagates the error", func() { + _, err := sut.Resolve(ctx, newRequest("example.com.", A)) + Expect(err).Should(MatchError("upstream error")) + }) + }) + + When("chained above a validating DNSSEC resolver", func() { + It("filters the validated answer without extra DNSKEY/DS lookups", func() { + // the validator sits below this resolver in the server chain and sees + // the real upstream answer; the synthetic filtered response replaces it + // after validation and carries no AD flag (spec: "DNSSEC interplay") + mockAnswer.Answer = []dns.RR{rebindTestA("rebind.example.com.", "192.168.1.100")} + + dnssecRes, err := NewDNSSECResolver(ctx, config.DNSSEC{Validate: true}, m) + Expect(err).Should(Succeed()) + + chained := Chain(sut, dnssecRes, m) + + resp, err := chained.Resolve(ctx, newRequest("rebind.example.com.", A)) + Expect(err).Should(Succeed()) + Expect(resp).Should(SatisfyAll( + HaveNoAnswer(), + HaveResponseType(ResponseTypeFILTERED), + HaveReturnCode(dns.RcodeSuccess), + )) + // the unsigned answer is classified insecure without DNSKEY/DS + // lookups — one upstream call only + Expect(m.Calls).Should(HaveLen(1)) + Expect(resp.Res.AuthenticatedData).Should(BeFalse()) + }) + }) + + When("chained above a caching resolver", func() { + It("re-inspects cached answers on every hit", func() { + mockAnswer.Answer = []dns.RR{rebindTestA("rebind.example.com.", "192.168.1.100")} + + cachingCfg, err := config.WithDefaults[config.Caching]() + Expect(err).Should(Succeed()) + + cachingRes, err := NewCachingResolver(ctx, cachingCfg, nil) + Expect(err).Should(Succeed()) + + chained := Chain(sut, cachingRes, m) + + By("first query is resolved upstream and filtered", func() { + resp, err := chained.Resolve(ctx, newRequest("rebind.example.com.", A)) + Expect(err).Should(Succeed()) + Expect(resp).Should(SatisfyAll( + HaveNoAnswer(), + HaveResponseType(ResponseTypeFILTERED), + HaveReturnCode(dns.RcodeSuccess), + )) + Expect(m.Calls).Should(HaveLen(1)) + }) + + By("second query hits the cache and is filtered again", func() { + resp, err := chained.Resolve(ctx, newRequest("rebind.example.com.", A)) + Expect(err).Should(Succeed()) + Expect(resp).Should(SatisfyAll( + HaveNoAnswer(), + HaveResponseType(ResponseTypeFILTERED), + HaveReturnCode(dns.RcodeSuccess), + )) + // the real answer is cached below this resolver and re-filtered + // on every hit; the upstream was not asked again + Expect(m.Calls).Should(HaveLen(1)) + }) + }) + + It("never filters conditional answers, also on repeat queries", func() { + // regression: the cache used to store conditional answers and re-serve + // them re-labeled as CACHED, so trusted internal-zone answers were + // inspected and filtered from the second query onward + a := rebindTestA("router.home.lab.", "192.168.2.1") + mockAnswer.Answer = []dns.RR{a} + + m = &mockResolver{} + m.On("Resolve", mock.Anything). + Return(&Response{Res: mockAnswer, RType: ResponseTypeCONDITIONAL, Reason: "CONDITIONAL"}, nil) + + cachingCfg, err := config.WithDefaults[config.Caching]() + Expect(err).Should(Succeed()) + + cachingRes, err := NewCachingResolver(ctx, cachingCfg, nil) + Expect(err).Should(Succeed()) + + chained := Chain(sut, cachingRes, m) + + By("first query passes through", func() { + resp, err := chained.Resolve(ctx, newRequest("router.home.lab.", A)) + Expect(err).Should(Succeed()) + Expect(resp.RType).Should(Equal(ResponseTypeCONDITIONAL)) + Expect(resp.Res.Answer).Should(ConsistOf(a)) + }) + + By("repeat query passes through as well", func() { + resp, err := chained.Resolve(ctx, newRequest("router.home.lab.", A)) + Expect(err).Should(Succeed()) + Expect(resp.RType).Should(Equal(ResponseTypeCONDITIONAL)) + Expect(resp.Res.Answer).Should(ConsistOf(a)) + + // conditional answers are served fresh, never from the cache + Expect(m.Calls).Should(HaveLen(2)) + }) + }) + }) +}) diff --git a/resolver/sudn_resolver.go b/resolver/sudn_resolver.go index 3b0a35f8..0d43ca8f 100644 --- a/resolver/sudn_resolver.go +++ b/resolver/sudn_resolver.go @@ -129,22 +129,10 @@ func (r *SpecialUseDomainNamesResolver) Resolve(ctx context.Context, request *mo } func (r *SpecialUseDomainNamesResolver) handler(request *model.Request) sudnHandler { - q := request.Req.Question[0] - domain := q.Name + // DNS names are case-insensitive (RFC 4343); the sudnHandlers keys are lowercase + _, handler, _ := searchDomainOrParent(sudnHandlers, strings.ToLower(request.Req.Question[0].Name)) - for { - handler, ok := sudnHandlers[domain] - if ok { - return handler - } - - _, after, ok := strings.Cut(domain, ".") - if !ok { - return nil - } - - domain = after - } + return handler } func newSUDNResponse(response *model.Request, rcode int) *model.Response { diff --git a/resolver/sudn_resolver_test.go b/resolver/sudn_resolver_test.go index 4a2e8685..b1802c70 100644 --- a/resolver/sudn_resolver_test.go +++ b/resolver/sudn_resolver_test.go @@ -150,6 +150,11 @@ var _ = Describe("SudnResolver", Label("sudnResolver"), func() { entry(A, "something.home.", dns.RcodeNameError), entry(A, "something.lan.", dns.RcodeNameError), entry(A, "something.onion.", dns.RcodeNameError), + + // DNS names are case-insensitive (RFC 4343): mixed-case queries + // (clients, dns0x20 randomization) must not skip special-use handling + entry(A, "LOCALHOST.", dns.RcodeSuccess, BeDNSRecord("LOCALHOST.", A, loopbackV4.String())), + entry(A, "SoMeThInG.TeSt.", dns.RcodeNameError), ) When("RFC 6762 Appendix G is disabled", func() { diff --git a/server/server.go b/server/server.go index effbdfb9..07042294 100644 --- a/server/server.go +++ b/server/server.go @@ -533,6 +533,14 @@ func createQueryResolver( resolver.NewMetricsResolver(cfg.Prometheus), customDNS, hostsFile, + // above blocking and the cache: it inspects only RESOLVED/CACHED answers + // (conditional/custom DNS/hosts file/SUDN/blocked answers are recognized + // by response type and pass through; the cache stores only + // upstream-derived answers, so CACHED implies upstream origin), cached + // answers — incl. entries synced via redis — are re-inspected on every + // hit, and blocking's internal FQDN client-identifier lookups enter the + // chain below it + resolver.NewRebindingProtectionResolver(cfg.RebindingProtection), blocking, dnssecResolver, // DNSSEC validation BEFORE caching - validates all responses before they are cached cachingResolver,