Skip to content

Security hardening: HTTP, permissions, XSS, error sanitization#8

Merged
lazypower merged 2 commits intomainfrom
security/hardening
Apr 24, 2026
Merged

Security hardening: HTTP, permissions, XSS, error sanitization#8
lazypower merged 2 commits intomainfrom
security/hardening

Conversation

@lazypower
Copy link
Copy Markdown
Owner

Summary

Full codebase security audit and remediation across 13 files.

  • DNS rebinding protection: Host header validation middleware rejects non-localhost requests
  • File permissions hardened: Dirs → 0700, files → 0600, with auto-tightening for existing installs on startup
  • XSS eliminated: Replaced {@html} in ProfilePanel with safe Svelte text interpolation
  • HTTP hardened: Request body limits (1MB), server timeouts (Read/Write/Idle), security headers (X-Content-Type-Options, X-Frame-Options, Referrer-Policy)
  • Error responses sanitized: Generic messages to clients, full errors logged server-side via jsonError() helper — eliminates JSON injection via string concatenation
  • Input bounds enforced: Hook stdin capped at 10MB, tool_input truncated to 10KB (matching tool_response), search limit capped at 100
  • Removed middleware.RealIP: Unnecessary for local service, trusts spoofable X-Forwarded-For
  • Removed db_path from /api/health: No reason to advertise filesystem layout

Test plan

  • go test ./... all packages green
  • go build ./... clean
  • Verify UI renders correctly after ProfilePanel/Header changes
  • Verify existing ~/.continuity/ permissions are tightened on first startup after upgrade

🤖 Generated with Claude Code

…tion

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>
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Security hardening across the HTTP API server, hooks, storage layer, and UI to reduce attack surface (DNS rebinding, XSS), limit resource usage, and avoid leaking sensitive details.

Changes:

  • Added HTTP middleware for localhost-only access, security headers, and request body limits; added server-side timeouts and header size cap.
  • Hardened filesystem permissions for continuity-managed directories/files and added startup tightening for existing installs.
  • Replaced Svelte {@html} rendering with safe text interpolation; sanitized API error responses via a JSON helper and removed db_path from health output.

Reviewed changes

Copilot reviewed 13 out of 13 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
ui/src/components/ProfilePanel.svelte Removes {@html} rendering of profile text and renders parsed lines safely.
ui/src/components/Header.svelte Removes {@html} usage for tab icons by switching to Unicode characters.
internal/store/observations.go Truncates large observation fields (now including tool_input) and logs truncation events.
internal/store/db.go Creates DB directories with 0700 and tightens permissions on existing DB/WAL/SHM files.
internal/server/server_test.go Adds a test helper to set Host: localhost to satisfy localhost-only middleware.
internal/server/server.go Installs new middleware (localhost-only, security headers, request size limit) and removes RealIP.
internal/server/routes_test.go Updates tests to use the new request helper so Host validation doesn’t reject them.
internal/server/routes.go Adds jsonError() and sanitizes several internal error responses; caps search limit; removes db_path exposure.
internal/server/middleware.go Introduces localhost-only host validation, security headers, and request body limiting middleware.
internal/hooks/handler.go Caps hook stdin decoding to 10MB to prevent unbounded reads.
internal/hooks/autostart.go Creates autostart logs with 0600 and tightens permissions for existing log files.
internal/cli/serve.go Adds HTTP server timeouts and MaxHeaderBytes to harden the server.
internal/cli/init.go Writes continuity autostart artifacts with 0700/0600 permissions.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread internal/server/server.go Outdated
Comment on lines +44 to +45
r.Use(localhostOnly)
r.Use(securityHeaders)
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Middleware ordering: localhostOnly runs before securityHeaders, so 403 responses generated by localhostOnly won't include the security headers. If the intent is truly "all responses", register securityHeaders earlier in the chain (before localhostOnly) so even rejected requests get the headers.

Suggested change
r.Use(localhostOnly)
r.Use(securityHeaders)
r.Use(securityHeaders)
r.Use(localhostOnly)

Copilot uses AI. Check for mistakes.
Comment thread internal/server/middleware.go Outdated
Comment on lines +4 to +19
"net"
"net/http"
)

const maxRequestBody = 1 << 20 // 1MB

// localhostOnly rejects requests where the Host header is not localhost.
// Prevents DNS rebinding attacks against the local API server.
func localhostOnly(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
host := r.Host
if h, _, err := net.SplitHostPort(host); err == nil {
host = h
}
if host != "localhost" && host != "127.0.0.1" && host != "::1" {
http.Error(w, "Forbidden", http.StatusForbidden)
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

localhostOnly has a few correctness issues in Host parsing/validation: (1) hostname comparisons are case-sensitive even though Host is case-insensitive, so Host: Localhost would be incorrectly rejected; (2) IPv6 literals without a port commonly appear bracketed as Host: [::1], but net.SplitHostPort fails on that (missing port) and the code then compares against "::1", causing valid loopback requests to be rejected; (3) it returns a plain-text http.Error, which is inconsistent with the JSON API responses. Consider normalizing the host (lowercase, unbracket IPv6, optional trailing-dot trim) and returning a JSON error (e.g., via jsonError).

Suggested change
"net"
"net/http"
)
const maxRequestBody = 1 << 20 // 1MB
// localhostOnly rejects requests where the Host header is not localhost.
// Prevents DNS rebinding attacks against the local API server.
func localhostOnly(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
host := r.Host
if h, _, err := net.SplitHostPort(host); err == nil {
host = h
}
if host != "localhost" && host != "127.0.0.1" && host != "::1" {
http.Error(w, "Forbidden", http.StatusForbidden)
"encoding/json"
"net"
"net/http"
"strings"
)
const maxRequestBody = 1 << 20 // 1MB
func normalizeHost(host string) string {
if h, _, err := net.SplitHostPort(host); err == nil {
host = h
} else if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") {
host = strings.TrimPrefix(strings.TrimSuffix(host, "]"), "[")
}
host = strings.ToLower(host)
host = strings.TrimSuffix(host, ".")
return host
}
func writeJSONError(w http.ResponseWriter, status int, message string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(map[string]string{"error": message})
}
// localhostOnly rejects requests where the Host header is not localhost.
// Prevents DNS rebinding attacks against the local API server.
func localhostOnly(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
host := normalizeHost(r.Host)
if host != "localhost" && host != "127.0.0.1" && host != "::1" {
writeJSONError(w, http.StatusForbidden, "Forbidden")

Copilot uses AI. Check for mistakes.
Comment thread internal/server/routes.go Outdated
Comment on lines +16 to +22
// jsonError writes a JSON error response. All error responses should use this
// to avoid JSON injection via string concatenation.
func jsonError(w http.ResponseWriter, msg string, code int) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
json.NewEncoder(w).Encode(map[string]string{"error": msg})
}
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment says "All error responses should use" jsonError(), but several handlers in this file still use http.Error with JSON-looking strings (which results in Content-Type: text/plain and an extra trailing newline). Either update those remaining error paths to call jsonError, or soften the comment so it matches actual behavior and avoids misleading future changes.

Copilot uses AI. Check for mistakes.
Comment thread internal/store/observations.go Outdated
Comment on lines +23 to +27
// AddObservation stores a tool use observation. Truncates large fields to prevent DB bloat.
func (db *DB) AddObservation(sessionID, toolName, toolInput, toolResponse string) error {
if len(toolInput) > maxToolResponseSize {
log.Printf("observation: tool_input truncated for session %s: %d → %d bytes", sessionID, len(toolInput), maxToolResponseSize)
toolInput = toolInput[:maxToolResponseSize]
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AddObservation now truncates both tool_input and tool_response, but the constant name/comment (maxToolResponseSize) still reads as response-only. To avoid confusion, consider renaming the constant (and updating its doc) to reflect that it applies to both fields (e.g., maxToolFieldSize).

Copilot uses AI. Check for mistakes.
Comment thread internal/store/observations.go Outdated
Comment on lines +25 to +28
if len(toolInput) > maxToolResponseSize {
log.Printf("observation: tool_input truncated for session %s: %d → %d bytes", sessionID, len(toolInput), maxToolResponseSize)
toolInput = toolInput[:maxToolResponseSize]
}
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New behavior: tool_input is now truncated to the same limit as tool_response, but observations_test.go only asserts truncation for tool_response. Adding a test assertion for tool_input truncation would help prevent regressions in this bound.

Copilot uses AI. Check for mistakes.
Comment thread internal/store/db.go Outdated
Comment on lines +78 to +86
if err := os.Chmod(dir, 0700); err == nil {
fmt.Fprintf(os.Stderr, " security: tightened %s to 0700\n", dir)
}
}
for _, f := range []string{dbPath, dbPath + "-wal", dbPath + "-shm"} {
if info, err := os.Stat(f); err == nil && info.Mode().Perm()&0077 != 0 {
if err := os.Chmod(f, 0600); err == nil {
fmt.Fprintf(os.Stderr, " security: tightened %s to 0600\n", f)
}
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hardenPermissions writes directly to os.Stderr from within store.Open(). Since store.Open() is a library entry point (used by CLI and potentially other consumers), emitting unsolicited stderr output can pollute program output and complicate embedding/testing. Consider routing this through the existing logging approach, returning a status to the caller, or gating the message behind a verbose/debug flag.

Suggested change
if err := os.Chmod(dir, 0700); err == nil {
fmt.Fprintf(os.Stderr, " security: tightened %s to 0700\n", dir)
}
}
for _, f := range []string{dbPath, dbPath + "-wal", dbPath + "-shm"} {
if info, err := os.Stat(f); err == nil && info.Mode().Perm()&0077 != 0 {
if err := os.Chmod(f, 0600); err == nil {
fmt.Fprintf(os.Stderr, " security: tightened %s to 0600\n", f)
}
_ = os.Chmod(dir, 0700)
}
for _, f := range []string{dbPath, dbPath + "-wal", dbPath + "-shm"} {
if info, err := os.Stat(f); err == nil && info.Mode().Perm()&0077 != 0 {
_ = os.Chmod(f, 0600)

Copilot uses AI. Check for mistakes.
- Fix middleware ordering: securityHeaders before localhostOnly so 403s get headers
- Normalize Host header: case-insensitive, handle bracketed IPv6 [::1], trim trailing dots
- Return JSON from localhostOnly rejection (was plaintext)
- Migrate all remaining http.Error calls to jsonError for consistent JSON responses
- Rename maxToolResponseSize → maxToolFieldSize (now covers both fields)
- Add tool_input truncation assertion to TestAddObservationTruncation
- Remove stderr output from hardenPermissions (library code, silent chmod)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@lazypower lazypower merged commit 80edddd into main Apr 24, 2026
2 checks passed
@lazypower lazypower deleted the security/hardening branch April 24, 2026 01:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants