Skip to content

Commit bca07d6

Browse files
authored
fix(proxy): isolate and apply 30s timeout to upgrade proxy dialer (#4125)
This Pull Request resolves a critical P0 availability defect regarding unbounded goroutine accumulation within the WebSocket and HTTP upgrade proxy pathways under stalled backend conditions. By replacing the blocking, context-less net.Dial and tls.Dial calls inside dialBackend() with context-aware DialContext routines, the proxy now correctly propagates req.Context() cancellation and enforces a strict dial timeout. The timeout has been fully wired into the proxy configuration factory, defaulting to 30 seconds while allowing direct overrides via Params.Timeout. Comprehensive regression and E2E timeout verification tests have been integrated into proxy/upgrade_test.go. --------- Signed-off-by: glatinone <93207632+glatinone@users.noreply.github.com>
1 parent e51f8e6 commit bca07d6

5 files changed

Lines changed: 262 additions & 7 deletions

File tree

config/config.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,7 @@ type Config struct {
300300
MaxHeaderBytes int `yaml:"max-header-bytes"`
301301
EnableConnMetricsServer bool `yaml:"enable-connection-metrics"`
302302
TimeoutBackend time.Duration `yaml:"timeout-backend"`
303+
UpgradeDialTimeout time.Duration `yaml:"upgrade-dial-timeout"`
303304
KeepaliveBackend time.Duration `yaml:"keepalive-backend"`
304305
EnableDualstackBackend bool `yaml:"enable-dualstack-backend"`
305306
TlsHandshakeTimeoutBackend time.Duration `yaml:"tls-timeout-backend"`
@@ -689,6 +690,7 @@ func NewConfig() *Config {
689690
flag.IntVar(&cfg.MaxHeaderBytes, "max-header-bytes", http.DefaultMaxHeaderBytes, "set MaxHeaderBytes for http server connections")
690691
flag.BoolVar(&cfg.EnableConnMetricsServer, "enable-connection-metrics", false, "enables connection metrics for http server connections")
691692
flag.DurationVar(&cfg.TimeoutBackend, "timeout-backend", 60*time.Second, "sets the TCP client connection timeout for backend connections")
693+
flag.DurationVar(&cfg.UpgradeDialTimeout, "upgrade-dial-timeout", 0, "sets the explicit connect-time ceiling for websocket/spdy upgrade backend connections. Zero falls back to the built-in 30s default; negative disables the ceiling entirely")
692694
flag.DurationVar(&cfg.KeepaliveBackend, "keepalive-backend", 30*time.Second, "sets the keepalive for backend connections")
693695
flag.BoolVar(&cfg.EnableDualstackBackend, "enable-dualstack-backend", true, "enables DualStack for backend connections")
694696
flag.DurationVar(&cfg.TlsHandshakeTimeoutBackend, "tls-timeout-backend", 60*time.Second, "sets the TLS handshake timeout for backend connections")
@@ -1139,6 +1141,7 @@ func (c *Config) ToOptions() skipper.Options {
11391141
MaxHeaderBytes: c.MaxHeaderBytes,
11401142
EnableConnMetricsServer: c.EnableConnMetricsServer,
11411143
TimeoutBackend: c.TimeoutBackend,
1144+
UpgradeDialTimeout: c.UpgradeDialTimeout,
11421145
KeepAliveBackend: c.KeepaliveBackend,
11431146
DualStackBackend: c.EnableDualstackBackend,
11441147
TLSHandshakeTimeoutBackend: c.TlsHandshakeTimeoutBackend,

proxy/proxy.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,11 @@ type Params struct {
342342
// Timeout sets the TCP client connection timeout for proxy http connections to the backend
343343
Timeout time.Duration
344344

345+
// UpgradeDialTimeout sets the explicit connect-time ceiling for
346+
// WebSocket/SPDY upgrade connections to the backend. Zero falls back
347+
// to the built-in default; negative disables the ceiling entirely.
348+
UpgradeDialTimeout time.Duration
349+
345350
// ResponseHeaderTimeout sets the HTTP response timeout for
346351
// proxy http connections to the backend.
347352
ResponseHeaderTimeout time.Duration
@@ -491,6 +496,7 @@ type Proxy struct {
491496
upgradeAuditLogOut io.Writer
492497
upgradeAuditLogErr io.Writer
493498
auditLogHook chan struct{}
499+
upgradeDialTimeout time.Duration
494500
clientTLS *tls.Config
495501
hostname string
496502
onPanicSometimes rate.Sometimes
@@ -926,6 +932,7 @@ func WithParams(p Params) *Proxy {
926932
flushInterval: p.FlushInterval,
927933
experimentalUpgrade: p.ExperimentalUpgrade,
928934
experimentalUpgradeAudit: p.ExperimentalUpgradeAudit,
935+
upgradeDialTimeout: p.UpgradeDialTimeout,
929936
maxLoops: p.MaxLoopbacks,
930937
breakers: p.CircuitBreakers,
931938
limiters: p.RateLimiters,
@@ -1037,6 +1044,7 @@ func (p *Proxy) makeUpgradeRequest(ctx *context, req *http.Request) {
10371044
auditLogOut: p.upgradeAuditLogOut,
10381045
auditLogErr: p.upgradeAuditLogErr,
10391046
auditLogHook: p.auditLogHook,
1047+
dialTimeout: p.upgradeDialTimeout,
10401048
}
10411049

10421050
upgradeProxy.serveHTTP(ctx.responseWriter, req)

proxy/upgrade.go

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,13 @@ import (
1313
"net/http/httputil"
1414
"net/url"
1515
"strings"
16+
"time"
1617

1718
log "github.com/sirupsen/logrus"
1819
)
1920

21+
const defaultUpgradeDialTimeout = 30 * time.Second
22+
2023
// isUpgradeRequest returns true if and only if there is a "Connection"
2124
// key with the value "Upgrade" in Headers of the given request.
2225
func isUpgradeRequest(req *http.Request) bool {
@@ -38,7 +41,6 @@ func getUpgradeRequest(req *http.Request) string {
3841
return ""
3942
}
4043

41-
// UpgradeProxy stores everything needed to make the connection upgrade.
4244
type upgradeProxy struct {
4345
backendAddr *url.URL
4446
reverseProxy *httputil.ReverseProxy
@@ -48,6 +50,24 @@ type upgradeProxy struct {
4850
auditLogOut io.Writer
4951
auditLogErr io.Writer
5052
auditLogHook chan struct{}
53+
dialTimeout time.Duration
54+
}
55+
56+
// resolvedDialTimeout applies a strict 3-state backward-compatibility
57+
// contract for the configured dial timeout:
58+
// - negative: the ceiling is disabled entirely (0), matching
59+
// net.Dialer.Timeout == 0 semantics.
60+
// - zero (unset): falls back to defaultUpgradeDialTimeout.
61+
// - positive: used as configured.
62+
func (p *upgradeProxy) resolvedDialTimeout() time.Duration {
63+
switch {
64+
case p.dialTimeout < 0:
65+
return 0
66+
case p.dialTimeout == 0:
67+
return defaultUpgradeDialTimeout
68+
default:
69+
return p.dialTimeout
70+
}
5171
}
5272

5373
// TODO: add user here
@@ -190,26 +210,32 @@ func (p *upgradeProxy) dialBackend(req *http.Request) (net.Conn, error) {
190210

191211
switch p.backendAddr.Scheme {
192212
case "http":
193-
return net.Dial("tcp", dialAddr)
213+
return (&net.Dialer{Timeout: p.resolvedDialTimeout()}).DialContext(req.Context(), "tcp", dialAddr)
214+
194215
case "https":
195-
tlsConn, err := tls.Dial("tcp", dialAddr, p.tlsClientConfig)
216+
tlsDialer := &tls.Dialer{
217+
NetDialer: &net.Dialer{Timeout: p.resolvedDialTimeout()},
218+
Config: p.tlsClientConfig,
219+
}
220+
conn, err := tlsDialer.DialContext(req.Context(), "tcp", dialAddr)
196221
if err != nil {
197222
return nil, err
198223
}
199224

200225
if !p.insecure {
201226
hostToVerify, _, err := net.SplitHostPort(dialAddr)
202227
if err != nil {
228+
conn.Close()
203229
return nil, err
204230
}
205-
err = tlsConn.VerifyHostname(hostToVerify)
206-
if err != nil {
207-
tlsConn.Close()
231+
if err = conn.(*tls.Conn).VerifyHostname(hostToVerify); err != nil {
232+
conn.Close()
208233
return nil, err
209234
}
210235
}
211236

212-
return tlsConn, nil
237+
return conn, nil
238+
213239
default:
214240
return nil, fmt.Errorf("unknown scheme: %s", p.backendAddr.Scheme)
215241
}

proxy/upgrade_test.go

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ package proxy
33
import (
44
"bufio"
55
"bytes"
6+
stdlibcontext "context"
7+
"crypto/tls"
68
"fmt"
79
"io"
810
"math/rand"
@@ -479,3 +481,213 @@ func TestAuditLogging(t *testing.T) {
479481
}
480482
}))
481483
}
484+
485+
func TestResolvedDialTimeoutDefault(t *testing.T) {
486+
p := getUpgradeProxy() // dialTimeout is zero-value
487+
got := p.resolvedDialTimeout()
488+
if got != defaultUpgradeDialTimeout {
489+
t.Errorf("resolvedDialTimeout() = %v; want defaultUpgradeDialTimeout (%v)",
490+
got, defaultUpgradeDialTimeout)
491+
}
492+
}
493+
494+
func TestResolvedDialTimeoutConfigured(t *testing.T) {
495+
const want = 5 * time.Second
496+
u, _ := url.ParseRequestURI("http://127.0.0.1:8080/foo")
497+
p := &upgradeProxy{
498+
backendAddr: u,
499+
dialTimeout: want,
500+
}
501+
if got := p.resolvedDialTimeout(); got != want {
502+
t.Errorf("resolvedDialTimeout() = %v; want %v", got, want)
503+
}
504+
}
505+
506+
func TestResolvedDialTimeoutNegativeDisablesTimeout(t *testing.T) {
507+
u, _ := url.ParseRequestURI("http://127.0.0.1:8080/foo")
508+
p := &upgradeProxy{
509+
backendAddr: u,
510+
dialTimeout: -1 * time.Second,
511+
}
512+
if got := p.resolvedDialTimeout(); got != 0 {
513+
t.Errorf("resolvedDialTimeout() = %v; want 0 (ceiling disabled)", got)
514+
}
515+
}
516+
517+
func TestDialBackendHTTPSTimesOutOnStalledHandshake(t *testing.T) {
518+
ln, err := net.Listen("tcp", "127.0.0.1:0")
519+
require.NoError(t, err)
520+
defer ln.Close()
521+
522+
done := make(chan struct{})
523+
t.Cleanup(func() { close(done) })
524+
525+
accepted := make(chan struct{})
526+
go func() {
527+
conn, err := ln.Accept()
528+
if err != nil {
529+
return
530+
}
531+
close(accepted)
532+
defer conn.Close()
533+
// Stall: hold the TCP connection open but never write TLS data,
534+
// bounded by the test's own lifetime via done.
535+
select {
536+
case <-done:
537+
return
538+
case <-time.After(30 * time.Second):
539+
}
540+
}()
541+
542+
const dialTimeout = 250 * time.Millisecond
543+
544+
backendURL, _ := url.Parse("https://" + ln.Addr().String())
545+
p := &upgradeProxy{
546+
backendAddr: backendURL,
547+
/* #nosec G402 – test-only, no production TLS */
548+
tlsClientConfig: &tls.Config{InsecureSkipVerify: true},
549+
insecure: true,
550+
dialTimeout: dialTimeout,
551+
}
552+
553+
req, err := http.NewRequestWithContext(
554+
stdlibcontext.Background(),
555+
http.MethodGet,
556+
"https://"+ln.Addr().String()+"/ws",
557+
nil,
558+
)
559+
require.NoError(t, err)
560+
req.URL = backendURL
561+
562+
start := time.Now()
563+
conn, err := p.dialBackend(req)
564+
elapsed := time.Since(start)
565+
566+
select {
567+
case <-accepted:
568+
case <-time.After(2 * time.Second):
569+
t.Fatal("backend never accepted TCP connection — test precondition failed")
570+
}
571+
572+
require.Error(t, err, "dialBackend to a stalled TLS backend must return an error")
573+
assert.Nil(t, conn, "conn must be nil on dial timeout")
574+
575+
assert.GreaterOrEqual(t, elapsed, dialTimeout,
576+
"dial returned before dialTimeout (%v); got %v — timeout may be wired to zero", dialTimeout, elapsed)
577+
assert.Less(t, elapsed, 3*dialTimeout,
578+
"dial took %v, expected < 3×dialTimeout (%v) — default 30 s may have been used instead", elapsed, 3*dialTimeout)
579+
}
580+
581+
func TestDialBackendRespectsContextCancellation(t *testing.T) {
582+
ln, err := net.Listen("tcp", "127.0.0.1:0")
583+
require.NoError(t, err)
584+
defer ln.Close()
585+
586+
done := make(chan struct{})
587+
t.Cleanup(func() { close(done) })
588+
589+
go func() {
590+
conn, err := ln.Accept()
591+
if err != nil {
592+
return
593+
}
594+
defer conn.Close()
595+
// stall TLS handshake, bounded by the test's own lifetime via done.
596+
select {
597+
case <-done:
598+
return
599+
case <-time.After(30 * time.Second):
600+
}
601+
}()
602+
603+
backendURL, _ := url.Parse("https://" + ln.Addr().String())
604+
p := &upgradeProxy{
605+
backendAddr: backendURL,
606+
/* #nosec G402 – test-only */
607+
tlsClientConfig: &tls.Config{InsecureSkipVerify: true},
608+
insecure: true,
609+
dialTimeout: 10 * time.Second, // long hard ceiling — context fires first
610+
}
611+
612+
const ctxTimeout = 150 * time.Millisecond
613+
ctx, cancel := stdlibcontext.WithTimeout(stdlibcontext.Background(), ctxTimeout)
614+
defer cancel()
615+
616+
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
617+
"https://"+ln.Addr().String()+"/ws", nil)
618+
require.NoError(t, err)
619+
req.URL = backendURL
620+
621+
start := time.Now()
622+
_, err = p.dialBackend(req)
623+
elapsed := time.Since(start)
624+
625+
require.Error(t, err, "dialBackend must fail when context is cancelled")
626+
assert.Less(t, elapsed, 3*ctxTimeout,
627+
"dial must abort close to context deadline (%v), not wait for dialTimeout (10 s); took %v",
628+
ctxTimeout, elapsed)
629+
}
630+
631+
func TestDialBackendTimeoutViaParams(t *testing.T) {
632+
ln, err := net.Listen("tcp", "127.0.0.1:0")
633+
require.NoError(t, err)
634+
defer ln.Close()
635+
636+
done := make(chan struct{})
637+
t.Cleanup(func() { close(done) })
638+
639+
go func() {
640+
conn, err := ln.Accept()
641+
if err != nil {
642+
return
643+
}
644+
defer conn.Close()
645+
select {
646+
case <-done:
647+
return
648+
case <-time.After(30 * time.Second):
649+
}
650+
}()
651+
652+
const customTimeout = 300 * time.Millisecond
653+
backendAddr := "https://" + ln.Addr().String()
654+
routes := fmt.Sprintf(`route: Path("/ws") -> "%s";`, backendAddr)
655+
656+
tp, err := newTestProxyWithParams(routes, Params{
657+
ExperimentalUpgrade: true,
658+
UpgradeDialTimeout: customTimeout,
659+
})
660+
require.NoError(t, err)
661+
defer tp.close()
662+
663+
skipper := httptest.NewServer(tp.proxy)
664+
defer skipper.Close()
665+
666+
skipperURL, _ := url.Parse(skipper.URL)
667+
clientConn, err := net.Dial("tcp", skipperURL.Host)
668+
require.NoError(t, err)
669+
defer clientConn.Close()
670+
671+
u, _ := url.ParseRequestURI("wss://www.example.org/ws")
672+
r := &http.Request{
673+
URL: u,
674+
Method: http.MethodGet,
675+
Header: http.Header{
676+
"Connection": []string{"Upgrade"},
677+
"Upgrade": []string{"websocket"},
678+
},
679+
}
680+
require.NoError(t, r.Write(clientConn))
681+
682+
start := time.Now()
683+
reader := bufio.NewReader(clientConn)
684+
resp, err := http.ReadResponse(reader, r)
685+
elapsed := time.Since(start)
686+
require.NoError(t, err)
687+
688+
assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode,
689+
"stalled backend must yield 503 ServiceUnavailable")
690+
assert.Less(t, elapsed, 3*customTimeout,
691+
"proxy must fail within 3×Params.Timeout (%v); took %v — wiring may be broken",
692+
customTimeout, elapsed)
693+
}

skipper.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -450,6 +450,11 @@ type Options struct {
450450
// proxy http connections to the backend.
451451
TimeoutBackend time.Duration
452452

453+
// UpgradeDialTimeout sets the explicit connect-time ceiling for
454+
// WebSocket/SPDY upgrade connections to the backend. Zero falls back
455+
// to the built-in default; negative disables the ceiling entirely.
456+
UpgradeDialTimeout time.Duration
457+
453458
// ResponseHeaderTimeout sets the HTTP response timeout for
454459
// proxy http connections to the backend.
455460
ResponseHeaderTimeoutBackend time.Duration
@@ -2513,6 +2518,7 @@ func run(o Options, sig chan os.Signal, idleConnsCH chan struct{}) error {
25132518
MaxLoopbacks: o.MaxLoopbacks,
25142519
DefaultHTTPStatus: o.DefaultHTTPStatus,
25152520
Timeout: o.TimeoutBackend,
2521+
UpgradeDialTimeout: o.UpgradeDialTimeout,
25162522
ResponseHeaderTimeout: o.ResponseHeaderTimeoutBackend,
25172523
ExpectContinueTimeout: o.ExpectContinueTimeoutBackend,
25182524
KeepAlive: o.KeepAliveBackend,

0 commit comments

Comments
 (0)