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 <me@cherr.cc>

* 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 <noreply@anthropic.com>

---------

Signed-off-by: Cherrling <me@cherr.cc>
Co-authored-by: Toby <tobyxdd@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
白日梦主义 2026-05-17 05:06:04 +08:00 committed by GitHub
parent 0e2b37ad6c
commit 3991117d27
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 53 additions and 2 deletions

View file

@ -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{}, ""

View file

@ -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 {