FileCodeBox Go 重写版 v2.5.6(安全审计修复版)
Go 1.27.1 (Gin+GORM) + Vue 3 文件快传服务: - 安全审计全部修复(docs/security-audit-2026-09-05.md): bcrypt 密码哈希与自动升级、presign 直传服务端大小/内容校验、 全局请求体上限、依赖升级(govulncheck 0 命中)、janitor 后台清理、 管理端审计动作落库、/admin CORS 收紧、通知内容白名单净化、 会话默认 7 天、限流缓存故障降级、robots.txt 端点等 - 前端:取件链接复制修复(不再重复拼接提取码)、markdown 净化器加固 - Redis 支持库号(FCB_REDIS_DB / redis://…/db URL) - 文档:docs/api/* 与 openapi.yaml 同步最新行为(robots.txt、 提码 5 位起、chunk 32MiB 上限、admin 审计动作等) 验证:gofmt/go vet/go test 全绿;二进制端到端冒烟通过
This commit is contained in:
@@ -0,0 +1,244 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// authMode 认证模式(WebDAV 服务端挑战后自动协商)。
|
||||
type authMode int
|
||||
|
||||
const (
|
||||
authModeUnknown authMode = iota // 未定:先发 Basic 探测
|
||||
authModeBasic
|
||||
authModeDigest
|
||||
)
|
||||
|
||||
// authState WebDAV Basic/Digest 认证状态。
|
||||
//
|
||||
// 策略:
|
||||
// - 首个请求预置 Basic;若服务端 401 且挑战为 Digest,则解析挑战参数切换为 Digest;
|
||||
// - Digest 按 RFC 2617/7616 实现 qop=auth(MD5 / SHA-256,含 -sess 变体);
|
||||
// qop 缺失时回退 RFC 2069 旧式响应;
|
||||
// - nonce 变更时重置 nc 计数;nc/cnonce 在互斥锁内生成保证并发唯一。
|
||||
type authState struct {
|
||||
mu sync.Mutex
|
||||
username string
|
||||
password string
|
||||
mode authMode
|
||||
realm string
|
||||
nonce string
|
||||
qop string // 选定的 qop("auth" 或空 = RFC2069)
|
||||
opaque string
|
||||
algorithm string // MD5 | MD5-sess | SHA-256 | SHA-256-sess
|
||||
nc uint32
|
||||
knownBasicOK bool // 已确认 Basic 可用
|
||||
}
|
||||
|
||||
// newAuthState 构造认证状态(默认以 Basic 起步)。
|
||||
func newAuthState(username, password string) *authState {
|
||||
return &authState{username: username, password: password}
|
||||
}
|
||||
|
||||
// apply 为请求设置 Authorization 头(每次请求调用,Digest 时消耗一个 nc)。
|
||||
func (a *authState) apply(req *http.Request) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
switch {
|
||||
case a.mode == authModeDigest && a.nonce != "":
|
||||
req.Header.Set("Authorization", a.digestHeader(req))
|
||||
default:
|
||||
req.SetBasicAuth(a.username, a.password)
|
||||
}
|
||||
}
|
||||
|
||||
// digestHeader 依据缓存的挑战参数计算 Digest Authorization 头(调用方需持锁)。
|
||||
func (a *authState) digestHeader(req *http.Request) string {
|
||||
uri := req.URL.RequestURI()
|
||||
method := strings.ToUpper(req.Method)
|
||||
ncStr := fmt.Sprintf("%08x", a.nc+1)
|
||||
a.nc++
|
||||
cnonce := randomHex(8)
|
||||
|
||||
var ha1 string
|
||||
switch strings.ToLower(a.algorithm) {
|
||||
case "md5-sess":
|
||||
ha1 = hashHex("md5", hashHex("md5", a.username+":"+a.realm+":"+a.password)+":"+a.nonce+":"+cnonce)
|
||||
case "sha-256-sess":
|
||||
ha1 = hashHex("sha256", hashHex("sha256", a.username+":"+a.realm+":"+a.password)+":"+a.nonce+":"+cnonce)
|
||||
case "sha-256":
|
||||
ha1 = hashHex("sha256", a.username+":"+a.realm+":"+a.password)
|
||||
default: // md5
|
||||
ha1 = hashHex("md5", a.username+":"+a.realm+":"+a.password)
|
||||
}
|
||||
ha2 := hashHex(algoName(a.algorithm), method+":"+uri)
|
||||
|
||||
var response string
|
||||
var fields []string
|
||||
esc := escapeDigestValue(a.username)
|
||||
if a.qop == "" { // RFC 2069
|
||||
response = hashHex(algoName(a.algorithm), ha1+":"+a.nonce+":"+ha2)
|
||||
fields = append(fields,
|
||||
`Digest username="`+esc+`"`,
|
||||
`realm="`+escapeDigestValue(a.realm)+`"`,
|
||||
`nonce="`+escapeDigestValue(a.nonce)+`"`,
|
||||
`uri="`+escapeDigestValue(uri)+`"`,
|
||||
`response="`+response+`"`)
|
||||
} else {
|
||||
response = hashHex(algoName(a.algorithm), ha1+":"+a.nonce+":"+ncStr+":"+cnonce+":"+a.qop+":"+ha2)
|
||||
fields = append(fields,
|
||||
`Digest username="`+esc+`"`,
|
||||
`realm="`+escapeDigestValue(a.realm)+`"`,
|
||||
`nonce="`+escapeDigestValue(a.nonce)+`"`,
|
||||
`uri="`+escapeDigestValue(uri)+`"`,
|
||||
`cnonce="`+cnonce+`"`,
|
||||
`nc=`+ncStr,
|
||||
`qop=`+a.qop,
|
||||
`response="`+response+`"`,
|
||||
`algorithm=`+a.algorithm)
|
||||
}
|
||||
if a.opaque != "" {
|
||||
fields = append(fields, `opaque="`+escapeDigestValue(a.opaque)+`"`)
|
||||
}
|
||||
return strings.Join(fields, ", ")
|
||||
}
|
||||
|
||||
// challenge 处理 401 的 WWW-Authenticate 挑战;返回是否已切换认证方式可重试。
|
||||
// 返回 false 表示凭据错误或算法不受支持,调用方应直接报错。
|
||||
func (a *authState) challenge(header string) bool {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
h := strings.TrimSpace(header)
|
||||
lower := strings.ToLower(h)
|
||||
switch {
|
||||
case strings.HasPrefix(lower, "digest"):
|
||||
params := parseChallengeParams(strings.TrimPrefix(h[len("Digest"):], " "))
|
||||
algo := strings.ToUpper(strings.TrimSpace(params["algorithm"]))
|
||||
if algo == "" {
|
||||
algo = "MD5"
|
||||
}
|
||||
switch algo {
|
||||
case "MD5", "MD5-SESS", "SHA-256", "SHA-256-SESS":
|
||||
default:
|
||||
return false // 不支持的摘要算法
|
||||
}
|
||||
if params["nonce"] == "" || params["realm"] == "" {
|
||||
return false
|
||||
}
|
||||
qop := ""
|
||||
if raw := strings.TrimSpace(params["qop"]); raw != "" {
|
||||
for _, candidate := range strings.Split(raw, ",") {
|
||||
if strings.EqualFold(strings.TrimSpace(candidate), "auth") {
|
||||
qop = "auth"
|
||||
break
|
||||
}
|
||||
}
|
||||
if qop == "" {
|
||||
return false // 仅支持 auth-int 等需要 body 哈希的模式
|
||||
}
|
||||
}
|
||||
if a.nonce != params["nonce"] {
|
||||
a.nc = 0
|
||||
}
|
||||
a.realm, a.nonce, a.qop = params["realm"], params["nonce"], qop
|
||||
a.opaque, a.algorithm = params["opaque"], strings.ToLower(algo)
|
||||
a.mode = authModeDigest
|
||||
a.knownBasicOK = false
|
||||
return true
|
||||
case strings.HasPrefix(lower, "basic"):
|
||||
if a.knownBasicOK || a.mode == authModeBasic {
|
||||
return false // 已用 Basic 仍 401:凭据错误
|
||||
}
|
||||
a.mode = authModeBasic
|
||||
a.knownBasicOK = true
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// parseChallengeParams 解析 "realm=\"x\", nonce=\"y\"" 形式的挑战参数(引号内逗号不切分)。
|
||||
func parseChallengeParams(s string) map[string]string {
|
||||
out := map[string]string{}
|
||||
for _, item := range splitAuthParams(s) {
|
||||
kv := strings.SplitN(item, "=", 2)
|
||||
if len(kv) != 2 {
|
||||
continue
|
||||
}
|
||||
k := strings.ToLower(strings.TrimSpace(kv[0]))
|
||||
v := strings.TrimSpace(kv[1])
|
||||
if len(v) >= 2 && strings.HasPrefix(v, `"`) && strings.HasSuffix(v, `"`) {
|
||||
v = v[1 : len(v)-1]
|
||||
}
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// splitAuthParams 逗号切分但忽略引号内的逗号。
|
||||
func splitAuthParams(s string) []string {
|
||||
var parts []string
|
||||
var b strings.Builder
|
||||
inQuote := false
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
switch {
|
||||
case c == '"':
|
||||
inQuote = !inQuote
|
||||
b.WriteByte(c)
|
||||
case c == ',' && !inQuote:
|
||||
if t := strings.TrimSpace(b.String()); t != "" {
|
||||
parts = append(parts, t)
|
||||
}
|
||||
b.Reset()
|
||||
default:
|
||||
b.WriteByte(c)
|
||||
}
|
||||
}
|
||||
if t := strings.TrimSpace(b.String()); t != "" {
|
||||
parts = append(parts, t)
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
// algoName 映射哈希函数名。
|
||||
func algoName(algorithm string) string {
|
||||
switch strings.ToLower(algorithm) {
|
||||
case "sha-256", "sha-256-sess":
|
||||
return "sha256"
|
||||
default:
|
||||
return "md5"
|
||||
}
|
||||
}
|
||||
|
||||
// hashHex 通用哈希摘要(algo: md5|sha256)。
|
||||
func hashHex(algo, s string) string {
|
||||
if algo == "sha256" {
|
||||
sum := sha256.Sum256([]byte(s))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
sum := md5.Sum([]byte(s))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// escapeDigestValue 转义引号。
|
||||
func escapeDigestValue(s string) string {
|
||||
return strings.ReplaceAll(s, `"`, `\"`)
|
||||
}
|
||||
|
||||
// randomHex 生成 n 字节随机 hex。
|
||||
func randomHex(n int) string {
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
// crypto/rand 失败极其罕见;退化为全零仍保持协议可用。
|
||||
for i := range b {
|
||||
b[i] = 0
|
||||
}
|
||||
}
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
Reference in New Issue
Block a user