mirror of
https://github.com/0xERR0R/blocky.git
synced 2026-10-12 02:01:13 +00:00
feat(resolver): add DNS rebinding protection (#2111)
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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": {
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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 {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user