2728c909
feat(sync): bound the unauthenticated head of a QUIC session
a73x 2026-07-29 14:47
Commit message
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) |