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>
158 lines
4.4 KiB
Go
158 lines
4.4 KiB
Go
package outbounds
|
|
|
|
import (
|
|
"fmt"
|
|
"net"
|
|
"strconv"
|
|
|
|
"github.com/apernet/hysteria/core/v2/server"
|
|
)
|
|
|
|
// The PluggableOutbound system is designed to function in a chain-like manner.
|
|
// Not every outbound is an actual outbound; some are just wrappers around other
|
|
// outbounds, such as custom resolvers, ACL engine, etc. It is a pipeline where
|
|
// each stage can check (and optionally modify) the request before passing it
|
|
// on to the next stage. The last stage in the pipeline is always a real outbound
|
|
// that actually implements the logic of connecting to the remote server.
|
|
// There can also be instances of branching, where requests can be sent to
|
|
// different outbound sub-pipelines based on some criteria.
|
|
|
|
// PluggableOutbound differs from the built-in Outbound interface from Hysteria core
|
|
// in that it uses an AddrEx struct for addresses instead of a string. Because of this
|
|
// difference, we need a special PluggableOutboundAdapter to convert between the two
|
|
// for use in Hysteria core config.
|
|
type PluggableOutbound interface {
|
|
TCP(reqAddr *AddrEx) (net.Conn, error)
|
|
UDP(reqAddr *AddrEx) (UDPConn, error)
|
|
CheckUDP(reqAddr *AddrEx) error
|
|
}
|
|
|
|
type UDPConn interface {
|
|
ReadFrom(b []byte) (int, *AddrEx, error)
|
|
WriteTo(b []byte, addr *AddrEx) (int, error)
|
|
Close() error
|
|
}
|
|
|
|
// AddrEx keeps both the original string representation of the address and
|
|
// the resolved IP addresses from the resolver, if any.
|
|
// The actual outbound implementations can choose to use either the string
|
|
// representation or the resolved IP addresses, depending on their capabilities.
|
|
// A SOCKS5 outbound, for example, should prefer the string representation
|
|
// because SOCKS5 protocol supports sending the hostname to the proxy server
|
|
// and let the proxy server do the DNS resolution.
|
|
type AddrEx struct {
|
|
Host string // String representation of the host, can be an IP or a domain name
|
|
Port uint16
|
|
ResolveInfo *ResolveInfo // Only set if there's a resolver in the pipeline
|
|
}
|
|
|
|
func (a *AddrEx) String() string {
|
|
return net.JoinHostPort(a.Host, strconv.Itoa(int(a.Port)))
|
|
}
|
|
|
|
// ResolveInfo contains the resolved IP addresses from the resolver, and any
|
|
// error that occurred during the resolution.
|
|
// Note that there could be no error but also no resolved IP addresses,
|
|
// or there could be an error but also some resolved IP addresses.
|
|
// It's up to the actual outbound implementation to decide how to handle
|
|
// these cases.
|
|
type ResolveInfo struct {
|
|
IPv4 net.IP
|
|
IPv6 net.IP
|
|
Err error
|
|
}
|
|
|
|
var _ server.Outbound = (*PluggableOutboundAdapter)(nil)
|
|
|
|
type PluggableOutboundAdapter struct {
|
|
PluggableOutbound
|
|
}
|
|
|
|
func parsePortUint16(port string) (uint16, error) {
|
|
portUint, err := strconv.ParseUint(port, 10, 16)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("invalid port: %w", err)
|
|
}
|
|
return uint16(portUint), nil
|
|
}
|
|
|
|
func (a *PluggableOutboundAdapter) TCP(reqAddr string) (net.Conn, error) {
|
|
host, port, err := net.SplitHostPort(reqAddr)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
portUint, err := parsePortUint16(port)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return a.PluggableOutbound.TCP(&AddrEx{
|
|
Host: host,
|
|
Port: portUint,
|
|
})
|
|
}
|
|
|
|
func (a *PluggableOutboundAdapter) UDP(reqAddr string) (server.UDPConn, error) {
|
|
host, port, err := net.SplitHostPort(reqAddr)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
portUint, err := parsePortUint16(port)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
conn, err := a.PluggableOutbound.UDP(&AddrEx{
|
|
Host: host,
|
|
Port: portUint,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &udpConnAdapter{conn}, nil
|
|
}
|
|
|
|
func (a *PluggableOutboundAdapter) CheckUDP(reqAddr string) error {
|
|
host, port, err := net.SplitHostPort(reqAddr)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
portUint, err := parsePortUint16(port)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return a.PluggableOutbound.CheckUDP(&AddrEx{
|
|
Host: host,
|
|
Port: portUint,
|
|
})
|
|
}
|
|
|
|
type udpConnAdapter struct {
|
|
UDPConn
|
|
}
|
|
|
|
func (u *udpConnAdapter) ReadFrom(b []byte) (int, string, error) {
|
|
n, addr, err := u.UDPConn.ReadFrom(b)
|
|
if addr != nil {
|
|
return n, addr.String(), err
|
|
} else {
|
|
return n, "", err
|
|
}
|
|
}
|
|
|
|
func (u *udpConnAdapter) WriteTo(b []byte, addr string) (int, error) {
|
|
host, port, err := net.SplitHostPort(addr)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
portUint, err := parsePortUint16(port)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return u.UDPConn.WriteTo(b, &AddrEx{
|
|
Host: host,
|
|
Port: portUint,
|
|
})
|
|
}
|
|
|
|
func (u *udpConnAdapter) Close() error {
|
|
return u.UDPConn.Close()
|
|
}
|