NaiveProxy Protocol
NaiveProxy disguises proxy traffic as normal HTTP/2 or HTTP/3 traffic using the CONNECT method. The inbound implements a NaiveProxy-compatible server with padding support, while the outbound uses the Cronet (Chromium network stack) library to mimic a real Chrome client.
Source: protocol/naive/inbound.go, protocol/naive/inbound_conn.go, protocol/naive/outbound.go, protocol/naive/quic/
Inbound Architecture
type Inbound struct {
inbound.Adapter
ctx context.Context
router adapter.ConnectionRouterEx
logger logger.ContextLogger
listener *listener.Listener
network []string
networkIsDefault bool
authenticator *auth.Authenticator
tlsConfig tls.ServerConfig
httpServer *http.Server
h3Server io.Closer
}Dual Transport: HTTP/2 + HTTP/3
NaiveProxy supports both HTTP/2 (TCP) and HTTP/3 (QUIC). The network defaults to TCP, with optional UDP for HTTP/3:
if common.Contains(inbound.network, N.NetworkUDP) {
if options.TLS == nil || !options.TLS.Enabled {
return nil, E.New("TLS is required for QUIC server")
}
}HTTP/2 Server (TCP)
The TCP listener serves HTTP/2 via h2c (HTTP/2 cleartext) with optional TLS:
n.httpServer = &http.Server{
Handler: h2c.NewHandler(n, &http2.Server{}),
}
go func() {
listener := net.Listener(tcpListener)
if n.tlsConfig != nil {
// Ensure HTTP/2 ALPN is present
if !common.Contains(n.tlsConfig.NextProtos(), http2.NextProtoTLS) {
n.tlsConfig.SetNextProtos(append([]string{http2.NextProtoTLS}, n.tlsConfig.NextProtos()...))
}
listener = aTLS.NewListener(tcpListener, n.tlsConfig)
}
n.httpServer.Serve(listener)
}()HTTP/3 Server (QUIC)
HTTP/3 is initialized via a configurable function pointer:
var ConfigureHTTP3ListenerFunc func(ctx, logger, listener, handler, tlsConfig, options) (io.Closer, error)This is registered externally in protocol/naive/quic/inbound_init.go, which uses the sing-quic library with configurable congestion control.
CONNECT Request Processing
The core protocol logic is in ServeHTTP:
func (n *Inbound) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
// 1. Reject non-CONNECT requests
if request.Method != "CONNECT" {
rejectHTTP(writer, http.StatusBadRequest)
return
}
// 2. Require padding header (distinguishes NaiveProxy from plain CONNECT)
if request.Header.Get("Padding") == "" {
rejectHTTP(writer, http.StatusBadRequest)
return
}
// 3. Authenticate via Proxy-Authorization header
userName, password, authOk := sHttp.ParseBasicAuth(request.Header.Get("Proxy-Authorization"))
if authOk {
authOk = n.authenticator.Verify(userName, password)
}
if !authOk {
rejectHTTP(writer, http.StatusProxyAuthRequired)
return
}
// 4. Send response with padding
writer.Header().Set("Padding", generatePaddingHeader())
writer.WriteHeader(http.StatusOK)
writer.(http.Flusher).Flush()
// 5. Extract destination from custom or standard headers
hostPort := request.Header.Get("-connect-authority")
if hostPort == "" {
hostPort = request.URL.Host
}
// 6. Wrap connection with padding for first 8 frames
// HTTP/1.1: hijack the connection
// HTTP/2: use request.Body + response writer
}Rejection Behavior
On rejection, the connection is RST'd rather than gracefully closed, to mimic real web server behavior:
func rejectHTTP(writer http.ResponseWriter, statusCode int) {
hijacker, ok := writer.(http.Hijacker)
if !ok {
writer.WriteHeader(statusCode)
return
}
conn, _, _ := hijacker.Hijack()
if tcpConn, isTCP := common.Cast[*net.TCPConn](conn); isTCP {
tcpConn.SetLinger(0) // RST instead of FIN
}
conn.Close()
}Padding Protocol
The padding protocol adds random padding to the first 8 read/write operations to resist traffic fingerprinting.
Constants and Structure
const paddingCount = 8
type paddingConn struct {
readPadding int // frames read with padding so far
writePadding int // frames written with padding so far
readRemaining int // remaining data bytes in current frame
paddingRemaining int // remaining padding bytes to skip
}Padding Header Format
The Padding HTTP header uses a random string of 30-62 characters from the set !#$()+<>?@[]^{}~`:
func generatePaddingHeader() string {
paddingLen := rand.Intn(32) + 30
padding := make([]byte, paddingLen)
bits := rand.Uint64()
for i := 0; i < 16; i++ {
padding[i] = "!#$()+<>?@[]^`{}"[bits&15]
bits >>= 4
}
for i := 16; i < paddingLen; i++ {
padding[i] = '~'
}
return string(padding)
}Wire Format (Padded Frame)
Each of the first 8 frames is encoded as:
+---------------+----------+------+---------+
| Data Length | Pad Size | Data | Padding |
| (2 bytes BE) | (1 byte) | (var)| (var) |
+---------------+----------+------+---------+func (p *paddingConn) writeWithPadding(writer io.Writer, data []byte) (n int, err error) {
if p.writePadding < paddingCount {
paddingSize := rand.Intn(256)
buffer := buf.NewSize(3 + len(data) + paddingSize)
header := buffer.Extend(3)
binary.BigEndian.PutUint16(header, uint16(len(data)))
header[2] = byte(paddingSize)
buffer.Write(data)
buffer.Extend(paddingSize) // random padding bytes
_, err = writer.Write(buffer.Bytes())
p.writePadding++
return
}
// After 8 frames, write directly
return writer.Write(data)
}Reading Padded Frames
func (p *paddingConn) readWithPadding(reader io.Reader, buffer []byte) (n int, err error) {
// If we have remaining data from the current frame, read it
if p.readRemaining > 0 { /* read remaining */ }
// Skip any remaining padding from the previous frame
if p.paddingRemaining > 0 {
rw.SkipN(reader, p.paddingRemaining)
}
// Read next padded frame header (3 bytes)
if p.readPadding < paddingCount {
io.ReadFull(reader, paddingHeader[:3])
originalDataSize := binary.BigEndian.Uint16(paddingHeader[:2])
paddingSize := int(paddingHeader[2])
n, _ = reader.Read(buffer[:originalDataSize])
p.readPadding++
p.readRemaining = originalDataSize - n
p.paddingRemaining = paddingSize
return
}
// After 8 frames, read directly
return reader.Read(buffer)
}Connection Replaceability
After the padding phase (8 frames), the padding wrapper becomes transparent:
func (p *paddingConn) readerReplaceable() bool {
return p.readPadding == paddingCount
}
func (p *paddingConn) writerReplaceable() bool {
return p.writePadding == paddingCount
}Two Connection Types
naiveConn: For HTTP/1.1 hijacked connections (wrapsnet.Conn)naiveH2Conn: For HTTP/2 streams (wrapsio.Reader+io.Writer+http.Flusher); must flush after each write
Outbound Architecture (Cronet)
The outbound uses the Cronet library (Chromium's network stack) to make connections indistinguishable from real Chrome:
//go:build with_naive_outbound
type Outbound struct {
outbound.Adapter
ctx context.Context
logger logger.ContextLogger
client *cronet.NaiveClient
uotClient *uot.Client
}Build Tag
The outbound requires the with_naive_outbound build tag.
TLS Restrictions
Many TLS options are unsupported because Cronet manages its own TLS:
if options.TLS.DisableSNI { return nil, E.New("not supported") }
if options.TLS.Insecure { return nil, E.New("not supported") }
if len(options.TLS.ALPN) > 0 { return nil, E.New("not supported") }
if options.TLS.UTLS != nil { return nil, E.New("not supported") }
if options.TLS.Reality != nil { return nil, E.New("not supported") }
// ... and many moreClient Configuration
client, _ := cronet.NewNaiveClient(cronet.NaiveClientOptions{
ServerAddress: serverAddress,
ServerName: serverName,
Username: options.Username,
Password: options.Password,
InsecureConcurrency: options.InsecureConcurrency,
ExtraHeaders: extraHeaders,
TrustedRootCertificates: trustedRootCertificates,
Dialer: outboundDialer,
DNSResolver: dnsResolver,
ECHEnabled: echEnabled,
QUIC: options.QUIC,
QUICCongestionControl: quicCongestionControl,
})QUIC Congestion Control (Outbound)
The outbound supports multiple QUIC congestion control algorithms:
switch options.QUICCongestionControl {
case "bbr": quicCongestionControl = cronet.QUICCongestionControlBBR
case "bbr2": quicCongestionControl = cronet.QUICCongestionControlBBRv2
case "cubic": quicCongestionControl = cronet.QUICCongestionControlCubic
case "reno": quicCongestionControl = cronet.QUICCongestionControlReno
}ECH Support
The outbound supports Encrypted Client Hello:
if options.TLS.ECH != nil && options.TLS.ECH.Enabled {
echEnabled = true
echConfigList = block.Bytes // PEM-decoded "ECH CONFIGS"
}DNS Integration
The outbound uses the sing-box DNS router for name resolution within Cronet:
dnsResolver = func(dnsContext context.Context, request *mDNS.Msg) *mDNS.Msg {
response, _ := dnsRouter.Exchange(dnsContext, request, outboundDialer.(dialer.ResolveDialer).QueryOptions())
return response
}UDP Support via UoT
UDP is only available through UDP-over-TCP:
if uotOptions.Enabled {
outbound.uotClient = &uot.Client{
Dialer: &naiveDialer{client},
Version: uotOptions.Version,
}
}Configuration Examples
Inbound
{
"type": "naive",
"tag": "naive-in",
"listen": "::",
"listen_port": 443,
"users": [
{ "username": "user1", "password": "pass1" }
],
"tls": {
"enabled": true,
"certificate_path": "/path/to/cert.pem",
"key_path": "/path/to/key.pem"
}
}Outbound
{
"type": "naive",
"tag": "naive-out",
"server": "example.com",
"server_port": 443,
"username": "user1",
"password": "pass1",
"tls": {
"enabled": true,
"server_name": "example.com"
},
"udp_over_tcp": {
"enabled": true,
"version": 2
}
}