Skip to content

Commit 9e886c5

Browse files
committed
fix: address critical security and code quality issues
This commit resolves multiple HIGH and MEDIUM severity issues found during security audit. All tests pass after changes. **HIGH Severity Fixes:** 1. Fix nil pointer panic with --print-openapi flag - Added nil check before process.Wait() to prevent crash - File: cmd/server/server.go 2. Fix goroutine leak in snapshot loop - Added context cancellation with select statement - Changed time.Sleep to ticker for proper cleanup - File: lib/httpapi/server.go 3. Fix race condition in terminal screen reading - Always hold lock when reading terminal state - Prevents concurrent read/write on p.xp.State - File: lib/termexec/termexec.go 4. Add defensive checks for unsafe reflection - Added defer/recover to catch reflection failures - Improved error logging with recovery attempts - Warns about unsafe xpty dependency - File: lib/termexec/termexec.go **MEDIUM Severity Fixes:** 5. Add message size validation - MaxMessageSize: 10MB for user messages - MaxRawMessageSize: 1KB for raw terminal input - Prevents DoS via oversized messages - File: lib/httpapi/server.go 6. Add HTTP server timeouts - ReadTimeout: 15s (prevent slow header attacks) - ReadHeaderTimeout: 5s (specifically for headers) - IdleTimeout: 60s (close idle connections) - WriteTimeout: 0 (disabled for SSE long-polling) - File: lib/httpapi/server.go 7. Replace panics with error handling - logctx.From() returns default logger instead of panic - conversation.statusInner() logs errors and returns safe fallback - convertStatus() logs unknown status instead of panic - Files: lib/logctx/logctx.go, lib/screentracker/conversation.go, lib/httpapi/events.go All existing tests pass (CGO_ENABLED=0 go test ./...).
1 parent e47a7ca commit 9e886c5

File tree

6 files changed

+98
-31
lines changed

6 files changed

+98
-31
lines changed

cmd/server/server.go

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -118,15 +118,18 @@ func runServer(ctx context.Context, logger *slog.Logger, argsToPass []string) er
118118
processExitCh := make(chan error, 1)
119119
go func() {
120120
defer close(processExitCh)
121-
if err := process.Wait(); err != nil {
122-
if errors.Is(err, termexec.ErrNonZeroExitCode) {
123-
processExitCh <- xerrors.Errorf("========\n%s\n========\n: %w", strings.TrimSpace(process.ReadScreen()), err)
124-
} else {
125-
processExitCh <- xerrors.Errorf("failed to wait for process: %w", err)
121+
// Only wait for process if it exists (not in --print-openapi mode)
122+
if process != nil {
123+
if err := process.Wait(); err != nil {
124+
if errors.Is(err, termexec.ErrNonZeroExitCode) {
125+
processExitCh <- xerrors.Errorf("========\n%s\n========\n: %w", strings.TrimSpace(process.ReadScreen()), err)
126+
} else {
127+
processExitCh <- xerrors.Errorf("failed to wait for process: %w", err)
128+
}
129+
}
130+
if err := srv.Stop(ctx); err != nil {
131+
logger.Error("Failed to stop server", "error", err)
126132
}
127-
}
128-
if err := srv.Stop(ctx); err != nil {
129-
logger.Error("Failed to stop server", "error", err)
130133
}
131134
}()
132135
if err := srv.Start(); err != nil && err != context.Canceled && err != http.ErrServerClosed {

lib/httpapi/events.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
package httpapi
22

33
import (
4-
"fmt"
4+
"log/slog"
55
"strings"
66
"sync"
77
"time"
@@ -75,7 +75,9 @@ func convertStatus(status st.ConversationStatus) AgentStatus {
7575
case st.ConversationStatusChanging:
7676
return AgentStatusRunning
7777
default:
78-
panic(fmt.Sprintf("unknown conversation status: %s", status))
78+
// Don't panic - log and return safe default
79+
slog.Error("Unknown conversation status", "status", status)
80+
return AgentStatusRunning
7981
}
8082
}
8183

lib/httpapi/server.go

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,14 @@ func (s *Server) GetOpenAPI() string {
6161
// because the action of taking a snapshot takes time too.
6262
const snapshotInterval = 25 * time.Millisecond
6363

64+
const (
65+
// MaxMessageSize is the maximum size for user messages (10MB)
66+
MaxMessageSize = 10 * 1024 * 1024
67+
// MaxRawMessageSize is the maximum size for raw terminal input (1KB)
68+
// Raw messages go directly to the terminal, so we're more conservative
69+
MaxRawMessageSize = 1024
70+
)
71+
6472
type ServerConfig struct {
6573
AgentType mf.AgentType
6674
Process *termexec.Process
@@ -264,11 +272,19 @@ func hostAuthorizationMiddleware(allowedHosts []string, badHostHandler http.Hand
264272
func (s *Server) StartSnapshotLoop(ctx context.Context) {
265273
s.conversation.StartSnapshotLoop(ctx)
266274
go func() {
275+
ticker := time.NewTicker(snapshotInterval)
276+
defer ticker.Stop()
277+
267278
for {
268-
s.emitter.UpdateStatusAndEmitChanges(s.conversation.Status())
269-
s.emitter.UpdateMessagesAndEmitChanges(s.conversation.Messages())
270-
s.emitter.UpdateScreenAndEmitChanges(s.conversation.Screen())
271-
time.Sleep(snapshotInterval)
279+
select {
280+
case <-ctx.Done():
281+
s.logger.Info("Snapshot loop exiting")
282+
return
283+
case <-ticker.C:
284+
s.emitter.UpdateStatusAndEmitChanges(s.conversation.Status())
285+
s.emitter.UpdateMessagesAndEmitChanges(s.conversation.Messages())
286+
s.emitter.UpdateScreenAndEmitChanges(s.conversation.Screen())
287+
}
272288
}
273289
}()
274290
}
@@ -354,6 +370,18 @@ func (s *Server) getMessages(ctx context.Context, input *struct{}) (*MessagesRes
354370

355371
// createMessage handles POST /message
356372
func (s *Server) createMessage(ctx context.Context, input *MessageRequest) (*MessageResponse, error) {
373+
// Validate message size based on type
374+
maxSize := MaxMessageSize
375+
if input.Body.Type == MessageTypeRaw {
376+
maxSize = MaxRawMessageSize
377+
}
378+
379+
if len(input.Body.Content) > maxSize {
380+
return nil, huma.Error400BadRequest(
381+
fmt.Sprintf("message too large (max %d bytes)", maxSize),
382+
)
383+
}
384+
357385
s.mu.Lock()
358386
defer s.mu.Unlock()
359387

@@ -447,8 +475,12 @@ func (s *Server) subscribeScreen(ctx context.Context, input *struct{}, send sse.
447475
func (s *Server) Start() error {
448476
addr := fmt.Sprintf(":%d", s.port)
449477
s.srv = &http.Server{
450-
Addr: addr,
451-
Handler: s.router,
478+
Addr: addr,
479+
Handler: s.router,
480+
ReadTimeout: 15 * time.Second, // Prevent slow header attacks
481+
WriteTimeout: 0, // Disabled for SSE long-polling
482+
IdleTimeout: 60 * time.Second, // Close idle connections
483+
ReadHeaderTimeout: 5 * time.Second, // Specifically for headers
452484
}
453485

454486
return s.srv.ListenAndServe()

lib/logctx/logctx.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,11 @@ func WithLogger(ctx context.Context, logger *slog.Logger) context.Context {
1616
return context.WithValue(ctx, loggerKey, logger)
1717
}
1818

19-
// From retrieves the logger from the context or panics if no logger is found
19+
// From retrieves the logger from the context or returns the default logger if none is found
2020
func From(ctx context.Context) *slog.Logger {
2121
if logger, ok := ctx.Value(loggerKey).(*slog.Logger); ok {
2222
return logger
2323
}
24-
panic("no logger found in context")
24+
// Return default logger instead of panicking
25+
return slog.Default()
2526
}

lib/screentracker/conversation.go

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ package screentracker
22

33
import (
44
"context"
5-
"fmt"
5+
"log/slog"
66
"strings"
77
"sync"
88
"time"
@@ -355,12 +355,16 @@ func (c *Conversation) SendMessage(messageParts ...MessagePart) error {
355355

356356
// Assumes that the caller holds the lock
357357
func (c *Conversation) statusInner() ConversationStatus {
358-
// sanity checks
358+
// sanity checks - log errors and return safe fallback instead of panicking
359359
if c.snapshotBuffer.Capacity() != c.stableSnapshotsThreshold {
360-
panic(fmt.Sprintf("snapshot buffer capacity %d is not equal to snapshot threshold %d. can't check stability", c.snapshotBuffer.Capacity(), c.stableSnapshotsThreshold))
360+
slog.Error("BUG: snapshot buffer capacity mismatch",
361+
"capacity", c.snapshotBuffer.Capacity(),
362+
"threshold", c.stableSnapshotsThreshold)
363+
return ConversationStatusChanging // Safe fallback
361364
}
362365
if c.stableSnapshotsThreshold == 0 {
363-
panic("stable snapshots threshold is 0. can't check stability")
366+
slog.Error("BUG: stable snapshots threshold is 0")
367+
return ConversationStatusChanging // Safe fallback
364368
}
365369

366370
snapshots := c.snapshotBuffer.GetAll()

lib/termexec/termexec.go

Lines changed: 34 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -74,16 +74,36 @@ func StartProcess(ctx context.Context, args StartProcessConfig) (*Process, error
7474
//
7575
// Warning: This depends on xpty internals and may break if xpty changes.
7676
// A proper fix would require forking xpty or getting upstream changes.
77+
// Add defensive programming to catch reflection failures
78+
defer func() {
79+
if r := recover(); r != nil {
80+
logger.Error("CRITICAL: unsafe reflection failed - xpty library may have changed",
81+
"panic", r,
82+
"field", "pp",
83+
"action", "Terminal will become unresponsive. Please update agentapi or pin xpty version.")
84+
// Attempt graceful shutdown
85+
if process.execCmd.Process != nil {
86+
_ = process.execCmd.Process.Signal(os.Interrupt)
87+
}
88+
}
89+
}()
90+
7791
pp := util.GetUnexportedField(xp, "pp").(*xpty.PassthroughPipe)
92+
logger.Warn("Using unsafe reflection to access xpty internals - this may break on xpty updates",
93+
"xpty_version", "0.6.0",
94+
"field", "pp")
95+
7896
for {
7997
r, _, err := pp.ReadRune()
8098
if err != nil {
8199
if err != io.EOF {
82100
logger.Error("Error reading from pseudo terminal", "error", err)
101+
// Attempt recovery: signal process termination
102+
if process.execCmd.Process != nil {
103+
logger.Info("Attempting to terminate unresponsive process")
104+
_ = process.execCmd.Process.Signal(os.Interrupt)
105+
}
83106
}
84-
// TODO: handle this error better. if this happens, the terminal
85-
// state will never be updated anymore and the process will appear
86-
// unresponsive.
87107
return
88108
}
89109
process.screenUpdateLock.Lock()
@@ -114,15 +134,20 @@ func (p *Process) Signal(sig os.Signal) error {
114134
func (p *Process) ReadScreen() string {
115135
for range 3 {
116136
p.screenUpdateLock.RLock()
117-
if time.Since(p.lastScreenUpdate) >= 16*time.Millisecond {
118-
state := p.xp.State.String()
119-
p.screenUpdateLock.RUnlock()
120-
return state
121-
}
137+
timeSinceUpdate := time.Since(p.lastScreenUpdate)
122138
p.screenUpdateLock.RUnlock()
139+
140+
if timeSinceUpdate >= 16*time.Millisecond {
141+
break
142+
}
123143
time.Sleep(16 * time.Millisecond)
124144
}
125-
return p.xp.State.String()
145+
146+
// Always read with lock held to prevent race condition
147+
p.screenUpdateLock.RLock()
148+
state := p.xp.State.String()
149+
p.screenUpdateLock.RUnlock()
150+
return state
126151
}
127152

128153
// Write sends input to the process via the pseudo terminal.

0 commit comments

Comments
 (0)