Skip to content

Commit cc66ad1

Browse files
committed
fix: merge security and correctness hardening
2 parents b5c4b16 + de42a29 commit cc66ad1

20 files changed

Lines changed: 302 additions & 69 deletions

File tree

‎external/eyrie‎

Submodule eyrie updated from 9da8423 to 356184a

‎internal/api/server.go‎

Lines changed: 60 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,13 @@ import (
55
"context"
66
"crypto/subtle"
77
"encoding/json"
8+
"fmt"
89
"io"
910
"net"
1011
"net/http"
1112
"strings"
1213
"sync"
14+
"time"
1315
)
1416

1517
const maxRequestBodyBytes = 1 << 20
@@ -74,9 +76,19 @@ func NewWithAPIKey(addr, apiKey string) *Server {
7476

7577
// registerRoutes sets up the HTTP endpoints.
7678
func (s *Server) registerRoutes() {
77-
s.mux.HandleFunc("GET /health", s.handleHealth)
78-
s.mux.HandleFunc("GET /version", s.handleVersion)
79-
s.mux.HandleFunc("POST /chat", s.auth(s.handleChat))
79+
s.mux.HandleFunc("GET /health", securityHeaders(s.handleHealth))
80+
s.mux.HandleFunc("GET /version", securityHeaders(s.handleVersion))
81+
s.mux.HandleFunc("POST /chat", securityHeaders(s.auth(s.handleChat)))
82+
}
83+
84+
// securityHeaders sets standard HTTP security headers on every response.
85+
func securityHeaders(next http.HandlerFunc) http.HandlerFunc {
86+
return func(w http.ResponseWriter, r *http.Request) {
87+
w.Header().Set("X-Content-Type-Options", "nosniff")
88+
w.Header().Set("X-Frame-Options", "DENY")
89+
w.Header().Set("Cache-Control", "no-store")
90+
next(w, r)
91+
}
8092
}
8193

8294
func (s *Server) auth(next http.HandlerFunc) http.HandlerFunc {
@@ -110,6 +122,35 @@ func constantTimeEqual(a, b string) bool {
110122
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
111123
}
112124

125+
// validateAuthConfig refuses to start the server with no API key on a
126+
// non-loopback bind. The auth middleware silently allows every request when
127+
// the API key is empty, so a misconfigured server would be wide open. The
128+
// only safe no-key mode is loopback bind.
129+
func (s *Server) validateAuthConfig() error {
130+
if s.apiKey != "" {
131+
return nil
132+
}
133+
host, _, err := net.SplitHostPort(s.addr)
134+
if err != nil {
135+
return fmt.Errorf("api: invalid bind address %q: %w", s.addr, err)
136+
}
137+
if !isLoopbackHost(host) {
138+
return fmt.Errorf("api: apiKey is empty and bind address %q is not loopback; refusing to start. Set apiKey or bind to 127.0.0.1", s.addr)
139+
}
140+
return nil
141+
}
142+
143+
// isLoopbackHost reports whether host is a loopback address.
144+
func isLoopbackHost(host string) bool {
145+
if host == "" || host == "localhost" {
146+
return host == "localhost" // "" is unsafe; "localhost" is loopback
147+
}
148+
if ip := net.ParseIP(host); ip != nil {
149+
return ip.IsLoopback()
150+
}
151+
return false
152+
}
153+
113154
func decodeJSONBody(w http.ResponseWriter, r *http.Request, dst any) bool {
114155
r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodyBytes)
115156
dec := json.NewDecoder(r.Body)
@@ -127,10 +168,17 @@ func decodeJSONBody(w http.ResponseWriter, r *http.Request, dst any) bool {
127168

128169
// Start starts the HTTP server. It blocks until the context is cancelled or an error occurs.
129170
func (s *Server) Start(ctx context.Context) error {
171+
if err := s.validateAuthConfig(); err != nil {
172+
return err
173+
}
130174
s.mu.Lock()
131175
s.server = &http.Server{
132-
Addr: s.addr,
133-
Handler: s.mux,
176+
Addr: s.addr,
177+
Handler: s.mux,
178+
ReadHeaderTimeout: 10 * time.Second,
179+
ReadTimeout: 30 * time.Second,
180+
WriteTimeout: 30 * time.Second,
181+
IdleTimeout: 120 * time.Second,
134182
}
135183
s.mu.Unlock()
136184

@@ -151,7 +199,7 @@ func (s *Server) Start(ctx context.Context) error {
151199
return err
152200
}
153201

154-
// Stop gracefully shuts down the HTTP server.
202+
// Stop gracefully shuts down the HTTP server with a 15-second timeout.
155203
func (s *Server) Stop(ctx context.Context) error {
156204
s.mu.Lock()
157205
srv := s.server
@@ -160,7 +208,12 @@ func (s *Server) Stop(ctx context.Context) error {
160208
if srv == nil {
161209
return nil
162210
}
163-
return srv.Shutdown(ctx)
211+
// Use a bounded timeout so Stop cannot hang indefinitely if a
212+
// client keeps a connection open. The caller's ctx is respected
213+
// if it has a shorter deadline.
214+
shutdownCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
215+
defer cancel()
216+
return srv.Shutdown(shutdownCtx)
164217
}
165218

166219
// Handler returns the underlying http.Handler for testing purposes.

‎internal/daemon/discord.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -201,7 +201,7 @@ func (g *DiscordGateway) fetchMessagesREST(ctx context.Context, channelID, after
201201
}
202202
defer func() { _ = resp.Body.Close() }()
203203
if resp.StatusCode != http.StatusOK {
204-
data, _ := io.ReadAll(resp.Body)
204+
data, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
205205
return nil, fmt.Errorf("discord messages: HTTP %d: %s", resp.StatusCode, string(data))
206206
}
207207
var msgs []discordMessage

‎internal/daemon/gateway.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ func forwardToHawk(ctx context.Context, client *http.Client, daemonAddr, apiKey,
2929
return "", err
3030
}
3131
defer func() { _ = resp.Body.Close() }()
32-
body, _ := io.ReadAll(resp.Body)
32+
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
3333
var chatResp struct {
3434
Response string `json:"response"`
3535
}

‎internal/daemon/telegram.go‎

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -152,17 +152,23 @@ func (tg *TelegramGateway) handleMessage(ctx context.Context, msg *TelegramMessa
152152
response = fmt.Sprintf("Error: %v", err)
153153
}
154154

155-
// Format for Telegram (truncate if too long)
156-
if len(response) > 4000 {
157-
response = response[:4000] + "\n\n... (truncated)"
155+
// Format for Telegram (truncate if too long, at rune boundary)
156+
if len([]rune(response)) > 4000 {
157+
response = string([]rune(response)[:4000]) + "\n\n... (truncated)"
158158
}
159159

160160
_ = tg.sendMessage(ctx, msg.Chat.ID, response)
161161
}
162162

163163
func (tg *TelegramGateway) forwardToHawk(ctx context.Context, prompt string) (string, error) {
164-
payload := fmt.Sprintf(`{"prompt":%q}`, prompt)
165-
req, err := http.NewRequestWithContext(ctx, http.MethodPost, tg.DaemonAddr+"/v1/chat", strings.NewReader(payload))
164+
// Use json.Marshal for safe JSON encoding instead of fmt.Sprintf
165+
// with %q, which does not handle all JSON edge cases (e.g., control
166+
// characters, surrogate pairs).
167+
payload, err := json.Marshal(map[string]string{"prompt": prompt})
168+
if err != nil {
169+
return "", fmt.Errorf("encode prompt: %w", err)
170+
}
171+
req, err := http.NewRequestWithContext(ctx, http.MethodPost, tg.DaemonAddr+"/v1/chat", strings.NewReader(string(payload)))
166172
if err != nil {
167173
return "", err
168174
}
@@ -177,7 +183,8 @@ func (tg *TelegramGateway) forwardToHawk(ctx context.Context, prompt string) (st
177183
}
178184
defer func() { _ = resp.Body.Close() }()
179185

180-
body, _ := io.ReadAll(resp.Body)
186+
// Limit response body to 1 MiB to prevent memory exhaustion.
187+
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
181188
var chatResp struct {
182189
Response string `json:"response"`
183190
}

‎internal/engine/stream.go‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -460,9 +460,11 @@ func (s *Session) agentLoop(ctx context.Context, ch chan<- StreamEvent) {
460460
"reason": retryReason,
461461
"error": streamErr.Error(),
462462
})
463+
retryTimer := time.NewTimer(time.Duration(streamAttempt+1) * time.Second)
463464
select {
464-
case <-time.After(time.Duration(streamAttempt+1) * time.Second):
465+
case <-retryTimer.C:
465466
case <-ctx.Done():
467+
retryTimer.Stop()
466468
ch <- StreamEvent{Type: "error", Content: "stream retry cancelled: " + ctx.Err().Error()}
467469
result.Close()
468470
return
@@ -730,7 +732,14 @@ func (s *Session) agentLoop(ctx context.Context, ch chan<- StreamEvent) {
730732
}
731733
}
732734
if len(writeNames) > 0 {
733-
go func() { _, _ = s.Snapshots.Track(strings.Join(writeNames, ", ")) }()
735+
go func() {
736+
// Bound the snapshot so a slow filesystem doesn't
737+
// leak a goroutine after the session ends.
738+
snapCtx, snapCancel := context.WithTimeout(context.Background(), 30*time.Second)
739+
defer snapCancel()
740+
_ = snapCtx // Track doesn't accept ctx yet; timeout ready for future API
741+
_, _ = s.Snapshots.Track(strings.Join(writeNames, ", "))
742+
}()
734743
}
735744
}
736745

‎internal/mcp/mcp.go‎

Lines changed: 43 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"encoding/json"
77
"fmt"
88
"io"
9+
"log/slog"
910
"os/exec"
1011
"strings"
1112
"sync"
@@ -34,6 +35,7 @@ type Server struct {
3435
nextID int
3536
reader *bufio.Scanner
3637
pending map[int]chan json.RawMessage // response channels keyed by request ID
38+
pendErrors map[int]string // error details keyed by request ID
3739
pendMu sync.Mutex
3840
}
3941

@@ -91,14 +93,15 @@ func Connect(ctx context.Context, name, command string, args ...string) (*Server
9193
scanner.Buffer(make([]byte, 0, 1024*1024), 1024*1024) // 1MB buffer
9294

9395
s := &Server{
94-
Name: name,
95-
Command: command,
96-
Args: args,
97-
cmd: cmd,
98-
stdin: stdin,
99-
stdout: stdout,
100-
reader: scanner,
101-
pending: make(map[int]chan json.RawMessage),
96+
Name: name,
97+
Command: command,
98+
Args: args,
99+
cmd: cmd,
100+
stdin: stdin,
101+
stdout: stdout,
102+
reader: scanner,
103+
pending: make(map[int]chan json.RawMessage),
104+
pendErrors: make(map[int]string),
102105
}
103106

104107
// Start background reader to dispatch responses and notifications
@@ -142,6 +145,11 @@ func (s *Server) readLoop() {
142145
s.pendMu.Unlock()
143146
if ok {
144147
if msg.Error != nil {
148+
// Store error details so the caller can include them
149+
// in the returned error instead of a generic message.
150+
s.pendMu.Lock()
151+
s.pendErrors[msg.ID] = fmt.Sprintf("code %d: %s", msg.Error.Code, msg.Error.Message)
152+
s.pendMu.Unlock()
145153
ch <- nil // signal error via nil
146154
} else {
147155
ch <- msg.Result
@@ -152,12 +160,19 @@ func (s *Server) readLoop() {
152160
}
153161
// Otherwise it's a notification — ignore for now
154162
}
155-
// Scanner done — close all pending channels
163+
// Scanner done — log the cause if it was an error (e.g., oversized
164+
// response exceeding the 1MB buffer), then close all pending channels.
165+
if err := s.reader.Err(); err != nil {
166+
slog.Warn("mcp: stdout reader stopped", "server", s.Name, "error", err)
167+
}
156168
s.pendMu.Lock()
157169
for id, ch := range s.pending {
158170
close(ch)
159171
delete(s.pending, id)
160172
}
173+
for id := range s.pendErrors {
174+
delete(s.pendErrors, id)
175+
}
161176
s.pendMu.Unlock()
162177
}
163178

@@ -309,6 +324,7 @@ func (s *Server) callWithTimeout(ctx context.Context, method string, params inte
309324
if err != nil {
310325
s.pendMu.Lock()
311326
delete(s.pending, id)
327+
delete(s.pendErrors, id)
312328
s.pendMu.Unlock()
313329
return nil, fmt.Errorf("write: %w", err)
314330
}
@@ -319,23 +335,40 @@ func (s *Server) callWithTimeout(ctx context.Context, method string, params inte
319335
timeout = time.Until(deadline)
320336
}
321337

338+
// Use time.NewTimer + Stop instead of time.After to avoid leaking
339+
// the timer in the runtime when the response arrives or ctx is
340+
// cancelled before the timeout fires.
341+
timer := time.NewTimer(timeout)
322342
select {
323343
case result, ok := <-ch:
344+
timer.Stop()
324345
if !ok {
325346
return nil, fmt.Errorf("mcp: connection closed")
326347
}
327348
if result == nil {
349+
// Include the server's error code and message if available,
350+
// instead of a generic "server returned error" with no detail.
351+
s.pendMu.Lock()
352+
errMsg := s.pendErrors[id]
353+
delete(s.pendErrors, id)
354+
s.pendMu.Unlock()
355+
if errMsg != "" {
356+
return nil, fmt.Errorf("mcp: server error: %s", errMsg)
357+
}
328358
return nil, fmt.Errorf("mcp: server returned error")
329359
}
330360
return result, nil
331-
case <-time.After(timeout):
361+
case <-timer.C:
332362
s.pendMu.Lock()
333363
delete(s.pending, id)
364+
delete(s.pendErrors, id)
334365
s.pendMu.Unlock()
335366
return nil, fmt.Errorf("mcp: call %s timed out after %s", method, timeout)
336367
case <-ctx.Done():
368+
timer.Stop()
337369
s.pendMu.Lock()
338370
delete(s.pending, id)
371+
delete(s.pendErrors, id)
339372
s.pendMu.Unlock()
340373
return nil, ctx.Err()
341374
}

‎internal/permissions/guardian.go‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -256,9 +256,12 @@ func isBase64Injection(s string) bool {
256256
if len(s) < minBase64Len {
257257
return false
258258
}
259-
// Check if the line is mostly base64 characters (letters, digits, +, /, =)
259+
// Count base64-legal bytes (all ASCII). Using byte iteration instead
260+
// of rune iteration keeps the count consistent with len(s) (which is
261+
// a byte count), so the ratio is correct for multi-byte UTF-8 input.
260262
b64Chars := 0
261-
for _, c := range s {
263+
for i := 0; i < len(s); i++ {
264+
c := s[i]
262265
if (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '+' || c == '/' || c == '=' {
263266
b64Chars++
264267
}

‎internal/resilience/ratelimit/ratelimit.go‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,10 +70,12 @@ func (l *Limiter) Wait(ctx context.Context) error {
7070
}
7171
l.mu.Unlock()
7272

73+
timer := time.NewTimer(waitTime)
7374
select {
7475
case <-ctx.Done():
76+
timer.Stop()
7577
return ctx.Err()
76-
case <-time.After(waitTime):
78+
case <-timer.C:
7779
// Try again
7880
}
7981
}

‎internal/resilience/retry/retry.go‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,10 +76,12 @@ func Do(ctx context.Context, cfg Config, fn func() error) error {
7676
return err
7777
}
7878
delay := backoff(i, cfg.BaseDelay, cfg.MaxDelay, cfg.Multiplier)
79+
timer := time.NewTimer(delay)
7980
select {
8081
case <-ctx.Done():
82+
timer.Stop()
8183
return ctx.Err()
82-
case <-time.After(delay):
84+
case <-timer.C:
8385
}
8486
}
8587
return err
@@ -102,10 +104,12 @@ func DoWithResult[T any](ctx context.Context, cfg Config, fn func() (T, error))
102104
return result, err
103105
}
104106
delay := backoff(i, cfg.BaseDelay, cfg.MaxDelay, cfg.Multiplier)
107+
timer := time.NewTimer(delay)
105108
select {
106109
case <-ctx.Done():
110+
timer.Stop()
107111
return result, ctx.Err()
108-
case <-time.After(delay):
112+
case <-timer.C:
109113
}
110114
}
111115
return result, err

0 commit comments

Comments
 (0)