605 lines
16 KiB
Go
605 lines
16 KiB
Go
|
|
//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)
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
}
|