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 +0 -8
- _fix2.py +0 -7
- internal/auth/admin.go +45 -4
- internal/auth/admin_security_test.go +212 -0
- internal/config/config.go +23 -0
- internal/devcapture/store.go +6 -5
- internal/httpapi/admin/configmgmt/handler_config_read.go +12 -4
- internal/httpapi/admin/configmgmt/handler_config_read_security_test.go +196 -0
- internal/httpapi/admin/rawsamples/handler_raw_samples_test.go +12 -0
- internal/httpapi/admin/token_runtime_http_test.go +7 -1
- security_best_practices_report.md +289 -0
|
@@ -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")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -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))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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("
|
|
|
|
|
|
|
| 43 |
})
|
| 44 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
}
|
| 46 |
|
| 47 |
func jwtSecret(store AdminConfigReader) string {
|
|
@@ -53,7 +89,12 @@ func jwtSecret(store AdminConfigReader) string {
|
|
| 53 |
return hash
|
| 54 |
}
|
| 55 |
}
|
| 56 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 {
|
|
@@ -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 |
+
}
|
|
@@ -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
|
|
@@ -73,7 +73,12 @@ func Global() *Store {
|
|
| 73 |
}
|
| 74 |
|
| 75 |
func NewFromEnv() *Store {
|
| 76 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
@@ -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":
|
| 75 |
-
"config":
|
| 76 |
-
"json":
|
| 77 |
-
"base64":
|
|
|
|
|
|
|
| 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 |
}
|
|
@@ -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 |
+
}
|
|
@@ -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) {
|
|
@@ -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 |
}
|
|
@@ -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*
|