From 3991117d271620b4dfd8ad555343b392ef6daec3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=99=BD=E6=97=A5=E6=A2=A6=E4=B8=BB=E4=B9=89?= Date: Sun, 17 May 2026 05:06:04 +0800 Subject: [PATCH] fix(acl): normalize trailing dot in domain matches (#1574) * fix(acl): normalize trailing dot in domain matches The ACL like ```yaml - reject(example.com) - reject(suffix:blocked.test) - reject(*.wild.test) ``` can be easily bypass through by adding a dot to the domain like example.com.:443 Signed-off-by: Cherrling * fix(acl): trim all trailing dots and add tests Use strings.TrimRight instead of strings.TrimSuffix so domains with multiple trailing dots (e.g. example.com..) are also normalized and cannot bypass ACL rules. Add test coverage for trailing-dot handling on exact, wildcard and suffix domain rules, as well as for trailing dots in rule patterns. Co-Authored-By: Claude Opus 4.7 --------- Signed-off-by: Cherrling Co-authored-by: Toby Co-authored-by: Claude Opus 4.7 --- extras/outbounds/acl/compile.go | 4 +-- extras/outbounds/acl/compile_test.go | 51 ++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/extras/outbounds/acl/compile.go b/extras/outbounds/acl/compile.go index 29433fe..4afc77b 100644 --- a/extras/outbounds/acl/compile.go +++ b/extras/outbounds/acl/compile.go @@ -86,7 +86,7 @@ type matchResultCacheKey struct { } func (s *compiledRuleSetImpl[O]) Match(host HostInfo, proto Protocol, port uint16) (O, net.IP) { - host.Name = strings.ToLower(host.Name) // Normalize host name to lower case + host.Name = strings.TrimRight(strings.ToLower(host.Name), ".") // Normalize host name (lower case, no trailing dots) key := matchResultCacheKey{ Host: host.String(), Proto: proto, @@ -233,7 +233,7 @@ func parseProtoPort(protoPort string) (Protocol, uint16, uint16, bool) { } func compileHostMatcher(addr string, geoLoader GeoLoader) (hostMatcher, string) { - addr = strings.ToLower(addr) // Normalize to lower case + addr = strings.TrimRight(strings.ToLower(addr), ".") // Normalize host pattern (lower case, no trailing dots) if addr == "*" || addr == "all" { // Match all hosts return &allMatcher{}, "" diff --git a/extras/outbounds/acl/compile_test.go b/extras/outbounds/acl/compile_test.go index 9e0ace2..2ba93a1 100644 --- a/extras/outbounds/acl/compile_test.go +++ b/extras/outbounds/acl/compile_test.go @@ -97,6 +97,12 @@ func TestCompile(t *testing.T) { ProtoPort: "tcp/6881-6889", HijackAddress: "", }, + { + Outbound: "ob1", + Address: "dotpattern.test.", // trailing dot in pattern should be normalized away + ProtoPort: "tcp/443", + HijackAddress: "", + }, } comp, err := Compile[int](rules, map[string]int{ "ob1": ob1, @@ -283,6 +289,51 @@ func TestCompile(t *testing.T) { wantOutbound: ob6, // match range port rule 6881-6889 wantIP: nil, }, + { + host: HostInfo{ + Name: "crap.v2ex.com.", // trailing dot must not bypass exact domain rule + }, + proto: ProtocolTCP, + port: 80, + wantOutbound: ob1, + wantIP: net.ParseIP("2.2.2.2"), + }, + { + host: HostInfo{ + Name: "hoho.v2ex.com.", // trailing dot must not bypass wildcard domain rule + }, + proto: ProtocolUDP, + port: 9999, + wantOutbound: ob3, + wantIP: nil, + }, + { + host: HostInfo{ + Name: "real.microsoft.com.", // trailing dot must not bypass suffix domain rule + }, + proto: ProtocolUDP, + port: 5353, + wantOutbound: ob5, + wantIP: nil, + }, + { + host: HostInfo{ + Name: "microsoft.com...", // multiple trailing dots must also be normalized + }, + proto: ProtocolTCP, + port: 6000, + wantOutbound: ob5, + wantIP: nil, + }, + { + host: HostInfo{ + Name: "dotpattern.test", // host without dot must match rule whose pattern had a trailing dot + }, + proto: ProtocolTCP, + port: 443, + wantOutbound: ob1, + wantIP: nil, + }, } for _, test := range tests {