feat(resolver): add DNS rebinding protection (#2111)

This commit is contained in:
Dimitri Herzog
2026-06-12 16:02:04 +00:00
committed by GitHub
parent 355a9a177a
commit 0b70e5cfd2
17 changed files with 1320 additions and 31 deletions
+6
View File
@@ -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)
+88
View File
@@ -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
}
+114
View File
@@ -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"))
})
})
})
+18
View File
@@ -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": {
+8
View File
@@ -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).
+72
View File
@@ -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.
+148
View File
@@ -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"))
})
})
})
})
+23
View File
@@ -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{
+18 -3
View File
@@ -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
+61
View File
@@ -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() {
+4 -13
View File
@@ -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
}
+22
View File
@@ -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
}
+183
View File
@@ -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()
}
+539
View File
@@ -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))
})
})
})
})
+3 -15
View File
@@ -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 {
+5
View File
@@ -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() {
+8
View File
@@ -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,