Files
Dimitri Herzog 84353421d7 fix(stats): give DNS rebinding protection its own REBIND response type (#2166)
* 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).

* fix(stats): give DNSSEC validation failures their own BOGUS response type

DNSSEC-bogus SERVFAILs reused ResponseTypeBLOCKED, which had two effects.

In the statistics they were counted as blocks: they inflated summary.blocked
and perHour[].blocked, and a domain whose operator had merely misconfigured
DNSSEC showed up in topBlockedDomains. That contradicts the definition of
"blocked" as queries blocked to protect the client.

Worse, EdeResolver sits above the DNSSEC resolver and rewrites the EDE option
from the response type, and util.SetEdns0Option replaces an existing option of
the same code. So the ExtendedErrorCodeDNSBogus (6) that the DNSSEC resolver
sets on its SERVFAIL was overwritten with Blocked (15) before the answer
reached the client. The existing test missed this because it exercises the
resolver in isolation, with no EDE resolver above it.

Give validation failures their own response type, mirroring REBIND: BOGUS maps
to ExtendedErrorCodeDNSBogus, so EdeResolver now reproduces the correct code
instead of clobbering it, and it is categorized as an error rather than a
block. A SERVFAIL is a resolution error - blocky could not obtain a trustworthy
answer - so it joins the chain-level failures in summary.errors. The curated
categories now partition every response type, so they always add up to queries.

Also point the shipped Grafana dashboard's blocked panels (block rate,
blocked/sec, block ratio, top block reasons) at BLOCKED|REBIND. They hard-coded
response_type="BLOCKED", so they would have disagreed with /api/stats about
what "blocked" means, and they silently counted DNSSEC failures as blocks.

The query-log dashboards keep filtering on BLOCKED alone: that panel is
"Blocked by Denylist" and is correctly scoped to denylist groups.

* docs: fix stale FILTERED terminology and a wrong symbol reference

Addresses the two Copilot review comments on #2166.

The categorize comment referred to `model.ToExtendedErrorCode`, but there is no
package-level function of that name - it is a method on `model.ResponseType`,
which makes the behavior hard to trace from the comment.

The rebinding docs and the RebindingProtectionResolver.Resolve comment still
described the response as FILTERED, mixing the old terminology with the new
REBIND semantics introduced in 66784f6d.

* refactor(stats): derive the per-hour series from curatedSummary

perHour re-implemented curatedSummary's category-summing loop, so every new
category had to be taught to two places: adding "filtered" in 66784f6d needed
an edit in both, and a future one that updated only the summary would leave the
per-hour series silently disagreeing about what counts as a block. Derive the
point from curatedSummary instead, so categorize() stays the single switch.

Correct the isBlockedRType comment while here: since perHour no longer calls
it, its only caller is the Top Blocked table.

Also reset the shared mock in the EdeResolver suite. `m` is a closure variable
that nothing set back to nil, so a resolver installed by one spec's BeforeEach
survived into the next and was reused by the `if m == nil` guard, leaking both
its response type and its accumulated Calls. It passed only because Ginkgo runs
specs in declaration order and the specs relying on the default mock happen to
be declared first; under --randomize-all it fails on every seed tried.

Finally, cover REBIND and BOGUS in the CLI stats fixture, which exercised
neither of the response types split out of BLOCKED.
2026-07-13 22:31:49 +02:00

236 lines
7.5 KiB
Go

// Code generated by go-enum DO NOT EDIT.
// Version: v0.9.2
// Built By: go install
package model
import (
"fmt"
"strings"
)
const (
// RequestProtocolTCP is a RequestProtocol of type TCP.
// is the TCP protocol
RequestProtocolTCP RequestProtocol = iota
// RequestProtocolUDP is a RequestProtocol of type UDP.
// is the UDP protocol
RequestProtocolUDP
)
var ErrInvalidRequestProtocol = fmt.Errorf("not a valid RequestProtocol, try [%s]", strings.Join(_RequestProtocolNames, ", "))
const _RequestProtocolName = "TCPUDP"
var _RequestProtocolNames = []string{
_RequestProtocolName[0:3],
_RequestProtocolName[3:6],
}
// RequestProtocolNames returns a list of possible string values of RequestProtocol.
func RequestProtocolNames() []string {
tmp := make([]string, len(_RequestProtocolNames))
copy(tmp, _RequestProtocolNames)
return tmp
}
var _RequestProtocolMap = map[RequestProtocol]string{
RequestProtocolTCP: _RequestProtocolName[0:3],
RequestProtocolUDP: _RequestProtocolName[3:6],
}
// String implements the Stringer interface.
func (x RequestProtocol) String() string {
if str, ok := _RequestProtocolMap[x]; ok {
return str
}
return fmt.Sprintf("RequestProtocol(%d)", x)
}
// IsValid provides a quick way to determine if the typed value is
// part of the allowed enumerated values
func (x RequestProtocol) IsValid() bool {
_, ok := _RequestProtocolMap[x]
return ok
}
var _RequestProtocolValue = map[string]RequestProtocol{
_RequestProtocolName[0:3]: RequestProtocolTCP,
_RequestProtocolName[3:6]: RequestProtocolUDP,
}
// ParseRequestProtocol attempts to convert a string to a RequestProtocol.
func ParseRequestProtocol(name string) (RequestProtocol, error) {
if x, ok := _RequestProtocolValue[name]; ok {
return x, nil
}
return RequestProtocol(0), fmt.Errorf("%s is %w", name, ErrInvalidRequestProtocol)
}
// MarshalText implements the text marshaller method.
func (x RequestProtocol) MarshalText() ([]byte, error) {
return []byte(x.String()), nil
}
// UnmarshalText implements the text unmarshaller method.
func (x *RequestProtocol) UnmarshalText(text []byte) error {
name := string(text)
tmp, err := ParseRequestProtocol(name)
if err != nil {
return err
}
*x = tmp
return nil
}
// AppendText appends the textual representation of itself to the end of b
// (allocating a larger slice if necessary) and returns the updated slice.
//
// Implementations must not retain b, nor mutate any bytes within b[:len(b)].
func (x *RequestProtocol) AppendText(b []byte) ([]byte, error) {
return append(b, x.String()...), nil
}
const (
// ResponseTypeRESOLVED is a ResponseType of type RESOLVED.
// the response was resolved by the external upstream resolver
ResponseTypeRESOLVED ResponseType = iota
// ResponseTypeCACHED is a ResponseType of type CACHED.
// the response was resolved from cache
ResponseTypeCACHED
// ResponseTypeBLOCKED is a ResponseType of type BLOCKED.
// the query was blocked
ResponseTypeBLOCKED
// ResponseTypeCONDITIONAL is a ResponseType of type CONDITIONAL.
// the query was resolved by the conditional upstream resolver
ResponseTypeCONDITIONAL
// ResponseTypeCUSTOMDNS is a ResponseType of type CUSTOMDNS.
// the query was resolved by a custom rule
ResponseTypeCUSTOMDNS
// ResponseTypeHOSTSFILE is a ResponseType of type HOSTSFILE.
// the query was resolved by looking up the hosts file
ResponseTypeHOSTSFILE
// ResponseTypeFILTERED is a ResponseType of type FILTERED.
// the query was filtered by query type
ResponseTypeFILTERED
// ResponseTypeNOTFQDN is a ResponseType of type NOTFQDN.
// the query was filtered as it is not fqdn conform
ResponseTypeNOTFQDN
// ResponseTypeSPECIAL is a ResponseType of type SPECIAL.
// the query was resolved by the special use domain name resolver
ResponseTypeSPECIAL
// 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
// ResponseTypeBOGUS is a ResponseType of type BOGUS.
// the answer failed DNSSEC validation
ResponseTypeBOGUS
)
var ErrInvalidResponseType = fmt.Errorf("not a valid ResponseType, try [%s]", strings.Join(_ResponseTypeNames, ", "))
const _ResponseTypeName = "RESOLVEDCACHEDBLOCKEDCONDITIONALCUSTOMDNSHOSTSFILEFILTEREDNOTFQDNSPECIALSYNTHESIZEDREBINDBOGUS"
var _ResponseTypeNames = []string{
_ResponseTypeName[0:8],
_ResponseTypeName[8:14],
_ResponseTypeName[14:21],
_ResponseTypeName[21:32],
_ResponseTypeName[32:41],
_ResponseTypeName[41:50],
_ResponseTypeName[50:58],
_ResponseTypeName[58:65],
_ResponseTypeName[65:72],
_ResponseTypeName[72:83],
_ResponseTypeName[83:89],
_ResponseTypeName[89:94],
}
// ResponseTypeNames returns a list of possible string values of ResponseType.
func ResponseTypeNames() []string {
tmp := make([]string, len(_ResponseTypeNames))
copy(tmp, _ResponseTypeNames)
return tmp
}
var _ResponseTypeMap = map[ResponseType]string{
ResponseTypeRESOLVED: _ResponseTypeName[0:8],
ResponseTypeCACHED: _ResponseTypeName[8:14],
ResponseTypeBLOCKED: _ResponseTypeName[14:21],
ResponseTypeCONDITIONAL: _ResponseTypeName[21:32],
ResponseTypeCUSTOMDNS: _ResponseTypeName[32:41],
ResponseTypeHOSTSFILE: _ResponseTypeName[41:50],
ResponseTypeFILTERED: _ResponseTypeName[50:58],
ResponseTypeNOTFQDN: _ResponseTypeName[58:65],
ResponseTypeSPECIAL: _ResponseTypeName[65:72],
ResponseTypeSYNTHESIZED: _ResponseTypeName[72:83],
ResponseTypeREBIND: _ResponseTypeName[83:89],
ResponseTypeBOGUS: _ResponseTypeName[89:94],
}
// String implements the Stringer interface.
func (x ResponseType) String() string {
if str, ok := _ResponseTypeMap[x]; ok {
return str
}
return fmt.Sprintf("ResponseType(%d)", x)
}
// IsValid provides a quick way to determine if the typed value is
// part of the allowed enumerated values
func (x ResponseType) IsValid() bool {
_, ok := _ResponseTypeMap[x]
return ok
}
var _ResponseTypeValue = map[string]ResponseType{
_ResponseTypeName[0:8]: ResponseTypeRESOLVED,
_ResponseTypeName[8:14]: ResponseTypeCACHED,
_ResponseTypeName[14:21]: ResponseTypeBLOCKED,
_ResponseTypeName[21:32]: ResponseTypeCONDITIONAL,
_ResponseTypeName[32:41]: ResponseTypeCUSTOMDNS,
_ResponseTypeName[41:50]: ResponseTypeHOSTSFILE,
_ResponseTypeName[50:58]: ResponseTypeFILTERED,
_ResponseTypeName[58:65]: ResponseTypeNOTFQDN,
_ResponseTypeName[65:72]: ResponseTypeSPECIAL,
_ResponseTypeName[72:83]: ResponseTypeSYNTHESIZED,
_ResponseTypeName[83:89]: ResponseTypeREBIND,
_ResponseTypeName[89:94]: ResponseTypeBOGUS,
}
// ParseResponseType attempts to convert a string to a ResponseType.
func ParseResponseType(name string) (ResponseType, error) {
if x, ok := _ResponseTypeValue[name]; ok {
return x, nil
}
return ResponseType(0), fmt.Errorf("%s is %w", name, ErrInvalidResponseType)
}
// MarshalText implements the text marshaller method.
func (x ResponseType) MarshalText() ([]byte, error) {
return []byte(x.String()), nil
}
// UnmarshalText implements the text unmarshaller method.
func (x *ResponseType) UnmarshalText(text []byte) error {
name := string(text)
tmp, err := ParseResponseType(name)
if err != nil {
return err
}
*x = tmp
return nil
}
// AppendText appends the textual representation of itself to the end of b
// (allocating a larger slice if necessary) and returns the updated slice.
//
// Implementations must not retain b, nor mutate any bytes within b[:len(b)].
func (x *ResponseType) AppendText(b []byte) ([]byte, error) {
return append(b, x.String()...), nil
}