diff --git a/ASSURANCE_CASE.md b/ASSURANCE_CASE.md index ccd924f..97c8679 100644 --- a/ASSURANCE_CASE.md +++ b/ASSURANCE_CASE.md @@ -88,6 +88,7 @@ The following maps [OWASP Top 10](https://owasp.org/www-project-top-ten/) and [C | Weakness | Applicability | Countermeasure | |----------|---------------|----------------| | **A03:2021 Injection** (CWE-78 OS Command Injection) | All `exec.Command` calls use `git` with explicit argument lists — no shell interpolation. `--end-of-options` prevents flag injection. | Mitigated | +| **A03:2021 Injection** (CWE-79 Cross-site Scripting) | Viewer template output is HTML-escaped by `html/template`. Defense-in-depth: every viewer response carries a strict Content-Security-Policy (`default-src 'self'`, no `unsafe-inline`) plus `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy`, and `Permissions-Policy` (`internal/viewer/securityheaders.go`). | Mitigated | | **A01:2021 Broken Access Control** (CWE-22 Path Traversal) | Agent file-read tool validates paths with `pathutil.WithinBase()` before and after symlink resolution (`internal/tool/filereader.go:91-112`). | Mitigated | | **A02:2021 Cryptographic Failures** | All API communication uses HTTPS/TLS 1.2+. Go's default TLS configuration is used without weakening. `InsecureSkipVerify` is never set. | Mitigated | | **A07:2021 Auth Failures** (CWE-798 Hard-coded Credentials) | API keys are read exclusively from environment variables, never embedded in code or config files, never logged. | Mitigated | diff --git a/internal/viewer/securityheaders.go b/internal/viewer/securityheaders.go new file mode 100644 index 0000000..ff9428a --- /dev/null +++ b/internal/viewer/securityheaders.go @@ -0,0 +1,35 @@ +package viewer + +import "net/http" + +// contentSecurityPolicy locks the viewer down to first-party resources only. +// The viewer loads no third-party scripts, styles, fonts, or frames, so a +// strict same-origin policy holds without any 'unsafe-inline' relaxation +// (the previously-inline session script now lives in static/session.js). +// This mitigates injection of active content should any user- or LLM-supplied +// value ever escape HTML escaping in a template. +const contentSecurityPolicy = "default-src 'self'; " + + "script-src 'self'; " + + "style-src 'self'; " + + "img-src 'self' data:; " + + "object-src 'none'; " + + "base-uri 'none'; " + + "frame-ancestors 'none'; " + + "form-action 'none'" + +// securityHeaders wraps a handler and sets defense-in-depth response headers on +// every reply. These harden the local viewer's browser-facing surface (the +// session JSONL exposed here contains reviewed source code and the LLM's +// analysis of it). HSTS is intentionally omitted: the viewer serves plain HTTP +// on loopback, where HSTS is meaningless and would wrongly pin localhost. +func securityHeaders(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + h := w.Header() + h.Set("Content-Security-Policy", contentSecurityPolicy) + h.Set("X-Content-Type-Options", "nosniff") + h.Set("X-Frame-Options", "DENY") + h.Set("Referrer-Policy", "no-referrer") + h.Set("Permissions-Policy", "geolocation=(), camera=(), microphone=()") + next.ServeHTTP(w, r) + }) +} diff --git a/internal/viewer/securityheaders_test.go b/internal/viewer/securityheaders_test.go new file mode 100644 index 0000000..4c65e42 --- /dev/null +++ b/internal/viewer/securityheaders_test.go @@ -0,0 +1,54 @@ +package viewer + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestSecurityHeadersSetsAllHeaders(t *testing.T) { + inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) + }) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/", nil) + securityHeaders(inner).ServeHTTP(rec, req) + + want := map[string]string{ + "Content-Security-Policy": contentSecurityPolicy, + "X-Content-Type-Options": "nosniff", + "X-Frame-Options": "DENY", + "Referrer-Policy": "no-referrer", + "Permissions-Policy": "geolocation=(), camera=(), microphone=()", + } + for k, v := range want { + if got := rec.Header().Get(k); got != v { + t.Errorf("header %q = %q, want %q", k, got, v) + } + } +} + +// The viewer serves plain HTTP on loopback; HSTS would wrongly pin localhost. +func TestSecurityHeadersOmitsHSTS(t *testing.T) { + inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/", nil) + securityHeaders(inner).ServeHTTP(rec, req) + + if got := rec.Header().Get("Strict-Transport-Security"); got != "" { + t.Errorf("HSTS should not be set on the loopback viewer, got %q", got) + } +} + +// The CSP must stay strict: no 'unsafe-inline'/'unsafe-eval' relaxation, since +// the formerly-inline session script now lives in static/session.js. +func TestContentSecurityPolicyIsStrict(t *testing.T) { + for _, bad := range []string{"unsafe-inline", "unsafe-eval", "*"} { + if strings.Contains(contentSecurityPolicy, bad) { + t.Errorf("CSP unexpectedly contains %q: %s", bad, contentSecurityPolicy) + } + } +} diff --git a/internal/viewer/server.go b/internal/viewer/server.go index b84e2d6..e73789f 100644 --- a/internal/viewer/server.go +++ b/internal/viewer/server.go @@ -11,7 +11,7 @@ import ( "time" ) -//go:embed templates/*.html static/style.css +//go:embed templates/*.html static/style.css static/session.js var assets embed.FS func StartServer(addr string) error { @@ -54,9 +54,12 @@ func StartServer(addr string) error { allowed := resolveAllowedHostsFromEnv(addr) guarded := hostGuard(allowed, mux) + // Outermost layer: set defense-in-depth security headers on every response. + handler := securityHeaders(guarded) + srv := &http.Server{ Addr: addr, - Handler: guarded, + Handler: handler, } fmt.Printf("\nOpen browser: http://%s\n", DisplayAddr(addr)) diff --git a/internal/viewer/static/session.js b/internal/viewer/static/session.js new file mode 100644 index 0000000..800ab98 --- /dev/null +++ b/internal/viewer/static/session.js @@ -0,0 +1,24 @@ +document.querySelectorAll('.response-text').forEach(function(el) { + const text = el.textContent; + const esc = function(s) { + return s.replace(/&/g, '&').replace(//g, '>') + .replace(/"/g, '"').replace(/'/g, '''); + }; + const codeBlocks = []; + let html = esc(text); + html = html.replace(/ + html = html + .replace(/`([^`]+)`/g, '$1') + .replace(/\*\*(.+?)\*\*/g, '$1') + .replace(/^### (.+)$/gm, '
$1
') + .replace(/^## (.+)$/gm, '
$1
') + .replace(/^# (.+)$/gm, '
$1
') + .replace(/^[-*] (.+)$/gm, '
• $1
') + .replace(/\n{2,}/g, '

') + .replace(/\n/g, '
'); + codeBlocks.forEach(function(code, i) { + html = html.replace('%%CODEBLOCK_' + i + '%%', + '
' + code + '
'); + }); + el.innerHTML = html; +}); diff --git a/internal/viewer/templates/session.html b/internal/viewer/templates/session.html index 8afa663..9858bcd 100644 --- a/internal/viewer/templates/session.html +++ b/internal/viewer/templates/session.html @@ -231,35 +231,7 @@ {{end}} - +