Files
opsxapi/apps/server/internal/mocksrv/server.go
T
logan 0edd3e674a Mock 地址安全加固:空间 ID → 16 位随机 mock_token
- 问题:/mock/{自增空间ID} 可枚举且服务绑定所有网卡,局域网可猜他人空间
- workspaces 新增 mock_token(UNIQUE,随机 8 字节 hex;老库自动 ALTER + 回填)
- /mock/{token}/path:解析失败统一 404(不暴露存在性);限流 key 沿用 token
- /api/me 与发布响应返回 token 形态 base;弹窗 URL 改用 token
- 验证:token URL 正常随机生成;旧数字 ID 与任意伪造 token 均 404
2026-09-18 12:33:58 +08:00

185 lines
3.9 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.
package mocksrv
import (
"net/http"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
)
/* ---------------- 存储 ---------------- */
// Endpoint 一条已发布的 Mock 接口。
type Endpoint struct {
Method string `json:"method"`
Path string `json:"path"`
Status int `json:"status"`
ContentType string `json:"contentType"`
Body string `json:"body"`
DelayMs int `json:"delayMs"`
}
type storeAPI interface {
ReplaceMockEndpoints(workspaceID int64, list []Endpoint) error
FindMockEndpoint(workspaceID int64, method, path string) (*Endpoint, bool, error)
ResolveMockToken(token string) (int64, bool, error)
}
/* ---------------- 限流:20 QPS / 60 QPM(按 IP + 空间,滑动窗口) ---------------- */
const (
LimitQPS = 20
LimitQPM = 60
)
type slot struct {
at int64 // 秒时间戳
n int64
}
type bucket struct {
slots [60]slot
}
type limiter struct {
mu sync.Mutex
buckets map[string]*bucket
}
func newLimiter() *limiter { return &limiter{buckets: map[string]*bucket{}} }
// allow 滑动窗口判定:最近 1 秒 ≤ 20 且最近 60 秒 ≤ 60。
func (l *limiter) allow(key string, now time.Time) bool {
l.mu.Lock()
b, ok := l.buckets[key]
if !ok {
b = &bucket{}
l.buckets[key] = b
if len(l.buckets) > 10000 { // 粗略防膨胀
l.buckets = map[string]*bucket{key: b}
}
}
l.mu.Unlock()
s := now.Unix()
cur := &b.slots[s%60]
if cur.at != s {
cur.at = s
cur.n = 0
}
var minCount int64
for i := range b.slots {
if b.slots[i].at > s-60 {
minCount += b.slots[i].n
}
}
if cur.n >= LimitQPS || minCount >= LimitQPM {
return false
}
cur.n++
return true
}
/* ---------------- 服务 ---------------- */
type Server struct {
store storeAPI
lim *limiter
}
func New(store storeAPI) *Server {
return &Server{store: store, lim: newLimiter()}
}
func (s *Server) Register(r *gin.Engine) {
g := r.Group("/mock")
g.Any("/:wsid/*path", s.handle)
}
// handle:发布查询 → 限流 → 延迟 → 生成
func (s *Server) handle(c *gin.Context) {
wsid := c.Param("wsid")
path := normalizePath(c.Param("path"))
method := strings.ToUpper(c.Query("_method"))
if method == "" {
method = strings.ToUpper(c.Request.Method)
}
if method == "HEAD" {
method = "GET"
}
key := clientIP(c) + "|" + wsid
if !s.lim.allow(key, time.Now()) {
c.Header("Retry-After", "60")
c.JSON(http.StatusTooManyRequests, gin.H{
"error": "请求过于频繁,已限流(20 QPS / 60 QPM)",
"terms": "/terms",
"reason": "服务条款明确禁止滥用:通过技术手段规避限流属于违规行为",
})
return
}
// wsid 实为 mock_token:不可枚举、不暴露空间归属;解析失败统一 404
wsID, ok, err := s.store.ResolveMockToken(wsid)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "mock 存储读取失败"})
return
}
if !ok {
c.JSON(http.StatusNotFound, gin.H{"error": "Mock 地址无效或已失效"})
return
}
ep, found, err := s.store.FindMockEndpoint(wsID, method, path)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "mock 存储读取失败"})
return
}
if !found {
c.JSON(http.StatusNotFound, gin.H{
"error": "Mock 接口不存在:" + method + " " + path + ",请先在客户端「Mock 链接」中发布",
})
return
}
if ep.DelayMs > 0 {
if ep.DelayMs > 5000 {
ep.DelayMs = 5000
}
time.Sleep(time.Duration(ep.DelayMs) * time.Millisecond)
}
ct := ep.ContentType
if ct == "" {
ct = "application/json"
}
c.Data(ep.statusOrOK(), ct, []byte(GenerateJSON(ep.Body)))
}
func (e *Endpoint) statusOrOK() int {
if e.Status <= 0 {
return http.StatusOK
}
return e.Status
}
func normalizePath(p string) string {
if p == "" {
return "/"
}
if !strings.HasPrefix(p, "/") {
p = "/" + p
}
if len(p) > 1 {
p = strings.TrimSuffix(p, "/")
}
return p
}
func clientIP(c *gin.Context) string {
ip := c.ClientIP()
if ip == "" {
ip = "unknown"
}
return ip
}