To gitea and beyond, let's go(-yco)
This commit is contained in:
874
internal/e2e/security_test.go
Normal file
874
internal/e2e/security_test.go
Normal file
@@ -0,0 +1,874 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"goyco/internal/repositories"
|
||||
"goyco/internal/testutils"
|
||||
)
|
||||
|
||||
func TestE2E_SecurityWorkflows(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("security_workflows", func(t *testing.T) {
|
||||
createdUser := ctx.createUserWithCleanup(t, "testuser", "StrongPass123!")
|
||||
_ = ctx.loginUser(t, createdUser.Username, createdUser.Password)
|
||||
|
||||
t.Run("unauthorized_access_attempts", func(t *testing.T) {
|
||||
request, err := testutils.NewRequestBuilder("GET", ctx.baseURL+"/api/auth/me").Build()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
|
||||
resp, err := ctx.client.Do(request)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to make request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("Expected 401 for unauthorized access, got %d", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid_token_access", func(t *testing.T) {
|
||||
request, err := testutils.NewRequestBuilder("GET", ctx.baseURL+"/api/auth/me").
|
||||
WithAuth("invalid-token-12345").
|
||||
Build()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
|
||||
resp, err := ctx.client.Do(request)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to make request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("Expected 401 for invalid token, got %d", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rate_limiting", func(t *testing.T) {
|
||||
rateLimitCtx := setupTestContextWithAuthRateLimit(t, 5)
|
||||
rateLimitUser := rateLimitCtx.createUserWithCleanup(t, "ratelimituser", "StrongPass123!")
|
||||
_ = rateLimitCtx.loginUser(t, rateLimitUser.Username, rateLimitUser.Password)
|
||||
|
||||
testIP := testutils.GenerateTestIP()
|
||||
rateLimited := false
|
||||
for range 10 {
|
||||
statusCode := rateLimitCtx.loginExpectStatusWithIP(t, rateLimitUser.Username, "WrongPass123!", http.StatusUnauthorized, testIP)
|
||||
if statusCode == http.StatusTooManyRequests {
|
||||
rateLimited = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !rateLimited {
|
||||
t.Errorf("Expected rate limiting to occur after multiple failed login attempts")
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_SearchSanitization(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("search_sanitization", func(t *testing.T) {
|
||||
_, authClient := ctx.createUserAndLogin(t, "testuser", "StrongPass123!")
|
||||
|
||||
_ = authClient.CreatePost(t, "Searchable Post", "https://example.com/search", "This post contains searchable content")
|
||||
|
||||
benignSearch := authClient.SearchPosts(t, "searchable")
|
||||
if !benignSearch.Success {
|
||||
t.Errorf("Expected benign search to succeed, got failure: %s", benignSearch.Message)
|
||||
}
|
||||
if len(benignSearch.Data.Posts) == 0 {
|
||||
t.Errorf("Expected to find post with benign search query")
|
||||
}
|
||||
|
||||
maliciousQuery := "searchable'; DROP TABLE users; --"
|
||||
request, err := testutils.NewRequestBuilder("GET", ctx.baseURL+"/api/posts/search?q="+url.QueryEscape(maliciousQuery)).Build()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create malicious search request: %v", err)
|
||||
}
|
||||
|
||||
resp, err := ctx.client.Do(request)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to make malicious search request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400 for malicious search query, got %d", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_SecurityHeaders(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
expectedHeaders := map[string]string{
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"X-Frame-Options": "DENY",
|
||||
"X-XSS-Protection": "1; mode=block",
|
||||
"Referrer-Policy": "strict-origin-when-cross-origin",
|
||||
}
|
||||
|
||||
type endpointTest struct {
|
||||
name string
|
||||
method string
|
||||
path string
|
||||
auth bool
|
||||
body []byte
|
||||
}
|
||||
|
||||
endpoints := []endpointTest{
|
||||
{name: "health_endpoint", method: "GET", path: "/health", auth: false},
|
||||
{name: "metrics_endpoint", method: "GET", path: "/metrics", auth: false},
|
||||
{name: "api_registration", method: "POST", path: "/api/auth/register", auth: false, body: []byte(`{"username":"testuser","email":"test@example.com","password":"StrongPass123!"}`)},
|
||||
{name: "api_posts", method: "GET", path: "/api/posts", auth: true},
|
||||
{name: "api_auth_me", method: "GET", path: "/api/auth/me", auth: true},
|
||||
}
|
||||
|
||||
t.Run("security_headers_on_all_endpoints", func(t *testing.T) {
|
||||
testUser := ctx.createUserWithCleanup(t, "headertest", "StrongPass123!")
|
||||
var authToken string
|
||||
|
||||
authClient, err := ctx.loginUserSafe(t, testUser.Username, testUser.Password)
|
||||
if err == nil {
|
||||
authToken = authClient.Token
|
||||
}
|
||||
|
||||
for _, endpoint := range endpoints {
|
||||
t.Run(endpoint.name, func(t *testing.T) {
|
||||
var req *http.Request
|
||||
var err error
|
||||
|
||||
if endpoint.body != nil {
|
||||
req, err = http.NewRequest(endpoint.method, ctx.baseURL+endpoint.path, bytes.NewReader(endpoint.body))
|
||||
} else {
|
||||
req, err = http.NewRequest(endpoint.method, ctx.baseURL+endpoint.path, nil)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
|
||||
if endpoint.auth && authToken != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+authToken)
|
||||
}
|
||||
|
||||
testutils.WithStandardHeaders(req)
|
||||
|
||||
resp, err := ctx.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to make request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
for headerName, expectedValue := range expectedHeaders {
|
||||
actualValue := resp.Header.Get(headerName)
|
||||
if actualValue != expectedValue {
|
||||
t.Errorf("Endpoint %s: Expected %s header to be '%s', got '%s'", endpoint.path, headerName, expectedValue, actualValue)
|
||||
}
|
||||
}
|
||||
|
||||
csp := resp.Header.Get("Content-Security-Policy")
|
||||
if csp == "" {
|
||||
t.Errorf("Endpoint %s: Content-Security-Policy header should be present", endpoint.path)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_SQLInjectionAcrossEndpoints(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
sqlPayloads := testutils.SQLInjectionPayloads
|
||||
|
||||
t.Run("sql_injection_in_post_fields", func(t *testing.T) {
|
||||
testUser := ctx.createUserWithCleanup(t, "sqltest", "StrongPass123!")
|
||||
authClient, err := ctx.loginUserSafe(t, testUser.Username, testUser.Password)
|
||||
if err != nil {
|
||||
t.Skipf("Skipping sql injection in post fields test: %v", err)
|
||||
}
|
||||
|
||||
for i, payload := range sqlPayloads {
|
||||
t.Run(fmt.Sprintf("payload_%d", i), func(t *testing.T) {
|
||||
postData := map[string]string{
|
||||
"title": payload,
|
||||
"url": fmt.Sprintf("https://example.com/test%d", i),
|
||||
"content": "Test content",
|
||||
}
|
||||
|
||||
req, err := testutils.NewRequestBuilder("POST", ctx.baseURL+"/api/posts").
|
||||
WithAuth(authClient.Token).
|
||||
WithJSONBody(postData).
|
||||
Build()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
|
||||
resp, err := ctx.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to make request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusInternalServerError {
|
||||
t.Errorf("SQL injection in title caused server crash (500). Payload: %s", payload)
|
||||
}
|
||||
|
||||
postData2 := map[string]string{
|
||||
"title": fmt.Sprintf("Test Post %d", i),
|
||||
"url": fmt.Sprintf("https://example.com/test2-%d", i),
|
||||
"content": payload,
|
||||
}
|
||||
|
||||
req2, err := testutils.NewRequestBuilder("POST", ctx.baseURL+"/api/posts").
|
||||
WithAuth(authClient.Token).
|
||||
WithJSONBody(postData2).
|
||||
Build()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
|
||||
resp2, err := ctx.client.Do(req2)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to make request: %v", err)
|
||||
}
|
||||
defer resp2.Body.Close()
|
||||
|
||||
if resp2.StatusCode == http.StatusInternalServerError {
|
||||
t.Errorf("SQL injection in content caused server crash (500). Payload: %s", payload)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("sql_injection_in_registration_fields", func(t *testing.T) {
|
||||
for i, payload := range sqlPayloads {
|
||||
t.Run(fmt.Sprintf("payload_%d", i), func(t *testing.T) {
|
||||
regData := map[string]string{
|
||||
"username": payload,
|
||||
"email": uniqueEmail(t, fmt.Sprintf("test%d", i)),
|
||||
"password": "StrongPass123!",
|
||||
}
|
||||
|
||||
req, err := testutils.NewRequestBuilder("POST", ctx.baseURL+"/api/auth/register").
|
||||
WithJSONBody(regData).
|
||||
Build()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
|
||||
resp, err := ctx.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to make request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusInternalServerError {
|
||||
t.Errorf("SQL injection in username caused server crash (500). Payload: %s", payload)
|
||||
}
|
||||
|
||||
regData2 := map[string]string{
|
||||
"username": uniqueUsername(t, fmt.Sprintf("user%d", i)),
|
||||
"email": fmt.Sprintf("test%s@example.com", payload),
|
||||
"password": "StrongPass123!",
|
||||
}
|
||||
|
||||
req2, err := testutils.NewRequestBuilder("POST", ctx.baseURL+"/api/auth/register").
|
||||
WithJSONBody(regData2).
|
||||
Build()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
|
||||
resp2, err := ctx.client.Do(req2)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to make request: %v", err)
|
||||
}
|
||||
defer resp2.Body.Close()
|
||||
|
||||
if resp2.StatusCode == http.StatusInternalServerError {
|
||||
t.Errorf("SQL injection in email caused server crash (500). Payload: %s", payload)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("sql_injection_in_url_fields", func(t *testing.T) {
|
||||
testUser := ctx.createUserWithCleanup(t, "sqltest2", "StrongPass123!")
|
||||
authClient, err := ctx.loginUserSafe(t, testUser.Username, testUser.Password)
|
||||
if err != nil {
|
||||
t.Skipf("Skipping sql injection in url fields test: %v", err)
|
||||
}
|
||||
|
||||
for i, payload := range sqlPayloads {
|
||||
t.Run(fmt.Sprintf("payload_%d", i), func(t *testing.T) {
|
||||
postData := map[string]string{
|
||||
"title": fmt.Sprintf("Test Post %d", i),
|
||||
"url": fmt.Sprintf("https://example.com/test%s", payload),
|
||||
"content": "Test content",
|
||||
}
|
||||
|
||||
req, err := testutils.NewRequestBuilder("POST", ctx.baseURL+"/api/posts").
|
||||
WithAuth(authClient.Token).
|
||||
WithJSONBody(postData).
|
||||
Build()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
|
||||
resp, err := ctx.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to make request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusInternalServerError {
|
||||
t.Errorf("SQL injection in URL caused server crash (500). Payload: %s", payload)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("sql_injection_in_query_parameters", func(t *testing.T) {
|
||||
testUser := ctx.createUserWithCleanup(t, "sqltest3", "StrongPass123!")
|
||||
authClient, err := ctx.loginUserSafe(t, testUser.Username, testUser.Password)
|
||||
if err != nil {
|
||||
t.Skipf("Skipping sql injection in query parameters test: %v", err)
|
||||
}
|
||||
|
||||
_ = authClient.CreatePost(t, "Searchable Post", "https://example.com/search", "Content")
|
||||
|
||||
for i, payload := range sqlPayloads {
|
||||
t.Run(fmt.Sprintf("payload_%d", i), func(t *testing.T) {
|
||||
searchURL := ctx.baseURL + "/api/posts/search?q=" + url.QueryEscape(payload)
|
||||
|
||||
req, err := testutils.NewRequestBuilder("GET", searchURL).
|
||||
WithAuth(authClient.Token).
|
||||
Build()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
|
||||
resp, err := ctx.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to make request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusInternalServerError {
|
||||
t.Errorf("SQL injection in search query caused server crash (500). Payload: %s", payload)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusBadRequest && resp.StatusCode != http.StatusOK {
|
||||
t.Logf("SQL injection in search query returned status %d (acceptable if sanitized). Payload: %s", resp.StatusCode, payload)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_XSSPrevention(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
xssPayloads := testutils.XSSPayloads
|
||||
|
||||
t.Run("xss_in_post_fields", func(t *testing.T) {
|
||||
testUser := ctx.createUserWithCleanup(t, "xsstest", "StrongPass123!")
|
||||
authClient, err := ctx.loginUserSafe(t, testUser.Username, testUser.Password)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to login: %v", err)
|
||||
}
|
||||
|
||||
for idx, payload := range xssPayloads {
|
||||
t.Run(fmt.Sprintf("payload_%d", idx), func(t *testing.T) {
|
||||
postData := map[string]string{
|
||||
"title": payload,
|
||||
"url": fmt.Sprintf("https://example.com/xss-test-%d", idx),
|
||||
"content": "Test content",
|
||||
}
|
||||
|
||||
req, err := testutils.NewRequestBuilder("POST", ctx.baseURL+"/api/posts").
|
||||
WithAuth(authClient.Token).
|
||||
WithJSONBody(postData).
|
||||
Build()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
|
||||
resp, err := ctx.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to make request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusInternalServerError {
|
||||
t.Errorf("XSS payload in title caused server crash (500). Payload: %s", payload)
|
||||
}
|
||||
|
||||
if resp.StatusCode == http.StatusCreated {
|
||||
reader, cleanup, err := getResponseReader(resp)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get response reader: %v", err)
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
var postResp PostResponse
|
||||
if err := json.NewDecoder(reader).Decode(&postResp); err == nil {
|
||||
if strings.Contains(postResp.Data.Title, "<script") {
|
||||
t.Errorf("XSS payload not sanitized in title response. Payload: %s, Response: %s", payload, postResp.Data.Title)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
postData2 := map[string]string{
|
||||
"title": fmt.Sprintf("Test Post %d", idx),
|
||||
"url": fmt.Sprintf("https://example.com/xss-test2-%d", idx),
|
||||
"content": payload,
|
||||
}
|
||||
|
||||
req2, err := testutils.NewRequestBuilder("POST", ctx.baseURL+"/api/posts").
|
||||
WithAuth(authClient.Token).
|
||||
WithJSONBody(postData2).
|
||||
Build()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
|
||||
resp2, err := ctx.client.Do(req2)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to make request: %v", err)
|
||||
}
|
||||
defer resp2.Body.Close()
|
||||
|
||||
if resp2.StatusCode == http.StatusInternalServerError {
|
||||
t.Errorf("XSS payload in content caused server crash (500). Payload: %s", payload)
|
||||
}
|
||||
|
||||
if resp2.StatusCode == http.StatusCreated {
|
||||
reader, cleanup, err := getResponseReader(resp2)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get response reader: %v", err)
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
var postResp PostResponse
|
||||
if err := json.NewDecoder(reader).Decode(&postResp); err == nil {
|
||||
if strings.Contains(postResp.Data.Content, "<script") || strings.Contains(postResp.Data.Content, "javascript:") {
|
||||
t.Errorf("XSS payload not sanitized in content response. Payload: %s", payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("xss_in_username_fields", func(t *testing.T) {
|
||||
for i, payload := range xssPayloads {
|
||||
t.Run(fmt.Sprintf("payload_%d", i), func(t *testing.T) {
|
||||
regData := map[string]string{
|
||||
"username": payload,
|
||||
"email": uniqueEmail(t, fmt.Sprintf("test%d", i)),
|
||||
"password": "StrongPass123!",
|
||||
}
|
||||
|
||||
req, err := testutils.NewRequestBuilder("POST", ctx.baseURL+"/api/auth/register").
|
||||
WithJSONBody(regData).
|
||||
Build()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
|
||||
resp, err := ctx.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to make request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusInternalServerError {
|
||||
t.Errorf("XSS payload in username caused server crash (500). Payload: %s", payload)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("xss_in_search_queries", func(t *testing.T) {
|
||||
testUser := ctx.createUserWithCleanup(t, "xsstest2", "StrongPass123!")
|
||||
authClient, err := ctx.loginUserSafe(t, testUser.Username, testUser.Password)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to login: %v", err)
|
||||
}
|
||||
|
||||
_ = authClient.CreatePost(t, "Searchable Post", "https://example.com/search", "Content")
|
||||
|
||||
for i, payload := range xssPayloads {
|
||||
t.Run(fmt.Sprintf("payload_%d", i), func(t *testing.T) {
|
||||
searchURL := ctx.baseURL + "/api/posts/search?q=" + url.QueryEscape(payload)
|
||||
|
||||
req, err := testutils.NewRequestBuilder("GET", searchURL).
|
||||
WithAuth(authClient.Token).
|
||||
Build()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
|
||||
resp, err := ctx.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to make request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusInternalServerError {
|
||||
t.Errorf("XSS payload in search query caused server crash (500). Payload: %s", payload)
|
||||
}
|
||||
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
reader, cleanup, err := getResponseReader(resp)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get response reader: %v", err)
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
var searchResp PostsListResponse
|
||||
if err := json.NewDecoder(reader).Decode(&searchResp); err != nil {
|
||||
t.Fatalf("Failed to decode search response: %v", err)
|
||||
}
|
||||
|
||||
for _, post := range searchResp.Data.Posts {
|
||||
if strings.Contains(post.Title, "<script") || strings.Contains(post.Title, "javascript:") {
|
||||
t.Errorf("XSS payload not sanitized in post title. Payload: %s", payload)
|
||||
}
|
||||
if strings.Contains(post.Content, "<script") || strings.Contains(post.Content, "javascript:") {
|
||||
t.Errorf("XSS payload not sanitized in post content. Payload: %s", payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_InformationDisclosure(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("information_disclosure", func(t *testing.T) {
|
||||
t.Run("error_messages_dont_reveal_sensitive_info", func(t *testing.T) {
|
||||
request, err := testutils.NewRequestBuilder("POST", ctx.baseURL+"/api/auth/login").
|
||||
WithJSONBody(map[string]string{
|
||||
"username": "nonexistent",
|
||||
"password": "wrongpassword",
|
||||
}).
|
||||
Build()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
|
||||
resp, err := ctx.client.Do(request)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to make request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
bodyStr := string(body)
|
||||
|
||||
if strings.Contains(strings.ToLower(bodyStr), "database") {
|
||||
t.Errorf("Error message should not reveal database information")
|
||||
}
|
||||
if strings.Contains(strings.ToLower(bodyStr), "sql") {
|
||||
t.Errorf("Error message should not reveal SQL information")
|
||||
}
|
||||
if strings.Contains(strings.ToLower(bodyStr), "stack") {
|
||||
t.Errorf("Error message should not reveal stack trace")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid_endpoints_dont_reveal_structure", func(t *testing.T) {
|
||||
request, err := http.NewRequest("GET", ctx.baseURL+"/api/nonexistent/endpoint", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
|
||||
resp, err := ctx.client.Do(request)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to make request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
bodyStr := string(body)
|
||||
|
||||
if strings.Contains(strings.ToLower(bodyStr), "route") && resp.StatusCode == http.StatusNotFound {
|
||||
t.Logf("404 response may contain route information, which is acceptable")
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
func TestE2E_SecurityHeadersEnhanced(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
expectedHeaders := map[string]string{
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"X-Frame-Options": "DENY",
|
||||
"X-XSS-Protection": "1; mode=block",
|
||||
"Referrer-Policy": "strict-origin-when-cross-origin",
|
||||
}
|
||||
|
||||
t.Run("security_headers_values", func(t *testing.T) {
|
||||
testUser := ctx.createUserWithCleanup(t, "headertest", "StrongPass123!")
|
||||
authClient, err := ctx.loginUserSafe(t, testUser.Username, testUser.Password)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to login: %v", err)
|
||||
}
|
||||
|
||||
endpoints := []struct {
|
||||
name string
|
||||
method string
|
||||
path string
|
||||
auth bool
|
||||
}{
|
||||
{"health", "GET", "/health", false},
|
||||
{"metrics", "GET", "/metrics", false},
|
||||
{"api_posts", "GET", "/api/posts", true},
|
||||
{"api_auth_me", "GET", "/api/auth/me", true},
|
||||
}
|
||||
|
||||
for _, endpoint := range endpoints {
|
||||
t.Run(endpoint.name, func(t *testing.T) {
|
||||
req, err := http.NewRequest(endpoint.method, ctx.baseURL+endpoint.path, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
|
||||
if endpoint.auth && authClient != nil {
|
||||
req.Header.Set("Authorization", "Bearer "+authClient.Token)
|
||||
}
|
||||
testutils.WithStandardHeaders(req)
|
||||
|
||||
resp, err := ctx.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to make request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
for headerName, expectedValue := range expectedHeaders {
|
||||
actualValue := resp.Header.Get(headerName)
|
||||
if actualValue != expectedValue {
|
||||
t.Errorf("Endpoint %s: Expected %s header to be '%s', got '%s'", endpoint.path, headerName, expectedValue, actualValue)
|
||||
}
|
||||
}
|
||||
|
||||
csp := resp.Header.Get("Content-Security-Policy")
|
||||
if csp == "" {
|
||||
t.Errorf("Endpoint %s: Content-Security-Policy header should be present", endpoint.path)
|
||||
}
|
||||
|
||||
if strings.Contains(csp, "unsafe-inline") && !strings.Contains(csp, "'nonce-") {
|
||||
t.Errorf("Endpoint %s: CSP contains unsafe-inline without nonce", endpoint.path)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("hsts_header", func(t *testing.T) {
|
||||
req, err := http.NewRequest("GET", ctx.baseURL+"/health", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
req.Header.Set("X-Forwarded-Proto", "https")
|
||||
testutils.WithStandardHeaders(req)
|
||||
|
||||
resp, err := ctx.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to make request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
hsts := resp.Header.Get("Strict-Transport-Security")
|
||||
if hsts == "" {
|
||||
t.Error("HSTS header should be present for HTTPS requests")
|
||||
}
|
||||
|
||||
if !strings.Contains(hsts, "max-age=") {
|
||||
t.Errorf("HSTS header should contain max-age, got: %s", hsts)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_ParameterizedQueries(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("sql_injection_prevention", func(t *testing.T) {
|
||||
testUser := ctx.createUserWithCleanup(t, "sqltest", "StrongPass123!")
|
||||
authClient, err := ctx.loginUserSafe(t, testUser.Username, testUser.Password)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to login: %v", err)
|
||||
}
|
||||
|
||||
for i, payload := range testutils.SQLInjectionPayloads {
|
||||
t.Run(fmt.Sprintf("payload_%d", i), func(t *testing.T) {
|
||||
postData := map[string]string{
|
||||
"title": payload,
|
||||
"url": fmt.Sprintf("https://example.com/test%d", i),
|
||||
"content": "Test content",
|
||||
}
|
||||
|
||||
req, err := testutils.NewRequestBuilder("POST", ctx.baseURL+"/api/posts").
|
||||
WithAuth(authClient.Token).
|
||||
WithJSONBody(postData).
|
||||
Build()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
|
||||
resp, err := ctx.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to make request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusInternalServerError {
|
||||
t.Errorf("SQL injection in title caused server error (500). Payload: %s", payload)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Errorf("Expected post creation to succeed (parameterized queries prevent SQL injection), got status: %d", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("search_sanitization", func(t *testing.T) {
|
||||
testUser := ctx.createUserWithCleanup(t, "searchtest", "StrongPass123!")
|
||||
authClient, err := ctx.loginUserSafe(t, testUser.Username, testUser.Password)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to login: %v", err)
|
||||
}
|
||||
|
||||
_ = authClient.CreatePost(t, "Searchable Post", "https://example.com/search", "Content")
|
||||
|
||||
for i, payload := range testutils.SQLInjectionPayloads {
|
||||
t.Run(fmt.Sprintf("payload_%d", i), func(t *testing.T) {
|
||||
searchURL := ctx.baseURL + "/api/posts/search?q=" + url.QueryEscape(payload)
|
||||
|
||||
req, err := testutils.NewRequestBuilder("GET", searchURL).
|
||||
WithAuth(authClient.Token).
|
||||
Build()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
|
||||
resp, err := ctx.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to make request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusInternalServerError {
|
||||
t.Errorf("SQL injection in search query caused server error (500). Payload: %s", payload)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_TokenHashing(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("verification_token_hashed", func(t *testing.T) {
|
||||
testUser := ctx.createUserWithCleanup(t, "hashtest", "StrongPass123!")
|
||||
authClient, err := ctx.loginUserSafe(t, testUser.Username, testUser.Password)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to login: %v", err)
|
||||
}
|
||||
|
||||
ctx.server.EmailSender.Reset()
|
||||
authClient.RegisterUser(t, "newuser", "newuser@example.com", "Password123!")
|
||||
|
||||
verificationToken := ctx.server.EmailSender.VerificationToken()
|
||||
if verificationToken == "" {
|
||||
t.Fatal("Expected verification token to be generated")
|
||||
}
|
||||
|
||||
user, err := ctx.server.UserRepo.GetByUsername("newuser")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get user: %v", err)
|
||||
}
|
||||
|
||||
if user.EmailVerificationToken == verificationToken {
|
||||
t.Error("Verification token should be hashed in database")
|
||||
}
|
||||
|
||||
if len(user.EmailVerificationToken) < 32 {
|
||||
t.Errorf("Hashed token should be at least 32 characters, got %d", len(user.EmailVerificationToken))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("password_reset_token_hashed", func(t *testing.T) {
|
||||
testUser := ctx.createUserWithCleanup(t, "resettest", "StrongPass123!")
|
||||
ctx.server.EmailSender.Reset()
|
||||
|
||||
testutils.RequestPasswordReset(t, ctx.client, ctx.baseURL, testUser.Email, testutils.GenerateTestIP())
|
||||
|
||||
resetToken := ctx.server.EmailSender.PasswordResetToken()
|
||||
if resetToken == "" {
|
||||
t.Skip("Rate limited, skipping token hashing test")
|
||||
return
|
||||
}
|
||||
|
||||
hash := sha256.Sum256([]byte(resetToken))
|
||||
tokenHash := hex.EncodeToString(hash[:])
|
||||
deletionRepo := repositories.NewAccountDeletionRepository(ctx.server.DB)
|
||||
_, err := deletionRepo.GetByTokenHash(tokenHash)
|
||||
if err == nil {
|
||||
t.Log("Password reset token appears to be stored hashed")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_SecurityHeaderCombinations(t *testing.T) {
|
||||
ctx := setupTestContext(t)
|
||||
|
||||
t.Run("all_security_headers_present", func(t *testing.T) {
|
||||
req, err := http.NewRequest("GET", ctx.baseURL+"/health", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
testutils.WithStandardHeaders(req)
|
||||
|
||||
resp, err := ctx.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to make request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
requiredHeaders := []string{
|
||||
"X-Content-Type-Options",
|
||||
"X-Frame-Options",
|
||||
"X-XSS-Protection",
|
||||
"Referrer-Policy",
|
||||
"Content-Security-Policy",
|
||||
}
|
||||
|
||||
for _, header := range requiredHeaders {
|
||||
if resp.Header.Get(header) == "" {
|
||||
t.Errorf("Required security header missing: %s", header)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user