Files
tokens-reef/backend/internal/server/api_security_test.go
Developer 349d783fd1 refactor: clean up project structure
- Remove old review reports (keep latest only)
- Move docs/ to deploy/docs-backup/
- Move performance-testing/ to deploy/
- Clean up test output files
- Organize root directory
2026-04-06 23:36:03 +08:00

605 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//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)
}
})
}
}