test(security): regression suite for input validation fixes (#685 #686 #687 #688)

30 test cases covering all four security fixes from PR #701:

  #686 — AdminAuth gate on GET /templates and GET /org/templates:
    - NoAuth returns 401 when tokens are enrolled
    - FreshInstall fails open (bootstraps correctly)

  #687 — UUID path param validation:
    - URL-encoded traversal (..%2f..%2fetc%2fpasswd) → 400
    - Non-UUID strings (not-a-uuid, ws-123, XSS payloads) → 400
    - Valid UUIDs pass through (regression check)

  #688 — Field length limits:
    - name=256, role=1001, model=101 chars → 400
    - Exact-boundary values (255/1000/100) → pass (off-by-one guard)

  #685 — YAML injection via newline/CR:
    - Newline in name, CR in role → 400
    - YAML multi-field injection payload "agent\nrole: injected" → 400

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Molecule AI QA Engineer 2026-04-17 11:52:57 +00:00 committed by Molecule AI Backend Engineer
parent 3f5dea791b
commit 5fd25dc0df

View File

@ -0,0 +1,477 @@
package handlers
// security_regression_685_686_687_688_test.go — regression suite for the
// input-validation security fixes shipped in PR #701.
//
// #686 — GET /templates and GET /org/templates now require AdminAuth
// #687 — UUID validation on workspace :id path params (invalid UUID → 400)
// #688 — Field length limits: name≤255, role≤1000, model/runtime≤100
// #685 — YAML injection: newline/CR characters rejected in name/role/model/runtime
//
// These tests are intentionally kept at the handler layer (not full router)
// for fast CI execution. The template auth tests are the exception — they wire
// AdminAuth middleware into a mini gin router to verify the actual security gate
// rather than the handler's internal logic.
import (
"bytes"
"database/sql"
"net/http"
"net/http/httptest"
"strings"
"testing"
sqlmock "github.com/DATA-DOG/go-sqlmock"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/middleware"
"github.com/gin-gonic/gin"
)
// authTokenQuery matches the SELECT issued by HasAnyLiveTokenGlobal inside AdminAuth.
const authTokenQuery = "SELECT COUNT.*workspace_auth_tokens"
// newEnrolledAuthDB returns a sqlmock DB pre-loaded so that the next
// HasAnyLiveTokenGlobal call reports one enrolled workspace (i.e., auth is enforced).
// The returned Sqlmock lets the caller verify expectations afterwards.
func newEnrolledAuthDB(t *testing.T) (*sql.DB, sqlmock.Sqlmock) {
t.Helper()
d, m, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock.New: %v", err)
}
t.Cleanup(func() { _ = d.Close() })
m.ExpectQuery(authTokenQuery).
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
return d, m
}
// newFreshInstallAuthDB returns a sqlmock DB where HasAnyLiveTokenGlobal
// reports zero enrolled workspaces — the platform is in fail-open bootstrap mode.
func newFreshInstallAuthDB(t *testing.T) (*sql.DB, sqlmock.Sqlmock) {
t.Helper()
d, m, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock.New: %v", err)
}
t.Cleanup(func() { _ = d.Close() })
m.ExpectQuery(authTokenQuery).
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0))
return d, m
}
// ─────────────────────────────────────────────────────────────────────────────
// #686 — AdminAuth gate on GET /templates
// ─────────────────────────────────────────────────────────────────────────────
// TestSecurity_GetTemplates_NoAuth_Returns401 verifies that once at least one
// workspace is enrolled (tokens exist), GET /templates without a bearer token
// is rejected with 401. Previously the route was unauthenticated (#686).
func TestSecurity_GetTemplates_NoAuth_Returns401(t *testing.T) {
setupTestDB(t)
setupTestRedis(t)
authDB, authMock := newEnrolledAuthDB(t)
tmpDir := t.TempDir()
tmplh := NewTemplatesHandler(tmpDir, nil)
r := gin.New()
r.GET("/templates", middleware.AdminAuth(authDB), tmplh.List)
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/templates", nil)
// Deliberately omit Authorization header — must be rejected.
r.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("#686 GET /templates no-auth: want 401, got %d body=%s", w.Code, w.Body.String())
}
if err := authMock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet auth mock expectations: %v", err)
}
}
// TestSecurity_GetTemplates_FreshInstall_FailsOpen verifies that GET /templates
// still succeeds on a fresh install (zero enrolled workspaces → AdminAuth fail-open).
// This is the regression check: the auth gate must not break new deployments.
func TestSecurity_GetTemplates_FreshInstall_FailsOpen(t *testing.T) {
setupTestDB(t)
setupTestRedis(t)
authDB, authMock := newFreshInstallAuthDB(t)
tmpDir := t.TempDir()
tmplh := NewTemplatesHandler(tmpDir, nil)
r := gin.New()
r.GET("/templates", middleware.AdminAuth(authDB), tmplh.List)
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/templates", nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("#686 GET /templates fresh-install: want 200 (fail-open), got %d body=%s", w.Code, w.Body.String())
}
if err := authMock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet auth mock expectations: %v", err)
}
}
// ─────────────────────────────────────────────────────────────────────────────
// #686 — AdminAuth gate on GET /org/templates
// ─────────────────────────────────────────────────────────────────────────────
// TestSecurity_GetOrgTemplates_NoAuth_Returns401 verifies that GET /org/templates
// requires a bearer token once the platform has enrolled workspaces.
// Previously the route was unauthenticated, exposing org structure details (#686).
func TestSecurity_GetOrgTemplates_NoAuth_Returns401(t *testing.T) {
setupTestDB(t)
setupTestRedis(t)
authDB, authMock := newEnrolledAuthDB(t)
tmpDir := t.TempDir()
wh := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", tmpDir)
orgh := NewOrgHandler(wh, newTestBroadcaster(), nil, nil, tmpDir, tmpDir)
r := gin.New()
r.GET("/org/templates", middleware.AdminAuth(authDB), orgh.ListTemplates)
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/org/templates", nil)
// No Authorization header — must be rejected.
r.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("#686 GET /org/templates no-auth: want 401, got %d body=%s", w.Code, w.Body.String())
}
if err := authMock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet auth mock expectations: %v", err)
}
}
// TestSecurity_GetOrgTemplates_FreshInstall_FailsOpen mirrors the /templates
// regression check for /org/templates — fresh installs must still work.
func TestSecurity_GetOrgTemplates_FreshInstall_FailsOpen(t *testing.T) {
setupTestDB(t)
setupTestRedis(t)
authDB, authMock := newFreshInstallAuthDB(t)
tmpDir := t.TempDir()
wh := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", tmpDir)
orgh := NewOrgHandler(wh, newTestBroadcaster(), nil, nil, tmpDir, tmpDir)
r := gin.New()
r.GET("/org/templates", middleware.AdminAuth(authDB), orgh.ListTemplates)
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/org/templates", nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("#686 GET /org/templates fresh-install: want 200 (fail-open), got %d body=%s", w.Code, w.Body.String())
}
if err := authMock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet auth mock expectations: %v", err)
}
}
// ─────────────────────────────────────────────────────────────────────────────
// #687 — UUID validation on workspace :id path params
// ─────────────────────────────────────────────────────────────────────────────
// TestSecurity_Get_URLEncodedTraversal_Returns400 verifies that a URL-encoded
// path traversal sequence — the type a browser or curl submits as
// /workspaces/..%252f..%252fetc%252fpasswd (double-encoded → decoded to
// ..%2f..%2fetc%2fpasswd by the HTTP layer) — is rejected 400 before any DB
// query. Previously a non-UUID id caused a Postgres syntax error → 500.
func TestSecurity_Get_URLEncodedTraversal_Returns400(t *testing.T) {
setupTestDB(t)
setupTestRedis(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
// gin decodes %25 → %, so the outer HTTP layer hands the handler this value.
traversalID := "..%2f..%2fetc%2fpasswd"
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: traversalID}}
c.Request = httptest.NewRequest(http.MethodGet, "/workspaces/"+traversalID, nil)
handler.Get(c)
if w.Code != http.StatusBadRequest {
t.Errorf("#687 URL-encoded traversal Get(%q): want 400, got %d body=%s",
traversalID, w.Code, w.Body.String())
}
}
// TestSecurity_Get_NotUUID_Returns400 checks the simplest non-UUID rejection.
func TestSecurity_Get_NotUUID_Returns400(t *testing.T) {
setupTestDB(t)
setupTestRedis(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
for _, badID := range []string{
"not-a-uuid",
"ws-123",
"123",
"../etc/passwd",
"<script>alert(1)</script>",
} {
t.Run(badID, func(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: badID}}
c.Request = httptest.NewRequest(http.MethodGet, "/workspaces/"+badID, nil)
handler.Get(c)
if w.Code != http.StatusBadRequest {
t.Errorf("#687 Get(%q): want 400, got %d", badID, w.Code)
}
})
}
}
// TestSecurity_ValidUUID_PassesUUIDValidation verifies that a well-formed UUID
// passes the validateWorkspaceID guard — i.e., the fix doesn't false-positive
// on legitimate workspace IDs.
func TestSecurity_ValidUUID_PassesUUIDValidation(t *testing.T) {
if err := validateWorkspaceID("550e8400-e29b-41d4-a716-446655440000"); err != nil {
t.Errorf("regression: valid UUID rejected: %v", err)
}
if err := validateWorkspaceID("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"); err != nil {
t.Errorf("regression: valid UUID rejected: %v", err)
}
}
// ─────────────────────────────────────────────────────────────────────────────
// #688 — Field length limits on POST /workspaces
// ─────────────────────────────────────────────────────────────────────────────
// TestSecurity_Create_NameTooLong_Returns400 verifies a 256-character name is
// rejected before any DB interaction. The limit is 255 characters (#688).
func TestSecurity_Create_NameTooLong_Returns400(t *testing.T) {
setupTestDB(t)
setupTestRedis(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
name256 := strings.Repeat("a", 256)
body := `{"name":"` + name256 + `"}`
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodPost, "/workspaces", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
handler.Create(c)
if w.Code != http.StatusBadRequest {
t.Errorf("#688 name=256 chars: want 400, got %d body=%s", w.Code, w.Body.String())
}
}
// TestSecurity_Create_RoleTooLong_Returns400 verifies a 1001-character role is
// rejected. The limit is 1000 characters (#688).
func TestSecurity_Create_RoleTooLong_Returns400(t *testing.T) {
setupTestDB(t)
setupTestRedis(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
role1001 := strings.Repeat("r", 1001)
body := `{"name":"valid-name","role":"` + role1001 + `"}`
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodPost, "/workspaces", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
handler.Create(c)
if w.Code != http.StatusBadRequest {
t.Errorf("#688 role=1001 chars: want 400, got %d body=%s", w.Code, w.Body.String())
}
}
// TestSecurity_Create_ModelTooLong_Returns400 verifies a 101-character model
// is rejected (#688). The limit is 100 characters.
func TestSecurity_Create_ModelTooLong_Returns400(t *testing.T) {
setupTestDB(t)
setupTestRedis(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
model101 := strings.Repeat("m", 101)
body := `{"name":"valid-name","model":"` + model101 + `"}`
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodPost, "/workspaces", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
handler.Create(c)
if w.Code != http.StatusBadRequest {
t.Errorf("#688 model=101 chars: want 400, got %d body=%s", w.Code, w.Body.String())
}
}
// ─────────────────────────────────────────────────────────────────────────────
// #685 — YAML injection: newline/CR rejection
// ─────────────────────────────────────────────────────────────────────────────
// TestSecurity_Create_NameWithNewline_Returns400 verifies that a workspace name
// containing a literal newline character is rejected before DB interaction.
// Newlines break YAML multi-line quoting even with yamlQuote escaping (#685).
func TestSecurity_Create_NameWithNewline_Returns400(t *testing.T) {
setupTestDB(t)
setupTestRedis(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
// JSON \n is a literal newline in the parsed string value.
body := `{"name":"bad\nname"}`
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodPost, "/workspaces", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
handler.Create(c)
if w.Code != http.StatusBadRequest {
t.Errorf("#685 name with \\n: want 400, got %d body=%s", w.Code, w.Body.String())
}
}
// TestSecurity_Create_YAMLInjectionViaNewline_Returns400 verifies that a
// workspace name crafted to inject YAML fields via a newline is caught by the
// newline-rejection gate before reaching the provisioner.
//
// The attack string "agent\nrole: injected_value" would, if written unquoted
// into a YAML config, silently set the role field to "injected_value". The
// newline is the injection vector — it is rejected by #685.
//
// Note: curly-brace injection like "{inject: yaml}" does not contain newlines
// and is handled separately by yamlQuote escaping in the provisioner
// (defence-in-depth). That value is intentionally allowed through here and
// must be tested against the provisioner's yamlQuote output, not this gate.
func TestSecurity_Create_YAMLInjectionViaNewline_Returns400(t *testing.T) {
setupTestDB(t)
setupTestRedis(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
// The injected string breaks out of a YAML scalar via newline.
body := "{\"name\":\"agent\\nrole: injected_value\"}"
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodPost, "/workspaces", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
handler.Create(c)
if w.Code != http.StatusBadRequest {
t.Errorf("#685 YAML injection via \\n: want 400, got %d body=%s", w.Code, w.Body.String())
}
}
// TestSecurity_Create_RoleWithCR_Returns400 verifies carriage-return rejection
// in the role field (#685). CR alone can also break YAML multi-line values.
func TestSecurity_Create_RoleWithCR_Returns400(t *testing.T) {
setupTestDB(t)
setupTestRedis(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
body := "{\"name\":\"ok\",\"role\":\"bad\\rrole\"}"
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodPost, "/workspaces", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
handler.Create(c)
if w.Code != http.StatusBadRequest {
t.Errorf("#685 role with \\r: want 400, got %d body=%s", w.Code, w.Body.String())
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Regression: validateWorkspaceFields direct unit coverage
// ─────────────────────────────────────────────────────────────────────────────
// TestSecurity_ValidateWorkspaceFields_BoundaryValues exercises exact-boundary
// values for all four field limits to ensure the fence posts are correct.
// These are regression checks: fixing the upper limits must not accidentally
// tighten or loosen the constraint by ±1.
func TestSecurity_ValidateWorkspaceFields_BoundaryValues(t *testing.T) {
cases := []struct {
label string
name string
role string
model string
runtime string
wantErr bool
}{
// Exact maximum lengths — must PASS.
{"name_at_255", strings.Repeat("a", 255), "", "", "", false},
{"role_at_1000", "", strings.Repeat("r", 1000), "", "", false},
{"model_at_100", "", "", strings.Repeat("m", 100), "", false},
{"runtime_at_100", "", "", "", strings.Repeat("x", 100), false},
// One over the limit — must FAIL.
{"name_at_256", strings.Repeat("a", 256), "", "", "", true},
{"role_at_1001", "", strings.Repeat("r", 1001), "", "", true},
{"model_at_101", "", "", strings.Repeat("m", 101), "", true},
{"runtime_at_101", "", "", "", strings.Repeat("x", 101), true},
// Newline/CR in each field — must FAIL.
{"name_newline", "a\nb", "", "", "", true},
{"role_cr", "", "a\rb", "", "", true},
{"model_newline", "", "", "a\nb", "", true},
{"runtime_newline", "", "", "", "a\nb", true},
// Fully valid — must PASS.
{"all_valid", "My Agent", "You are a helpful agent.", "claude-opus-4-7", "langgraph", false},
}
for _, tc := range cases {
t.Run(tc.label, func(t *testing.T) {
err := validateWorkspaceFields(tc.name, tc.role, tc.model, tc.runtime)
if tc.wantErr && err == nil {
t.Errorf("want error, got nil")
}
if !tc.wantErr && err != nil {
t.Errorf("want nil, got %v", err)
}
})
}
}
// TestSecurity_ValidateWorkspaceID_ValidUUIDs verifies that real workspace UUIDs
// (RFC 4122 v4) are accepted. Regression check: the fix must not reject valid IDs.
func TestSecurity_ValidateWorkspaceID_ValidUUIDs(t *testing.T) {
valid := []string{
"550e8400-e29b-41d4-a716-446655440000", // RFC 4122 example
"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
"00000000-0000-0000-0000-000000000000",
"dddddddd-0001-0000-0000-000000000000", // used in other handler tests
}
for _, id := range valid {
if err := validateWorkspaceID(id); err != nil {
t.Errorf("regression: valid UUID %q rejected: %v", id, err)
}
}
}
// TestSecurity_ValidateWorkspaceID_InvalidIDs checks that non-UUID strings all
// return errors from validateWorkspaceID.
func TestSecurity_ValidateWorkspaceID_InvalidIDs(t *testing.T) {
invalid := []string{
"not-a-uuid",
"ws-abc",
"",
"../etc/passwd",
"..%2f..%2fetc%2fpasswd",
"<script>",
"1",
"00000000-0000-0000-0000", // too short
}
for _, id := range invalid {
if err := validateWorkspaceID(id); err == nil {
t.Errorf("expected error for id %q, got nil", id)
}
}
}