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>
194 lines
4.6 KiB
Go
194 lines
4.6 KiB
Go
package outbounds
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"crypto/tls"
|
|
"encoding/base64"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
httpRequestTimeout = 10 * time.Second
|
|
)
|
|
|
|
var (
|
|
errHTTPUDPNotSupported = errors.New("UDP not supported by HTTP proxy")
|
|
errHTTPUnsupportedScheme = errors.New("unsupported scheme for HTTP proxy (use http:// or https://)")
|
|
)
|
|
|
|
type errHTTPRequestFailed struct {
|
|
Status int
|
|
}
|
|
|
|
func (e errHTTPRequestFailed) Error() string {
|
|
return fmt.Sprintf("HTTP request failed: %d", e.Status)
|
|
}
|
|
|
|
// httpOutbound is a PluggableOutbound that connects to the target using
|
|
// an HTTP/HTTPS proxy server (that supports the CONNECT method).
|
|
// HTTP proxies don't support UDP by design, so this outbound will reject
|
|
// any UDP request with errHTTPUDPNotSupported.
|
|
// Since HTTP proxies support using either IP or domain name as the target
|
|
// address, it will ignore ResolveInfo in AddrEx and always only use Host.
|
|
type httpOutbound struct {
|
|
Dialer *net.Dialer
|
|
Addr string
|
|
HTTPS bool
|
|
Insecure bool
|
|
ServerName string
|
|
BasicAuth string // This is after Base64 encoding
|
|
}
|
|
|
|
func NewHTTPOutbound(proxyURL string, insecure bool) (PluggableOutbound, error) {
|
|
u, err := url.Parse(proxyURL)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if u.Scheme != "http" && u.Scheme != "https" {
|
|
return nil, errHTTPUnsupportedScheme
|
|
}
|
|
addr := u.Host
|
|
if u.Port() == "" {
|
|
if u.Scheme == "http" {
|
|
addr = net.JoinHostPort(u.Host, "80")
|
|
} else {
|
|
addr = net.JoinHostPort(u.Host, "443")
|
|
}
|
|
}
|
|
var basicAuth string
|
|
if u.User != nil {
|
|
username := u.User.Username()
|
|
password, _ := u.User.Password()
|
|
basicAuth = "Basic " + base64.StdEncoding.EncodeToString([]byte(username+":"+password))
|
|
}
|
|
return &httpOutbound{
|
|
Dialer: &net.Dialer{Timeout: defaultDialerTimeout},
|
|
Addr: addr,
|
|
HTTPS: u.Scheme == "https",
|
|
Insecure: insecure,
|
|
ServerName: u.Hostname(),
|
|
BasicAuth: basicAuth,
|
|
}, nil
|
|
}
|
|
|
|
func (o *httpOutbound) dial() (net.Conn, error) {
|
|
conn, err := o.Dialer.Dial("tcp", o.Addr)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if o.HTTPS {
|
|
// Wrap the connection with TLS if the proxy is HTTPS.
|
|
conn = tls.Client(conn, &tls.Config{
|
|
InsecureSkipVerify: o.Insecure,
|
|
ServerName: o.Addr,
|
|
})
|
|
}
|
|
return conn, nil
|
|
}
|
|
|
|
func (o *httpOutbound) addrExToRequest(reqAddr *AddrEx) (*http.Request, error) {
|
|
req := &http.Request{
|
|
Method: http.MethodConnect,
|
|
URL: &url.URL{
|
|
Host: net.JoinHostPort(reqAddr.Host, strconv.Itoa(int(reqAddr.Port))),
|
|
},
|
|
Header: http.Header{
|
|
"Proxy-Connection": []string{"Keep-Alive"},
|
|
},
|
|
}
|
|
if o.BasicAuth != "" {
|
|
req.Header.Add("Proxy-Authorization", o.BasicAuth)
|
|
}
|
|
return req, nil
|
|
}
|
|
|
|
func (o *httpOutbound) TCP(reqAddr *AddrEx) (net.Conn, error) {
|
|
req, err := o.addrExToRequest(reqAddr)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
conn, err := o.dial()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := req.Write(conn); err != nil {
|
|
_ = conn.Close()
|
|
return nil, err
|
|
}
|
|
if err := conn.SetDeadline(time.Now().Add(httpRequestTimeout)); err != nil {
|
|
_ = conn.Close()
|
|
return nil, err
|
|
}
|
|
bufReader := bufio.NewReader(conn)
|
|
resp, err := http.ReadResponse(bufReader, req)
|
|
if resp != nil {
|
|
// Don't need response body here.
|
|
_ = resp.Body.Close()
|
|
}
|
|
if err != nil {
|
|
_ = conn.Close()
|
|
return nil, err
|
|
}
|
|
if resp.StatusCode != http.StatusOK {
|
|
_ = conn.Close()
|
|
return nil, errHTTPRequestFailed{resp.StatusCode}
|
|
}
|
|
if err := conn.SetDeadline(time.Time{}); err != nil {
|
|
_ = conn.Close()
|
|
return nil, err
|
|
}
|
|
if bufReader.Buffered() > 0 {
|
|
// There is still data in the buffered reader.
|
|
// We need to get it out and put it into a cachedConn,
|
|
// so that handleConnect can read it.
|
|
data := make([]byte, bufReader.Buffered())
|
|
_, err := io.ReadFull(bufReader, data)
|
|
if err != nil {
|
|
// Read from buffer failed, is this possible?
|
|
_ = conn.Close()
|
|
return nil, err
|
|
}
|
|
cachedConn := &cachedConn{
|
|
Conn: conn,
|
|
Buffer: *bytes.NewBuffer(data),
|
|
}
|
|
return cachedConn, nil
|
|
} else {
|
|
return conn, nil
|
|
}
|
|
}
|
|
|
|
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 {
|
|
net.Conn
|
|
Buffer bytes.Buffer
|
|
}
|
|
|
|
func (c *cachedConn) Read(b []byte) (int, error) {
|
|
if c.Buffer.Len() > 0 {
|
|
n, err := c.Buffer.Read(b)
|
|
if err == io.EOF {
|
|
// Buffer is drained, hide it from the caller
|
|
err = nil
|
|
}
|
|
return n, err
|
|
}
|
|
return c.Conn.Read(b)
|
|
}
|