diff --git a/supervisor/tunnel.go b/supervisor/tunnel.go index 01cca579321..723f8bbebf4 100644 --- a/supervisor/tunnel.go +++ b/supervisor/tunnel.go @@ -380,6 +380,16 @@ func isQuicBroken(cause error) bool { return true } + // A CRYPTO_ERROR raised by the peer means the TLS handshake was rejected, + // for example with no_application_protocol (0x178) when something on the + // path intercepts QUIC without supporting the protocols cloudflared offers. + // Retrying QUIC cannot recover from that, so treat it like a broken + // transport and let the connection fall back. + if transportErr, ok := errors.AsType[*quic.TransportError](cause); ok && + transportErr.Remote && transportErr.ErrorCode.IsCryptoError() { + return true + } + return false } diff --git a/supervisor/tunnel_test.go b/supervisor/tunnel_test.go index 66229c543a7..a37f1ba14ed 100644 --- a/supervisor/tunnel_test.go +++ b/supervisor/tunnel_test.go @@ -99,6 +99,36 @@ func TestWaitForBackoffFallback(t *testing.T) { assert.False(t, ok) } +// A QUIC handshake the peer rejects with a CRYPTO_ERROR (e.g. TLS alert 120, +// no_application_protocol, from a middlebox intercepting QUIC) cannot be +// recovered by retrying QUIC, so it must switch to the fallback protocol +// straight away instead of retrying until the backoff is exhausted. +func TestFallbackOnRemoteCryptoError(t *testing.T) { + maxRetries := uint(3) + backoff := retry.NewBackoff(maxRetries, 40*time.Millisecond, false) + backoff.Clock.After = immediateTimeAfter + log := zerolog.Nop() + protocolSelector, err := connection.NewProtocolSelector("auto", &log) + require.NoError(t, err) + + protoFallback := &protocolFallback{backoff, protocolSelector.Current(), false} + assert.Equal(t, connection.QUIC, protoFallback.protocol) + + // 0x178 is CRYPTO_ERROR for TLS alert 120 (no_application_protocol). + cause := &connection.EdgeQuicDialError{ + Cause: &quic.TransportError{ + Remote: true, + ErrorCode: quic.TransportErrorCode(0x178), + ErrorMessage: "tls: no application protocol", + }, + } + + protoFallback.BackoffTimer() // simulate retry + ok := selectNextProtocol(&log, protoFallback, protocolSelector, cause) + assert.True(t, ok) + assert.Equal(t, connection.HTTP2, protoFallback.protocol) +} + func TestIsRetryableStartupError(t *testing.T) { t.Parallel()