a73x

2728c909

feat(sync): bound the unauthenticated head of a QUIC session

a73x   2026-07-29 14:47

Commit message
feat(sync): bound the unauthenticated head of a QUIC session

Stream accept and the first (Hello) read run under a 30s hello grace,
mirroring the SSH gate's handshake grace: the sync port is raw internet
surface, and a peer that handshakes then stalls would otherwise park a
goroutine for as long as it keepalives. The deadline clears once the
Hello frame is read, so long-lived sessions are untouched.

internal/server/syncsvc/syncsvc.go
Old New
@@ -108,18 +108,34 @@ func (s *Service) credStale(claims hosttoken.Claims, hostRow store.Host) (stale,
108 return stale, expired 108 return stale, expired
109 } 109 }
110 110
111 // helloGrace bounds the unauthenticated head of a sync session, mirroring the
112 // SSH gate's handshakeGrace: a peer that completes the QUIC handshake but never
113 // sends Hello would otherwise park a goroutine for as long as it keeps the
114 // connection alive. A var (not const) so tests can shrink it.
115 var helloGrace = 30 * time.Second
116
111 // NOTE (verified Task 1): quic-go v0.48.2 uses INTERFACES quic.Connection and 117 // NOTE (verified Task 1): quic-go v0.48.2 uses INTERFACES quic.Connection and
112 // quic.Stream (not *quic.Conn/*quic.Stream, which only exist in v0.49+). 118 // quic.Stream (not *quic.Conn/*quic.Stream, which only exist in v0.49+).
113 func (s *Service) handleConn(ctx context.Context, conn quic.Connection) { 119 func (s *Service) handleConn(ctx context.Context, conn quic.Connection) {
114 // Up-stream: agent opens it and sends Hello first. 120 // Up-stream: agent opens it and sends Hello first. Both the accept and the
115 up, err := conn.AcceptStream(ctx) 121 // first read run under the hello grace so an unauthenticated peer that
122 // handshakes then stalls cannot park this goroutine indefinitely.
123 acceptCtx, cancel := context.WithTimeout(ctx, helloGrace)
124 up, err := conn.AcceptStream(acceptCtx)
125 cancel()
116 if err != nil { 126 if err != nil {
127 _ = conn.CloseWithError(transport.CodeAuthRejected, "no stream before hello grace")
117 return 128 return
118 } 129 }
130 _ = up.SetReadDeadline(time.Now().Add(helloGrace))
119 var first pb.AgentMessage 131 var first pb.AgentMessage
120 if err := transport.ReadMsg(up, &first, transport.DefaultMaxFrame); err != nil { 132 if err := transport.ReadMsg(up, &first, transport.DefaultMaxFrame); err != nil {
133 _ = conn.CloseWithError(transport.CodeAuthRejected, "no hello before grace")
121 return 134 return
122 } 135 }
136 // Hello received — clear the deadline so it never applies to the long-lived
137 // report stream that follows.
138 _ = up.SetReadDeadline(time.Time{})
123 h := first.GetHello() 139 h := first.GetHello()
124 if h == nil { 140 if h == nil {
125 _ = conn.CloseWithError(transport.CodeAuthRejected, "first frame must be Hello") 141 _ = conn.CloseWithError(transport.CodeAuthRejected, "first frame must be Hello")
internal/server/syncsvc/syncsvc_test.go
Old New
@@ -420,6 +420,38 @@ func TestStalledReaderDoesNotPinServer(t *testing.T) {
420 } 420 }
421 } 421 }
422 422
423 // TestStalledPreAuthConnDropped pins the pre-auth DoS guard: a peer that
424 // completes the QUIC handshake and opens the stream but never sends a full
425 // Hello must be dropped by the hello grace, not parked forever holding a
426 // goroutine. Writing a single byte surfaces the stream to the server's
427 // AcceptStream yet can never form a Hello frame (the 4-byte length prefix alone
428 // is incomplete), so the guard must fire on the Hello read deadline.
429 func TestStalledPreAuthConnDropped(t *testing.T) {
430 orig := helloGrace
431 helloGrace = 150 * time.Millisecond
432 t.Cleanup(func() { helloGrace = orig })
433
434 f := setup(t)
435 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
436 defer cancel()
437 conn, err := quic.DialAddr(ctx, f.addr, transport.ClientTLS(f.fp),
438 &quic.Config{MaxIdleTimeout: 5 * time.Second})
439 require.NoError(t, err)
440 stream, err := conn.OpenStreamSync(ctx)
441 require.NoError(t, err)
442 _, err = stream.Write([]byte{0})
443 require.NoError(t, err)
444
445 start := time.Now()
446 _ = stream.SetReadDeadline(time.Now().Add(5 * time.Second))
447 buf := make([]byte, 1)
448 _, err = stream.Read(buf)
449 require.Error(t, err)
450 var appErr *quic.ApplicationError
451 require.True(t, errors.As(err, &appErr), "server must close the conn, got %T: %v", err, err)
452 assert.Less(t, time.Since(start), 3*time.Second, "stalled conn must drop near the hello grace")
453 }
454
423 func TestWrongCertPinRejected(t *testing.T) { 455 func TestWrongCertPinRejected(t *testing.T) {
424 f := setup(t) 456 f := setup(t)
425 wrongFP := strings.Repeat("00", 32) 457 wrongFP := strings.Repeat("00", 32)