Skip to content

Commit 80edddd

Browse files
authored
Merge pull request #8 from lazypower/security/hardening
Security hardening: HTTP, permissions, XSS, error sanitization
2 parents 623f6a4 + 1342a73 commit 80edddd

14 files changed

Lines changed: 233 additions & 96 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: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
package server
2+
3+
import (
4+
"net"
5+
"net/http"
6+
"strings"
7+
)
8+
9+
const maxRequestBody = 1 << 20 // 1MB
10+
11+
// normalizeHost extracts and normalizes the hostname from a Host header.
12+
// Handles ports, bracketed IPv6, case folding, and trailing dots.
13+
func normalizeHost(host string) string {
14+
if h, _, err := net.SplitHostPort(host); err == nil {
15+
host = h
16+
} else if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") {
17+
host = host[1 : len(host)-1]
18+
}
19+
host = strings.ToLower(host)
20+
host = strings.TrimSuffix(host, ".")
21+
return host
22+
}
23+
24+
// localhostOnly rejects requests where the Host header is not localhost.
25+
// Prevents DNS rebinding attacks against the local API server.
26+
func localhostOnly(next http.Handler) http.Handler {
27+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
28+
host := normalizeHost(r.Host)
29+
if host != "localhost" && host != "127.0.0.1" && host != "::1" {
30+
jsonError(w, "forbidden", http.StatusForbidden)
31+
return
32+
}
33+
next.ServeHTTP(w, r)
34+
})
35+
}
36+
37+
// securityHeaders adds standard security headers to all responses.
38+
func securityHeaders(next http.Handler) http.Handler {
39+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
40+
w.Header().Set("X-Content-Type-Options", "nosniff")
41+
w.Header().Set("X-Frame-Options", "DENY")
42+
w.Header().Set("Referrer-Policy", "no-referrer")
43+
next.ServeHTTP(w, r)
44+
})
45+
}
46+
47+
// limitRequestBody caps the size of incoming request bodies to prevent OOM.
48+
func limitRequestBody(next http.Handler) http.Handler {
49+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
50+
r.Body = http.MaxBytesReader(w, r.Body, maxRequestBody)
51+
next.ServeHTTP(w, r)
52+
})
53+
}

internal/server/routes.go

Lines changed: 46 additions & 32 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,23 +13,32 @@ import (
1413
"github.com/lazypower/continuity/internal/engine"
1514
)
1615

16+
// jsonError writes a JSON error response with proper Content-Type and encoding.
17+
// Prefer this over http.Error for consistent JSON responses.
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"`
2027
Project string `json:"project"`
2128
}
2229
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
23-
http.Error(w, `{"error":"invalid json"}`, http.StatusBadRequest)
30+
jsonError(w, "invalid json", http.StatusBadRequest)
2431
return
2532
}
2633
if req.SessionID == "" {
27-
http.Error(w, `{"error":"session_id required"}`, http.StatusBadRequest)
34+
jsonError(w, "session_id required", http.StatusBadRequest)
2835
return
2936
}
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

@@ -52,16 +60,17 @@ func (s *Server) handleAddObservation(w http.ResponseWriter, r *http.Request) {
5260
}
5361
body, err := io.ReadAll(r.Body)
5462
if err != nil {
55-
http.Error(w, `{"error":"read body failed"}`, http.StatusBadRequest)
63+
jsonError(w, "read body failed", http.StatusBadRequest)
5664
return
5765
}
5866
if err := json.Unmarshal(body, &req); err != nil {
59-
http.Error(w, `{"error":"invalid json"}`, http.StatusBadRequest)
67+
jsonError(w, "invalid json", http.StatusBadRequest)
6068
return
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

@@ -108,7 +119,7 @@ func (s *Server) handleExtractSession(w http.ResponseWriter, r *http.Request) {
108119
Force bool `json:"force"`
109120
}
110121
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
111-
http.Error(w, `{"error":"invalid json"}`, http.StatusBadRequest)
122+
jsonError(w, "invalid json", http.StatusBadRequest)
112123
return
113124
}
114125

@@ -144,11 +155,11 @@ func (s *Server) handleSignal(w http.ResponseWriter, r *http.Request) {
144155
Prompt string `json:"prompt"`
145156
}
146157
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
147-
http.Error(w, `{"error":"invalid json"}`, http.StatusBadRequest)
158+
jsonError(w, "invalid json", http.StatusBadRequest)
148159
return
149160
}
150161
if req.Prompt == "" {
151-
http.Error(w, `{"error":"prompt required"}`, http.StatusBadRequest)
162+
jsonError(w, "prompt required", http.StatusBadRequest)
152163
return
153164
}
154165

@@ -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

@@ -241,11 +248,11 @@ func (s *Server) handleRemember(w http.ResponseWriter, r *http.Request) {
241248
SessionID string `json:"session_id"`
242249
}
243250
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
244-
http.Error(w, `{"error":"invalid json"}`, http.StatusBadRequest)
251+
jsonError(w, "invalid json", http.StatusBadRequest)
245252
return
246253
}
247254
if req.Category == "" || req.Name == "" || req.Summary == "" || req.Body == "" {
248-
http.Error(w, `{"error":"category, name, summary, and body are required"}`, http.StatusBadRequest)
255+
jsonError(w, "category, name, summary, and body are required", http.StatusBadRequest)
249256
return
250257
}
251258

@@ -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

@@ -289,7 +295,7 @@ func (s *Server) handleRemember(w http.ResponseWriter, r *http.Request) {
289295
func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
290296
query := r.URL.Query().Get("q")
291297
if query == "" {
292-
http.Error(w, `{"error":"q parameter required"}`, http.StatusBadRequest)
298+
jsonError(w, "q parameter required", http.StatusBadRequest)
293299
return
294300
}
295301

@@ -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)