Merge commit from fork

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>
This commit is contained in:
Toby 2026-05-21 14:09:41 -07:00 committed by GitHub
parent 02d7b0a54d
commit 2412f23646
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 412 additions and 4 deletions

View file

@ -4,10 +4,9 @@ package mocks
import (
net "net"
time "time"
mock "github.com/stretchr/testify/mock"
time "time"
)
// MockConn is an autogenerated mock type for the Conn type

View file

@ -5,9 +5,8 @@ package mocks
import (
net "net"
mock "github.com/stretchr/testify/mock"
server "github.com/apernet/hysteria/core/v2/server"
mock "github.com/stretchr/testify/mock"
)
// MockOutbound is an autogenerated mock type for the Outbound type
@ -23,6 +22,52 @@ func (_m *MockOutbound) EXPECT() *MockOutbound_Expecter {
return &MockOutbound_Expecter{mock: &_m.Mock}
}
// CheckUDP provides a mock function with given fields: reqAddr
func (_m *MockOutbound) CheckUDP(reqAddr string) error {
ret := _m.Called(reqAddr)
if len(ret) == 0 {
panic("no return value specified for CheckUDP")
}
var r0 error
if rf, ok := ret.Get(0).(func(string) error); ok {
r0 = rf(reqAddr)
} else {
r0 = ret.Error(0)
}
return r0
}
// MockOutbound_CheckUDP_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CheckUDP'
type MockOutbound_CheckUDP_Call struct {
*mock.Call
}
// CheckUDP is a helper method to define mock.On call
// - reqAddr string
func (_e *MockOutbound_Expecter) CheckUDP(reqAddr interface{}) *MockOutbound_CheckUDP_Call {
return &MockOutbound_CheckUDP_Call{Call: _e.mock.On("CheckUDP", reqAddr)}
}
func (_c *MockOutbound_CheckUDP_Call) Run(run func(reqAddr string)) *MockOutbound_CheckUDP_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(string))
})
return _c
}
func (_c *MockOutbound_CheckUDP_Call) Return(_a0 error) *MockOutbound_CheckUDP_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockOutbound_CheckUDP_Call) RunAndReturn(run func(string) error) *MockOutbound_CheckUDP_Call {
_c.Call.Return(run)
return _c
}
// TCP provides a mock function with given fields: reqAddr
func (_m *MockOutbound) TCP(reqAddr string) (net.Conn, error) {
ret := _m.Called(reqAddr)

View file

@ -0,0 +1,177 @@
package integration_tests
import (
"errors"
"net"
"sync/atomic"
"testing"
"time"
"github.com/apernet/hysteria/core/v2/client"
"github.com/apernet/hysteria/core/v2/internal/integration_tests/mocks"
"github.com/apernet/hysteria/core/v2/server"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
type gatedOutbound struct {
blocked string
checkCalls atomic.Int32
dialedAddrs atomic.Int32
}
func (o *gatedOutbound) TCP(reqAddr string) (net.Conn, error) {
return net.Dial("tcp", reqAddr)
}
func (o *gatedOutbound) UDP(reqAddr string) (server.UDPConn, error) {
if reqAddr == o.blocked {
return nil, errors.New("rejected")
}
o.dialedAddrs.Add(1)
c, err := net.ListenUDP("udp", nil)
if err != nil {
return nil, err
}
return &gatedUDPConn{UDPConn: c}, nil
}
func (o *gatedOutbound) CheckUDP(reqAddr string) error {
o.checkCalls.Add(1)
if reqAddr == o.blocked {
return errors.New("rejected")
}
return nil
}
type gatedUDPConn struct {
*net.UDPConn
}
func (c *gatedUDPConn) ReadFrom(b []byte) (int, string, error) {
n, addr, err := c.UDPConn.ReadFrom(b)
if addr != nil {
return n, addr.String(), err
}
return n, "", err
}
func (c *gatedUDPConn) WriteTo(b []byte, addr string) (int, error) {
uAddr, err := net.ResolveUDPAddr("udp", addr)
if err != nil {
return 0, err
}
return c.UDPConn.WriteTo(b, uAddr)
}
func TestClientServerUDPACLBypass(t *testing.T) {
const allowed, blocked = "127.0.0.1:22444", "127.0.0.1:22445"
ob := &gatedOutbound{blocked: blocked}
udpConn, udpAddr, err := serverConn()
assert.NoError(t, err)
auth := mocks.NewMockAuthenticator(t)
auth.EXPECT().Authenticate(mock.Anything, mock.Anything, mock.Anything).Return(true, "nobody")
s, err := server.NewServer(&server.Config{
TLSConfig: serverTLSConfig(),
Conn: udpConn,
Authenticator: auth,
Outbound: ob,
})
assert.NoError(t, err)
defer s.Close()
go s.Serve()
allowedConn, err := net.ListenPacket("udp", allowed)
assert.NoError(t, err)
defer allowedConn.Close()
go (&udpEchoServer{Conn: allowedConn}).Serve()
blockedConn, err := net.ListenPacket("udp", blocked)
assert.NoError(t, err)
defer blockedConn.Close()
go (&udpEchoServer{Conn: blockedConn}).Serve()
c, _, err := client.NewClient(&client.Config{
ServerAddr: udpAddr,
TLSConfig: client.TLSConfig{InsecureSkipVerify: true},
})
assert.NoError(t, err)
defer c.Close()
conn, err := c.UDP()
assert.NoError(t, err)
defer conn.Close()
assert.NoError(t, conn.Send([]byte("hello"), allowed))
rData, rAddr, err := conn.Receive()
assert.NoError(t, err)
assert.Equal(t, []byte("hello"), rData)
assert.Equal(t, allowed, rAddr)
assert.NoError(t, conn.Send([]byte("ssrf"), blocked))
done := make(chan struct{})
var leakedAddr string
go func() {
_, addr, err := conn.Receive()
if err == nil {
leakedAddr = addr
}
close(done)
}()
select {
case <-done:
assert.NotEqual(t, blocked, leakedAddr, "ACL bypass: blocked destination relayed")
case <-time.After(500 * time.Millisecond):
}
assert.GreaterOrEqual(t, ob.checkCalls.Load(), int32(1), "CheckUDP not invoked for subsequent packet")
assert.Equal(t, int32(1), ob.dialedAddrs.Load(), "outbound dial must happen only on first allowed destination")
}
func TestClientServerUDPACLMultiDestAllowed(t *testing.T) {
const dest1, dest2 = "127.0.0.1:22448", "127.0.0.1:22449"
ob := &gatedOutbound{blocked: ""}
udpConn, udpAddr, err := serverConn()
assert.NoError(t, err)
auth := mocks.NewMockAuthenticator(t)
auth.EXPECT().Authenticate(mock.Anything, mock.Anything, mock.Anything).Return(true, "nobody")
s, err := server.NewServer(&server.Config{
TLSConfig: serverTLSConfig(),
Conn: udpConn,
Authenticator: auth,
Outbound: ob,
})
assert.NoError(t, err)
defer s.Close()
go s.Serve()
for _, addr := range []string{dest1, dest2} {
ec, err := net.ListenPacket("udp", addr)
assert.NoError(t, err)
defer ec.Close()
go (&udpEchoServer{Conn: ec}).Serve()
}
c, _, err := client.NewClient(&client.Config{
ServerAddr: udpAddr,
TLSConfig: client.TLSConfig{InsecureSkipVerify: true},
})
assert.NoError(t, err)
defer c.Close()
conn, err := c.UDP()
assert.NoError(t, err)
defer conn.Close()
for _, addr := range []string{dest1, dest2} {
assert.NoError(t, conn.Send([]byte("hi"), addr))
rData, rAddr, err := conn.Receive()
assert.NoError(t, err)
assert.Equal(t, []byte("hi"), rData)
assert.Equal(t, addr, rAddr)
}
}

View file

@ -153,9 +153,11 @@ type RequestHook interface {
// Although UDP includes a reqAddr, the implementation does not necessarily have to use it
// to make a "connected" UDP connection that does not accept packets from other addresses.
// In fact, the default implementation simply uses net.ListenUDP for a "full-cone" behavior.
// CheckUDP is used to check if a UDP packet to reqAddr is permitted (useful for e.g. ACL).
type Outbound interface {
TCP(reqAddr string) (net.Conn, error)
UDP(reqAddr string) (UDPConn, error)
CheckUDP(reqAddr string) error
}
// UDPConn is like net.PacketConn, but uses string for addresses.
@ -183,6 +185,10 @@ func (o *defaultOutbound) UDP(reqAddr string) (UDPConn, error) {
return &defaultUDPConn{conn}, nil
}
func (o *defaultOutbound) CheckUDP(reqAddr string) error {
return nil
}
type defaultUDPConn struct {
*net.UDPConn
}

View file

@ -20,6 +20,52 @@ func (_m *mockUDPIO) EXPECT() *mockUDPIO_Expecter {
return &mockUDPIO_Expecter{mock: &_m.Mock}
}
// CheckUDP provides a mock function with given fields: reqAddr
func (_m *mockUDPIO) CheckUDP(reqAddr string) error {
ret := _m.Called(reqAddr)
if len(ret) == 0 {
panic("no return value specified for CheckUDP")
}
var r0 error
if rf, ok := ret.Get(0).(func(string) error); ok {
r0 = rf(reqAddr)
} else {
r0 = ret.Error(0)
}
return r0
}
// mockUDPIO_CheckUDP_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CheckUDP'
type mockUDPIO_CheckUDP_Call struct {
*mock.Call
}
// CheckUDP is a helper method to define mock.On call
// - reqAddr string
func (_e *mockUDPIO_Expecter) CheckUDP(reqAddr interface{}) *mockUDPIO_CheckUDP_Call {
return &mockUDPIO_CheckUDP_Call{Call: _e.mock.On("CheckUDP", reqAddr)}
}
func (_c *mockUDPIO_CheckUDP_Call) Run(run func(reqAddr string)) *mockUDPIO_CheckUDP_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(string))
})
return _c
}
func (_c *mockUDPIO_CheckUDP_Call) Return(_a0 error) *mockUDPIO_CheckUDP_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *mockUDPIO_CheckUDP_Call) RunAndReturn(run func(string) error) *mockUDPIO_CheckUDP_Call {
_c.Call.Return(run)
return _c
}
// Hook provides a mock function with given fields: data, reqAddr
func (_m *mockUDPIO) Hook(data []byte, reqAddr *string) error {
ret := _m.Called(data, reqAddr)

View file

@ -398,6 +398,10 @@ func (io *udpIOImpl) UDP(reqAddr string) (UDPConn, error) {
return io.Outbound.UDP(reqAddr)
}
func (io *udpIOImpl) CheckUDP(reqAddr string) error {
return io.Outbound.CheckUDP(reqAddr)
}
type udpEventLoggerImpl struct {
Conn *quic.Conn
AuthID string

View file

@ -15,6 +15,7 @@ import (
const (
idleCleanupInterval = 1 * time.Second
maxSessionACLCache = 256
)
type udpIO interface {
@ -22,6 +23,7 @@ type udpIO interface {
SendMessage([]byte, *protocol.UDPMessage) error
Hook(data []byte, reqAddr *string) error
UDP(reqAddr string) (UDPConn, error)
CheckUDP(reqAddr string) error
}
type udpEventLogger interface {
@ -43,6 +45,8 @@ type udpSessionEntry struct {
conn UDPConn
connLock sync.Mutex
closed bool
aclCache map[string]error
}
func newUDPSessionEntry(
@ -101,16 +105,41 @@ func (e *udpSessionEntry) Feed(msg *protocol.UDPMessage) (int, error) {
if err != nil {
return 0, err
}
if e.OverrideAddr == "" {
e.aclCache = map[string]error{dfMsg.Addr: nil}
}
}
addr := dfMsg.Addr
if e.OverrideAddr != "" {
addr = e.OverrideAddr
} else if err := e.checkAddr(addr); err != nil {
return 0, err
}
return e.conn.WriteTo(dfMsg.Data, addr)
}
// checkAddr checks outbound policy for the given address.
// The decision is cached in e.aclCache for future use.
func (e *udpSessionEntry) checkAddr(addr string) error {
if decision, ok := e.aclCache[addr]; ok {
return decision
}
decision := e.IO.CheckUDP(addr)
if len(e.aclCache) >= maxSessionACLCache {
for k := range e.aclCache {
delete(e.aclCache, k)
break
}
}
if e.aclCache == nil {
e.aclCache = make(map[string]error, 4)
}
e.aclCache[addr] = decision
return decision
}
// initConn initializes the UDP connection of the session.
// If no error is returned, the e.conn is set to the new connection.
func (e *udpSessionEntry) initConn(firstMsg *protocol.UDPMessage) error {

View file

@ -110,6 +110,11 @@ func (a *aclEngine) UDP(reqAddr *AddrEx) (UDPConn, error) {
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) {
@ -119,3 +124,7 @@ func (a *aclRejectOutbound) TCP(reqAddr *AddrEx) (net.Conn, error) {
func (a *aclRejectOutbound) UDP(reqAddr *AddrEx) (UDPConn, error) {
return nil, errRejected
}
func (a *aclRejectOutbound) CheckUDP(reqAddr *AddrEx) error {
return errRejected
}

View file

@ -87,3 +87,8 @@ func (r *dohResolver) UDP(reqAddr *AddrEx) (UDPConn, error) {
r.resolve(reqAddr)
return r.Next.UDP(reqAddr)
}
func (r *dohResolver) CheckUDP(reqAddr *AddrEx) error {
r.resolve(reqAddr)
return r.Next.CheckUDP(reqAddr)
}

View file

@ -219,3 +219,8 @@ func (r *standardResolver) UDP(reqAddr *AddrEx) (UDPConn, error) {
r.resolve(reqAddr)
return r.Next.UDP(reqAddr)
}
func (r *standardResolver) CheckUDP(reqAddr *AddrEx) error {
r.resolve(reqAddr)
return r.Next.CheckUDP(reqAddr)
}

View file

@ -39,3 +39,8 @@ func (r *systemResolver) UDP(reqAddr *AddrEx) (UDPConn, error) {
r.resolve(reqAddr)
return r.Next.UDP(reqAddr)
}
func (r *systemResolver) CheckUDP(reqAddr *AddrEx) error {
r.resolve(reqAddr)
return r.Next.CheckUDP(reqAddr)
}

View file

@ -24,6 +24,7 @@ import (
type PluggableOutbound interface {
TCP(reqAddr *AddrEx) (net.Conn, error)
UDP(reqAddr *AddrEx) (UDPConn, error)
CheckUDP(reqAddr *AddrEx) error
}
type UDPConn interface {
@ -109,6 +110,21 @@ func (a *PluggableOutboundAdapter) UDP(reqAddr string) (server.UDPConn, error) {
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
}

View file

@ -21,6 +21,52 @@ func (_m *mockPluggableOutbound) EXPECT() *mockPluggableOutbound_Expecter {
return &mockPluggableOutbound_Expecter{mock: &_m.Mock}
}
// CheckUDP provides a mock function with given fields: reqAddr
func (_m *mockPluggableOutbound) CheckUDP(reqAddr *AddrEx) error {
ret := _m.Called(reqAddr)
if len(ret) == 0 {
panic("no return value specified for CheckUDP")
}
var r0 error
if rf, ok := ret.Get(0).(func(*AddrEx) error); ok {
r0 = rf(reqAddr)
} else {
r0 = ret.Error(0)
}
return r0
}
// mockPluggableOutbound_CheckUDP_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CheckUDP'
type mockPluggableOutbound_CheckUDP_Call struct {
*mock.Call
}
// CheckUDP is a helper method to define mock.On call
// - reqAddr *AddrEx
func (_e *mockPluggableOutbound_Expecter) CheckUDP(reqAddr interface{}) *mockPluggableOutbound_CheckUDP_Call {
return &mockPluggableOutbound_CheckUDP_Call{Call: _e.mock.On("CheckUDP", reqAddr)}
}
func (_c *mockPluggableOutbound_CheckUDP_Call) Run(run func(reqAddr *AddrEx)) *mockPluggableOutbound_CheckUDP_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(*AddrEx))
})
return _c
}
func (_c *mockPluggableOutbound_CheckUDP_Call) Return(_a0 error) *mockPluggableOutbound_CheckUDP_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *mockPluggableOutbound_CheckUDP_Call) RunAndReturn(run func(*AddrEx) error) *mockPluggableOutbound_CheckUDP_Call {
_c.Call.Return(run)
return _c
}
// TCP provides a mock function with given fields: reqAddr
func (_m *mockPluggableOutbound) TCP(reqAddr *AddrEx) (net.Conn, error) {
ret := _m.Called(reqAddr)

View file

@ -402,6 +402,10 @@ func (u *directOutboundUDPConn) Close() error {
return u.UDPConn.Close()
}
func (d *directOutbound) CheckUDP(reqAddr *AddrEx) error {
return nil
}
func (d *directOutbound) UDP(reqAddr *AddrEx) (UDPConn, error) {
if d.BindIP4 == nil && d.BindIP6 == nil {
// No bind address specified, use default dual stack implementation

View file

@ -170,6 +170,10 @@ func (o *httpOutbound) UDP(reqAddr *AddrEx) (UDPConn, error) {
return nil, errHTTPUDPNotSupported
}
func (o *httpOutbound) CheckUDP(reqAddr *AddrEx) error {
return errHTTPUDPNotSupported
}
// cachedConn is a net.Conn wrapper that first Read()s from a buffer,
// and then from the underlying net.Conn when the buffer is drained.
type cachedConn struct {

View file

@ -171,6 +171,10 @@ func (s *socks5Outbound) TCP(reqAddr *AddrEx) (net.Conn, error) {
return conn, nil
}
func (s *socks5Outbound) CheckUDP(reqAddr *AddrEx) error {
return nil
}
func (s *socks5Outbound) UDP(reqAddr *AddrEx) (UDPConn, error) {
conn, err := s.dialAndNegotiate()
if err != nil {

View file

@ -34,3 +34,7 @@ func (s *speedtestHandler) TCP(reqAddr *AddrEx) (net.Conn, error) {
func (s *speedtestHandler) UDP(reqAddr *AddrEx) (UDPConn, error) {
return s.Next.UDP(reqAddr)
}
func (s *speedtestHandler) CheckUDP(reqAddr *AddrEx) error {
return s.Next.CheckUDP(reqAddr)
}