diff --git a/peer/peer.go b/peer/peer.go index 500ae7a5..027d662b 100644 --- a/peer/peer.go +++ b/peer/peer.go @@ -207,6 +207,16 @@ func (c *Client) Start(ctx context.Context) error { return fmt.Errorf("register with lantern-cloud: %w", err) } + // Defence-in-depth: refuse to start the box if the server-supplied + // launch_cfg is missing the expected abuse-handling rules. A + // server-side regression that silently shipped an open-proxy + // config would otherwise turn every peer in the field into one + // until the next deploy. The peer prefers failing to share over + // sharing unsafely. + if err := validateAbuseRules(regResp.ServerConfig); err != nil { + return fmt.Errorf("launch_cfg failed abuse-rule sanity check: %w", err) + } + // The peer's outbound traffic must bypass any TUN device the user's own // VPN may have installed — otherwise censored clients' traffic would // egress through the local user's Lantern proxy instead of their diff --git a/peer/peer_test.go b/peer/peer_test.go index dd24c0ea..389aa4ac 100644 --- a/peer/peer_test.go +++ b/peer/peer_test.go @@ -159,7 +159,7 @@ func newStubServer(t *testing.T) *stubServer { deregisterStatus: http.StatusOK, registerResp: RegisterResponse{ RouteID: "00000000-0000-0000-0000-000000000123", - ServerConfig: `{"inbounds": [{"type":"samizdat","tag":"samizdat-in"}]}`, + ServerConfig: minimalValidLaunchCfg, HeartbeatIntervalSeconds: 60, }, } @@ -421,6 +421,32 @@ func TestClient_Start_BoxStartFailureUnwinds(t *testing.T) { assert.Equal(t, int64(1), srv.deregisterCount.Load()) } +// A launch_cfg that fails the abuse-rule sanity check must unwind +// every resource Start has taken so far — port forward, registration +// — without ever building or starting the box. This is the +// defence-in-depth gate that keeps a server-side regression from +// turning every peer into an open proxy. +func TestClient_Start_AbuseRuleValidationFailureUnwinds(t *testing.T) { + fwd := &fakeForwarder{externalIP: "203.0.113.42"} + box := &fakeBoxService{} + srv := newStubServer(t) + // Strip the abuse rules from the launch_cfg the stub returns — + // just the inbound, no route block. validateAbuseRules will reject + // this as "missing route block". + srv.registerResp.ServerConfig = `{"inbounds":[{"type":"samizdat","tag":"samizdat-in"}]}` + c := newTestClient(t, fwd, box, srv) + + err := c.Start(context.Background()) + require.Error(t, err) + assert.ErrorContains(t, err, "abuse-rule sanity check") + + assert.False(t, c.IsActive()) + assert.True(t, fwd.wasUnmapped(), "validation failure must unmap the port forward") + assert.False(t, box.started.Load(), "validation must run before box.Start") + assert.False(t, box.closed.Load(), "box was never started, nothing to close") + assert.Equal(t, int64(1), srv.deregisterCount.Load(), "validation failure must deregister the route we just registered") +} + func TestClient_Stop_HappyPath(t *testing.T) { fwd := &fakeForwarder{} box := &fakeBoxService{} diff --git a/peer/validate.go b/peer/validate.go new file mode 100644 index 00000000..4810effd --- /dev/null +++ b/peer/validate.go @@ -0,0 +1,271 @@ +package peer + +import ( + "encoding/json" + "errors" + "fmt" +) + +// abuseRuleSetTags is the canonical list of abuse rule_set tags that +// the peer launch_cfg MUST carry. Mirrors the server-side abuseTags +// list that emits the rule_set entries into the registration response. +// If the server-side list grows or renames a tag, this list grows +// with it — the server-side test asserts the emit side; this list +// asserts the client side sees the same shape after registration. +var abuseRuleSetTags = []string{ + "geosite-malware", + "geoip-malware", + "geosite-phishing", + "geosite-cryptominers", +} + +// rfc1918CanaryCIDR and smtpCanaryPort are sentinel values that, if +// missing from the launch_cfg's reject rules, indicate the server- +// side static peer-egress-block list was dropped or mutated. We pick +// one IP-CIDR and one port from each block as a low-cost smoke test; +// a full structural check would be brittle to upstream additions. +const ( + rfc1918CanaryCIDR = "10.0.0.0/8" + smtpCanaryPort = float64(25) +) + +// validateAbuseRules is a defence-in-depth check on the sing-box +// options returned by /v1/peer/register. The server is supposed to +// embed a set of route.rule_set + route.rules entries that block the +// peer from forwarding traffic to known-malicious destinations, +// RFC1918 CIDRs, and abuse-prone ports. +// +// If a future server-side regression ships a launch_cfg without those +// rules, every newly-registered peer would silently turn into an open +// residential proxy until someone noticed. This validator blocks +// Start before libbox runs an unsafe config; the peer prefers to fail +// to share at all rather than share unsafely. +// +// The check is structural-only — it confirms the expected rule_set +// tags appear in both route.rule_set and route.rules (as an +// unconditional reject), plus two canary entries from the static +// reject block. It does NOT verify the .srs files at the rule_set +// URLs are uncorrupted or that the URLs themselves are trustworthy; +// those are separate supply-chain concerns and are not in scope for +// this gate. +func validateAbuseRules(optionsJSON string) error { + var raw map[string]any + if err := json.Unmarshal([]byte(optionsJSON), &raw); err != nil { + return fmt.Errorf("parse launch_cfg JSON: %w", err) + } + route, ok := raw["route"].(map[string]any) + if !ok { + return errors.New("launch_cfg is missing route block — peer would have no abuse blocking at all") + } + + var errs []error + if err := validateAbuseRuleSetTags(route); err != nil { + errs = append(errs, err) + } + if err := validateAbuseRejectRules(route); err != nil { + errs = append(errs, err) + } + if err := validateStaticRejectCanaries(route); err != nil { + errs = append(errs, err) + } + return errors.Join(errs...) +} + +// validateAbuseRuleSetTags asserts every entry in abuseRuleSetTags is +// declared in route.rule_set. Missing entries mean sing-box won't even +// download the abuse list, so no destination check ever happens. +func validateAbuseRuleSetTags(route map[string]any) error { + rsList, _ := route["rule_set"].([]any) + got := map[string]bool{} + for _, rs := range rsList { + rsMap, ok := rs.(map[string]any) + if !ok { + continue + } + if tag, _ := rsMap["tag"].(string); tag != "" { + got[tag] = true + } + } + var missing []string + for _, want := range abuseRuleSetTags { + if !got[want] { + missing = append(missing, want) + } + } + if len(missing) > 0 { + return fmt.Errorf("route.rule_set is missing abuse tags: %v (peer would not block matching destinations)", missing) + } + return nil +} + +// validateAbuseRejectRules asserts every abuseRuleSetTags entry also +// has a matching reject rule in route.rules. A rule_set without a +// matching reject is a no-op — sing-box downloads the list and does +// nothing with it. +// +// Only counts *unconditional* rejects (see isUnconditionalReject): +// a reject rule with an extra match constraint (port, domain, source +// IP, etc.) or with invert=true would let traffic in the abuse list +// through under most conditions; counting it as covering the tag +// would mask a misconfigured launch_cfg. +func validateAbuseRejectRules(route map[string]any) error { + rules, _ := route["rules"].([]any) + rejectedTags := map[string]bool{} + for _, r := range rules { + body := ruleBody(r) + if body == nil { + continue + } + if !isUnconditionalReject(body, "rule_set") { + continue + } + for _, t := range asStringSlice(body["rule_set"]) { + rejectedTags[t] = true + } + } + var missing []string + for _, want := range abuseRuleSetTags { + if !rejectedTags[want] { + missing = append(missing, want) + } + } + if len(missing) > 0 { + return fmt.Errorf("route.rules has no unconditional reject for abuse tags: %v (rule_sets would download but not unconditionally block)", missing) + } + return nil +} + +// validateStaticRejectCanaries spot-checks that the static +// destination-based reject rules (RFC1918 CIDRs + abuse ports) are +// present. Picks one canary from each block rather than asserting the +// full set so legitimate additions in samizdat.go don't break this +// check. +func validateStaticRejectCanaries(route map[string]any) error { + rules, _ := route["rules"].([]any) + gotRFC1918 := false + gotSMTP := false + for _, r := range rules { + body := ruleBody(r) + if body == nil { + continue + } + // Each canary is checked against an unconditional reject scoped + // to its own match field. A reject that ANDs ip_cidr with a port + // or domain (or sets invert) would not actually cover the + // destination class the canary represents, so don't credit it. + if isUnconditionalReject(body, "ip_cidr") { + for _, cidr := range asStringSlice(body["ip_cidr"]) { + if cidr == rfc1918CanaryCIDR { + gotRFC1918 = true + } + } + } + if isUnconditionalReject(body, "port") { + for _, p := range asFloatSlice(body["port"]) { + if p == smtpCanaryPort { + gotSMTP = true + } + } + } + } + var missing []string + if !gotRFC1918 { + missing = append(missing, fmt.Sprintf("RFC1918 reject (canary %s)", rfc1918CanaryCIDR)) + } + if !gotSMTP { + missing = append(missing, fmt.Sprintf("SMTP-port reject (canary :%d)", int(smtpCanaryPort))) + } + if len(missing) > 0 { + return fmt.Errorf("route.rules is missing static abuse blocks: %v", missing) + } + return nil +} + +// isUnconditionalReject reports whether the rule body is a reject +// action whose scope is defined solely by matchKey — no other match +// fields and no invert. matchKey is the field expected to carry the +// rule's scope ("rule_set", "ip_cidr", or "port"). +// +// The pure-reject shape we want for each abuse-block category is: +// +// {"action": "reject", "": [...]} // canonical +// {"action": "reject", "": [...], "invert": false} // explicit no-op +// +// Anything else either narrows the match (e.g. adding "port": 80 +// to a rule_set reject limits it to port-80 traffic) or inverts the +// match (invert=true rejects everything OUTSIDE the matchKey). In +// both cases the launch_cfg would not actually block the abuse +// destination class the rule claims to cover, so callers must not +// credit it as covering the tag. +func isUnconditionalReject(body map[string]any, matchKey string) bool { + if action, _ := body["action"].(string); action != "reject" { + return false + } + if invert, _ := body["invert"].(bool); invert { + return false + } + allowed := map[string]bool{"action": true, "invert": true, matchKey: true} + for k := range body { + if !allowed[k] { + return false + } + } + return true +} + +// ruleBody returns the field-bearing inner object of a sing-box +// route Rule. sing-box marshals "default" rules in two equivalent +// shapes: inlined at the top level (no "default" wrapper) or nested +// under "default". We accept both. +func ruleBody(r any) map[string]any { + m, ok := r.(map[string]any) + if !ok { + return nil + } + if nested, ok := m["default"].(map[string]any); ok { + return nested + } + return m +} + +// asStringSlice normalizes a sing-box rule field that can be encoded +// as either a scalar string or a string array. Both forms appear in +// practice: `{"rule_set": "sr-direct"}` and `{"rule_set": ["a","b"]}` +// are equivalent at the route layer. Treating only the array form as +// valid here would false-positive a launch_cfg that emits the scalar. +func asStringSlice(v any) []string { + if s, ok := v.(string); ok { + return []string{s} + } + arr, ok := v.([]any) + if !ok { + return nil + } + out := make([]string, 0, len(arr)) + for _, x := range arr { + if s, ok := x.(string); ok { + out = append(out, s) + } + } + return out +} + +// asFloatSlice is the numeric counterpart of asStringSlice — fields +// like `port` can come back as `25` or `[25, 587]`. JSON unmarshals +// every number to float64, so the canary comparison uses float64 too. +func asFloatSlice(v any) []float64 { + if f, ok := v.(float64); ok { + return []float64{f} + } + arr, ok := v.([]any) + if !ok { + return nil + } + out := make([]float64, 0, len(arr)) + for _, x := range arr { + if f, ok := x.(float64); ok { + out = append(out, f) + } + } + return out +} diff --git a/peer/validate_test.go b/peer/validate_test.go new file mode 100644 index 00000000..2a306b57 --- /dev/null +++ b/peer/validate_test.go @@ -0,0 +1,326 @@ +package peer + +import ( + "strings" + "testing" +) + +// minimalValidLaunchCfg returns a launch_cfg JSON that passes +// validateAbuseRules: the four abuse rule_set tags (each as a +// "remote" rule_set + a matching unconditional reject rule), plus +// one RFC1918 and one SMTP canary in reject rules. Shared by the +// stub server used in Start-path tests so the existing tests do not +// regress on the new check. +const minimalValidLaunchCfg = `{ + "inbounds":[{"type":"samizdat","tag":"samizdat-in"}], + "route":{ + "rule_set":[ + {"type":"remote","tag":"geosite-malware","format":"binary","url":"https://example/geosite-malware.srs","download_detour":"direct"}, + {"type":"remote","tag":"geoip-malware","format":"binary","url":"https://example/geoip-malware.srs","download_detour":"direct"}, + {"type":"remote","tag":"geosite-phishing","format":"binary","url":"https://example/geosite-phishing.srs","download_detour":"direct"}, + {"type":"remote","tag":"geosite-cryptominers","format":"binary","url":"https://example/geosite-cryptominers.srs","download_detour":"direct"} + ], + "rules":[ + {"action":"reject","rule_set":["geosite-malware"]}, + {"action":"reject","rule_set":["geoip-malware"]}, + {"action":"reject","rule_set":["geosite-phishing"]}, + {"action":"reject","rule_set":["geosite-cryptominers"]}, + {"action":"reject","ip_cidr":["10.0.0.0/8","172.16.0.0/12","192.168.0.0/16","127.0.0.0/8","169.254.0.0/16","::1/128","fc00::/7","fe80::/10"]}, + {"action":"reject","port":[25,465,587,2525,6660,6661,6662,6663,6664,6665,6666,6667,6668,6669,6697]} + ] + } +}` + +func TestValidateAbuseRules_HappyPath(t *testing.T) { + if err := validateAbuseRules(minimalValidLaunchCfg); err != nil { + t.Fatalf("expected canonical launch_cfg to pass, got: %v", err) + } +} + +func TestValidateAbuseRules_NestedDefaultForm(t *testing.T) { + // sing-box may marshal "default" rules either inlined or nested + // under a "default" key. validateAbuseRules must accept both — + // otherwise a future libbox version change would silently break + // the check. + const nested = `{ + "route":{ + "rule_set":[ + {"type":"remote","tag":"geosite-malware"}, + {"type":"remote","tag":"geoip-malware"}, + {"type":"remote","tag":"geosite-phishing"}, + {"type":"remote","tag":"geosite-cryptominers"} + ], + "rules":[ + {"type":"default","default":{"action":"reject","rule_set":["geosite-malware"]}}, + {"type":"default","default":{"action":"reject","rule_set":["geoip-malware"]}}, + {"type":"default","default":{"action":"reject","rule_set":["geosite-phishing"]}}, + {"type":"default","default":{"action":"reject","rule_set":["geosite-cryptominers"]}}, + {"type":"default","default":{"action":"reject","ip_cidr":["10.0.0.0/8"]}}, + {"type":"default","default":{"action":"reject","port":[25]}} + ] + } + }` + if err := validateAbuseRules(nested); err != nil { + t.Fatalf("nested default form should pass, got: %v", err) + } +} + +func TestValidateAbuseRules_MissingRouteBlock(t *testing.T) { + err := validateAbuseRules(`{"inbounds":[]}`) + if err == nil { + t.Fatal("expected error when route block is absent") + } + if !strings.Contains(err.Error(), "route block") { + t.Errorf("error should mention route block, got: %v", err) + } +} + +func TestValidateAbuseRules_MissingRuleSetTag(t *testing.T) { + // Drop geosite-phishing from the rule_set list. The reject rule + // for it can stay; the check should still flag the missing tag + // because the reject is a no-op without the rule_set. + bad := strings.Replace(minimalValidLaunchCfg, + `{"type":"remote","tag":"geosite-phishing","format":"binary","url":"https://example/geosite-phishing.srs","download_detour":"direct"},`, + ``, 1) + err := validateAbuseRules(bad) + if err == nil { + t.Fatal("expected error when an abuse tag is missing from route.rule_set") + } + if !strings.Contains(err.Error(), "geosite-phishing") { + t.Errorf("error should name the missing tag, got: %v", err) + } +} + +func TestValidateAbuseRules_MissingRejectRule(t *testing.T) { + // Keep all rule_sets but drop one reject rule. sing-box will + // download the list but never enforce it. + bad := strings.Replace(minimalValidLaunchCfg, + `{"action":"reject","rule_set":["geosite-cryptominers"]},`, + ``, 1) + err := validateAbuseRules(bad) + if err == nil { + t.Fatal("expected error when an abuse tag has no reject rule") + } + if !strings.Contains(err.Error(), "geosite-cryptominers") { + t.Errorf("error should name the unrejected tag, got: %v", err) + } +} + +func TestValidateAbuseRules_MissingRFC1918Canary(t *testing.T) { + // Strip the RFC1918 reject rule. SMTP block stays — we want to + // see the RFC1918-specific error message. + bad := strings.Replace(minimalValidLaunchCfg, + `{"action":"reject","ip_cidr":["10.0.0.0/8","172.16.0.0/12","192.168.0.0/16","127.0.0.0/8","169.254.0.0/16","::1/128","fc00::/7","fe80::/10"]},`, + ``, 1) + err := validateAbuseRules(bad) + if err == nil { + t.Fatal("expected error when RFC1918 reject is missing") + } + if !strings.Contains(err.Error(), "RFC1918") { + t.Errorf("error should mention RFC1918, got: %v", err) + } +} + +func TestValidateAbuseRules_MissingSMTPCanary(t *testing.T) { + // Drop the SMTP/IRC port-reject rule. Removes the preceding + // comma too so the resulting JSON is still well-formed (the + // port-reject is the last entry in the rules array). + bad := strings.Replace(minimalValidLaunchCfg, + `, + {"action":"reject","port":[25,465,587,2525,6660,6661,6662,6663,6664,6665,6666,6667,6668,6669,6697]}`, + ``, 1) + err := validateAbuseRules(bad) + if err == nil { + t.Fatal("expected error when SMTP port reject is missing") + } + if !strings.Contains(err.Error(), "SMTP") { + t.Errorf("error should mention SMTP, got: %v", err) + } +} + +func TestValidateAbuseRules_NonRejectRulesIgnored(t *testing.T) { + // A rule_set with action "route" (not reject) should NOT count + // — sing-box would forward those flows to a named outbound + // instead of dropping them. validateAbuseRules must demand the + // reject action specifically. + bad := strings.Replace(minimalValidLaunchCfg, + `{"action":"reject","rule_set":["geosite-malware"]},`, + `{"action":"route","outbound":"direct","rule_set":["geosite-malware"]},`, 1) + err := validateAbuseRules(bad) + if err == nil { + t.Fatal("expected error when abuse tag has 'route' action instead of 'reject'") + } + if !strings.Contains(err.Error(), "geosite-malware") { + t.Errorf("error should name the wrongly-actioned tag, got: %v", err) + } +} + +func TestValidateAbuseRules_AllErrorsReported(t *testing.T) { + // errors.Join means a thoroughly-broken config should surface + // all the missing pieces in one report. Operators triaging + // "why is my peer refusing to start?" deserve a complete + // picture, not a fix-one-thing-find-the-next loop. + err := validateAbuseRules(`{"route":{}}`) + if err == nil { + t.Fatal("expected error for empty route block") + } + msg := err.Error() + for _, want := range []string{"abuse tags", "RFC1918", "SMTP"} { + if !strings.Contains(msg, want) { + t.Errorf("combined error should mention %q, got: %v", want, err) + } + } +} + +func TestValidateAbuseRules_BadJSON(t *testing.T) { + err := validateAbuseRules(`{not valid json`) + if err == nil { + t.Fatal("expected error for malformed JSON") + } + if !strings.Contains(err.Error(), "parse launch_cfg JSON") { + t.Errorf("error should mention JSON parse failure, got: %v", err) + } +} + +// sing-box accepts both `"rule_set": "tag"` (scalar) and +// `"rule_set": ["tag"]` (array) — the validator must too, otherwise +// a perfectly valid launch_cfg that happens to use the scalar form +// would be flagged as missing the tag. +func TestValidateAbuseRules_AcceptsScalarRuleSet(t *testing.T) { + cfg := `{ + "route":{ + "rule_set":[ + {"type":"remote","tag":"geosite-malware"}, + {"type":"remote","tag":"geoip-malware"}, + {"type":"remote","tag":"geosite-phishing"}, + {"type":"remote","tag":"geosite-cryptominers"} + ], + "rules":[ + {"action":"reject","rule_set":"geosite-malware"}, + {"action":"reject","rule_set":"geoip-malware"}, + {"action":"reject","rule_set":"geosite-phishing"}, + {"action":"reject","rule_set":"geosite-cryptominers"}, + {"action":"reject","ip_cidr":"10.0.0.0/8"}, + {"action":"reject","port":25} + ] + }}` + if err := validateAbuseRules(cfg); err != nil { + t.Fatalf("scalar rule_set / ip_cidr / port should be valid, got: %v", err) + } +} + +// A reject rule with invert=true rejects everything EXCEPT the +// listed match — the opposite of what an abuse-block rule should do. +// It must not satisfy the abuse-tag check. +func TestValidateAbuseRules_RejectsInverted(t *testing.T) { + cfg := `{ + "route":{ + "rule_set":[ + {"type":"remote","tag":"geosite-malware"}, + {"type":"remote","tag":"geoip-malware"}, + {"type":"remote","tag":"geosite-phishing"}, + {"type":"remote","tag":"geosite-cryptominers"} + ], + "rules":[ + {"action":"reject","rule_set":["geosite-malware"],"invert":true}, + {"action":"reject","rule_set":["geoip-malware"]}, + {"action":"reject","rule_set":["geosite-phishing"]}, + {"action":"reject","rule_set":["geosite-cryptominers"]}, + {"action":"reject","ip_cidr":["10.0.0.0/8"]}, + {"action":"reject","port":[25]} + ] + }}` + err := validateAbuseRules(cfg) + if err == nil { + t.Fatal("inverted reject must not satisfy the abuse-tag check") + } + if !strings.Contains(err.Error(), "geosite-malware") { + t.Errorf("error should call out the inverted tag, got: %v", err) + } +} + +// A reject rule that ANDs the abuse rule_set with another constraint +// (port, domain, source IP, etc.) only fires for the intersection — +// most abuse-list traffic still passes. Must not count as covering +// the tag. +func TestValidateAbuseRules_RejectsExtraConstraint(t *testing.T) { + cfg := `{ + "route":{ + "rule_set":[ + {"type":"remote","tag":"geosite-malware"}, + {"type":"remote","tag":"geoip-malware"}, + {"type":"remote","tag":"geosite-phishing"}, + {"type":"remote","tag":"geosite-cryptominers"} + ], + "rules":[ + {"action":"reject","rule_set":["geosite-malware"],"port":[80]}, + {"action":"reject","rule_set":["geoip-malware"]}, + {"action":"reject","rule_set":["geosite-phishing"]}, + {"action":"reject","rule_set":["geosite-cryptominers"]}, + {"action":"reject","ip_cidr":["10.0.0.0/8"]}, + {"action":"reject","port":[25]} + ] + }}` + err := validateAbuseRules(cfg) + if err == nil { + t.Fatal("reject with extra constraint must not satisfy the abuse-tag check") + } + if !strings.Contains(err.Error(), "geosite-malware") { + t.Errorf("error should call out the constrained tag, got: %v", err) + } +} + +// Same predicate-narrowing concern applies to the static canary +// reject rules. An ip_cidr reject ANDed with a port no longer +// covers all RFC1918 traffic and must not count as the canary. +func TestValidateAbuseRules_RejectsStaticCanaryWithExtraConstraint(t *testing.T) { + cfg := `{ + "route":{ + "rule_set":[ + {"type":"remote","tag":"geosite-malware"}, + {"type":"remote","tag":"geoip-malware"}, + {"type":"remote","tag":"geosite-phishing"}, + {"type":"remote","tag":"geosite-cryptominers"} + ], + "rules":[ + {"action":"reject","rule_set":["geosite-malware"]}, + {"action":"reject","rule_set":["geoip-malware"]}, + {"action":"reject","rule_set":["geosite-phishing"]}, + {"action":"reject","rule_set":["geosite-cryptominers"]}, + {"action":"reject","ip_cidr":["10.0.0.0/8"],"port":[80]}, + {"action":"reject","port":[25]} + ] + }}` + err := validateAbuseRules(cfg) + if err == nil { + t.Fatal("RFC1918 canary ANDed with port must not satisfy the static-block check") + } + if !strings.Contains(err.Error(), "RFC1918") { + t.Errorf("error should call out the missing RFC1918 canary, got: %v", err) + } +} + +// Explicit invert=false should be treated as the canonical no-op +// (sing-box's default is false) and still credit the rule. +func TestValidateAbuseRules_AcceptsExplicitInvertFalse(t *testing.T) { + cfg := `{ + "route":{ + "rule_set":[ + {"type":"remote","tag":"geosite-malware"}, + {"type":"remote","tag":"geoip-malware"}, + {"type":"remote","tag":"geosite-phishing"}, + {"type":"remote","tag":"geosite-cryptominers"} + ], + "rules":[ + {"action":"reject","rule_set":["geosite-malware"],"invert":false}, + {"action":"reject","rule_set":["geoip-malware"]}, + {"action":"reject","rule_set":["geosite-phishing"]}, + {"action":"reject","rule_set":["geosite-cryptominers"]}, + {"action":"reject","ip_cidr":["10.0.0.0/8"]}, + {"action":"reject","port":[25]} + ] + }}` + if err := validateAbuseRules(cfg); err != nil { + t.Fatalf("explicit invert=false should be treated as a pure reject, got: %v", err) + } +}