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

security: 修复管理员鉴权与配置导出泄露等严重漏洞

Browse files

针对部署在 Hugging Face Spaces 上的 ds2api 进行安全审计并修复 5 个严重问题:

FIXED-1 (CRITICAL): effectiveAdminKey 不再硬编码返回 "admin",
未配置凭据时返回空字符串禁用密码登录,避免任何知道源码的攻击者
直接登录未配置管理员密钥的部署。

FIXED-2 (CRITICAL): jwtSecret 在未配置时不再回退到 "admin",
新增 fallbackJWTSecret() 使用 crypto/rand 生成进程级 32 字节
随机 secret,防止攻击者用历史默认值伪造 JWT 绕过 RequireAdmin。

FIXED-3 (HIGH): /admin/config/export 的 config 字段改用
Config.RedactSecrets() 清除所有 token/password/vercel token/
admin password_hash,避免 admin JWT 泄露链导致凭据全部泄露。
json/base64 字段保留密码(迁移必需)但清除 live token,
并新增 secrets_in_json 警告标志。

FIXED-4 (MEDIUM): 删除可疑的 _fix.py 和 _fix2.py,其中
_fix2.py 使用 chr() 字符编码混淆访问 "Dockerfile",符合
安全审计中的可疑模式。

FIXED-5 (MEDIUM): devcapture 改为默认禁用,必须显式设置
DS2API_DEV_PACKET_CAPTURE=1 才启用。原逻辑在非 Vercel 环境
默认启用,会捕获每次请求的完整 prompt/response(最大 5MB)
并通过 /admin/dev/captures 暴露,存在内存与隐私风险。

新增 12 个回归测试覆盖所有修复点,包括默认密钥拒绝、JWT 伪造
拒绝、配置导出脱敏、迁移用途保留等攻击场景。详细审计报告与
待修复问题见 security_best_practices_report.md。

_fix.py DELETED
@@ -1,8 +0,0 @@
1
- import pathlib
2
- p = pathlib.Path("Dockerfile")
3
- t = p.read_text()
4
- old = 'CMD["/usr/local/bin/ds2api"]'
5
- new = 'COPY entrypoint.sh /usr/local/bin/entrypoint.sh\nRUN chmod +x /usr/local/bin/entrypoint.sh\nCMD["/usr/local/bin/entrypoint.sh"]'
6
- t = t.replace(old, new)
7
- p.write_text(t)
8
- print("Done")
 
 
 
 
 
 
 
 
 
_fix2.py DELETED
@@ -1,7 +0,0 @@
1
- import pathlib
2
- p=pathlib.Path(chr(68)+chr(111)+chr(99)+chr(107)+chr(101)+chr(114)+chr(102)+chr(105)+chr(108)+chr(101))
3
- t=p.read_text()
4
- old=CMD [/usr/local/bin/ds2api]
5
- new=CMD [/usr/local/bin/entrypoint.sh]
6
- print(repr(old))
7
- print(repr(new))
 
 
 
 
 
 
 
 
internal/auth/admin.go CHANGED
@@ -2,9 +2,11 @@ package auth
2
 
3
  import (
4
  "crypto/hmac"
 
5
  "crypto/sha256"
6
  "crypto/subtle"
7
  "encoding/base64"
 
8
  "encoding/hex"
9
  "encoding/json"
10
  "errors"
@@ -17,7 +19,17 @@ import (
17
  "time"
18
  )
19
 
20
- var warnOnce sync.Once
 
 
 
 
 
 
 
 
 
 
21
 
22
  type AdminConfigReader interface {
23
  AdminPasswordHash() string
@@ -39,9 +51,33 @@ func effectiveAdminKey(store AdminConfigReader) string {
39
  return v
40
  }
41
  warnOnce.Do(func() {
42
- slog.Warn("⚠️ DS2API_ADMIN_KEY is not set! Using insecure default \"admin\". Set a strong key in production!")
 
 
43
  })
44
- return "admin"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
  }
46
 
47
  func jwtSecret(store AdminConfigReader) string {
@@ -53,7 +89,12 @@ func jwtSecret(store AdminConfigReader) string {
53
  return hash
54
  }
55
  }
56
- return effectiveAdminKey(store)
 
 
 
 
 
57
  }
58
 
59
  func jwtExpireHours(store AdminConfigReader) int {
 
2
 
3
  import (
4
  "crypto/hmac"
5
+ "crypto/rand"
6
  "crypto/sha256"
7
  "crypto/subtle"
8
  "encoding/base64"
9
+ "encoding/binary"
10
  "encoding/hex"
11
  "encoding/json"
12
  "errors"
 
19
  "time"
20
  )
21
 
22
+ var (
23
+ warnOnce sync.Once
24
+
25
+ // fallbackSecretOnce lazily generates a process-local random secret used
26
+ // only when no explicit JWT secret AND no admin credentials are configured.
27
+ // Because admin login is also disabled in that state (see
28
+ // effectiveAdminKey), no real token can be minted with this secret; it
29
+ // exists solely so token signing never falls back to a hardcoded value.
30
+ fallbackSecretOnce sync.Once
31
+ fallbackSecret []byte
32
+ )
33
 
34
  type AdminConfigReader interface {
35
  AdminPasswordHash() string
 
51
  return v
52
  }
53
  warnOnce.Do(func() {
54
+ slog.Warn("DS2API_ADMIN_KEY is not set and no admin password hash is configured. " +
55
+ "Admin login is DISABLED until you set DS2API_ADMIN_KEY or configure a password via the admin panel. " +
56
+ "Set a strong DS2API_ADMIN_KEY in your deployment secrets.")
57
  })
58
+ // Security: do NOT fall back to an insecure hardcoded default like "admin".
59
+ // Returning empty disables admin password login until the operator
60
+ // configures credentials, which is the safe-by-default behavior.
61
+ return ""
62
+ }
63
+
64
+ // fallbackJWTSecret returns a process-local random secret used only when no
65
+ // explicit JWT secret and no admin credentials are configured. Because admin
66
+ // login is also disabled in that state (see effectiveAdminKey), no real token
67
+ // can be minted with this secret; it exists solely so token signing uses a
68
+ // non-predictable key rather than the historical hardcoded "admin" value.
69
+ func fallbackJWTSecret() []byte {
70
+ fallbackSecretOnce.Do(func() {
71
+ buf := make([]byte, 32)
72
+ if _, err := rand.Read(buf); err != nil {
73
+ // Extremely unlikely; derive from time + pid as a last resort so
74
+ // we never return an empty secret.
75
+ binary.BigEndian.PutUint64(buf[0:8], uint64(time.Now().UnixNano()))
76
+ binary.BigEndian.PutUint64(buf[8:16], uint64(os.Getpid()))
77
+ }
78
+ fallbackSecret = buf
79
+ })
80
+ return fallbackSecret
81
  }
82
 
83
  func jwtSecret(store AdminConfigReader) string {
 
89
  return hash
90
  }
91
  }
92
+ if key := effectiveAdminKey(store); key != "" {
93
+ return key
94
+ }
95
+ // Security: never fall back to a hardcoded value. Use a process-local
96
+ // random secret so tokens cannot be forged even if minting were possible.
97
+ return string(fallbackJWTSecret())
98
  }
99
 
100
  func jwtExpireHours(store AdminConfigReader) int {
internal/auth/admin_security_test.go ADDED
@@ -0,0 +1,212 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package auth
2
+
3
+ import (
4
+ "crypto/hmac"
5
+ "crypto/sha256"
6
+ "encoding/json"
7
+ "net/http"
8
+ "net/http/httptest"
9
+ "os"
10
+ "strings"
11
+ "testing"
12
+
13
+ "ds2api/internal/config"
14
+ )
15
+
16
+ // stubStore implements AdminConfigReader for testing without pulling in the
17
+ // full config store. We intentionally use a struct (not a mock) so the test
18
+ // exercises the real auth code paths.
19
+ type stubStore struct {
20
+ passwordHash string
21
+ expireHours int
22
+ validAfter int64
23
+ }
24
+
25
+ func (s stubStore) AdminPasswordHash() string { return s.passwordHash }
26
+ func (s stubStore) AdminJWTExpireHours() int { return s.expireHours }
27
+ func (s stubStore) AdminJWTValidAfterUnix() int64 { return s.validAfter }
28
+
29
+ func TestEffectiveAdminKeyNeverReturnsHardcodedDefault(t *testing.T) {
30
+ // Security: ensure the historical "admin" default is gone. If this test
31
+ // ever fails, it means a regression reintroduced a publicly-known default
32
+ // credential that would let anyone log in to deployments without
33
+ // DS2API_ADMIN_KEY set.
34
+ t.Setenv("DS2API_ADMIN_KEY", "")
35
+ t.Setenv("DS2API_CONFIG_JSON", "")
36
+
37
+ stub := stubStore{}
38
+ if got := effectiveAdminKey(stub); got != "" {
39
+ t.Fatalf("effectiveAdminKey with no env and no password hash must be empty, got %q", got)
40
+ }
41
+ if got := AdminKey(); got != "" {
42
+ t.Fatalf("AdminKey() with no env must be empty, got %q", got)
43
+ }
44
+ }
45
+
46
+ func TestVerifyAdminCredentialRejectsEmptyAndDefaultAdmin(t *testing.T) {
47
+ // Security: with no admin key configured and no password hash, login
48
+ // MUST be impossible — even if an attacker tries "admin" or empty string.
49
+ t.Setenv("DS2API_ADMIN_KEY", "")
50
+ t.Setenv("DS2API_CONFIG_JSON", "")
51
+
52
+ stub := stubStore{}
53
+ for _, candidate := range []string{"", "admin", "password", "123456", "default"} {
54
+ if VerifyAdminCredential(candidate, stub) {
55
+ t.Fatalf("VerifyAdminCredential must reject %q when no credentials configured", candidate)
56
+ }
57
+ }
58
+ }
59
+
60
+ func TestVerifyAdminCredentialRejectsKnownDefaultAdminWithEnvSet(t *testing.T) {
61
+ // Security: when DS2API_ADMIN_KEY is set to a strong value, the literal
62
+ // "admin" must NOT authenticate.
63
+ t.Setenv("DS2API_ADMIN_KEY", "a-strong-and-random-key-12345")
64
+ t.Setenv("DS2API_CONFIG_JSON", "")
65
+
66
+ stub := stubStore{}
67
+ if VerifyAdminCredential("admin", stub) {
68
+ t.Fatal("VerifyAdminCredential must reject literal \"admin\" when env key is set to something else")
69
+ }
70
+ if !VerifyAdminCredential("a-strong-and-random-key-12345", stub) {
71
+ t.Fatal("VerifyAdminCredential must accept the configured env key")
72
+ }
73
+ }
74
+
75
+ func TestJWTSecretNotHardcodedAdminWhenUnconfigured(t *testing.T) {
76
+ // Security: when no DS2API_JWT_SECRET, no password hash, and no
77
+ // DS2API_ADMIN_KEY are configured, the JWT signing secret must NOT be
78
+ // the historical hardcoded "admin". A process-local random secret is
79
+ // used instead so attackers cannot forge tokens.
80
+ t.Setenv("DS2API_ADMIN_KEY", "")
81
+ t.Setenv("DS2API_JWT_SECRET", "")
82
+ t.Setenv("DS2API_CONFIG_JSON", "")
83
+
84
+ secret := jwtSecret(nil)
85
+ if secret == "admin" {
86
+ t.Fatal("jwtSecret must not fall back to hardcoded \"admin\"")
87
+ }
88
+ if secret == "" {
89
+ t.Fatal("jwtSecret must be non-empty so token signing is non-trivial")
90
+ }
91
+ // The fallback secret must be process-stable (same value across calls).
92
+ if secret != jwtSecret(nil) {
93
+ t.Fatal("jwtSecret fallback must be process-stable so minted tokens verify")
94
+ }
95
+ }
96
+
97
+ func TestJWTForgedWithAdminSecretFailsVerification(t *testing.T) {
98
+ // Security: an attacker who knows the historical default "admin" cannot
99
+ // forge a token that verifies when no credentials are configured, because
100
+ // the actual signing secret is a process-local random value.
101
+ t.Setenv("DS2API_ADMIN_KEY", "")
102
+ t.Setenv("DS2API_JWT_SECRET", "")
103
+
104
+ // Forge a token signed with the historical "admin" secret.
105
+ header := map[string]any{"alg": "HS256", "typ": "JWT"}
106
+ payload := map[string]any{
107
+ "iat": 0,
108
+ "exp": 9999999999,
109
+ "role": "admin",
110
+ }
111
+ h, _ := json.Marshal(header)
112
+ p, _ := json.Marshal(payload)
113
+ msg := rawB64Encode(h) + "." + rawB64Encode(p)
114
+
115
+ mac := hmac.New(sha256.New, []byte("admin"))
116
+ _, _ = mac.Write([]byte(msg))
117
+ forgedSig := mac.Sum(nil)
118
+ forged := msg + "." + rawB64Encode(forgedSig)
119
+
120
+ if _, err := VerifyJWT(forged); err == nil {
121
+ t.Fatal("forged token signed with hardcoded \"admin\" secret must NOT verify")
122
+ }
123
+ }
124
+
125
+ func TestAdminLoginDisabledWithoutCredentials(t *testing.T) {
126
+ // Security: end-to-end check that VerifyAdminRequestWithStore rejects
127
+ // Bearer tokens when no credentials are configured, regardless of what
128
+ // string the attacker supplies.
129
+ t.Setenv("DS2API_ADMIN_KEY", "")
130
+ t.Setenv("DS2API_CONFIG_JSON", "")
131
+
132
+ stub := stubStore{}
133
+ for _, token := range []string{"admin", "", "Bearer admin", "anything"} {
134
+ req := httptest.NewRequest(http.MethodGet, "/admin/config", nil)
135
+ req.Header.Set("Authorization", "Bearer "+token)
136
+ if err := VerifyAdminRequestWithStore(req, stub); err == nil {
137
+ t.Fatalf("VerifyAdminRequestWithStore must reject token %q when no credentials configured", token)
138
+ }
139
+ }
140
+ }
141
+
142
+ func TestAdminLoginWithConfiguredPasswordHash(t *testing.T) {
143
+ // Regression: ensure password-hash based login still works after the
144
+ // default-removal change.
145
+ t.Setenv("DS2API_ADMIN_KEY", "")
146
+ t.Setenv("DS2API_JWT_SECRET", "")
147
+
148
+ stub := stubStore{passwordHash: HashAdminPassword("super-secret-pw")}
149
+ if !VerifyAdminCredential("super-secret-pw", stub) {
150
+ t.Fatal("VerifyAdminCredential must accept the configured password")
151
+ }
152
+ if VerifyAdminCredential("wrong-password", stub) {
153
+ t.Fatal("VerifyAdminCredential must reject wrong password")
154
+ }
155
+ // JWT secret must derive from the password hash, not the empty admin key.
156
+ secret := jwtSecret(stub)
157
+ if secret != strings.TrimSpace(stub.passwordHash) {
158
+ t.Fatalf("jwtSecret must be the password hash when no env secret set, got %q", secret)
159
+ }
160
+ }
161
+
162
+ func TestAdminLoginWithEnvKey(t *testing.T) {
163
+ // Regression: ensure env-key based login still works after the change.
164
+ t.Setenv("DS2API_ADMIN_KEY", "env-key-12345")
165
+ t.Setenv("DS2API_JWT_SECRET", "")
166
+
167
+ stub := stubStore{}
168
+ if !VerifyAdminCredential("env-key-12345", stub) {
169
+ t.Fatal("VerifyAdminCredential must accept the configured env key")
170
+ }
171
+ if VerifyAdminCredential("env-key-99999", stub) {
172
+ t.Fatal("VerifyAdminCredential must reject wrong env key")
173
+ }
174
+ }
175
+
176
+ func TestAdminLoginWithJWTSecretEnv(t *testing.T) {
177
+ // Regression: ensure an explicit JWT secret takes precedence and works
178
+ // for both signing and verifying.
179
+ t.Setenv("DS2API_ADMIN_KEY", "env-key-12345")
180
+ t.Setenv("DS2API_JWT_SECRET", "explicit-jwt-secret")
181
+
182
+ stub := stubStore{}
183
+ if jwtSecret(stub) != "explicit-jwt-secret" {
184
+ t.Fatalf("jwtSecret must use explicit env value, got %q", jwtSecret(stub))
185
+ }
186
+
187
+ token, err := CreateJWTWithStore(1, stub)
188
+ if err != nil {
189
+ t.Fatalf("CreateJWTWithStore failed: %v", err)
190
+ }
191
+ if _, err := VerifyJWTWithStore(token, stub); err != nil {
192
+ t.Fatalf("VerifyJWTWithStore failed: %v", err)
193
+ }
194
+ }
195
+
196
+ // usingRealStoreHelper wraps a minimal config.Store so we exercise the same
197
+ // store code paths used in production. We avoid pulling in the full server.
198
+ func TestUsingRealStoreNoCredentials(t *testing.T) {
199
+ if os.Getenv("DS2API_RUN_REAL_STORE_TEST") == "" {
200
+ t.Skip("skipping real-store test; set DS2API_RUN_REAL_STORE_TEST=1 to run")
201
+ }
202
+ t.Setenv("DS2API_ADMIN_KEY", "")
203
+ t.Setenv("DS2API_JWT_SECRET", "")
204
+ t.Setenv("DS2API_CONFIG_JSON", "{}")
205
+ store := config.LoadStore()
206
+ if !UsingDefaultAdminKey(store) {
207
+ t.Fatal("UsingDefaultAdminKey must be true when nothing is configured")
208
+ }
209
+ if VerifyAdminCredential("admin", store) {
210
+ t.Fatal("VerifyAdminCredential must reject \"admin\" with no credentials configured")
211
+ }
212
+ }
internal/config/config.go CHANGED
@@ -86,6 +86,29 @@ func (c *Config) ClearAccountTokens() {
86
  }
87
  }
88
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
89
  func (c *Config) NormalizeCredentials() {
90
  if c == nil {
91
  return
 
86
  }
87
  }
88
 
89
+ // RedactSecrets returns a copy of the config with all sensitive credentials
90
+ // removed (tokens, passwords, vercel token, admin password hash). Used for
91
+ // display-only snapshots that must not leak secrets even to authenticated
92
+ // admins (e.g. the `config` field of /admin/config/export). Migration-grade
93
+ // exports that need passwords to re-import on another deployment should use
94
+ // ExportJSONAndBase64 instead, which keeps passwords but clears live tokens.
95
+ func (c *Config) RedactSecrets() Config {
96
+ if c == nil {
97
+ return Config{}
98
+ }
99
+ out := c.Clone()
100
+ out.ClearAccountTokens()
101
+ for i := range out.Accounts {
102
+ out.Accounts[i].Password = ""
103
+ }
104
+ for i := range out.Proxies {
105
+ out.Proxies[i].Password = ""
106
+ }
107
+ out.Vercel.Token = ""
108
+ out.Admin.PasswordHash = ""
109
+ return out
110
+ }
111
+
112
  func (c *Config) NormalizeCredentials() {
113
  if c == nil {
114
  return
internal/devcapture/store.go CHANGED
@@ -73,7 +73,12 @@ func Global() *Store {
73
  }
74
 
75
  func NewFromEnv() *Store {
76
- enabled := !isVercelRuntime()
 
 
 
 
 
77
  if raw, ok := os.LookupEnv("DS2API_DEV_PACKET_CAPTURE"); ok {
78
  enabled = parseBool(raw)
79
  }
@@ -96,10 +101,6 @@ func NewFromEnv() *Store {
96
  }
97
  }
98
 
99
- func isVercelRuntime() bool {
100
- return strings.TrimSpace(os.Getenv("VERCEL")) != "" || strings.TrimSpace(os.Getenv("NOW_REGION")) != ""
101
- }
102
-
103
  func (s *Store) Enabled() bool {
104
  if s == nil {
105
  return false
 
73
  }
74
 
75
  func NewFromEnv() *Store {
76
+ // Security: dev packet capture stores full request/response bodies (up to
77
+ // 5MB each) in memory and exposes them via /admin/dev/captures. Default
78
+ // to disabled everywhere (including HF Spaces and local dev) to avoid
79
+ // silently capturing user prompts and DeepSeek responses. Operators who
80
+ // need this for debugging must opt in with DS2API_DEV_PACKET_CAPTURE=1.
81
+ enabled := false
82
  if raw, ok := os.LookupEnv("DS2API_DEV_PACKET_CAPTURE"); ok {
83
  enabled = parseBool(raw)
84
  }
 
101
  }
102
  }
103
 
 
 
 
 
104
  func (s *Store) Enabled() bool {
105
  if s == nil {
106
  return false
internal/httpapi/admin/configmgmt/handler_config_read.go CHANGED
@@ -70,10 +70,18 @@ func (h *Handler) configExport(w http.ResponseWriter, _ *http.Request) {
70
  writeJSON(w, http.StatusInternalServerError, map[string]any{"detail": err.Error()})
71
  return
72
  }
 
 
 
 
 
 
73
  writeJSON(w, http.StatusOK, map[string]any{
74
- "success": true,
75
- "config": snap,
76
- "json": jsonStr,
77
- "base64": b64,
 
 
78
  })
79
  }
 
70
  writeJSON(w, http.StatusInternalServerError, map[string]any{"detail": err.Error()})
71
  return
72
  }
73
+ // Security: the `config` field is a display-only snapshot and must never
74
+ // leak live tokens, plaintext passwords, the Vercel API token, or the
75
+ // admin password hash. Authenticated admins who need a migration-grade
76
+ // export with passwords (e.g. to seed a new deployment) should use the
77
+ // `json`/`base64` fields, which keep passwords but still strip live tokens.
78
+ redacted := snap.RedactSecrets()
79
  writeJSON(w, http.StatusOK, map[string]any{
80
+ "success": true,
81
+ "config": redacted,
82
+ "json": jsonStr,
83
+ "base64": b64,
84
+ "secrets_in_json": true,
85
+ "secrets_in_json_note": "json/base64 contain account and proxy passwords required for migration; treat as a secret and do not share or log.",
86
  })
87
  }
internal/httpapi/admin/configmgmt/handler_config_read_security_test.go ADDED
@@ -0,0 +1,196 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package configmgmt
2
+
3
+ import (
4
+ "encoding/base64"
5
+ "encoding/json"
6
+ "net/http"
7
+ "net/http/httptest"
8
+ "strings"
9
+ "testing"
10
+
11
+ "github.com/go-chi/chi/v5"
12
+ )
13
+
14
+ // TestConfigExportRedactsSecretsFromConfigField is a security regression test
15
+ // for the HIGH-severity finding: the `config` field of /admin/config/export
16
+ // previously returned a raw Snapshot() including live account tokens,
17
+ // plaintext passwords, the Vercel API token, and the admin password hash.
18
+ // It must now return a redacted snapshot.
19
+ func TestConfigExportRedactsSecretsFromConfigField(t *testing.T) {
20
+ raw := `{
21
+ "api_keys":[{"key":"sk-test-123","name":"primary"}],
22
+ "accounts":[
23
+ {"email":"alice@example.com","password":"alice-secret-pw","token":"live-deepseek-token-abc"},
24
+ {"email":"bob@example.com","password":"bob-secret-pw","token":"live-deepseek-token-def"}
25
+ ],
26
+ "proxies":[
27
+ {"id":"proxy_1","name":"main","type":"socks5","host":"p.example.com","port":1080,"username":"u1","password":"proxy-secret-pw"}
28
+ ],
29
+ "vercel":{"token":"vercel-api-token-secret","project_id":"prj_123","team_id":"team_456"},
30
+ "admin":{"password_hash":"sha256:abcdef0123456789","jwt_expire_hours":12}
31
+ }`
32
+ h := newAdminTestHandler(t, raw)
33
+
34
+ r := chi.NewRouter()
35
+ r.Get("/admin/config/export", h.configExport)
36
+
37
+ req := httptest.NewRequest(http.MethodGet, "/admin/config/export", nil)
38
+ rec := httptest.NewRecorder()
39
+ r.ServeHTTP(rec, req)
40
+ if rec.Code != http.StatusOK {
41
+ t.Fatalf("export status=%d body=%s", rec.Code, rec.Body.String())
42
+ }
43
+
44
+ var resp struct {
45
+ Success bool `json:"success"`
46
+ Config *json.RawMessage `json:"config"`
47
+ JSON string `json:"json"`
48
+ Base64 string `json:"base64"`
49
+ SecretsInJSON bool `json:"secrets_in_json"`
50
+ SecretsInJSONNote string `json:"secrets_in_json_note"`
51
+ }
52
+ if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
53
+ t.Fatalf("unmarshal export response failed: %v body=%s", err, rec.Body.String())
54
+ }
55
+
56
+ if resp.Config == nil {
57
+ t.Fatal("config field must be present in export response")
58
+ }
59
+ configField := string(*resp.Config)
60
+
61
+ // CRITICAL: the config field must NOT leak live tokens or plaintext passwords.
62
+ forbiddenInConfig := []string{
63
+ "live-deepseek-token-abc",
64
+ "live-deepseek-token-def",
65
+ "alice-secret-pw",
66
+ "bob-secret-pw",
67
+ "proxy-secret-pw",
68
+ "vercel-api-token-secret",
69
+ "sha256:abcdef0123456789",
70
+ }
71
+ for _, secret := range forbiddenInConfig {
72
+ if strings.Contains(configField, secret) {
73
+ t.Fatalf("config field leaked secret %q in: %s", secret, configField)
74
+ }
75
+ }
76
+
77
+ if !resp.SecretsInJSON {
78
+ t.Fatal("secrets_in_json flag must be true so admins know json/base64 contain credentials")
79
+ }
80
+ if !strings.Contains(strings.ToLower(resp.SecretsInJSONNote), "secret") {
81
+ t.Fatalf("secrets_in_json_note should warn about credentials, got %q", resp.SecretsInJSONNote)
82
+ }
83
+ }
84
+
85
+ // TestConfigExportJSONStillContainsPasswordsForMigration verifies that the
86
+ // json/base64 fields still contain passwords so the admin can migrate
87
+ // deployments. Live tokens, however, must remain stripped.
88
+ func TestConfigExportJSONStillContainsPasswordsForMigration(t *testing.T) {
89
+ raw := `{
90
+ "accounts":[
91
+ {"email":"alice@example.com","password":"alice-secret-pw","token":"live-deepseek-token-abc"}
92
+ ],
93
+ "proxies":[
94
+ {"id":"proxy_1","name":"main","type":"socks5","host":"p.example.com","port":1080,"password":"proxy-secret-pw"}
95
+ ]
96
+ }`
97
+ h := newAdminTestHandler(t, raw)
98
+
99
+ r := chi.NewRouter()
100
+ r.Get("/admin/config/export", h.configExport)
101
+
102
+ req := httptest.NewRequest(http.MethodGet, "/admin/config/export", nil)
103
+ rec := httptest.NewRecorder()
104
+ r.ServeHTTP(rec, req)
105
+ if rec.Code != http.StatusOK {
106
+ t.Fatalf("export status=%d body=%s", rec.Code, rec.Body.String())
107
+ }
108
+
109
+ var resp struct {
110
+ JSON string `json:"json"`
111
+ Base64 string `json:"base64"`
112
+ }
113
+ if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
114
+ t.Fatalf("unmarshal export response failed: %v", err)
115
+ }
116
+
117
+ // Passwords are needed for migration (a fresh deployment needs the
118
+ // password to log into DeepSeek and obtain a fresh token).
119
+ if !strings.Contains(resp.JSON, "alice-secret-pw") {
120
+ t.Fatal("json field must keep account passwords for migration use case")
121
+ }
122
+ if !strings.Contains(resp.JSON, "proxy-secret-pw") {
123
+ t.Fatal("json field must keep proxy passwords for migration use case")
124
+ }
125
+
126
+ // Live tokens must NEVER be in any export field — they can be replayed
127
+ // to authenticate as the user while still valid.
128
+ if strings.Contains(resp.JSON, "live-deepseek-token-abc") {
129
+ t.Fatal("json field must NOT contain live account tokens")
130
+ }
131
+
132
+ // Base64 must decode to the same JSON content.
133
+ decoded, err := base64.StdEncoding.DecodeString(resp.Base64)
134
+ if err != nil {
135
+ t.Fatalf("base64 decode failed: %v", err)
136
+ }
137
+ if !strings.Contains(string(decoded), "alice-secret-pw") {
138
+ t.Fatal("base64 field must decode to JSON containing migration passwords")
139
+ }
140
+ if strings.Contains(string(decoded), "live-deepseek-token-abc") {
141
+ t.Fatal("base64 field must NOT contain live account tokens")
142
+ }
143
+ }
144
+
145
+ // TestRedactSecretsIsComplete is a unit-level regression test for
146
+ // Config.RedactSecrets covering every secret-bearing field.
147
+ func TestRedactSecretsIsComplete(t *testing.T) {
148
+ raw := `{
149
+ "accounts":[
150
+ {"email":"a@x.com","password":"pw-a","token":"tok-a"},
151
+ {"email":"b@x.com","password":"pw-b","token":"tok-b"}
152
+ ],
153
+ "proxies":[
154
+ {"id":"p1","host":"h","port":1,"password":"proxy-pw"}
155
+ ],
156
+ "vercel":{"token":"vercel-tok","project_id":"prj"},
157
+ "admin":{"password_hash":"sha256:hashed","jwt_expire_hours":24}
158
+ }`
159
+ h := newAdminTestHandler(t, raw)
160
+ snap := h.Store.Snapshot()
161
+ redacted := snap.RedactSecrets()
162
+
163
+ for i, acc := range redacted.Accounts {
164
+ if acc.Token != "" {
165
+ t.Fatalf("account %d token not redacted: %q", i, acc.Token)
166
+ }
167
+ if acc.Password != "" {
168
+ t.Fatalf("account %d password not redacted: %q", i, acc.Password)
169
+ }
170
+ }
171
+ for i, p := range redacted.Proxies {
172
+ if p.Password != "" {
173
+ t.Fatalf("proxy %d password not redacted: %q", i, p.Password)
174
+ }
175
+ }
176
+ if redacted.Vercel.Token != "" {
177
+ t.Fatalf("vercel token not redacted: %q", redacted.Vercel.Token)
178
+ }
179
+ if redacted.Admin.PasswordHash != "" {
180
+ t.Fatalf("admin password hash not redacted: %q", redacted.Admin.PasswordHash)
181
+ }
182
+
183
+ // Non-secret fields must remain intact so the redacted view is still useful.
184
+ if len(redacted.Accounts) != 2 {
185
+ t.Fatalf("redacted accounts count mismatch: %d", len(redacted.Accounts))
186
+ }
187
+ if redacted.Accounts[0].Email != "a@x.com" {
188
+ t.Fatalf("redacted account email lost: %q", redacted.Accounts[0].Email)
189
+ }
190
+ if redacted.Vercel.ProjectID != "prj" {
191
+ t.Fatalf("redacted vercel project id lost: %q", redacted.Vercel.ProjectID)
192
+ }
193
+ if redacted.Admin.JWTExpireHours != 24 {
194
+ t.Fatalf("redacted admin jwt_expire_hours lost: %d", redacted.Admin.JWTExpireHours)
195
+ }
196
+ }
internal/httpapi/admin/rawsamples/handler_raw_samples_test.go CHANGED
@@ -15,6 +15,18 @@ import (
15
  "ds2api/internal/devcapture"
16
  )
17
 
 
 
 
 
 
 
 
 
 
 
 
 
18
  type stubOpenAIChatCaller struct{}
19
 
20
  func (stubOpenAIChatCaller) ChatCompletions(w http.ResponseWriter, _ *http.Request) {
 
15
  "ds2api/internal/devcapture"
16
  )
17
 
18
+ // TestMain enables dev packet capture for the rawsamples test binary so the
19
+ // devcapture.Global() singleton initializes as enabled. The rawsamples
20
+ // capture handler only records captures when devcapture is enabled; in
21
+ // production this must be opted in via DS2API_DEV_PACKET_CAPTURE=1, but for
22
+ // these tests we always need it on.
23
+ func TestMain(m *testing.M) {
24
+ if err := os.Setenv("DS2API_DEV_PACKET_CAPTURE", "1"); err != nil {
25
+ panic("failed to set DS2API_DEV_PACKET_CAPTURE: " + err.Error())
26
+ }
27
+ os.Exit(m.Run())
28
+ }
29
+
30
  type stubOpenAIChatCaller struct{}
31
 
32
  func (stubOpenAIChatCaller) ChatCompletions(w http.ResponseWriter, _ *http.Request) {
internal/httpapi/admin/token_runtime_http_test.go CHANGED
@@ -17,6 +17,12 @@ import (
17
 
18
  func newHTTPAdminHarness(t *testing.T, rawConfig string, ds adminshared.DeepSeekCaller) http.Handler {
19
  t.Helper()
 
 
 
 
 
 
20
  t.Setenv("DS2API_CONFIG_JSON", rawConfig)
21
  store := config.LoadStore()
22
  h := &Handler{
@@ -31,7 +37,7 @@ func newHTTPAdminHarness(t *testing.T, rawConfig string, ds adminshared.DeepSeek
31
 
32
  func adminReq(method, path string, body []byte) *http.Request {
33
  req := httptest.NewRequest(method, path, bytes.NewReader(body))
34
- req.Header.Set("Authorization", "Bearer admin")
35
  req.Header.Set("Content-Type", "application/json")
36
  return req
37
  }
 
17
 
18
  func newHTTPAdminHarness(t *testing.T, rawConfig string, ds adminshared.DeepSeekCaller) http.Handler {
19
  t.Helper()
20
+ // Security regression fix: admin login is disabled when no credentials
21
+ // are configured. Tests in this file exercise admin endpoints, so set a
22
+ // test-only admin key to authenticate. This also ensures the JWT secret
23
+ // is deterministic rather than a process-local random fallback.
24
+ t.Setenv("DS2API_ADMIN_KEY", "test-admin-key-12345")
25
+ t.Setenv("DS2API_JWT_SECRET", "test-jwt-secret")
26
  t.Setenv("DS2API_CONFIG_JSON", rawConfig)
27
  store := config.LoadStore()
28
  h := &Handler{
 
37
 
38
  func adminReq(method, path string, body []byte) *http.Request {
39
  req := httptest.NewRequest(method, path, bytes.NewReader(body))
40
+ req.Header.Set("Authorization", "Bearer test-admin-key-12345")
41
  req.Header.Set("Content-Type", "application/json")
42
  return req
43
  }
security_best_practices_report.md ADDED
@@ -0,0 +1,289 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ds2api 安全审计报告
2
+
3
+ ## 概述
4
+
5
+ 本次安全审计针对部署在 Hugging Face Spaces 上的 ds2api(DeepSeek 反向代理)项目。背景是该用户的其他 HF Spaces 遭到猛烈的扫描攻击(探测 `/.git/config`、`/.env`、`/wp-config.php.bak`、`/proc/self/environ`、`/phpinfo.php` 等敏感路径),因此对当前项目进行全面审查以排除潜在隐患。
6
+
7
+ 审计覆盖范围:
8
+ - Go 后端(chi router)+ React/Vite 前端 webui + Cloudflare Worker(cron 自愈)+ Node.js chat-stream(Vercel runtime)
9
+ - JWT (HS256) 管理员鉴权 + 可选 sha256 密码哈希
10
+ - DeepSeek PoW + 多账号池 + SOCKS5 代理池
11
+ - devcapture(请求/响应体内存捕获)+ rawsample(持久化到 `tests/raw_stream_samples/`)
12
+ - 协议适配器边界(OpenAI/Claude/Gemini/Ollama)
13
+
14
+ **严重问题已在本次提交中直接修复并通过测试验证**(详见"已修复问题"章节)。本报告其余章节列出尚未修复的中低风险问题,建议按优先级排期处理。
15
+
16
+ ---
17
+
18
+ ## 已修复问题(本次提交)
19
+
20
+ ### FIXED-1: 默认管理员密钥为 "admin"(CRITICAL)
21
+
22
+ **位置**: `internal/auth/admin.go:32-45`(修复前)
23
+
24
+ **问题**: `effectiveAdminKey` 在 `DS2API_ADMIN_KEY` 未设置且未配置 password_hash 时,硬编码返回 `"admin"`。这意味着任何未配置管理员密钥的部署(包括全新 HF Space、本地开发实例)都能用 `admin` 作为 admin_key 登录后台。扫描攻击日志显示攻击者正在批量探测 `/admin`、`/admin/config` 等路径。
25
+
26
+ **影响**: 任何知道项目源码的攻击者可以直接登录未配置管理员密钥的部署,获取所有 DeepSeek 账号、代理、API key 等敏感配置。
27
+
28
+ **修复**: `effectiveAdminKey` 在未配置任何凭据时返回空字符串,禁用密码登录。`VerifyAdminCredential` 已有空检查会拒绝空 key。同时把警告日志从"使用不安全默认 admin"改为"管理员登录已禁用,请配置 DS2API_ADMIN_KEY 或在管理面板设置密码"。
29
+
30
+ ### FIXED-2: JWT secret 回退到 "admin"(CRITICAL)
31
+
32
+ **位置**: `internal/auth/admin.go:47-57`(修复前)
33
+
34
+ **问题**: `jwtSecret` 在没有 `DS2API_JWT_SECRET`、没有 password_hash 时回退到 `effectiveAdminKey(store)`,也就是上面的 `"admin"`。这意味着即使攻击者无法登录(因为 `VerifyAdminCredential` 会拒绝),他也可以用公开已知的 `"admin"` 作为 HS256 密钥**伪造任意 JWT**,绕过 `RequireAdmin` 中间件直接访问所有 admin 端点。
35
+
36
+ **影响**: 与 FIXED-1 等价 —— 任何未配置 JWT secret 的部署,攻击者可伪造 JWT 直接访问所有 `/admin/*` 端点,包括导出全部账号密码的 `/admin/config/export`。
37
+
38
+ **修复**: 新增 `fallbackJWTSecret()`,使用 `crypto/rand` 生成 32 字节进程级随机 secret(一次性生成、`sync.Once` 缓存),确保 JWT 签名密钥不可预测。即使攻击者知道历史默认值 `"admin"`,也无法伪造能通过验证的 token。
39
+
40
+ ### FIXED-3: `/admin/config/export` 泄露明文密码和 live token(HIGH)
41
+
42
+ **位置**: `internal/httpapi/admin/configmgmt/handler_config_read.go:66-78`(修复前)
43
+
44
+ **问题**: `configExport` 返回的 `config` 字段是 `h.Store.Snapshot()` 的原始对象,包含:
45
+ - `accounts[].password`(DeepSeek 账号明文密码)
46
+ - `accounts[].token`(**实时**有效的 DeepSeek 会话 token,可被重放)
47
+ - `proxies[].password`(SOCKS5 代理密码)
48
+ - `vercel.token`(Vercel API token,第三方凭据)
49
+ - `admin.password_hash`(管理员密码哈希,可用于伪造 JWT)
50
+
51
+ 虽然该端点要求 Bearer token 鉴权,但一旦 admin JWT 泄露(XSS、浏览器扩展、日志捕获、共享开发机器),所有密码和 live token 立刻泄露。前端实际只用 `json`/`base64` 字段(用于迁移),`config` 字段是冗余的展示性快照。
52
+
53
+ **影响**: 通过 admin token 泄露链,攻击者可一次性获取所有 DeepSeek 账号凭据,可被用于登录 DeepSeek 网站或重放 API 调用。
54
+
55
+ **修复**:
56
+ 1. 新增 `Config.RedactSecrets()` 方法,返回清除所有 token/password/vercel token/admin password_hash 的快照副本。
57
+ 2. `configExport` 的 `config` 字段改用 `snap.RedactSecrets()`,前端展示用,永不包含敏感数据。
58
+ 3. `json`/`base64` 字段保留密码(迁移用途必需 —— 新部署需要密码才能登录 DeepSeek 获取新 token),但 `accounts[].token` 仍由 `ExportJSONAndBase64` 的 `ClearAccountTokens()` 清除。
59
+ 4. 新增 `secrets_in_json: true` 标志和 `secrets_in_json_note` 警告,提醒管理员 json/base64 包含凭据,应作为机密处理。
60
+
61
+ ### FIXED-4: 删除可疑的 `_fix.py` 和 `_fix2.py`(MEDIUM)
62
+
63
+ **位置**: `/workspace/_fix.py`、`/workspace/_fix2.py`
64
+
65
+ **问题**:
66
+ - `_fix.py` 是一次性脚本,用于修改 Dockerfile 的 CMD(已被 `entrypoint.sh` 取代)。
67
+ - `_fix2.py` 使用 `chr()` 字符编码混淆访问 `"Dockerfile"`(`chr(68)+chr(111)+chr(99)+chr(107)+chr(101)+chr(114)+chr(102)+chr(105)+chr(108)+chr(101)`),这种混淆在安全审计中属于可疑模式 —— 攻击者常用此技巧逃避 `grep "Dockerfile"` 类的静态扫描。虽然内容看起来是良性的(patch Dockerfile),但既然已经废弃,不应留在仓库里。审计期间也发现 `_fix2.py` 还有语法错误(`old=CMD [/usr/local/bin/ds2api]` 缺引号),说明它从未实际运行过。
68
+
69
+ **影响**: 低,但会误导后续审计、增加攻击面认知噪音。
70
+
71
+ **修复**: 直接删除两个文件。
72
+
73
+ ### FIXED-5: devcapture 默认在非 Vercel 启用,捕获完整 prompt/response(MEDIUM)
74
+
75
+ **位置**: `internal/devcapture/store.go:75-97`(修复前)
76
+
77
+ **问题**: `NewFromEnv` 的逻辑是 `enabled := !isVercelRuntime()`,即在 HF Spaces、本地开发、Docker 部署等所有非 Vercel 环境下**默认启用** devcapture。该模块会捕获每次请求的完整 body 和响应 body(最大 5MB),存于内存并通过 `/admin/dev/captures` 端点暴露。对于 DeepSeek 代理来说,请求 body 包含用户的完整 prompt(可能含敏感对话内容),响应 body 包含完整 LLM 回复。
78
+
79
+ **影响**:
80
+ - 内存占用:高并发下 20 条 × 5MB = 100MB,可能触发 OOM。
81
+ - 隐私泄露:管理员 JWT 泄露后,攻击者可读取最近 20 条用户的完整 prompt/response。
82
+ - 默认开启:大多数部署者不知道此功能存在。
83
+
84
+ **修复**: 改为默认禁用,必须显式设置 `DS2API_DEV_PACKET_CAPTURE=1` 才启用。同时移除不再使用的 `isVercelRuntime()` 辅助函数。
85
+
86
+ ---
87
+
88
+ ## 待修复问题(按优先级排序)
89
+
90
+ ### TODO-1: `/admin/login` 无速率限制(MEDIUM)
91
+
92
+ **位置**: `internal/httpapi/admin/auth/handler_auth.go` 的 `login` handler
93
+
94
+ **问题**: 登录端点直接调用 `VerifyAdminCredential`,没有任何速率限制、IP 封禁或失败计数。攻击者可对 admin key 进行暴力枚举。
95
+
96
+ **影响**:
97
+ - 当 admin key 较弱(如 `admin123`、`password`)时,可被字典攻击破解。
98
+ - 即使 admin key 强,暴力请求也会消耗服务器资源。
99
+
100
+ **建议修复**:
101
+ 1. 在 `login` handler 中维护进程级失败计数器(IP → 失败次数 + 最后失败时间)。
102
+ 2. 同一 IP 连续失败 5 次后,5 分钟内拒绝所有登录请求(返回 429)。
103
+ 3. 失败计数器在成功登录或时间窗口过后重置。
104
+ 4. 可选:使用 `golang.org/x/time/rate` 实现 token bucket。
105
+
106
+ **注意**: HF Spaces 通常有多副本,进程级限流可被绕过(每个副本独立计数)。若需更强保护,可结合 Cloudflare Worker 或外部 WAF。
107
+
108
+ ### TODO-2: rawsample 持久化完整 prompt/response 到磁盘(MEDIUM)
109
+
110
+ **位置**: `internal/httpapi/admin/rawsamples/handler_raw_samples.go` + `internal/rawsample/rawsample.go`
111
+
112
+ **问题**: 当 `DS2API_RAW_SAMPLE_CAPTURE=1` 时,系统会把请求/响应的完整原始字节流持久化到 `tests/raw_stream_samples/` 目录。每个 sample 包含:
113
+ - 完整的用户 prompt
114
+ - DeepSeek 的完整响应
115
+ - 账号 identifier
116
+ - 时间戳
117
+
118
+ 这些文件以 `.json` 形式落盘,没有加密,且在 git 提交时可能被意外加入。在生产 HF Space 上启用时,`/data/` 持久化卷会一直保留这些 sample。
119
+
120
+ **影响**:
121
+ - 隐私:用户 prompt 持久化到磁盘。
122
+ - 合规:可能违反 GDPR/CCPA 等数据最小化原则。
123
+ - 攻击面:管理员 JWT 泄露后,攻击者可下载历史 sample。
124
+
125
+ **建议修复**:
126
+ 1. 默认禁用(已是默认行为,确认即可)。
127
+ 2. 在 admin UI 上明确警告"启用后会持久化用户 prompt 到磁盘"。
128
+ 3. 增加自动过期机制(如 24 小时后自动删除)。
129
+ 4. 在 `.gitignore` 中确认 `tests/raw_stream_samples/` 已被忽略(已确认)。
130
+
131
+ ### TODO-3: 版本检查 handler 指向不存在的 GitHub 仓库(LOW)
132
+
133
+ **位置**: `internal/httpapi/admin/version/handler_version.go`
134
+
135
+ **问题**: 版本检查 handler 调用 GitHub API 比对版本,但硬编码的仓库是 `CJackHwang/ds2api`,而实际部署的 remote 是 `luckfun233/ds2api`(或 `a3216/ds2api`)。这导致:
136
+ 1. 版本检查永远返回"有更新可用"或"无法获取"。
137
+ 2. 误导管理员升级到无关项目的版本。
138
+ 3. 信任了一个第三方仓库的 release tag,存在供应链风险。
139
+
140
+ **影响**: 低,但会误导管理员。
141
+
142
+ **建议修复**:
143
+ 1. 把仓库 ID 改为实际部署的 `a3216/ds2api`(或通过环境变量配置)。
144
+ 2. 或者移除版本检查功能,让管理员自行关注 release。
145
+
146
+ ### TODO-4: CORS reflect origin(LOW)
147
+
148
+ **位置**: `internal/server/router.go` 的 CORS 中间件
149
+
150
+ **问题**: CORS 策略反射请求的 `Origin` header,允许任意网站发起跨域请求。虽然所有 admin 端点都要求 Bearer token(不会被 cookie 自动携带),但:
151
+ 1. 如果未来添加了基于 cookie 的鉴权,反射 origin 会变成漏洞。
152
+ 2. 反射 origin 让攻击者网站可以发起 authenticated CORS 请求(如果前端把 token 存在内存里被 XSS 偷走)。
153
+
154
+ **影响**: 低,因为当前 admin 鉴权用 Bearer token 而非 cookie。
155
+
156
+ **建议修复**:
157
+ 1. 把 CORS 限制为已知的前端域名(如 `https://a3216-ds2api.hf.space`)。
158
+ 2. 或者只允许同源请求(不设 `Access-Control-Allow-Origin`)。
159
+ 3. 如果保留 reflect origin,确保 `Access-Control-Allow-Credentials: false`(当前未设 credential,符合预期)。
160
+
161
+ ### TODO-5: account token 在内存中以明文存在(LOW)
162
+
163
+ **位置**: `internal/config/store.go` 的 `cfg.Accounts[].Token`
164
+
165
+ **问题**: DeepSeek 账号的 session token 在内存中以明文存储。如果服务器进程内存被 dump(如通过 `/proc/<pid>/mem`、core dump、swap 文件),token 会泄露。
166
+
167
+ **影响**: 低,需要本地权限提升才能利用。
168
+
169
+ **建议修复**: 不建议立即修复 —— Go 进程内存加密会显著增加复杂度且无法抵御 root 攻击者。建议:
170
+ 1. 确保 HF Space 容器以非 root 用户运行(已确认 `ds2api` 用户)。
171
+ 2. 定期刷新 token(已有 `TokenRefreshIntervalHours` 配置)。
172
+ 3. 监控异常 token 使用。
173
+
174
+ ### TODO-6: webui 静态文件路径穿越(已防护,建议加固)
175
+
176
+ **位置**: `internal/webui/handler.go` 的 `serveFromDisk`
177
+
178
+ **当前状态**: 已正确实现路径穿越防护 —— 使用 `filepath.Clean` + `isPathInsideRoot` 检查。
179
+
180
+ **建议加固**:
181
+ 1. 在 `isPathInsideRoot` 失败时返回 404 而非 403(避免泄露路径存在性)。
182
+ 2. 对所有静态文件请求添加 `X-Content-Type-Options: nosniff` header。
183
+ 3. 对 `.json`、`.map` 等开发文件在生产环境返回 404。
184
+
185
+ ### TODO-7: chat_history 持久化用户对话(LOW)
186
+
187
+ **位置**: `internal/chathistory/store.go`
188
+
189
+ **问题**: chat_history 默认开启,把每个会话的完整消息持久化到 `/data/chat_history.json`。这是设计意图(用于上下文续接),但:
190
+ 1. 文件未加密。
191
+ 2. 没有 TTL 自动清理。
192
+ 3. HF Space 重启时 `/data/` 保留,历史数据可能长期堆积。
193
+
194
+ **影响**: 隐私 —— 用户对话长期持久化。
195
+
196
+ **建议修复**:
197
+ 1. 添加 `DS2API_CHAT_HISTORY_TTL_HOURS` 环境变量,自动清理超期记录。
198
+ 2. 在 admin UI 提供"一键清空 chat history"按钮。
199
+ 3. 文档中明确告知部署者 chat_history 会持久化用户对话。
200
+
201
+ ### TODO-8: env writeback 把环境变量写回 config.json(LOW)
202
+
203
+ **位置**: `internal/config/store_env_writeback.go`
204
+
205
+ **问题**: 当 `DS2API_ENV_WRITEBACK=1` 时,系统会把 `DS2API_CONFIG_JSON` 环境变量的内容写回 `/data/config.json`。这意味着:
206
+ 1. 如果环境变量包含明文密码(常见于 HF Space Secrets),密码会被复制到 `/data/config.json`。
207
+ 2. `/data/config.json` 的权限是 644(world-readable),同机其他用户可读。
208
+
209
+ **影响**: 低 —— HF Space 容器隔离,无其他用户。但本地开发或自托管场景下有风险。
210
+
211
+ **建议修复**:
212
+ 1. 把 `/data/config.json` 权限改为 600(仅 owner 可读)。
213
+ 2. 在 `entrypoint.sh` 中 `chmod 600 /data/config.json`。
214
+ 3. 文档中说明 env writeback 会把密码写入磁盘。
215
+
216
+ ### TODO-9: 没有请求体大小限制(LOW)
217
+
218
+ **位置**: `internal/server/router.go`
219
+
220
+ **问题**: chi router 没有全局的 `http.MaxBytesReader`。攻击者可发送超大请求体消耗内存。
221
+
222
+ **影响**: 低 —— DeepSeek API 本身有请求大小限制,且 chi 默认有超时。
223
+
224
+ **建议修复**:
225
+ 1. 在 router 中间件添加 `http.MaxBytesReader(w, r.Body, 10*1024*1024)`(10MB)。
226
+ 2. 对 `/admin/config/import` 等需要大 body 的端点单独放宽限制。
227
+
228
+ ### TODO-10: 没有 HSTS / Secure cookie(按 skill 建议不报)
229
+
230
+ 按 `security-best-practices` skill 指引,HSTS 在不完全理解长期影响时不建议启用,且 HF Space 已自带 HTTPS 终结,故不作为问题报告。
231
+
232
+ ---
233
+
234
+ ## 渗透测试验证
235
+
236
+ 针对已修复的 CRITICAL/HIGH 问题,本次审计通过单元测试模拟了以下攻击场景:
237
+
238
+ 1. **默认密钥登录**:`TestVerifyAdminCredentialRejectsEmptyAndDefaultAdmin` —— 验证未配置凭据时,`"admin"`、空字符串、`"password"`、`"123456"` 等常见弱密码全部被拒绝。
239
+
240
+ 2. **JWT 伪造**:`TestJWTForgedWithAdminSecretFailsVerification` —— 模拟攻击者用历史默认 `"admin"` 作为 HS256 密钥伪造 JWT,验证 `VerifyJWT` 拒绝该 token。
241
+
242
+ 3. **配置导出泄露**:`TestConfigExportRedactsSecretsFromConfigField` —— 验证 `config` 字段不包含 live token、明文密码、vercel token、admin password hash。
243
+
244
+ 4. **迁移用途保留**:`TestConfigExportJSONStillContainsPasswordsForMigration` —— 验证 `json`/`base64` 字段仍保留密码(迁移必需),但绝不包含 live token。
245
+
246
+ 5. **密码哈希登录回归**:`TestAdminLoginWithConfiguredPasswordHash` —— 验证配置 password_hash 后,密码登录和 JWT 签名都正常工作。
247
+
248
+ 6. **环境变量密钥登录回归**:`TestAdminLoginWithEnvKey` —— 验证配置 `DS2API_ADMIN_KEY` 后,env key 登录正常。
249
+
250
+ 7. **显�� JWT secret 优先**:`TestAdminLoginWithJWTSecretEnv` —— 验证 `DS2API_JWT_SECRET` 优先级最高。
251
+
252
+ 所有测试通过。建议在 CI 中持续运行这些回归测试,防止未来重构时 reintroduce 这些漏洞。
253
+
254
+ ---
255
+
256
+ ## 部署者操作建议
257
+
258
+ 本次修复后,部署者应:
259
+
260
+ 1. **必须**:在 HF Space Secrets 中设置 `DS2API_ADMIN_KEY` 为强随机字符串(至少 32 字符)。否则管理员登录将完全禁用。
261
+ 2. **强烈建议**:设置 `DS2API_JWT_SECRET` 为另一个独立的强随机字符串(不要复用 `DS2API_ADMIN_KEY`)。
262
+ 3. **建议**:通过 admin UI 的"修改密码"功能设置管理员密码(会写入 `admin.password_hash`),这样即使 `DS2API_ADMIN_KEY` 泄露,密码登录通道仍可用。
263
+ 4. **可选**:如需调试,设置 `DS2API_DEV_PACKET_CAPTURE=1` 临时启用 devcapture,调试完毕后务必设回 `0` 或删除该环境变量。
264
+ 5. **审计**:检查 `/data/config.json` 是否包含不再需要的账号密码,定期清理。
265
+ 6. **监控**:定期查看 HF Space 日志,关注异常 admin 端点访问。
266
+
267
+ ---
268
+
269
+ ## 审计方法说明
270
+
271
+ 本次审计采用以下方法:
272
+
273
+ 1. **静态代码审查**:阅读 30+ 个关键文件,包括鉴权、配置、API 路由、前端、CF Worker、Dockerfile。
274
+ 2. **数据流追踪**:从 HTTP 请求入口追踪到配置持久化,识别敏感数据(密码、token、prompt)的存储和暴露路径。
275
+ 3. **攻击者视角**:假设攻击者拥有源码(项目开源),从扫描攻击日志中提取常见攻击模式,验证项目是否易受这些攻击。
276
+ 4. **依赖审查**:检查 `go.mod` 和 `package.json`,未发现已知漏洞依赖。
277
+ 5. **配置审查**:检查 Dockerfile、entrypoint.sh、CF Worker 配置,确认容器以非 root 运行、端口隔离、配置文件权限合理。
278
+
279
+ 未覆盖的范围(建议后续审计):
280
+ - DeepSeek PoW 实现的密码学正确性(`pow/deepseek_pow.go`)
281
+ - utls 指纹模拟的隐私影响(`internal/deepseek/client/proxy.go`)
282
+ - shumei 设备 ID 生成算法(`internal/shumei/`)
283
+ - Vercel Edge Function 的资源限制行为
284
+ - 前端 XSS 防护(React 默认转义,但需检查 `dangerouslySetInnerHTML` 使用)
285
+
286
+ ---
287
+
288
+ *报告生成时间:2026-07-21*
289
+ *审计者:security-best-practices skill*