Files
tokens-reef/backend/internal/server/api_security_test.go

605 lines
16 KiB
Go
Raw Permalink Normal View History

//go:build security
package server_test
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/handler"
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestAPI_Security_BrokenObjectLevelAuthorization 测试IDOR/BOLA漏洞
// OWASP API Top 10 #1: Broken Object Level Authorization
func TestAPI_Security_BrokenObjectLevelAuthorization(t *testing.T) {
gin.SetMode(gin.TestMode)
tests := []struct {
name string
setup func() (*gin.Engine, map[string]string)
method string
path string
body string
userToken string
wantStatus int
wantErrContain string
}{
{
name: "用户不应访问其他用户的API Key详情",
setup: func() (*gin.Engine, map[string]string) {
// 返回测试路由和token映射
router, tokens := setupSecurityTestEnvironment()
return router, tokens
},
method: http.MethodGet,
path: "/api/v1/keys/99999", // 其他用户的key ID
userToken: "user_a_token",
wantStatus: http.StatusForbidden,
wantErrContain: "forbidden",
},
{
name: "用户不应修改其他用户的API Key",
setup: func() (*gin.Engine, map[string]string) {
router, tokens := setupSecurityTestEnvironment()
return router, tokens
},
method: http.MethodPut,
path: "/api/v1/keys/99999",
body: `{"name": "hacked"}`,
userToken: "user_a_token",
wantStatus: http.StatusForbidden,
wantErrContain: "forbidden",
},
{
name: "用户不应删除其他用户的API Key",
setup: func() (*gin.Engine, map[string]string) {
router, tokens := setupSecurityTestEnvironment()
return router, tokens
},
method: http.MethodDelete,
path: "/api/v1/keys/99999",
userToken: "user_a_token",
wantStatus: http.StatusForbidden,
wantErrContain: "forbidden",
},
{
name: "普通用户不应访问管理员接口",
setup: func() (*gin.Engine, map[string]string) {
router, tokens := setupSecurityTestEnvironment()
return router, tokens
},
method: http.MethodGet,
path: "/api/v1/admin/users",
userToken: "user_token",
wantStatus: http.StatusForbidden,
wantErrContain: "admin",
},
{
name: "普通用户不应查看其他用户信息",
setup: func() (*gin.Engine, map[string]string) {
router, tokens := setupSecurityTestEnvironment()
return router, tokens
},
method: http.MethodGet,
path: "/api/v1/admin/users/2",
userToken: "user_token",
wantStatus: http.StatusForbidden,
wantErrContain: "admin",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
router, tokens := tt.setup()
var bodyReader *bytes.Reader
if tt.body != "" {
bodyReader = bytes.NewReader([]byte(tt.body))
} else {
bodyReader = bytes.NewReader([]byte{})
}
req := httptest.NewRequest(tt.method, tt.path, bodyReader)
req.Header.Set("Authorization", "Bearer "+tokens[tt.userToken])
if tt.body != "" {
req.Header.Set("Content-Type", "application/json")
}
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, tt.wantStatus, w.Code, "状态码不匹配")
if tt.wantErrContain != "" {
assert.Contains(t, strings.ToLower(w.Body.String()), strings.ToLower(tt.wantErrContain))
}
})
}
}
// TestAPI_Security_BrokenAuthentication 测试认证漏洞
// OWASP API Top 10 #2: Broken Authentication
func TestAPI_Security_BrokenAuthentication(t *testing.T) {
gin.SetMode(gin.TestMode)
tests := []struct {
name string
method string
path string
token string
body string
wantStatus int
wantErrContain string
}{
{
name: "无效令牌应返回401",
method: http.MethodGet,
path: "/api/v1/keys",
token: "invalid_token_here",
wantStatus: http.StatusUnauthorized,
wantErrContain: "unauthorized",
},
{
name: "过期令牌应返回401",
method: http.MethodGet,
path: "/api/v1/keys",
token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE1MDAwMDAwMDB9.invalid",
wantStatus: http.StatusUnauthorized,
wantErrContain: "unauthorized",
},
{
name: "空令牌应返回401",
method: http.MethodGet,
path: "/api/v1/keys",
token: "",
wantStatus: http.StatusUnauthorized,
wantErrContain: "unauthorized",
},
{
name: "缺少Authorization头应返回401",
method: http.MethodGet,
path: "/api/v1/keys",
token: "NO_HEADER",
wantStatus: http.StatusUnauthorized,
wantErrContain: "unauthorized",
},
{
name: "错误格式的Bearer令牌应返回401",
method: http.MethodGet,
path: "/api/v1/keys",
token: "Basic dXNlcjpwYXNz", // Basic auth instead of Bearer
wantStatus: http.StatusUnauthorized,
wantErrContain: "unauthorized",
},
}
router, _ := setupSecurityTestEnvironment()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(tt.method, tt.path, nil)
if tt.token != "NO_HEADER" {
if tt.token != "" {
req.Header.Set("Authorization", "Bearer "+tt.token)
} else {
req.Header.Set("Authorization", "")
}
}
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, tt.wantStatus, w.Code)
if tt.wantErrContain != "" {
assert.Contains(t, strings.ToLower(w.Body.String()), strings.ToLower(tt.wantErrContain))
}
})
}
}
// TestAPI_Security_InputValidation 测试输入验证
// OWASP API Top 10 #6: Unrestricted Access to Sensitive Business Flows
func TestAPI_Security_InputValidation(t *testing.T) {
gin.SetMode(gin.TestMode)
tests := []struct {
name string
method string
path string
body string
query string
wantStatus int
validateFn func(t *testing.T, body string)
}{
{
name: "SQL注入尝试应被阻止或无害化",
method: http.MethodGet,
path: "/api/v1/admin/users",
query: "search='; DROP TABLE users; --",
wantStatus: http.StatusBadRequest,
},
{
name: "XSS尝试应在响应中被转义",
method: http.MethodPost,
path: "/api/v1/keys",
body: `{"name": "<script>alert('xss')</script>"}`,
wantStatus: http.StatusOK,
validateFn: func(t *testing.T, body string) {
// 确保响应中没有未转义的脚本标签
assert.NotContains(t, body, "<script>")
assert.Contains(t, body, "\u003cscript\u003e") // JSON转义
},
},
{
name: "命令注入尝试应被阻止",
method: http.MethodGet,
path: "/api/v1/admin/users",
query: "search=$(whoami)",
wantStatus: http.StatusBadRequest,
},
{
name: "路径遍历尝试应被阻止",
method: http.MethodGet,
path: "/api/v1/admin/backups/s3-config",
query: "path=../../../etc/passwd",
wantStatus: http.StatusBadRequest,
},
{
name: "NoSQL注入尝试应被阻止",
method: http.MethodGet,
path: "/api/v1/admin/users",
query: `search={"$ne": null}`,
wantStatus: http.StatusBadRequest,
},
{
name: "超大请求体应被拒绝",
method: http.MethodPost,
path: "/api/v1/keys",
body: fmt.Sprintf(`{"name": "%s"}`, strings.Repeat("a", 10*1024*1024)),
wantStatus: http.StatusRequestEntityTooLarge,
},
{
name: "特殊Unicode字符应被正确处理",
method: http.MethodPost,
path: "/api/v1/keys",
body: `{"name": "测试🔐Keyñ"}`,
wantStatus: http.StatusOK,
},
{
name: "空JSON体应返回400",
method: http.MethodPost,
path: "/api/v1/keys",
body: `{}`,
wantStatus: http.StatusBadRequest,
},
{
name: "无效JSON格式应返回400",
method: http.MethodPost,
path: "/api/v1/keys",
body: `{"name": "test",}`,
wantStatus: http.StatusBadRequest,
},
{
name: "数组溢出尝试应被阻止",
method: http.MethodPost,
path: "/api/v1/admin/accounts/batch",
body: fmt.Sprintf(`{"accounts": [%s]}`, strings.Repeat(`{"name":"test"},`, 10001)),
wantStatus: http.StatusBadRequest,
},
}
router, tokens := setupSecurityTestEnvironment()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
path := tt.path
if tt.query != "" {
path = path + "?" + tt.query
}
var bodyReader *bytes.Reader
if tt.body != "" {
bodyReader = bytes.NewReader([]byte(tt.body))
} else {
bodyReader = bytes.NewReader([]byte{})
}
req := httptest.NewRequest(tt.method, path, bodyReader)
req.Header.Set("Authorization", "Bearer "+tokens["admin_token"])
if tt.body != "" {
req.Header.Set("Content-Type", "application/json")
}
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, tt.wantStatus, w.Code)
if tt.validateFn != nil {
tt.validateFn(t, w.Body.String())
}
})
}
}
// TestAPI_Security_RateLimiting 测试速率限制
// OWASP API Top 10 #4: Unrestricted Resource Consumption
func TestAPI_Security_RateLimiting(t *testing.T) {
gin.SetMode(gin.TestMode)
router, _ := setupSecurityTestEnvironment()
tests := []struct {
name string
method string
path string
requests int
want429After int
wantErrContain string
}{
{
name: "登录接口应限制速率",
method: http.MethodPost,
path: "/api/v1/auth/login",
requests: 25,
want429After: 20,
},
{
name: "注册接口应限制速率",
method: http.MethodPost,
path: "/api/v1/auth/register",
requests: 10,
want429After: 5,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var rateLimited bool
var rateLimitCount int
for i := 0; i < tt.requests; i++ {
body := bytes.NewReader([]byte(`{"email":"test@test.com","password":"test"}`))
req := httptest.NewRequest(tt.method, tt.path, body)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code == http.StatusTooManyRequests {
rateLimited = true
rateLimitCount++
}
}
assert.True(t, rateLimited, "应该在 %d 请求后触发速率限制", tt.want429After)
t.Logf("Rate limited after %d requests", tt.requests-rateLimitCount)
})
}
}
// TestAPI_Security_SensitiveDataExposure 测试敏感数据泄露
// OWASP API Top 10 #3: Broken Object Property Level Authorization
func TestAPI_Security_SensitiveDataExposure(t *testing.T) {
gin.SetMode(gin.TestMode)
router, tokens := setupSecurityTestEnvironment()
tests := []struct {
name string
method string
path string
token string
forbiddenFields []string
}{
{
name: "API响应不应包含密码哈希",
method: http.MethodGet,
path: "/api/v1/auth/me",
token: "user_token",
forbiddenFields: []string{
"password",
"password_hash",
"hashed_password",
"pwd",
},
},
{
name: "API Key响应不应包含完整密钥",
method: http.MethodGet,
path: "/api/v1/keys",
token: "user_token",
forbiddenFields: []string{
// 列表接口通常只显示key的后几位
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(tt.method, tt.path, nil)
req.Header.Set("Authorization", "Bearer "+tokens[tt.token])
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
body := w.Body.String()
for _, field := range tt.forbiddenFields {
assert.NotContains(t, strings.ToLower(body), strings.ToLower(field),
"响应中不应包含敏感字段: %s", field)
}
})
}
}
// TestAPI_Security_CORS 测试CORS配置
func TestAPI_Security_CORS(t *testing.T) {
gin.SetMode(gin.TestMode)
router, _ := setupSecurityTestEnvironment()
tests := []struct {
name string
origin string
wantAllowOrigin string
wantMethods string
wantHeaders string
}{
{
name: "允许的Origin应返回正确的CORS头",
origin: "https://app.example.com",
wantAllowOrigin: "https://app.example.com",
wantMethods: "GET, POST, PUT, DELETE, OPTIONS",
wantHeaders: "Authorization, Content-Type",
},
{
name: "不允许的Origin不应返回Access-Control-Allow-Origin",
origin: "https://evil.com",
wantAllowOrigin: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodOptions, "/api/v1/keys", nil)
req.Header.Set("Origin", tt.origin)
req.Header.Set("Access-Control-Request-Method", "POST")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
allowOrigin := w.Header().Get("Access-Control-Allow-Origin")
assert.Equal(t, tt.wantAllowOrigin, allowOrigin)
})
}
}
// TestAPI_Security_SSRF 测试SSRF漏洞
// OWASP API Top 10 #7: Server Side Request Forgery
func TestAPI_Security_SSRF(t *testing.T) {
gin.SetMode(gin.TestMode)
router, tokens := setupSecurityTestEnvironment()
tests := []struct {
name string
method string
path string
body string
wantStatus int
}{
{
name: "不应允许访问内部IP",
method: http.MethodPost,
path: "/api/v1/admin/accounts/1/test",
body: `{"url": "http://169.254.169.254/latest/meta-data/"}`,
wantStatus: http.StatusBadRequest,
},
{
name: "不应允许访问localhost",
method: http.MethodPost,
path: "/api/v1/admin/accounts/1/test",
body: `{"url": "http://localhost:8080/admin"}`,
wantStatus: http.StatusBadRequest,
},
{
name: "不应允许访问私有IP段",
method: http.MethodPost,
path: "/api/v1/admin/accounts/1/test",
body: `{"url": "http://10.0.0.1/internal"}`,
wantStatus: http.StatusBadRequest,
},
{
name: "不应允许file协议",
method: http.MethodPost,
path: "/api/v1/admin/accounts/1/test",
body: `{"url": "file:///etc/passwd"}`,
wantStatus: http.StatusBadRequest,
},
{
name: "不应允许gopher协议",
method: http.MethodPost,
path: "/api/v1/admin/accounts/1/test",
body: `{"url": "gopher://internal:9000/"}`,
wantStatus: http.StatusBadRequest,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
bodyReader := bytes.NewReader([]byte(tt.body))
req := httptest.NewRequest(tt.method, tt.path, bodyReader)
req.Header.Set("Authorization", "Bearer "+tokens["admin_token"])
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, tt.wantStatus, w.Code)
})
}
}
// setupSecurityTestEnvironment 设置安全测试环境
func setupSecurityTestEnvironment() (*gin.Engine, map[string]string) {
// 这是一个mock实现实际应该使用测试数据库和真实的认证逻辑
router := gin.New()
// Mock tokens
tokens := map[string]string{
"admin_token": "mock_admin_jwt_token",
"user_token": "mock_user_jwt_token",
"user_a_token": "mock_user_a_jwt_token",
"user_b_token": "mock_user_b_jwt_token",
}
// 这里应该设置实际的路由和中间件
// 为了示例,我们只是返回一个基本的路由器
return router, tokens
}
// TestAPI_Security_Headers 测试安全响应头
func TestAPI_Security_Headers(t *testing.T) {
gin.SetMode(gin.TestMode)
router, tokens := setupSecurityTestEnvironment()
req := httptest.NewRequest(http.MethodGet, "/api/v1/keys", nil)
req.Header.Set("Authorization", "Bearer "+tokens["user_token"])
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
// 检查安全响应头
securityHeaders := map[string]string{
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY",
"X-XSS-Protection": "1; mode=block",
"Strict-Transport-Security": "max-age=31536000; includeSubDomains",
"Content-Security-Policy": "default-src 'self'",
}
for header, expected := range securityHeaders {
t.Run(fmt.Sprintf("检查 %s 头", header), func(t *testing.T) {
value := w.Header().Get(header)
if value == "" {
t.Logf("警告: %s 头未设置", header)
} else {
assert.Contains(t, value, expected)
}
})
}
}