molecule-core/platform/internal/middleware/securityheaders_test.go
Hongming Wang 510c40089f fix: address all code review findings + remove exposed secrets
Code review fixes:
- 🟡 #1: Replace python3 with jq in Dockerfile template stages (~50MB → ~2MB)
- 🟡 #2: Add clone count verification to scripts/clone-manifest.sh
  (set -e + expected vs actual count check — fails build if any clone fails)
- 🟡 #3: Drop 'unsafe-eval' from CSP (not needed for Next.js production
  standalone builds, only dev mode). Updated test assertion.
- 🟡 #4: Remove broken pyproject.toml from workspace-template/ (it claimed
  to package as molecule-ai-workspace-runtime but the directory structure
  didn't match — the real package ships from the standalone repo)
- 🔵 #1: Add version-pinning TODO comment to manifest.json
- 🔵 #3: Add full repo URLs + test counts for SDK/MCP/CLI/runtime in CLAUDE.md

Security (GitGuardian alert):
- Removed Telegram bot token (8633739353:AA...) from template-molecule-dev
  pm/.env — replaced with ${TELEGRAM_BOT_TOKEN} placeholder
- Removed Claude OAuth token (sk-ant-oat01-...) from template-molecule-dev
  root .env — replaced with ${CLAUDE_CODE_OAUTH_TOKEN} placeholder
- Both tokens need immediate rotation by the operator

Tests: Platform middleware tests updated + all pass.
2026-04-16 05:05:49 -07:00

127 lines
3.8 KiB
Go

package middleware
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
)
func init() {
gin.SetMode(gin.TestMode)
}
func TestSecurityHeaders(t *testing.T) {
r := gin.New()
r.Use(SecurityHeaders())
r.GET("/test", func(c *gin.Context) {
c.String(http.StatusOK, "ok")
})
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/test", nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", w.Code)
}
tests := []struct {
header string
want string
}{
{"X-Content-Type-Options", "nosniff"},
{"X-Frame-Options", "DENY"},
{"Strict-Transport-Security", "max-age=31536000; includeSubDomains"},
// #282: regression guards for the two headers that were
// documented in CLAUDE.md but missing from the implementation.
{"Referrer-Policy", "strict-origin-when-cross-origin"},
{"Permissions-Policy", "camera=(), microphone=(), geolocation=()"},
}
for _, tt := range tests {
got := w.Header().Get(tt.header)
if got != tt.want {
t.Errorf("header %s = %q, want %q", tt.header, got, tt.want)
}
}
// CSP: widened to allow Next.js inline scripts/styles + data:/blob:
// images because the canvas is reverse-proxied through the same
// gin middleware stack. Assert the policy starts with the tight
// default-src and contains each expected directive — exact-match
// would brittle-break every time we tune a subsource list.
csp := w.Header().Get("Content-Security-Policy")
for _, fragment := range []string{
"default-src 'self'",
"script-src 'self' 'unsafe-inline'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: blob:",
"connect-src 'self' ws: wss:",
"font-src 'self' data:",
} {
if !strings.Contains(csp, fragment) {
t.Errorf("CSP missing expected fragment %q (full CSP: %q)", fragment, csp)
}
}
}
func TestSecurityHeadersPresenceOnMultipleRoutes(t *testing.T) {
r := gin.New()
r.Use(SecurityHeaders())
r.GET("/a", func(c *gin.Context) { c.String(http.StatusOK, "a") })
r.POST("/b", func(c *gin.Context) { c.String(http.StatusCreated, "b") })
// GET /a
w1 := httptest.NewRecorder()
req1, _ := http.NewRequest(http.MethodGet, "/a", nil)
r.ServeHTTP(w1, req1)
if v := w1.Header().Get("X-Frame-Options"); v != "DENY" {
t.Errorf("GET /a: X-Frame-Options = %q, want DENY", v)
}
// POST /b
w2 := httptest.NewRecorder()
req2, _ := http.NewRequest(http.MethodPost, "/b", nil)
r.ServeHTTP(w2, req2)
if v := w2.Header().Get("X-Content-Type-Options"); v != "nosniff" {
t.Errorf("POST /b: X-Content-Type-Options = %q, want nosniff", v)
}
if v := w2.Header().Get("Strict-Transport-Security"); v != "max-age=31536000; includeSubDomains" {
t.Errorf("POST /b: Strict-Transport-Security = %q, want max-age=31536000; includeSubDomains", v)
}
// Fragment-match rather than exact — CSP subsource lists get tuned
// without changing the security posture. Test intent is "CSP is
// present + starts with the tight default-src", not "CSP matches
// this exact byte-for-byte string".
csp := w2.Header().Get("Content-Security-Policy")
if !strings.Contains(csp, "default-src 'self'") {
t.Errorf("POST /b: CSP missing default-src 'self' (full: %q)", csp)
}
}
func TestSecurityHeadersDoNotOverrideExisting(t *testing.T) {
r := gin.New()
r.Use(SecurityHeaders())
r.GET("/custom", func(c *gin.Context) {
// Handler sets its own X-Frame-Options — SecurityHeaders runs before
// the handler, so the handler's value will take precedence.
c.Header("X-Frame-Options", "SAMEORIGIN")
c.String(http.StatusOK, "custom")
})
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/custom", nil)
r.ServeHTTP(w, req)
// The handler's value should be present (may override middleware's)
got := w.Header().Get("X-Frame-Options")
if got != "SAMEORIGIN" {
t.Errorf("expected handler override SAMEORIGIN, got %q", got)
}
}