The UDP relay treated the destination address as packet-scoped while
applying ACL/outbound policy only once when a new session was created.
After an authenticated client opened a UDP session using a permitted
first destination, later packets carrying a different Addr in the same
SessionID were written via the established outbound socket without
re-checking policy, allowing the client to reach destinations that ACL
should reject — including localhost and RFC1918 from the server's
network perspective. See GHSA-vgrc-hq28-p3xp.
Add a no-I/O CheckUDP method to the Outbound / PluggableOutbound
chain. The UDP session entry now consults CheckUDP for every packet
whose destination differs from the session's first one, dropping
rejected packets before WriteTo. Decisions are cached per destination
within the session (bounded at 256 entries with simple eviction) so
steady-state cost is one map lookup per packet and no extra sockets
or dials. CheckUDP propagates through the existing chain:
- aclEngine routes through the matched outbound's CheckUDP, with
aclRejectOutbound returning the rejection error.
- directOutbound / socks5Outbound / speedtestHandler return nil.
- httpOutbound returns errHTTPUDPNotSupported.
- Resolvers (system / dot / doh) run resolve() then forward to
Next.CheckUDP so IP-based ACL rules keep matching.
Regression tests in core/internal/integration_tests/udp_acl_test.go
use an in-package stub Outbound to assert that a rejected destination
is not relayed after the session is opened on a permitted one, and
that multi-destination sessions over permitted addresses still work.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
130 lines
3.4 KiB
Go
130 lines
3.4 KiB
Go
package outbounds
|
|
|
|
import (
|
|
"errors"
|
|
"net"
|
|
"os"
|
|
"strings"
|
|
|
|
"github.com/apernet/hysteria/extras/v2/outbounds/acl"
|
|
)
|
|
|
|
const (
|
|
aclCacheSize = 1024
|
|
)
|
|
|
|
var errRejected = errors.New("rejected")
|
|
|
|
// aclEngine is a PluggableOutbound that dispatches connections to different
|
|
// outbounds based on ACL rules.
|
|
// There are 3 built-in outbounds:
|
|
// - direct: directOutbound, auto mode
|
|
// - reject: reject the connection
|
|
// - default: first outbound in the list, or if the list is empty, equal to direct
|
|
// If the user-defined outbounds contain any of the above names, they will
|
|
// override the built-in outbounds.
|
|
type aclEngine struct {
|
|
RuleSet acl.CompiledRuleSet[PluggableOutbound]
|
|
Default PluggableOutbound
|
|
}
|
|
|
|
type OutboundEntry struct {
|
|
Name string
|
|
Outbound PluggableOutbound
|
|
}
|
|
|
|
func NewACLEngineFromString(rules string, outbounds []OutboundEntry, geoLoader acl.GeoLoader) (PluggableOutbound, error) {
|
|
trs, err := acl.ParseTextRules(rules)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
obMap := outboundsToMap(outbounds)
|
|
rs, err := acl.Compile[PluggableOutbound](trs, obMap, aclCacheSize, geoLoader)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &aclEngine{rs, obMap["default"]}, nil
|
|
}
|
|
|
|
func NewACLEngineFromFile(filename string, outbounds []OutboundEntry, geoLoader acl.GeoLoader) (PluggableOutbound, error) {
|
|
bs, err := os.ReadFile(filename)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return NewACLEngineFromString(string(bs), outbounds, geoLoader)
|
|
}
|
|
|
|
func outboundsToMap(outbounds []OutboundEntry) map[string]PluggableOutbound {
|
|
obMap := make(map[string]PluggableOutbound)
|
|
for _, ob := range outbounds {
|
|
obMap[strings.ToLower(ob.Name)] = ob.Outbound
|
|
}
|
|
// Add built-in outbounds if not overridden
|
|
if _, ok := obMap["direct"]; !ok {
|
|
obMap["direct"] = NewDirectOutboundSimple(DirectOutboundModeAuto)
|
|
}
|
|
if _, ok := obMap["reject"]; !ok {
|
|
obMap["reject"] = &aclRejectOutbound{}
|
|
}
|
|
if _, ok := obMap["default"]; !ok {
|
|
if len(outbounds) > 0 {
|
|
obMap["default"] = outbounds[0].Outbound
|
|
} else {
|
|
obMap["default"] = obMap["direct"]
|
|
}
|
|
}
|
|
return obMap
|
|
}
|
|
|
|
func (a *aclEngine) handle(reqAddr *AddrEx, proto acl.Protocol) PluggableOutbound {
|
|
hostInfo := acl.HostInfo{Name: reqAddr.Host}
|
|
if reqAddr.ResolveInfo != nil {
|
|
hostInfo.IPv4 = reqAddr.ResolveInfo.IPv4
|
|
hostInfo.IPv6 = reqAddr.ResolveInfo.IPv6
|
|
}
|
|
ob, hijackIP := a.RuleSet.Match(hostInfo, proto, reqAddr.Port)
|
|
if ob == nil {
|
|
// No match, use default outbound
|
|
return a.Default
|
|
}
|
|
if hijackIP != nil {
|
|
// We must rewrite both Host & ResolveInfo,
|
|
// as some outbounds only care about Host.
|
|
reqAddr.Host = hijackIP.String()
|
|
if ip4 := hijackIP.To4(); ip4 != nil {
|
|
reqAddr.ResolveInfo = &ResolveInfo{IPv4: ip4}
|
|
} else {
|
|
reqAddr.ResolveInfo = &ResolveInfo{IPv6: hijackIP}
|
|
}
|
|
}
|
|
return ob
|
|
}
|
|
|
|
func (a *aclEngine) TCP(reqAddr *AddrEx) (net.Conn, error) {
|
|
ob := a.handle(reqAddr, acl.ProtocolTCP)
|
|
return ob.TCP(reqAddr)
|
|
}
|
|
|
|
func (a *aclEngine) UDP(reqAddr *AddrEx) (UDPConn, error) {
|
|
ob := a.handle(reqAddr, acl.ProtocolUDP)
|
|
return ob.UDP(reqAddr)
|
|
}
|
|
|
|
func (a *aclEngine) CheckUDP(reqAddr *AddrEx) error {
|
|
ob := a.handle(reqAddr, acl.ProtocolUDP)
|
|
return ob.CheckUDP(reqAddr)
|
|
}
|
|
|
|
type aclRejectOutbound struct{}
|
|
|
|
func (a *aclRejectOutbound) TCP(reqAddr *AddrEx) (net.Conn, error) {
|
|
return nil, errRejected
|
|
}
|
|
|
|
func (a *aclRejectOutbound) UDP(reqAddr *AddrEx) (UDPConn, error) {
|
|
return nil, errRejected
|
|
}
|
|
|
|
func (a *aclRejectOutbound) CheckUDP(reqAddr *AddrEx) error {
|
|
return errRejected
|
|
}
|