Internet exposure checks and response rate limiting
Jarl works out which views are reachable from the internet (match-clients
ending in a catch-all, not private-looking addresses: an internal view of a
global IPv6 prefix is still yours; a server without views: open allow-query
and a non-private listen address), with a per-view override (schema 9), and
checks public ones: rate limiting, open resolver, minimal-any, cache
snooping, open NOTIFY and transfers, version and host name, management
interfaces. Fixes are staged like any change, including rate limiting from
ISC's example in log-only mode with the secondaries exempt, then enforcing.
named logs what rate limiting limits (or would, in log-only mode, where its
counters don't move) as query-errors; Jarl routes those lines to its log
and counts them (rrl breakdown, ratelimited top lists, Prometheus
jarl_ratelimit_logged_total), shown on the new Exposure page.
A change that newly opens a public view up must be confirmed (accept_risk,
--accept-risk, a checkbox on the Changes page; 409 confirm_risk
otherwise). Findings can be acknowledged with a reason and stay visible as
info. GET /exposure, POST /exposure/fix|ack|unack, jarl exposure,
docs/exposure.md.
Also fixes query statistics skipping TSIG-signed queries ("IP#port/key
NAME" in named's log).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
cf550bd512
commit
bdd7acfd3b
50 files changed
+2019
-52
No files matched your search
@@ -0,0 +1,68 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExposureAPI(t *testing.T) {
|
||||
e := newAPIEnv(t)
|
||||
e.do("POST", "/zones", e.token, map[string]any{"name": "example.com", "records": []map[string]any{{"name": "ns1", "type": "A", "data": "192.0.2.1"}}}, 201)
|
||||
e.do("POST", "/views", e.token, map[string]any{"name": "internal", "match_clients": []string{"192.168.1.0/24", "localhost"}, "adopt_zones": true}, 201)
|
||||
ext := e.do("POST", "/views", e.token, map[string]any{"name": "external", "match_clients": []string{"any"}}, 201)["object"].(map[string]any)["id"]
|
||||
|
||||
type report struct {
|
||||
Scopes []struct {
|
||||
View string `json:"view"`
|
||||
Reason string `json:"reason"`
|
||||
RateLimit string `json:"rate_limit"`
|
||||
Public bool `json:"public"`
|
||||
Findings []struct {
|
||||
Code, Severity, Fix string
|
||||
}
|
||||
}
|
||||
Server []struct{ Code, Fix string }
|
||||
}
|
||||
get := func() report {
|
||||
b, status := e.req("GET", "/exposure", e.token, nil)
|
||||
if status != 200 {
|
||||
t.Fatalf("exposure = %d %s", status, b)
|
||||
}
|
||||
var r report
|
||||
json.Unmarshal(b, &r)
|
||||
return r
|
||||
}
|
||||
r := get()
|
||||
if len(r.Scopes) != 2 || r.Scopes[0].View != "internal" || r.Scopes[0].Public || !r.Scopes[1].Public {
|
||||
t.Fatalf("scopes = %+v", r.Scopes)
|
||||
}
|
||||
var codes []string
|
||||
for _, f := range r.Scopes[1].Findings {
|
||||
codes = append(codes, f.Code)
|
||||
if f.Code == "exposure_rate_limit" && !strings.Contains(f.Fix, "log-only yes") {
|
||||
t.Errorf("rate limit fix = %q", f.Fix)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(strings.Join(codes, " "), "exposure_rate_limit") || len(r.Server) < 2 {
|
||||
t.Errorf("findings = %v, server = %+v", codes, r.Server)
|
||||
}
|
||||
e.do("POST", "/exposure/fix", e.token, map[string]any{"view": "external", "code": "exposure_rate_limit"}, 201)
|
||||
if r := get(); r.Scopes[1].RateLimit != "log-only" {
|
||||
t.Errorf("rate limit = %q", r.Scopes[1].RateLimit)
|
||||
}
|
||||
if _, status := e.req("POST", "/exposure/fix", e.token, map[string]any{"view": "external", "code": "exposure_open_notify"}); status != 400 {
|
||||
t.Errorf("fix without an automatic fix = %d", status)
|
||||
}
|
||||
// Opening a public view up needs confirming.
|
||||
b, status := e.req("POST", "/options", e.token, map[string]any{"scope": "view", "scope_id": ext, "name": "allow-recursion", "value": "any"})
|
||||
if status != 409 || !strings.Contains(string(b), "confirm_risk") {
|
||||
t.Errorf("open resolver = %d %s", status, b)
|
||||
}
|
||||
e.do("POST", "/exposure/ack", e.token, map[string]any{"view": "external", "code": "exposure_minimal_any", "reason": "testing"}, 201)
|
||||
for _, f := range get().Scopes[1].Findings {
|
||||
if f.Code == "exposure_minimal_any" && f.Severity != "info" {
|
||||
t.Errorf("acknowledged finding = %+v", f)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user