mirror of
https://github.com/0xERR0R/blocky.git
synced 2026-10-12 02:01:13 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
|
||||
@@ -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
@@ -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
@@ -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).
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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) + "/)"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user