luckfun233 commited on
Commit
33b8f8e
·
1 Parent(s): c37473d

feat: 增强管理员登录安全性,添加IP失败计数器以防止字典攻击,限制请求体大小,优化配置文件权限

Browse files
entrypoint.sh CHANGED
@@ -14,7 +14,7 @@ fi
14
  # was created by a previous container with different ownership (common on
15
  # HuggingFace Spaces persistent storage after rebuild).
16
  chown ds2api:ds2api /data/config.json 2>/dev/null || true
17
- chmod 644 /data/config.json 2>/dev/null || true
18
 
19
  # Create chat history file if not exists or is empty
20
  if [ ! -f /data/chat_history.json ] || [ ! -s /data/chat_history.json ]; then
 
14
  # was created by a previous container with different ownership (common on
15
  # HuggingFace Spaces persistent storage after rebuild).
16
  chown ds2api:ds2api /data/config.json 2>/dev/null || true
17
+ chmod 600 /data/config.json 2>/dev/null || true
18
 
19
  # Create chat history file if not exists or is empty
20
  if [ ! -f /data/chat_history.json ] || [ ! -s /data/chat_history.json ]; then
internal/config/store.go CHANGED
@@ -151,7 +151,7 @@ func loadConfigFromFile(path string) (Config, error) {
151
  cfg.DropInvalidAccounts()
152
  if strings.Contains(string(content), `"test_status"`) && !IsVercel() {
153
  if b, err := json.MarshalIndent(cfg, "", " "); err == nil {
154
- _ = overwriteFile(path, b, 0o644)
155
  }
156
  }
157
  // 加载成功后刷新可用备份(仅当当前文件合法时才覆盖备份,避免用损坏
 
151
  cfg.DropInvalidAccounts()
152
  if strings.Contains(string(content), `"test_status"`) && !IsVercel() {
153
  if b, err := json.MarshalIndent(cfg, "", " "); err == nil {
154
+ _ = overwriteFile(path, b, configFilePerm())
155
  }
156
  }
157
  // 加载成功后刷新可用备份(仅当当前文件合法时才覆盖备份,避免用损坏
internal/config/store_env_writeback.go CHANGED
@@ -50,10 +50,16 @@ func overwriteFile(path string, b []byte, perm os.FileMode) error {
50
  return nil
51
  }
52
 
 
 
 
 
 
 
53
  func writeConfigBytes(path string, b []byte) error {
54
  dir := filepath.Dir(path)
55
  if dir == "." || dir == "" {
56
- return overwriteFile(path, b, 0o644)
57
  }
58
  if err := os.MkdirAll(dir, 0o755); err != nil {
59
  return fmt.Errorf("mkdir config dir: %w", err)
@@ -88,13 +94,15 @@ func writeConfigBytes(path string, b []byte) error {
88
  // Try removing the old file and renaming again.
89
  if removeErr := os.Remove(path); removeErr == nil {
90
  if renameErr := os.Rename(tmpName, path); renameErr == nil {
 
91
  return nil
92
  }
93
  }
94
  // Rename still failing (e.g. cross-filesystem); fall back to direct write.
95
  _ = os.Remove(tmpName)
96
- return overwriteFile(path, b, 0o644)
97
  }
 
98
  return nil
99
  }
100
 
@@ -104,7 +112,7 @@ func backupConfigFile(path string, content []byte) error {
104
  if len(content) == 0 {
105
  return nil
106
  }
107
- return overwriteFile(path+".bak", content, 0o644)
108
  }
109
 
110
  // resetConfigToEmpty 把 path 重置为 {},用于损坏文件无可恢复备份时的兜底。
@@ -123,7 +131,7 @@ func resetConfigToEmpty(path string) {
123
  // config.json 被截断成半截 JSON)。
124
  func recoverCorruptConfig(path string, corruptContent []byte) bool {
125
  corruptPath := fmt.Sprintf("%s.corrupt.%d", path, time.Now().Unix())
126
- if werr := overwriteFile(corruptPath, corruptContent, 0o644); werr != nil {
127
  Logger.Warn("[config] failed to preserve corrupt config for inspection", "path", corruptPath, "error", werr)
128
  }
129
 
 
50
  return nil
51
  }
52
 
53
+ // configFilePerm 返回配置文件的权限位。
54
+ // 0600 (owner-only read/write) 防止同机其他用户读取明文凭据。
55
+ func configFilePerm() os.FileMode {
56
+ return 0o600
57
+ }
58
+
59
  func writeConfigBytes(path string, b []byte) error {
60
  dir := filepath.Dir(path)
61
  if dir == "." || dir == "" {
62
+ return overwriteFile(path, b, configFilePerm())
63
  }
64
  if err := os.MkdirAll(dir, 0o755); err != nil {
65
  return fmt.Errorf("mkdir config dir: %w", err)
 
94
  // Try removing the old file and renaming again.
95
  if removeErr := os.Remove(path); removeErr == nil {
96
  if renameErr := os.Rename(tmpName, path); renameErr == nil {
97
+ _ = os.Chmod(path, configFilePerm())
98
  return nil
99
  }
100
  }
101
  // Rename still failing (e.g. cross-filesystem); fall back to direct write.
102
  _ = os.Remove(tmpName)
103
+ return overwriteFile(path, b, configFilePerm())
104
  }
105
+ _ = os.Chmod(path, configFilePerm())
106
  return nil
107
  }
108
 
 
112
  if len(content) == 0 {
113
  return nil
114
  }
115
+ return overwriteFile(path+".bak", content, configFilePerm())
116
  }
117
 
118
  // resetConfigToEmpty 把 path 重置为 {},用于损坏文件无可恢复备份时的兜底。
 
131
  // config.json 被截断成半截 JSON)。
132
  func recoverCorruptConfig(path string, corruptContent []byte) bool {
133
  corruptPath := fmt.Sprintf("%s.corrupt.%d", path, time.Now().Unix())
134
+ if werr := overwriteFile(corruptPath, corruptContent, configFilePerm()); werr != nil {
135
  Logger.Warn("[config] failed to preserve corrupt config for inspection", "path", corruptPath, "error", werr)
136
  }
137
 
internal/httpapi/admin/auth/handler_auth.go CHANGED
@@ -21,14 +21,21 @@ func (h *Handler) requireAdmin(next http.Handler) http.Handler {
21
  }
22
 
23
  func (h *Handler) login(w http.ResponseWriter, r *http.Request) {
 
 
 
 
 
24
  var req map[string]any
25
  _ = json.NewDecoder(r.Body).Decode(&req)
26
  adminKey, _ := req["admin_key"].(string)
27
  expireHours := intFrom(req["expire_hours"])
28
  if !authn.VerifyAdminCredential(adminKey, h.Store) {
 
29
  writeJSON(w, http.StatusUnauthorized, map[string]any{"detail": "Invalid admin key"})
30
  return
31
  }
 
32
  token, err := authn.CreateJWTWithStore(expireHours, h.Store)
33
  if err != nil {
34
  writeJSON(w, http.StatusInternalServerError, map[string]any{"detail": err.Error()})
 
21
  }
22
 
23
  func (h *Handler) login(w http.ResponseWriter, r *http.Request) {
24
+ ip := clientIP(r)
25
+ if !defaultLoginLimiter.allowLogin(ip) {
26
+ writeJSON(w, http.StatusTooManyRequests, map[string]any{"detail": "Too many failed login attempts. Please try again later."})
27
+ return
28
+ }
29
  var req map[string]any
30
  _ = json.NewDecoder(r.Body).Decode(&req)
31
  adminKey, _ := req["admin_key"].(string)
32
  expireHours := intFrom(req["expire_hours"])
33
  if !authn.VerifyAdminCredential(adminKey, h.Store) {
34
+ defaultLoginLimiter.recordFailure(ip)
35
  writeJSON(w, http.StatusUnauthorized, map[string]any{"detail": "Invalid admin key"})
36
  return
37
  }
38
+ defaultLoginLimiter.recordSuccess(ip)
39
  token, err := authn.CreateJWTWithStore(expireHours, h.Store)
40
  if err != nil {
41
  writeJSON(w, http.StatusInternalServerError, map[string]any{"detail": err.Error()})
internal/httpapi/admin/auth/login_rate_limit.go ADDED
@@ -0,0 +1,109 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package auth
2
+
3
+ import (
4
+ "net/http"
5
+ "sync"
6
+ "time"
7
+ )
8
+
9
+ // loginRateLimiter 进程级 IP 失败计数器,防止 admin key 字典攻击。
10
+ // 同一 IP 在 window 内连续失败 maxFailures 次后,lockout 期间拒绝所有登录请求。
11
+ // 注意:HF Spaces 多副本部署时,每个副本独立计数,可被绕过。
12
+ type loginRateLimiter struct {
13
+ mu sync.Mutex
14
+ failures map[string]*loginFailState
15
+ maxFailures int
16
+ window time.Duration // 失败计数累计窗口
17
+ lockout time.Duration // 触发后封禁时长
18
+ }
19
+
20
+ type loginFailState struct {
21
+ count int
22
+ lastFailAt time.Time
23
+ lockoutUntil time.Time
24
+ }
25
+
26
+ var defaultLoginLimiter = newLoginRateLimiter(5, 5*time.Minute, 5*time.Minute)
27
+
28
+ func newLoginRateLimiter(maxFailures int, window, lockout time.Duration) *loginRateLimiter {
29
+ return &loginRateLimiter{
30
+ failures: map[string]*loginFailState{},
31
+ maxFailures: maxFailures,
32
+ window: window,
33
+ lockout: lockout,
34
+ }
35
+ }
36
+
37
+ // allowLogin 检查 IP 是否被封禁。返回是否允许尝试登录。
38
+ func (l *loginRateLimiter) allowLogin(ip string) bool {
39
+ if ip == "" {
40
+ return true
41
+ }
42
+ l.mu.Lock()
43
+ defer l.mu.Unlock()
44
+ now := time.Now()
45
+ state, ok := l.failures[ip]
46
+ if !ok {
47
+ return true
48
+ }
49
+ if now.Before(state.lockoutUntil) {
50
+ return false
51
+ }
52
+ return true
53
+ }
54
+
55
+ // recordFailure 记录一次登录失败。达到阈值后进入封禁。
56
+ func (l *loginRateLimiter) recordFailure(ip string) {
57
+ if ip == "" {
58
+ return
59
+ }
60
+ l.mu.Lock()
61
+ defer l.mu.Unlock()
62
+ now := time.Now()
63
+ state, ok := l.failures[ip]
64
+ if !ok || now.Sub(state.lastFailAt) > l.window {
65
+ state = &loginFailState{count: 0, lastFailAt: now}
66
+ l.failures[ip] = state
67
+ }
68
+ state.count++
69
+ state.lastFailAt = now
70
+ if state.count >= l.maxFailures {
71
+ state.lockoutUntil = now.Add(l.lockout)
72
+ }
73
+ }
74
+
75
+ // recordSuccess 登录成功后重置该 IP 的失败计数。
76
+ func (l *loginRateLimiter) recordSuccess(ip string) {
77
+ if ip == "" {
78
+ return
79
+ }
80
+ l.mu.Lock()
81
+ defer l.mu.Unlock()
82
+ delete(l.failures, ip)
83
+ }
84
+
85
+ // clientIP 从请求中提取客户端 IP。优先使用 chi 的 RealIP 中间件已设置的 RemoteAddr。
86
+ func clientIP(r *http.Request) string {
87
+ if r == nil {
88
+ return ""
89
+ }
90
+ if ip := r.Header.Get("X-Real-IP"); ip != "" {
91
+ return ip
92
+ }
93
+ if ip := r.Header.Get("X-Forwarded-For"); ip != "" {
94
+ // 取第一个 IP
95
+ for i := 0; i < len(ip); i++ {
96
+ if ip[i] == ',' {
97
+ return ip[:i]
98
+ }
99
+ }
100
+ return ip
101
+ }
102
+ host := r.RemoteAddr
103
+ for i := len(host) - 1; i >= 0; i-- {
104
+ if host[i] == ':' {
105
+ return host[:i]
106
+ }
107
+ }
108
+ return host
109
+ }
internal/httpapi/admin/version/handler_version.go CHANGED
@@ -3,13 +3,24 @@ package version
3
  import (
4
  "encoding/json"
5
  "net/http"
 
6
  "strings"
7
  "time"
8
 
9
  "ds2api/internal/version"
10
  )
11
 
12
- const latestReleaseAPI = "https://api.github.com/repos/CJackHwang/ds2api/releases/latest"
 
 
 
 
 
 
 
 
 
 
13
 
14
  type latestReleasePayload struct {
15
  TagName string `json:"tag_name"`
@@ -27,7 +38,7 @@ func (h *Handler) getVersion(w http.ResponseWriter, _ *http.Request) {
27
  "checked_at": time.Now().UTC().Format(time.RFC3339),
28
  }
29
 
30
- req, err := http.NewRequest(http.MethodGet, latestReleaseAPI, nil)
31
  if err != nil {
32
  resp["check_error"] = err.Error()
33
  writeJSON(w, http.StatusOK, resp)
 
3
  import (
4
  "encoding/json"
5
  "net/http"
6
+ "os"
7
  "strings"
8
  "time"
9
 
10
  "ds2api/internal/version"
11
  )
12
 
13
+ // defaultReleaseRepo 是默认用于版本检查的 GitHub 仓库(owner/repo)。
14
+ // 可通过环境变量 DS2API_VERSION_CHECK_REPO 覆盖。
15
+ const defaultReleaseRepo = "luckfun233/ds2api"
16
+
17
+ func latestReleaseAPIURL() string {
18
+ repo := strings.TrimSpace(os.Getenv("DS2API_VERSION_CHECK_REPO"))
19
+ if repo == "" {
20
+ repo = defaultReleaseRepo
21
+ }
22
+ return "https://api.github.com/repos/" + repo + "/releases/latest"
23
+ }
24
 
25
  type latestReleasePayload struct {
26
  TagName string `json:"tag_name"`
 
38
  "checked_at": time.Now().UTC().Format(time.RFC3339),
39
  }
40
 
41
+ req, err := http.NewRequest(http.MethodGet, latestReleaseAPIURL(), nil)
42
  if err != nil {
43
  resp["check_error"] = err.Error()
44
  writeJSON(w, http.StatusOK, resp)
internal/httpapi/claude/handler_messages.go CHANGED
@@ -28,6 +28,7 @@ import (
28
  )
29
 
30
  func (h *Handler) Messages(w http.ResponseWriter, r *http.Request) {
 
31
  if strings.TrimSpace(r.Header.Get("anthropic-version")) == "" {
32
  r.Header.Set("anthropic-version", "2023-06-01")
33
  }
@@ -58,7 +59,9 @@ func isClaudeVercelProxyRequest(r *http.Request) bool {
58
  func (h *Handler) handleClaudeDirect(w http.ResponseWriter, r *http.Request) bool {
59
  raw, err := io.ReadAll(r.Body)
60
  if err != nil {
61
- if errors.Is(err, requestbody.ErrInvalidUTF8Body) {
 
 
62
  writeClaudeError(w, http.StatusBadRequest, "invalid json")
63
  } else {
64
  writeClaudeError(w, http.StatusBadRequest, "invalid body")
@@ -151,7 +154,9 @@ func (h *Handler) handleClaudeDirectStream(w http.ResponseWriter, r *http.Reques
151
  func (h *Handler) proxyViaOpenAI(w http.ResponseWriter, r *http.Request, store ConfigReader) bool {
152
  raw, err := io.ReadAll(r.Body)
153
  if err != nil {
154
- if errors.Is(err, requestbody.ErrInvalidUTF8Body) {
 
 
155
  writeClaudeError(w, http.StatusBadRequest, "invalid json")
156
  } else {
157
  writeClaudeError(w, http.StatusBadRequest, "invalid body")
 
28
  )
29
 
30
  func (h *Handler) Messages(w http.ResponseWriter, r *http.Request) {
31
+ r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodySize)
32
  if strings.TrimSpace(r.Header.Get("anthropic-version")) == "" {
33
  r.Header.Set("anthropic-version", "2023-06-01")
34
  }
 
59
  func (h *Handler) handleClaudeDirect(w http.ResponseWriter, r *http.Request) bool {
60
  raw, err := io.ReadAll(r.Body)
61
  if err != nil {
62
+ if strings.Contains(strings.ToLower(err.Error()), "too large") {
63
+ writeClaudeError(w, http.StatusRequestEntityTooLarge, "request body too large")
64
+ } else if errors.Is(err, requestbody.ErrInvalidUTF8Body) {
65
  writeClaudeError(w, http.StatusBadRequest, "invalid json")
66
  } else {
67
  writeClaudeError(w, http.StatusBadRequest, "invalid body")
 
154
  func (h *Handler) proxyViaOpenAI(w http.ResponseWriter, r *http.Request, store ConfigReader) bool {
155
  raw, err := io.ReadAll(r.Body)
156
  if err != nil {
157
+ if strings.Contains(strings.ToLower(err.Error()), "too large") {
158
+ writeClaudeError(w, http.StatusRequestEntityTooLarge, "request body too large")
159
+ } else if errors.Is(err, requestbody.ErrInvalidUTF8Body) {
160
  writeClaudeError(w, http.StatusBadRequest, "invalid json")
161
  } else {
162
  writeClaudeError(w, http.StatusBadRequest, "invalid body")
internal/httpapi/claude/handler_routes.go CHANGED
@@ -34,6 +34,10 @@ var (
34
  claudeStreamMaxKeepaliveCnt = dsprotocol.MaxKeepaliveCount
35
  )
36
 
 
 
 
 
37
  func RegisterRoutes(r chi.Router, h *Handler) {
38
  r.Get("/anthropic/v1/models", h.ListModels)
39
  r.Post("/anthropic/v1/messages", h.Messages)
 
34
  claudeStreamMaxKeepaliveCnt = dsprotocol.MaxKeepaliveCount
35
  )
36
 
37
+ // maxRequestBodySize limits total JSON request body size (100 MiB),
38
+ // consistent with OpenAI endpoints.
39
+ const maxRequestBodySize = 100 << 20
40
+
41
  func RegisterRoutes(r chi.Router, h *Handler) {
42
  r.Get("/anthropic/v1/models", h.ListModels)
43
  r.Post("/anthropic/v1/messages", h.Messages)
internal/httpapi/claude/handler_tokens.go CHANGED
@@ -3,9 +3,11 @@ package claude
3
  import (
4
  "encoding/json"
5
  "net/http"
 
6
  )
7
 
8
  func (h *Handler) CountTokens(w http.ResponseWriter, r *http.Request) {
 
9
  a, err := h.Auth.Determine(r)
10
  if err != nil {
11
  writeClaudeError(w, http.StatusUnauthorized, err.Error())
@@ -15,7 +17,11 @@ func (h *Handler) CountTokens(w http.ResponseWriter, r *http.Request) {
15
 
16
  var req map[string]any
17
  if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
18
- writeClaudeError(w, http.StatusBadRequest, "invalid json")
 
 
 
 
19
  return
20
  }
21
  model, _ := req["model"].(string)
 
3
  import (
4
  "encoding/json"
5
  "net/http"
6
+ "strings"
7
  )
8
 
9
  func (h *Handler) CountTokens(w http.ResponseWriter, r *http.Request) {
10
+ r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodySize)
11
  a, err := h.Auth.Determine(r)
12
  if err != nil {
13
  writeClaudeError(w, http.StatusUnauthorized, err.Error())
 
17
 
18
  var req map[string]any
19
  if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
20
+ if strings.Contains(strings.ToLower(err.Error()), "too large") {
21
+ writeClaudeError(w, http.StatusRequestEntityTooLarge, "request body too large")
22
+ } else {
23
+ writeClaudeError(w, http.StatusBadRequest, "invalid json")
24
+ }
25
  return
26
  }
27
  model, _ := req["model"].(string)
internal/httpapi/gemini/handler_generate.go CHANGED
@@ -28,6 +28,7 @@ import (
28
  )
29
 
30
  func (h *Handler) handleGenerateContent(w http.ResponseWriter, r *http.Request, stream bool) {
 
31
  if isGeminiVercelProxyRequest(r) && h.proxyViaOpenAI(w, r, stream) {
32
  return
33
  }
@@ -55,7 +56,9 @@ func isGeminiVercelProxyRequest(r *http.Request) bool {
55
  func (h *Handler) handleGeminiDirect(w http.ResponseWriter, r *http.Request, stream bool) bool {
56
  raw, err := io.ReadAll(r.Body)
57
  if err != nil {
58
- if errors.Is(err, requestbody.ErrInvalidUTF8Body) {
 
 
59
  writeGeminiError(w, http.StatusBadRequest, "invalid json")
60
  } else {
61
  writeGeminiError(w, http.StatusBadRequest, "invalid body")
@@ -143,7 +146,9 @@ func (h *Handler) handleGeminiDirectStream(w http.ResponseWriter, r *http.Reques
143
  func (h *Handler) proxyViaOpenAI(w http.ResponseWriter, r *http.Request, stream bool) bool {
144
  raw, err := io.ReadAll(r.Body)
145
  if err != nil {
146
- if errors.Is(err, requestbody.ErrInvalidUTF8Body) {
 
 
147
  writeGeminiError(w, http.StatusBadRequest, "invalid json")
148
  } else {
149
  writeGeminiError(w, http.StatusBadRequest, "invalid body")
 
28
  )
29
 
30
  func (h *Handler) handleGenerateContent(w http.ResponseWriter, r *http.Request, stream bool) {
31
+ r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodySize)
32
  if isGeminiVercelProxyRequest(r) && h.proxyViaOpenAI(w, r, stream) {
33
  return
34
  }
 
56
  func (h *Handler) handleGeminiDirect(w http.ResponseWriter, r *http.Request, stream bool) bool {
57
  raw, err := io.ReadAll(r.Body)
58
  if err != nil {
59
+ if strings.Contains(strings.ToLower(err.Error()), "too large") {
60
+ writeGeminiError(w, http.StatusRequestEntityTooLarge, "request body too large")
61
+ } else if errors.Is(err, requestbody.ErrInvalidUTF8Body) {
62
  writeGeminiError(w, http.StatusBadRequest, "invalid json")
63
  } else {
64
  writeGeminiError(w, http.StatusBadRequest, "invalid body")
 
146
  func (h *Handler) proxyViaOpenAI(w http.ResponseWriter, r *http.Request, stream bool) bool {
147
  raw, err := io.ReadAll(r.Body)
148
  if err != nil {
149
+ if strings.Contains(strings.ToLower(err.Error()), "too large") {
150
+ writeGeminiError(w, http.StatusRequestEntityTooLarge, "request body too large")
151
+ } else if errors.Is(err, requestbody.ErrInvalidUTF8Body) {
152
  writeGeminiError(w, http.StatusBadRequest, "invalid json")
153
  } else {
154
  writeGeminiError(w, http.StatusBadRequest, "invalid body")
internal/httpapi/gemini/handler_routes.go CHANGED
@@ -12,6 +12,10 @@ import (
12
 
13
  var writeJSON = util.WriteJSON
14
 
 
 
 
 
15
  type Handler struct {
16
  Store ConfigReader
17
  Auth AuthResolver
 
12
 
13
  var writeJSON = util.WriteJSON
14
 
15
+ // maxRequestBodySize limits total JSON request body size (100 MiB),
16
+ // consistent with OpenAI endpoints.
17
+ const maxRequestBodySize = 100 << 20
18
+
19
  type Handler struct {
20
  Store ConfigReader
21
  Auth AuthResolver
internal/httpapi/ollama/handler_routes.go CHANGED
@@ -11,6 +11,10 @@ import (
11
 
12
  var WriteJSON = util.WriteJSON
13
 
 
 
 
 
14
  type ConfigReader interface {
15
  ModelAliases() map[string]string
16
  }
@@ -38,6 +42,7 @@ func (h *Handler) ListOllamaModels(w http.ResponseWriter, r *http.Request) {
38
  WriteJSON(w, http.StatusOK, config.OllamaModelsResponse())
39
  }
40
  func (h *Handler) GetOllamaModel(w http.ResponseWriter, r *http.Request) {
 
41
  var payload OllamaModelRequest
42
  if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
43
  http.Error(w, "Invalid JSON body: "+err.Error(), http.StatusBadRequest)
 
11
 
12
  var WriteJSON = util.WriteJSON
13
 
14
+ // maxRequestBodySize limits total JSON request body size (100 MiB),
15
+ // consistent with OpenAI endpoints.
16
+ const maxRequestBodySize = 100 << 20
17
+
18
  type ConfigReader interface {
19
  ModelAliases() map[string]string
20
  }
 
42
  WriteJSON(w, http.StatusOK, config.OllamaModelsResponse())
43
  }
44
  func (h *Handler) GetOllamaModel(w http.ResponseWriter, r *http.Request) {
45
+ r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodySize)
46
  var payload OllamaModelRequest
47
  if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
48
  http.Error(w, "Invalid JSON body: "+err.Error(), http.StatusBadRequest)
internal/rawsample/rawsample.go CHANGED
@@ -7,12 +7,15 @@ import (
7
  "os"
8
  "path/filepath"
9
  "regexp"
 
10
  "strings"
11
  "time"
12
 
13
  "github.com/google/uuid"
14
  )
15
 
 
 
16
  var referenceMarkerRe = regexp.MustCompile(`(?i)\[reference:\s*\d+\]`)
17
 
18
  type CaptureRound struct {
@@ -49,6 +52,9 @@ type PersistOptions struct {
49
  Request any
50
  Capture CaptureSummary
51
  UpstreamBody []byte
 
 
 
52
  }
53
 
54
  type SavedSample struct {
@@ -125,6 +131,12 @@ func Persist(opts PersistOptions) (SavedSample, error) {
125
  return SavedSample{}, fmt.Errorf("promote sample dir: %w", err)
126
  }
127
 
 
 
 
 
 
 
128
  return SavedSample{
129
  SampleID: sampleID,
130
  Dir: finalDir,
@@ -197,3 +209,42 @@ func analyzeBytes(raw []byte) (containsReferenceMarkers bool, referenceMarkerCou
197
  containsFinishedToken = finishedTokenCount > 0
198
  return
199
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
  "os"
8
  "path/filepath"
9
  "regexp"
10
+ "sort"
11
  "strings"
12
  "time"
13
 
14
  "github.com/google/uuid"
15
  )
16
 
17
+ const DefaultMaxSamples = 50
18
+
19
  var referenceMarkerRe = regexp.MustCompile(`(?i)\[reference:\s*\d+\]`)
20
 
21
  type CaptureRound struct {
 
52
  Request any
53
  Capture CaptureSummary
54
  UpstreamBody []byte
55
+ // MaxSamples limits how many sample directories are retained in RootDir.
56
+ // If <= 0, DefaultMaxSamples is used. Excess samples (oldest first) are pruned after persist.
57
+ MaxSamples int
58
  }
59
 
60
  type SavedSample struct {
 
131
  return SavedSample{}, fmt.Errorf("promote sample dir: %w", err)
132
  }
133
 
134
+ maxKeep := opts.MaxSamples
135
+ if maxKeep <= 0 {
136
+ maxKeep = DefaultMaxSamples
137
+ }
138
+ pruneExcessSamples(root, maxKeep)
139
+
140
  return SavedSample{
141
  SampleID: sampleID,
142
  Dir: finalDir,
 
209
  containsFinishedToken = finishedTokenCount > 0
210
  return
211
  }
212
+
213
+ // pruneExcessSamples removes the oldest sample directories beyond maxKeep.
214
+ // Errors during individual removals are ignored (best-effort cleanup).
215
+ func pruneExcessSamples(rootDir string, maxKeep int) {
216
+ if maxKeep <= 0 {
217
+ return
218
+ }
219
+ entries, err := os.ReadDir(rootDir)
220
+ if err != nil {
221
+ return
222
+ }
223
+ type sampleInfo struct {
224
+ name string
225
+ mtime time.Time
226
+ }
227
+ var samples []sampleInfo
228
+ for _, entry := range entries {
229
+ if !entry.IsDir() {
230
+ continue
231
+ }
232
+ info, err := entry.Info()
233
+ if err != nil {
234
+ continue
235
+ }
236
+ samples = append(samples, sampleInfo{
237
+ name: entry.Name(),
238
+ mtime: info.ModTime(),
239
+ })
240
+ }
241
+ if len(samples) <= maxKeep {
242
+ return
243
+ }
244
+ sort.Slice(samples, func(i, j int) bool {
245
+ return samples[i].mtime.After(samples[j].mtime)
246
+ })
247
+ for _, s := range samples[maxKeep:] {
248
+ _ = os.RemoveAll(filepath.Join(rootDir, s.name))
249
+ }
250
+ }
internal/webui/handler.go CHANGED
@@ -95,6 +95,8 @@ func setStaticContentType(w http.ResponseWriter, fullPath string) {
95
  }
96
 
97
  func (h *Handler) serveFromDisk(w http.ResponseWriter, r *http.Request, staticDir string) {
 
 
98
  root := filepath.Clean(staticDir)
99
  path := strings.TrimPrefix(r.URL.Path, "/admin")
100
  path = strings.TrimPrefix(path, "/")
 
95
  }
96
 
97
  func (h *Handler) serveFromDisk(w http.ResponseWriter, r *http.Request, staticDir string) {
98
+ // 防止浏览器对响应内容做 MIME sniffing,避免 XSS 通过类型混淆绕过。
99
+ w.Header().Set("X-Content-Type-Options", "nosniff")
100
  root := filepath.Clean(staticDir)
101
  path := strings.TrimPrefix(r.URL.Path, "/admin")
102
  path = strings.TrimPrefix(path, "/")