Skip to content

Commit 811901d

Browse files
lazypowerclaude
andcommitted
Security hardening: HTTP layer, file permissions, XSS, error sanitization
Full codebase security audit with fixes across 12 files: - Add Host header validation middleware (blocks DNS rebinding) - Add security headers (X-Content-Type-Options, X-Frame-Options, Referrer-Policy) - Add request body size limits (1MB via MaxBytesReader middleware) - Add HTTP server timeouts (Read: 10s, Write: 30s, Idle: 120s) - Tighten file permissions to 0700/0600 for dirs/files in ~/.continuity/ - Auto-harden permissions on existing installs at startup - Fix XSS in ProfilePanel: replace {@html} with safe Svelte text interpolation - Fix {@html} for static icons in Header component - Sanitize all error responses: generic messages to clients, full errors to server logs - Add jsonError() helper to prevent JSON injection via string concatenation - Cap search limit parameter at 100 - Add io.LimitReader (10MB) on hook stdin parsing - Apply 10KB truncation to tool_input (matching tool_response) - Log truncation events with session ID and byte counts - Remove db_path from /api/health response - Remove middleware.RealIP (unnecessary, trusts spoofable headers) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 623f6a4 commit 811901d

13 files changed

Lines changed: 203 additions & 78 deletions

File tree

internal/cli/init.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,10 +93,10 @@ func runInit(cmd *cobra.Command, args []string) error {
9393
autostartPath := filepath.Join(homeDir, ".continuity", "autostart")
9494

9595
if initAutostart {
96-
if err := os.MkdirAll(filepath.Dir(autostartPath), 0755); err != nil {
96+
if err := os.MkdirAll(filepath.Dir(autostartPath), 0700); err != nil {
9797
return fmt.Errorf("create .continuity dir: %w", err)
9898
}
99-
if err := os.WriteFile(autostartPath, []byte("enabled\n"), 0644); err != nil {
99+
if err := os.WriteFile(autostartPath, []byte("enabled\n"), 0600); err != nil {
100100
return fmt.Errorf("write autostart marker: %w", err)
101101
}
102102
fmt.Println("Autostart enabled: continuity serve will launch automatically when needed.")

internal/cli/serve.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -107,8 +107,12 @@ func runServe(cmd *cobra.Command, args []string) error {
107107
addr := cfg.ListenAddr()
108108

109109
httpServer := &http.Server{
110-
Addr: addr,
111-
Handler: srv,
110+
Addr: addr,
111+
Handler: srv,
112+
ReadTimeout: 10 * time.Second,
113+
WriteTimeout: 30 * time.Second,
114+
IdleTimeout: 120 * time.Second,
115+
MaxHeaderBytes: 1 << 20, // 1MB
112116
}
113117

114118
// Graceful shutdown

internal/hooks/autostart.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,11 +56,15 @@ func TryAutostart() bool {
5656
return false
5757
}
5858
logPath := filepath.Join(home, ".continuity", "serve.log")
59-
logFile, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
59+
logFile, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
6060
if err != nil {
6161
fmt.Fprintf(os.Stderr, "continuity: autostart: open log: %v\n", err)
6262
return false
6363
}
64+
// Tighten existing log files from previous installs (0644 → 0600)
65+
if info, err := logFile.Stat(); err == nil && info.Mode().Perm()&0077 != 0 {
66+
os.Chmod(logPath, 0600)
67+
}
6468

6569
devNull, err := os.Open(os.DevNull)
6670
if err != nil {

internal/hooks/handler.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,13 @@ import (
66
"io"
77
)
88

9+
const maxHookInputSize = 10 << 20 // 10MB
10+
911
// Handle reads HookInput from the given reader, dispatches to the appropriate
1012
// handler based on the event argument, and writes output to stdout.
1113
func Handle(event string, stdin io.Reader) {
1214
var input HookInput
13-
if err := json.NewDecoder(stdin).Decode(&input); err != nil {
15+
if err := json.NewDecoder(io.LimitReader(stdin, maxHookInputSize)).Decode(&input); err != nil {
1416
// Stdin may be empty for some events — degrade gracefully
1517
if event == "start" {
1618
WriteSessionStartOutput("")

internal/server/middleware.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
package server
2+
3+
import (
4+
"net"
5+
"net/http"
6+
)
7+
8+
const maxRequestBody = 1 << 20 // 1MB
9+
10+
// localhostOnly rejects requests where the Host header is not localhost.
11+
// Prevents DNS rebinding attacks against the local API server.
12+
func localhostOnly(next http.Handler) http.Handler {
13+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
14+
host := r.Host
15+
if h, _, err := net.SplitHostPort(host); err == nil {
16+
host = h
17+
}
18+
if host != "localhost" && host != "127.0.0.1" && host != "::1" {
19+
http.Error(w, "Forbidden", http.StatusForbidden)
20+
return
21+
}
22+
next.ServeHTTP(w, r)
23+
})
24+
}
25+
26+
// securityHeaders adds standard security headers to all responses.
27+
func securityHeaders(next http.Handler) http.Handler {
28+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
29+
w.Header().Set("X-Content-Type-Options", "nosniff")
30+
w.Header().Set("X-Frame-Options", "DENY")
31+
w.Header().Set("Referrer-Policy", "no-referrer")
32+
next.ServeHTTP(w, r)
33+
})
34+
}
35+
36+
// limitRequestBody caps the size of incoming request bodies to prevent OOM.
37+
func limitRequestBody(next http.Handler) http.Handler {
38+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
39+
r.Body = http.MaxBytesReader(w, r.Body, maxRequestBody)
40+
next.ServeHTTP(w, r)
41+
})
42+
}

internal/server/routes.go

Lines changed: 36 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ package server
33
import (
44
"context"
55
"encoding/json"
6-
"fmt"
76
"io"
87
"log"
98
"net/http"
@@ -14,6 +13,14 @@ import (
1413
"github.com/lazypower/continuity/internal/engine"
1514
)
1615

16+
// jsonError writes a JSON error response. All error responses should use this
17+
// to avoid JSON injection via string concatenation.
18+
func jsonError(w http.ResponseWriter, msg string, code int) {
19+
w.Header().Set("Content-Type", "application/json")
20+
w.WriteHeader(code)
21+
json.NewEncoder(w).Encode(map[string]string{"error": msg})
22+
}
23+
1724
func (s *Server) handleSessionInit(w http.ResponseWriter, r *http.Request) {
1825
var req struct {
1926
SessionID string `json:"session_id"`
@@ -30,7 +37,8 @@ func (s *Server) handleSessionInit(w http.ResponseWriter, r *http.Request) {
3037

3138
sess, err := s.db.InitSession(req.SessionID, req.Project)
3239
if err != nil {
33-
http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusInternalServerError)
40+
log.Printf("init session: %v", err)
41+
jsonError(w, "internal error", http.StatusInternalServerError)
3442
return
3543
}
3644

@@ -61,7 +69,8 @@ func (s *Server) handleAddObservation(w http.ResponseWriter, r *http.Request) {
6169
}
6270

6371
if err := s.db.AddObservation(sessionID, req.ToolName, req.ToolInput, req.ToolResponse); err != nil {
64-
http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusInternalServerError)
72+
log.Printf("add observation: %v", err)
73+
jsonError(w, "internal error", http.StatusInternalServerError)
6574
return
6675
}
6776

@@ -79,8 +88,9 @@ func (s *Server) handleCompleteSession(w http.ResponseWriter, r *http.Request) {
7988
if err := s.db.CompleteSession(sessionID); err != nil {
8089
// Not finding an active session is not a server error — the session
8190
// may have already been completed or never existed. Log but return OK.
91+
log.Printf("complete session: %v", err)
8292
w.Header().Set("Content-Type", "application/json")
83-
json.NewEncoder(w).Encode(map[string]string{"status": "ok", "note": err.Error()})
93+
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
8494
return
8595
}
8696

@@ -92,7 +102,8 @@ func (s *Server) handleEndSession(w http.ResponseWriter, r *http.Request) {
92102
sessionID := chi.URLParam(r, "sessionID")
93103

94104
if err := s.db.EndSession(sessionID); err != nil {
95-
http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusInternalServerError)
105+
log.Printf("end session: %v", err)
106+
jsonError(w, "internal error", http.StatusInternalServerError)
96107
return
97108
}
98109

@@ -180,9 +191,8 @@ func (s *Server) handleSignal(w http.ResponseWriter, r *http.Request) {
180191
func (s *Server) handleUnmarkEmptyExtractions(w http.ResponseWriter, r *http.Request) {
181192
n, err := s.db.UnmarkEmptyExtractions()
182193
if err != nil {
183-
w.Header().Set("Content-Type", "application/json")
184-
w.WriteHeader(http.StatusInternalServerError)
185-
json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
194+
log.Printf("unmark empty extractions: %v", err)
195+
jsonError(w, "internal error", http.StatusInternalServerError)
186196
return
187197
}
188198

@@ -204,15 +214,12 @@ func (s *Server) handleGetMemory(w http.ResponseWriter, r *http.Request) {
204214

205215
node, err := s.db.GetNodeByURI(uri)
206216
if err != nil {
207-
w.Header().Set("Content-Type", "application/json")
208-
w.WriteHeader(http.StatusInternalServerError)
209-
json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
217+
log.Printf("get memory: %v", err)
218+
jsonError(w, "internal error", http.StatusInternalServerError)
210219
return
211220
}
212221
if node == nil {
213-
w.Header().Set("Content-Type", "application/json")
214-
w.WriteHeader(http.StatusNotFound)
215-
json.NewEncoder(w).Encode(map[string]string{"error": "memory not found: " + uri})
222+
jsonError(w, "memory not found", http.StatusNotFound)
216223
return
217224
}
218225

@@ -268,9 +275,8 @@ func (s *Server) handleRemember(w http.ResponseWriter, r *http.Request) {
268275
SessionID: req.SessionID,
269276
})
270277
if err != nil {
271-
w.Header().Set("Content-Type", "application/json")
272-
w.WriteHeader(http.StatusBadRequest)
273-
json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
278+
log.Printf("remember: %v", err)
279+
jsonError(w, "failed to store memory", http.StatusBadRequest)
274280
return
275281
}
276282

@@ -304,6 +310,9 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
304310
limit = n
305311
}
306312
}
313+
if limit > 100 {
314+
limit = 100
315+
}
307316

308317
category := r.URL.Query().Get("category")
309318

@@ -333,7 +342,8 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
333342
}
334343

335344
if err != nil {
336-
http.Error(w, fmt.Sprintf(`{"error":"%s"}`, err.Error()), http.StatusInternalServerError)
345+
log.Printf("search: %v", err)
346+
jsonError(w, "internal error", http.StatusInternalServerError)
337347
return
338348
}
339349

@@ -384,7 +394,8 @@ func (s *Server) handleTimeline(w http.ResponseWriter, r *http.Request) {
384394

385395
sessions, err := s.db.GetSessionsSince(sinceMs)
386396
if err != nil {
387-
http.Error(w, fmt.Sprintf(`{"error":"%s"}`, err.Error()), http.StatusInternalServerError)
397+
log.Printf("timeline: %v", err)
398+
jsonError(w, "internal error", http.StatusInternalServerError)
388399
return
389400
}
390401

@@ -415,7 +426,8 @@ func (s *Server) handleTimeline(w http.ResponseWriter, r *http.Request) {
415426
func (s *Server) handleProfile(w http.ResponseWriter, r *http.Request) {
416427
relProfile, err := s.db.GetNodeByURI("mem://user/profile/communication")
417428
if err != nil {
418-
http.Error(w, fmt.Sprintf(`{"error":"%s"}`, err.Error()), http.StatusInternalServerError)
429+
log.Printf("profile: %v", err)
430+
jsonError(w, "internal error", http.StatusInternalServerError)
419431
return
420432
}
421433

@@ -476,7 +488,8 @@ func (s *Server) handleTree(w http.ResponseWriter, r *http.Request) {
476488
// List roots
477489
roots, err := s.db.ListRoots()
478490
if err != nil {
479-
http.Error(w, fmt.Sprintf(`{"error":"%s"}`, err.Error()), http.StatusInternalServerError)
491+
log.Printf("tree roots: %v", err)
492+
jsonError(w, "internal error", http.StatusInternalServerError)
480493
return
481494
}
482495
for _, r := range roots {
@@ -492,7 +505,8 @@ func (s *Server) handleTree(w http.ResponseWriter, r *http.Request) {
492505
// List children
493506
children, err := s.db.GetChildren(uri)
494507
if err != nil {
495-
http.Error(w, fmt.Sprintf(`{"error":"%s"}`, err.Error()), http.StatusInternalServerError)
508+
log.Printf("tree children: %v", err)
509+
jsonError(w, "internal error", http.StatusInternalServerError)
496510
return
497511
}
498512
for _, c := range children {

0 commit comments

Comments
 (0)