Comprehensive security hardening from audit findings: - Add validation tags to all DTO request structs (max lengths, ranges, enums) - Replace unsafe type assertions with MustGetAuthUser helper across all handlers - Remove query-param token auth from admin middleware (prevents URL token leakage) - Add request validation calls in handlers that were missing c.Validate() - Remove goroutines in handlers (timezone update now synchronous) - Add sanitize middleware and path traversal protection (path_utils) - Stop resetting admin passwords on migration restart - Warn on well-known default SECRET_KEY - Add ~30 new test files covering security regressions, auth safety, repos, and services - Add deploy/ config, audit digests, and AUDIT_FINDINGS documentation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
200 lines
4.8 KiB
Go
200 lines
4.8 KiB
Go
package handlers
|
|
|
|
import (
|
|
"html"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
|
|
"github.com/treytartt/casera-api/internal/admin/dto"
|
|
)
|
|
|
|
func TestAdminSortBy_ValidColumn_Works(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
sortBy string
|
|
allowlist []string
|
|
defaultCol string
|
|
expected string
|
|
}{
|
|
{
|
|
name: "exact match returns column",
|
|
sortBy: "created_at",
|
|
allowlist: []string{"id", "created_at", "updated_at", "name"},
|
|
defaultCol: "created_at",
|
|
expected: "created_at",
|
|
},
|
|
{
|
|
name: "case insensitive match returns canonical column",
|
|
sortBy: "Created_At",
|
|
allowlist: []string{"id", "created_at", "updated_at", "name"},
|
|
defaultCol: "created_at",
|
|
expected: "created_at",
|
|
},
|
|
{
|
|
name: "different valid column",
|
|
sortBy: "name",
|
|
allowlist: []string{"id", "created_at", "updated_at", "name"},
|
|
defaultCol: "created_at",
|
|
expected: "name",
|
|
},
|
|
{
|
|
name: "date_joined for user handler",
|
|
sortBy: "date_joined",
|
|
allowlist: []string{"id", "username", "email", "date_joined", "last_login", "is_active"},
|
|
defaultCol: "date_joined",
|
|
expected: "date_joined",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
p := dto.PaginationParams{SortBy: tt.sortBy}
|
|
result := p.GetSafeSortBy(tt.allowlist, tt.defaultCol)
|
|
assert.Equal(t, tt.expected, result)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAdminSortBy_SQLInjection_ReturnsDefault(t *testing.T) {
|
|
allowlist := []string{"id", "created_at", "updated_at", "name"}
|
|
defaultCol := "created_at"
|
|
|
|
tests := []struct {
|
|
name string
|
|
sortBy string
|
|
}{
|
|
{
|
|
name: "SQL injection with DROP TABLE",
|
|
sortBy: "created_at; DROP TABLE users; --",
|
|
},
|
|
{
|
|
name: "SQL injection with UNION SELECT",
|
|
sortBy: "id UNION SELECT password FROM auth_user",
|
|
},
|
|
{
|
|
name: "SQL injection with subquery",
|
|
sortBy: "(SELECT password FROM auth_user LIMIT 1)",
|
|
},
|
|
{
|
|
name: "SQL injection with comment",
|
|
sortBy: "created_at--",
|
|
},
|
|
{
|
|
name: "SQL injection with semicolon",
|
|
sortBy: "created_at;",
|
|
},
|
|
{
|
|
name: "SQL injection with OR 1=1",
|
|
sortBy: "created_at OR 1=1",
|
|
},
|
|
{
|
|
name: "column not in allowlist",
|
|
sortBy: "password",
|
|
},
|
|
{
|
|
name: "SQL injection with single quotes",
|
|
sortBy: "name'; DROP TABLE users; --",
|
|
},
|
|
{
|
|
name: "SQL injection with double dashes",
|
|
sortBy: "id -- comment",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
p := dto.PaginationParams{SortBy: tt.sortBy}
|
|
result := p.GetSafeSortBy(allowlist, defaultCol)
|
|
assert.Equal(t, defaultCol, result, "SQL injection attempt should return default column")
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAdminSortBy_EmptyString_ReturnsDefault(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
sortBy string
|
|
defaultCol string
|
|
}{
|
|
{
|
|
name: "empty string returns default",
|
|
sortBy: "",
|
|
defaultCol: "created_at",
|
|
},
|
|
{
|
|
name: "whitespace only returns default",
|
|
sortBy: " ",
|
|
defaultCol: "created_at",
|
|
},
|
|
{
|
|
name: "tab only returns default",
|
|
sortBy: "\t",
|
|
defaultCol: "date_joined",
|
|
},
|
|
{
|
|
name: "different default column",
|
|
sortBy: "",
|
|
defaultCol: "completed_at",
|
|
},
|
|
}
|
|
|
|
allowlist := []string{"id", "created_at", "updated_at", "name"}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
p := dto.PaginationParams{SortBy: tt.sortBy}
|
|
result := p.GetSafeSortBy(allowlist, tt.defaultCol)
|
|
assert.Equal(t, tt.defaultCol, result)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSendEmail_XSSEscaped(t *testing.T) {
|
|
// SEC-22: Subject and Body must be HTML-escaped before insertion into email template.
|
|
// This tests the html.EscapeString behavior that the handler relies on.
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
expected string
|
|
}{
|
|
{
|
|
name: "script tag in subject",
|
|
input: `<script>alert("xss")</script>`,
|
|
expected: `<script>alert("xss")</script>`,
|
|
},
|
|
{
|
|
name: "img onerror payload",
|
|
input: `<img src=x onerror=alert(1)>`,
|
|
expected: `<img src=x onerror=alert(1)>`,
|
|
},
|
|
{
|
|
name: "ampersand and angle brackets",
|
|
input: `Tom & Jerry <bros>`,
|
|
expected: `Tom & Jerry <bros>`,
|
|
},
|
|
{
|
|
name: "plain text passes through",
|
|
input: "Hello World",
|
|
expected: "Hello World",
|
|
},
|
|
{
|
|
name: "single quotes",
|
|
input: `It's a 'test'`,
|
|
expected: `It's a 'test'`,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
escaped := html.EscapeString(tt.input)
|
|
assert.Equal(t, tt.expected, escaped)
|
|
// Verify the escaped output does NOT contain raw angle brackets from the input
|
|
if tt.input != tt.expected {
|
|
assert.NotContains(t, escaped, "<script>")
|
|
assert.NotContains(t, escaped, "<img")
|
|
}
|
|
})
|
|
}
|
|
}
|