fix(stats): give DNS rebinding protection its own REBIND response type

Rebinding protection returned ResponseTypeFILTERED, the same type used for
query-type filtering (filtering.queryTypes). Since #2161 excluded FILTERED
from the "blocked" stats, a rebinding hit — an actual attack being stopped —
was counted as a benign filter: absent from summary.blocked, topBlockedDomains
and the per-hour blocked series, and reported instead under "filtered", whose
API description explicitly claims it holds only query-type/NOTFQDN responses.

FILTERED was overloaded, so no stats-layer mapping could tell the two apart.
Split them at the source:

- add model.ResponseTypeREBIND (appended to the enum, so existing iota values
  are unchanged) and return it from RebindingProtectionResolver
- count REBIND as blocked in the stats (summary, Top Blocked, per-hour series)
- report REBIND to clients as EDE 15 (Blocked) rather than 17 (Filtered). Per
  RFC 8914, Blocked is blocking by operator security policy and Filtered is
  blocking requested by the client; rebinding protection is operator policy.
- FILTERED now means exactly what its enum comment says: filtered by query type

Also fixes gaps left by #2161:

- perHour gains a `filtered` series, so the volume the blocked series no longer
  carries stays observable instead of vanishing from the API
- docs/interfaces.md still documented blocked = BLOCKED + FILTERED + NOTFQDN
- cover the Filtered mapping in toAPIStats, the CLI summary row and the
  per-hour blocked series, none of which were tested

BREAKING CHANGE: rebinding-protection hits change response type FILTERED ->
REBIND and reason "FILTERED (rebinding protection)" -> "REBIND (rebinding
protection)" in query logs and Prometheus labels, and their EDE code changes
from 17 (Filtered) to 15 (Blocked).
This commit is contained in:
Dimitri Herzog
2026-07-13 21:45:12 +02:00
parent c07bd555b0
commit 66784f6d35
15 changed files with 180 additions and 51 deletions
+3 -1
View File
@@ -254,7 +254,9 @@ func toAPINameCounts(in []stats.NameCount) []ApiNameCount {
func toAPIHourPoints(in []stats.HourPoint) []ApiHourPoint {
out := make([]ApiHourPoint, 0, len(in))
for _, p := range in {
out = append(out, ApiHourPoint{Hour: p.Hour, Queries: p.Queries, Blocked: p.Blocked})
out = append(out, ApiHourPoint{
Hour: p.Hour, Queries: p.Queries, Blocked: p.Blocked, Filtered: p.Filtered,
})
}
return out
+16 -2
View File
@@ -287,12 +287,19 @@ var _ = Describe("API implementation tests", func() {
Describe("Stats API", func() {
It("returns 200 with the snapshot when enabled", func() {
hour := time.Date(2026, 6, 23, 10, 0, 0, 0, time.UTC)
statsMock.On("StatsEnabled").Return(true)
statsMock.On("Stats").Return(stats.Result{
Summary: stats.Summary{Queries: 3, Blocked: 1, Cached: 1, Forwarded: 1},
// distinct values per field so a mis-wired mapping cannot pass
Summary: stats.Summary{
Queries: 21, Cached: 2, Forwarded: 3, Blocked: 4, Filtered: 5,
Local: 6, Dropped: 7, Errors: 8, AvgResponseMs: 9, CacheHitRate: 0.4,
},
ByResponseType: map[string]int{"RESOLVED": 1, "CACHED": 1, "BLOCKED": 1},
ByQueryType: map[string]int{"A": 3},
ByResponseCode: map[string]int{"NOERROR": 3},
PerHour: []stats.HourPoint{{Hour: hour, Queries: 10, Blocked: 4, Filtered: 5}},
TopDomains: []stats.NameCount{{Name: "example.com", Count: 2}},
Lists: stats.ListCounts{Denylist: map[string]int{}, Allowlist: map[string]int{}},
})
@@ -302,7 +309,14 @@ var _ = Describe("API implementation tests", func() {
resp200, ok := resp.(GetStats200JSONResponse)
Expect(ok).Should(BeTrue())
Expect(resp200.Summary.Queries).Should(Equal(3))
Expect(resp200.Summary).Should(Equal(ApiStatsSummary{
Queries: 21, Cached: 2, Forwarded: 3, Blocked: 4, Filtered: 5,
Local: 6, Dropped: 7, Errors: 8, AvgResponseMs: 9, CacheHitRate: 0.4,
}))
Expect(resp200.PerHour).Should(ConsistOf(ApiHourPoint{
Hour: hour, Queries: 10, Blocked: 4, Filtered: 5,
}))
Expect(resp200.ByResponseType).Should(HaveKeyWithValue("BLOCKED", 1))
Expect(resp200.TopDomains).Should(HaveLen(1))
})
+6 -2
View File
@@ -27,8 +27,12 @@ type ApiCacheStats struct {
// ApiHourPoint defines model for api.HourPoint.
type ApiHourPoint struct {
// Blocked Real blocks in this hour, same definition as summary.blocked.
Blocked int `json:"blocked"`
// Filtered Query-type filtered / NOTFQDN responses in this hour, same definition as summary.filtered.
Filtered int `json:"filtered"`
// Hour Start of the hour bucket. UTC (RFC 3339).
Hour time.Time `json:"hour"`
Queries int `json:"queries"`
@@ -107,14 +111,14 @@ type ApiStats struct {
type ApiStatsSummary struct {
AvgResponseMs int `json:"avgResponseMs"`
// Blocked Real blocklist hits only (excludes query-type filtered / NOTFQDN).
// Blocked Real blocks: denylist hits (BLOCKED) and DNS rebinding-protection hits (REBIND). Excludes query-type filtered and NOTFQDN responses.
Blocked int `json:"blocked"`
CacheHitRate float64 `json:"cacheHitRate"`
Cached int `json:"cached"`
Dropped int `json:"dropped"`
Errors int `json:"errors"`
// Filtered Query-type filtered (e.g. AAAA) and NOTFQDN responses.
// Filtered Query-type filtered (e.g. AAAA via filtering.queryTypes) and NOTFQDN responses. These are not blocks.
Filtered int `json:"filtered"`
Forwarded int `json:"forwarded"`
Local int `json:"local"`
+7 -2
View File
@@ -21,12 +21,12 @@ func sampleStats() api.ApiStats {
Start: start,
End: start.Add(12 * time.Hour),
Summary: api.ApiStatsSummary{
Queries: 12431, Cached: 5102, Forwarded: 4445, Blocked: 2884,
Queries: 12431, Cached: 5102, Forwarded: 4445, Blocked: 2884, Filtered: 1620,
Local: 12, Dropped: 3, Errors: 7, AvgResponseMs: 7, CacheHitRate: 0.41,
},
ByQueryType: map[string]int{"A": 9000, "AAAA": 3431},
ByResponseCode: map[string]int{"NOERROR": 11000, "NXDOMAIN": 1431},
ByResponseType: map[string]int{"CACHED": 5102, "BLOCKED": 2884},
ByResponseType: map[string]int{"CACHED": 5102, "BLOCKED": 2884, "FILTERED": 1620},
TopDomains: []api.ApiNameCount{{Name: "github.com", Count: 1203}},
TopBlockedDomains: []api.ApiNameCount{{Name: "ads.example.com", Count: 402}},
TopClients: []api.ApiNameCount{{Name: "10.0.0.5", Count: 4310}},
@@ -58,6 +58,11 @@ var _ = Describe("renderStats", func() {
Expect(out).Should(ContainSubstring("github.com"))
Expect(out).Should(ContainSubstring("Top Blocked"))
Expect(out).Should(ContainSubstring("ads.example.com"))
// blocked and filtered are separate rows, each with its own share of queries
Expect(out).Should(ContainSubstring("Blocked"))
Expect(out).Should(ContainSubstring("2,884 (23.2%)"))
Expect(out).Should(ContainSubstring("Filtered"))
Expect(out).Should(ContainSubstring("1,620 (13.0%)"))
Expect(out).Should(ContainSubstring("Top Clients"))
Expect(out).Should(ContainSubstring("By Query Type"))
Expect(out).Should(ContainSubstring("Cache: 8,123 entries"))
+11 -2
View File
@@ -279,10 +279,14 @@ components:
type: integer
blocked:
type: integer
description: Real blocklist hits only (excludes query-type filtered / NOTFQDN).
description: >-
Real blocks: denylist hits (BLOCKED) and DNS rebinding-protection hits (REBIND).
Excludes query-type filtered and NOTFQDN responses.
filtered:
type: integer
description: Query-type filtered (e.g. AAAA) and NOTFQDN responses.
description: >-
Query-type filtered (e.g. AAAA via filtering.queryTypes) and NOTFQDN responses.
These are not blocks.
local:
type: integer
dropped:
@@ -326,10 +330,15 @@ components:
type: integer
blocked:
type: integer
description: Real blocks in this hour, same definition as summary.blocked.
filtered:
type: integer
description: Query-type filtered / NOTFQDN responses in this hour, same definition as summary.filtered.
required:
- hour
- queries
- blocked
- filtered
api.ListCounts:
type: object
description: Current per-group list entry counts (point-in-time, not windowed).
+5 -3
View File
@@ -395,8 +395,10 @@ under the attacker's origin. When this protection is enabled, blocky drops any a
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.**
response type `REBIND` with reason `REBIND (rebinding protection)` in query logs and metrics;
the offending IP is logged at debug level). Rebinding hits count as **blocked** in the
[statistics](#statistics), and are reported to clients as Extended DNS Error `15 (Blocked)`.
**Disabled by default.**
The following ranges are considered non-public:
@@ -443,7 +445,7 @@ startup, and internationalized domains must be given in punycode (`xn--…`) for
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
keep showing `REBIND`. 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.
+18 -3
View File
@@ -36,9 +36,24 @@ You can also browse the interactive API documentation (RapiDoc) documentation [o
!!! note "Statistics semantics"
For `/api/stats`, the `summary` fields are server-computed categories (e.g. `blocked` =
`BLOCKED` + `FILTERED` + `NOTFQDN`, `forwarded` = `RESOLVED` + `CONDITIONAL`), so callers
never interpret a raw response type. The `lists` and `cache` objects are point-in-time gauges
For `/api/stats`, the `summary` fields are server-computed categories, so callers never
interpret a raw response type:
| Field | Response types |
| ---------- | ---------------------------------------------------------- |
| `blocked` | `BLOCKED` + `REBIND` |
| `filtered` | `FILTERED` + `NOTFQDN` |
| `forwarded`| `RESOLVED` + `CONDITIONAL` |
| `cached` | `CACHED` |
| `local` | `CUSTOMDNS` + `HOSTSFILE` + `SPECIAL` + `SYNTHESIZED` |
`blocked` counts only queries blocked to protect the client: denylist hits and
[DNS rebinding](configuration.md#dns-rebinding-protection) hits. Query-type filtering (e.g.
`AAAA` via `filtering.queryTypes`) and non-FQDN rejections are *not* blocks and are counted
separately as `filtered`, so they do not inflate `blocked` or the `topBlockedDomains` list.
The same split applies to the `perHour` series, which carries both `blocked` and `filtered`.
The `lists` and `cache` objects are point-in-time gauges
(current values, not affected by the 24h window), while `start`/`end` bound the windowed fields
only. All timestamps (`start`, `end`, `perHour[].hour`) are always returned in UTC (RFC 3339,
`Z` suffix), regardless of the server's local time zone. Statistics are independent of Prometheus
+6
View File
@@ -19,6 +19,7 @@ import (
// NOTFQDN // the query was filtered as it is not fqdn conform
// SPECIAL // the query was resolved by the special use domain name resolver
// SYNTHESIZED // the response was synthesized by DNS64
// REBIND // the answer was blocked by the DNS rebinding protection
// )
type ResponseType int
@@ -38,6 +39,11 @@ func (t ResponseType) ToExtendedErrorCode() uint16 {
return dns.ExtendedErrorCodeBlocked
case ResponseTypeBLOCKED:
return dns.ExtendedErrorCodeBlocked
// RFC 8914: "Blocked" is blocking due to an internal security policy of the
// operator, "Filtered" is blocking requested by the client. Rebinding
// protection is operator policy, so it reports as Blocked.
case ResponseTypeREBIND:
return dns.ExtendedErrorCodeBlocked
case ResponseTypeFILTERED:
return dns.ExtendedErrorCodeFiltered
case ResponseTypeSPECIAL:
+7 -1
View File
@@ -123,11 +123,14 @@ const (
// ResponseTypeSYNTHESIZED is a ResponseType of type SYNTHESIZED.
// the response was synthesized by DNS64
ResponseTypeSYNTHESIZED
// ResponseTypeREBIND is a ResponseType of type REBIND.
// the answer was blocked by the DNS rebinding protection
ResponseTypeREBIND
)
var ErrInvalidResponseType = fmt.Errorf("not a valid ResponseType, try [%s]", strings.Join(_ResponseTypeNames, ", "))
const _ResponseTypeName = "RESOLVEDCACHEDBLOCKEDCONDITIONALCUSTOMDNSHOSTSFILEFILTEREDNOTFQDNSPECIALSYNTHESIZED"
const _ResponseTypeName = "RESOLVEDCACHEDBLOCKEDCONDITIONALCUSTOMDNSHOSTSFILEFILTEREDNOTFQDNSPECIALSYNTHESIZEDREBIND"
var _ResponseTypeNames = []string{
_ResponseTypeName[0:8],
@@ -140,6 +143,7 @@ var _ResponseTypeNames = []string{
_ResponseTypeName[58:65],
_ResponseTypeName[65:72],
_ResponseTypeName[72:83],
_ResponseTypeName[83:89],
}
// ResponseTypeNames returns a list of possible string values of ResponseType.
@@ -160,6 +164,7 @@ var _ResponseTypeMap = map[ResponseType]string{
ResponseTypeNOTFQDN: _ResponseTypeName[58:65],
ResponseTypeSPECIAL: _ResponseTypeName[65:72],
ResponseTypeSYNTHESIZED: _ResponseTypeName[72:83],
ResponseTypeREBIND: _ResponseTypeName[83:89],
}
// String implements the Stringer interface.
@@ -188,6 +193,7 @@ var _ResponseTypeValue = map[string]ResponseType{
_ResponseTypeName[58:65]: ResponseTypeNOTFQDN,
_ResponseTypeName[65:72]: ResponseTypeSPECIAL,
_ResponseTypeName[72:83]: ResponseTypeSYNTHESIZED,
_ResponseTypeName[83:89]: ResponseTypeREBIND,
}
// ParseResponseType attempts to convert a string to a ResponseType.
+2 -2
View File
@@ -184,9 +184,9 @@ var _ = Describe("BlockingResolver", Label("blockingResolver"), func() {
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
// the same name queried by a client passes the full chain and is blocked
Expect(chain.Resolve(ctx, newRequestWithClient("nas.ddns.example.com.", A, "192.168.1.5"))).
Should(HaveResponseType(ResponseTypeFILTERED))
Should(HaveResponseType(ResponseTypeREBIND))
})
})
})
+22
View File
@@ -136,6 +136,28 @@ var _ = Describe("EdeResolver", func() {
})
})
When("resolver returns a rebinding-protection response", func() {
BeforeEach(func() {
m = &mockResolver{}
m.On("Resolve", mock.Anything).Return(&Response{
Res: mockAnswer,
RType: ResponseTypeREBIND,
Reason: "REBIND (rebinding protection)",
}, nil)
})
It("reports it to the client as blocked, not filtered", func() {
Expect(sut.Resolve(ctx, newRequest("example.com.", A))).
Should(
SatisfyAll(
HaveEdnsOption(dns.EDNS0EDE),
WithTransform(extractEdeOption,
HaveField("InfoCode", Equal(dns.ExtendedErrorCodeBlocked)),
),
))
})
})
When("resolver returns a blocked response with an oversized reason", func() {
longReason := "BLOCKED (ads: /" + strings.Repeat("a", 500) + "/)"
+2 -2
View File
@@ -98,8 +98,8 @@ func (r *RebindingProtectionResolver) Resolve(ctx context.Context, request *mode
// 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
return model.NewResponseWithRcode(request, dns.RcodeSuccess, model.ResponseTypeREBIND,
"REBIND (rebinding protection)"), nil
}
// isAllowed reports whether the queried domain matches an allowlist entry exactly
+18 -18
View File
@@ -154,7 +154,7 @@ var _ = Describe("RebindingProtectionResolver", func() {
Expect(sut.Resolve(ctx, newRequest("rebind.example.com.", A))).
Should(SatisfyAll(
HaveNoAnswer(),
HaveResponseType(ResponseTypeFILTERED),
HaveResponseType(ResponseTypeREBIND),
))
})
})
@@ -167,7 +167,7 @@ var _ = Describe("RebindingProtectionResolver", func() {
Expect(sut.Resolve(ctx, newRequest("rebind.example.com.", A))).
Should(SatisfyAll(
HaveNoAnswer(),
HaveResponseType(ResponseTypeFILTERED),
HaveResponseType(ResponseTypeREBIND),
HaveReturnCode(dns.RcodeSuccess),
))
},
@@ -198,7 +198,7 @@ var _ = Describe("RebindingProtectionResolver", func() {
Expect(sut.Resolve(ctx, newRequest("rebind.example.com.", HTTPS))).
Should(SatisfyAll(
HaveNoAnswer(),
HaveResponseType(ResponseTypeFILTERED),
HaveResponseType(ResponseTypeREBIND),
))
})
@@ -216,7 +216,7 @@ var _ = Describe("RebindingProtectionResolver", func() {
Expect(sut.Resolve(ctx, newRequest("rebind.example.com.", dns.Type(dns.TypeSVCB)))).
Should(SatisfyAll(
HaveNoAnswer(),
HaveResponseType(ResponseTypeFILTERED),
HaveResponseType(ResponseTypeREBIND),
))
})
@@ -235,7 +235,7 @@ var _ = Describe("RebindingProtectionResolver", func() {
Expect(sut.Resolve(ctx, newRequest("rebind.example.com.", HTTPS))).
Should(SatisfyAll(
HaveNoAnswer(),
HaveResponseType(ResponseTypeFILTERED),
HaveResponseType(ResponseTypeREBIND),
))
})
@@ -265,7 +265,7 @@ var _ = Describe("RebindingProtectionResolver", func() {
resp, err := sut.Resolve(ctx, newRequest("rebind.example.com.", A))
Expect(err).Should(Succeed())
Expect(resp.Reason).Should(Equal("FILTERED (rebinding protection)"))
Expect(resp.Reason).Should(Equal("REBIND (rebinding protection)"))
})
It("filters answers mixing public and private records", func() {
@@ -277,7 +277,7 @@ var _ = Describe("RebindingProtectionResolver", func() {
Expect(sut.Resolve(ctx, newRequest("rebind.example.com.", A))).
Should(SatisfyAll(
HaveNoAnswer(),
HaveResponseType(ResponseTypeFILTERED),
HaveResponseType(ResponseTypeREBIND),
))
})
@@ -296,7 +296,7 @@ var _ = Describe("RebindingProtectionResolver", func() {
Expect(sut.Resolve(ctx, newRequest("rebind.example.com.", HTTPS))).
Should(SatisfyAll(
HaveNoAnswer(),
HaveResponseType(ResponseTypeFILTERED),
HaveResponseType(ResponseTypeREBIND),
))
})
@@ -304,7 +304,7 @@ var _ = Describe("RebindingProtectionResolver", 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))
Should(HaveResponseType(ResponseTypeREBIND))
})
It("filters private answers for requests without a question section", func() {
@@ -312,7 +312,7 @@ var _ = Describe("RebindingProtectionResolver", func() {
req.Req.Question = nil
mockAnswer.Answer = []dns.RR{rebindTestA("rebind.example.com.", "192.168.1.100")}
Expect(sut.Resolve(ctx, req)).Should(HaveResponseType(ResponseTypeFILTERED))
Expect(sut.Resolve(ctx, req)).Should(HaveResponseType(ResponseTypeREBIND))
})
It("obfuscates the dropped IP in the debug log when log privacy is enabled", func() {
@@ -341,7 +341,7 @@ var _ = Describe("RebindingProtectionResolver", func() {
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))
Should(HaveResponseType(ResponseTypeREBIND))
})
})
@@ -376,7 +376,7 @@ var _ = Describe("RebindingProtectionResolver", 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))
Should(HaveResponseType(ResponseTypeREBIND))
})
It("still filters CNAMEs pointing at an allowlisted name", func() {
@@ -389,7 +389,7 @@ var _ = Describe("RebindingProtectionResolver", func() {
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))
Should(HaveResponseType(ResponseTypeREBIND))
})
It("does not treat escaped dots as label boundaries", func() {
@@ -398,7 +398,7 @@ var _ = Describe("RebindingProtectionResolver", func() {
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))
Should(HaveResponseType(ResponseTypeREBIND))
})
It("never applies the allowlist to multi-question requests", func() {
@@ -409,7 +409,7 @@ var _ = Describe("RebindingProtectionResolver", func() {
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))
Expect(sut.Resolve(ctx, req)).Should(HaveResponseType(ResponseTypeREBIND))
})
})
@@ -466,7 +466,7 @@ var _ = Describe("RebindingProtectionResolver", func() {
Expect(err).Should(Succeed())
Expect(resp).Should(SatisfyAll(
HaveNoAnswer(),
HaveResponseType(ResponseTypeFILTERED),
HaveResponseType(ResponseTypeREBIND),
HaveReturnCode(dns.RcodeSuccess),
))
Expect(resp.Res.AuthenticatedData).Should(BeFalse())
@@ -490,7 +490,7 @@ var _ = Describe("RebindingProtectionResolver", func() {
Expect(err).Should(Succeed())
Expect(resp).Should(SatisfyAll(
HaveNoAnswer(),
HaveResponseType(ResponseTypeFILTERED),
HaveResponseType(ResponseTypeREBIND),
HaveReturnCode(dns.RcodeSuccess),
))
Expect(m.Calls).Should(HaveLen(1))
@@ -501,7 +501,7 @@ var _ = Describe("RebindingProtectionResolver", func() {
Expect(err).Should(Succeed())
Expect(resp).Should(SatisfyAll(
HaveNoAnswer(),
HaveResponseType(ResponseTypeFILTERED),
HaveResponseType(ResponseTypeREBIND),
HaveReturnCode(dns.RcodeSuccess),
))
// the real answer is cached below this resolver and re-filtered
+25 -13
View File
@@ -68,9 +68,10 @@ type Summary struct {
// HourPoint is one point in the per-hour time series.
type HourPoint struct {
Hour time.Time
Queries int
Blocked int
Hour time.Time
Queries int
Blocked int
Filtered int
}
// ListCounts holds current per-group list entry counts (point-in-time).
@@ -394,12 +395,17 @@ func categorize(rtype string) responseCategory {
return categoryCached
case model.ResponseTypeRESOLVED.String(), model.ResponseTypeCONDITIONAL.String():
return categoryForwarded
case model.ResponseTypeBLOCKED.String():
// A denylist hit and a rebinding-protection hit are both queries blocked to
// protect the client, so both belong in "blocked".
case model.ResponseTypeBLOCKED.String(), model.ResponseTypeREBIND.String():
return categoryBlocked
case model.ResponseTypeFILTERED.String(), model.ResponseTypeNOTFQDN.String():
// Query-type filtering (e.g. AAAA via filtering.queryTypes) and NOTFQDN
// rejections are not blocklist hits, so they get their own bucket and no
// longer inflate "blocked" or the Top Blocked table (#2151).
// rejections are not blocks, so they get their own bucket and no longer
// inflate "blocked" or the Top Blocked table (#2151). NOTFQDN sits here
// even though model.ToExtendedErrorCode reports it to DNS clients as
// "Blocked": the EDE code answers "did the server refuse this?", while
// this bucket answers "was something harmful blocked?".
return categoryFiltered
case model.ResponseTypeCUSTOMDNS.String(), model.ResponseTypeHOSTSFILE.String(),
model.ResponseTypeSPECIAL.String(), model.ResponseTypeSYNTHESIZED.String():
@@ -409,10 +415,10 @@ func categorize(rtype string) responseCategory {
}
}
// isBlockedRType reports whether a response type is a true blocklist hit
// (BLOCKED). Query-type-filtered / NOTFQDN responses are deliberately excluded
// so the Top Blocked table and the per-hour blocked series count only real
// blocks (#2151).
// isBlockedRType reports whether a response type is a true block: a denylist hit
// (BLOCKED) or a rebinding-protection hit (REBIND). Query-type-filtered /
// NOTFQDN responses are deliberately excluded so the Top Blocked table and the
// per-hour blocked series count only real blocks (#2151).
func isBlockedRType(rtype string) bool {
return categorize(rtype) == categoryBlocked
}
@@ -496,17 +502,23 @@ func perHour(buckets []*bucket) []HourPoint {
points := make([]HourPoint, 0, len(buckets))
for _, b := range buckets {
queries := b.dropped + b.errors
blocked := 0
blocked, filtered := 0, 0
for rtype, v := range b.byResponseType {
queries += v
if isBlockedRType(rtype) {
switch categorize(rtype) {
case categoryBlocked:
blocked += v
case categoryFiltered:
filtered += v
case categoryOther, categoryCached, categoryForwarded, categoryLocal:
}
}
points = append(points, HourPoint{Hour: b.hourStart, Queries: queries, Blocked: blocked})
points = append(points, HourPoint{
Hour: b.hourStart, Queries: queries, Blocked: blocked, Filtered: filtered,
})
}
sort.Slice(points, func(i, j int) bool {
+32
View File
@@ -60,6 +60,18 @@ var _ = Describe("Collector", func() {
Expect(names).ShouldNot(ContainElement("notfqdn.local"))
})
It("counts rebinding-protection hits as blocked, not filtered", func() {
sut.Record(answered("BLOCKED", "A", "NOERROR", "ads.com", "c1"))
sut.Record(answered("REBIND", "A", "NOERROR", "rebind.example.com", "c1"))
sut.Record(answered("FILTERED", "AAAA", "NOERROR", "example.com", "c1"))
res := sut.Snapshot()
Expect(res.Summary.Blocked).Should(Equal(2)) // BLOCKED + REBIND
Expect(res.Summary.Filtered).Should(Equal(1)) // FILTERED only
Expect(namesOf(res.TopBlockedDomains)).Should(ContainElement("rebind.example.com"))
})
It("computes cacheHitRate as cached/(cached+forwarded)", func() {
sut.Record(answered("CACHED", "A", "NOERROR", "a.com", "c1"))
sut.Record(answered("CACHED", "A", "NOERROR", "a.com", "c1"))
@@ -137,6 +149,26 @@ var _ = Describe("Collector", func() {
Expect(blocked).Should(Equal(1))
})
It("counts only true blocks in the per-hour blocked series", func() {
clk := &fakeClock{t: mustParse("2026-06-08T10:00:00Z")}
c := newCollectorWithClock(clk.now)
c.Record(Sample{Disposition: DispositionAnswered, RType: "BLOCKED", QType: "A", Domain: "ads.com"})
c.Record(Sample{Disposition: DispositionAnswered, RType: "REBIND", QType: "A", Domain: "rebind.com"})
c.Record(Sample{Disposition: DispositionAnswered, RType: "FILTERED", QType: "AAAA", Domain: "ok.com"})
c.Record(Sample{Disposition: DispositionAnswered, RType: "NOTFQDN", QType: "A", Domain: "notfqdn"})
res := c.Snapshot()
Expect(res.PerHour).Should(HaveLen(1))
Expect(res.PerHour[0].Queries).Should(Equal(4))
// BLOCKED + REBIND; query-type filtered and NOTFQDN are not blocks.
Expect(res.PerHour[0].Blocked).Should(Equal(2))
// FILTERED + NOTFQDN keep their own series, so the volume the blocked
// series no longer carries stays observable per hour.
Expect(res.PerHour[0].Filtered).Should(Equal(2))
})
It("prunes high-cardinality maps of closed buckets to keepPerHour", func() {
clk := &fakeClock{t: mustParse("2026-06-08T10:00:00Z")}
c := newCollectorWithClock(clk.now)