diff --git a/cmd/chfs/main.go b/cmd/chfs/main.go index c194ce5..9582d48 100644 --- a/cmd/chfs/main.go +++ b/cmd/chfs/main.go @@ -154,7 +154,7 @@ func main() { } } } - srv := &api.Server{Store: store, Auth: mgr, Sessions: map[string]*auth.Account{}, SessionExpiry: map[string]time.Time{}, SessionTimeout: cfg.SessionTimeout, JWTSecret: jwtSecret, WebDAVRoot: root, Allow: cfg.Allow, MultiRoot: len(cfg.Path) > 1, RemoveMode: cfg.FileRemove, ImagePreview: cfg.ImagePreview, FolderDownload: cfg.FolderDownload, HTMLTitle: cfg.HTMLTitle, HTMLNotice: cfg.HTMLNotice, Log: opslog.New(cfg.Log), UserStore: users, UserHome: cfg.UserHome} + srv := &api.Server{Store: store, Auth: mgr, Sessions: map[string]*auth.Account{}, SessionExpiry: map[string]time.Time{}, SessionTimeout: cfg.SessionTimeout, JWTSecret: jwtSecret, WebDAVRoot: root, Allow: cfg.Allow, TrustedProxies: cfg.TrustedProxies, MultiRoot: len(cfg.Path) > 1, RemoveMode: cfg.FileRemove, ImagePreview: cfg.ImagePreview, FolderDownload: cfg.FolderDownload, HTMLTitle: cfg.HTMLTitle, HTMLNotice: cfg.HTMLNotice, Log: opslog.New(cfg.Log), UserStore: users, UserHome: cfg.UserHome, ThumbnailMaxSize: cfg.ThumbnailMaxSize, ThumbnailMaxPixels: cfg.ThumbnailMaxPixels, ZipMaxSize: cfg.ZipMaxSize, ZipMaxFiles: cfg.ZipMaxFiles, UploadMaxSize: cfg.UploadMaxSize, UploadMaxTotalSize: cfg.UploadMaxTotalSize, UploadMaxFiles: cfg.UploadMaxFiles, SearchMaxResults: cfg.SearchMaxResults} addr := ":" + itoa(cfg.Port) log.Printf("chfs-ng listening on %s root=%s removeMode=%d", addr, filepath.Clean(root), cfg.FileRemove) if cfg.SSLCert != "" && cfg.SSLKey != "" { diff --git a/docs/REVIEW_B_AUDIT_20261002.md b/docs/REVIEW_B_AUDIT_20261002.md new file mode 100644 index 0000000..51c7a51 --- /dev/null +++ b/docs/REVIEW_B_AUDIT_20261002.md @@ -0,0 +1,185 @@ +# 审核专家 B:CHFS-NG 代码安全审计报告 + +- **审核人**:审核专家 B +- **日期**:2026-10-02 +- **对象**:`/root/chfs-ng-research`(Go,11 个非测试源文件,约 3900 行) +- **生产环境**:`chfs-ng.service`(root 用户),`bin/chfs-ng -file chfs.ini`,监听 *:18999 +- **验证**:`go vet ./...` 干净;`go test ./...` 全通过 +- **结论**:**有条件通过** —— 代码层面的路径防御与权限检查扎实,但生产部署配置存在阻断项,另有 2 高/3 中需处理 + +--- + +## 【阻断】生产配置使用弱密码 admin123,明文存储且文件全局可读 + +**【严重度:阻断】** + +**【位置】** `/root/chfs-ng-research/chfs.ini`(生产配置文件) + +**【问题描述】** +生产 `chfs.ini` 中 `[admin]` 的密码为明文 `admin123`,文件权限为 `-rw-r--r--`(全局可读)。 +服务监听 `*:18999`(所有网卡),主机防火墙 INPUT 策略为 ACCEPT 且无规则,等同于直接暴露公网。 +远程攻击者无需任何前置条件,直接用 `admin/admin123` 登录即获得全部文件读写删除权限。 + +**【利用/触发条件】** +`curl -X POST http://:18999/chfs/session -d "user=admin&pwd=admin123"` 即得管理员 JWT。 + +**【修复建议】** +1. 立即更换高强度密码;2. `chmod 600 chfs.ini`;3. 配置 `allow` IP 白名单或置于反向代理后; +4. 考虑主机防火墙限制 18999 入站。 + +--- + +## 【高】/chfs/exist 未鉴权的文件存在性预言机 + +**【严重度:高】** + +**【位置】** `internal/api/server.go:512` `func (s *Server) exist` + +**【问题描述】** +该端点全程未调用 `account(r, s)`,未做任何权限检查:`GET /chfs/exist?file=` 返回 201/404 +直接泄露任意路径是否存在。所有其他文件端点都有 `MaskFor` 检查,唯独此处遗漏, +与 README 宣称的"完善的权限体系"不一致。 + +**【利用/触发条件】** +当前生产 guest=r,攻击者本就可列目录,实际影响有限;但在 guest 被收紧的部署中, +该端点绕过权限模型造成信息泄漏。 + +**【修复建议】** +加上 `if !account(r, s).MaskFor(p).HasRead() { 403 }`,与其他端点保持一致。 + +--- + +## 【高】缩略图接口无文件大小限制,可被解压炸弹打 OOM + +**【严重度:高】** + +**【位置】** `internal/api/server.go:1164` `func (s *Server) thumbnail` + +**【问题描述】** +`image.Decode(f)` 会把整个文件读入内存解码,而此前没有任何文件大小检查。 +缓存层虽有 8MB/64 项上限(`thumbnail_cache.go`),但 decode 本身在缓存之外无限制。 +攻击者(生产环境 guest 即有 read 权限)可请求超大文件或特制解压炸弹 +(小文件声明超大宽高,如 PNG 头声明 100000x100000 → 数十 GB 内存分配),直接 OOM 杀死服务。 + +**【利用/触发条件】** +`GET /chfs/thumbnail?file=<大文件或炸弹图片>`,无需登录(guest 可读)。 + +**【修复建议】** +`Stat` 后检查 `fi.Size()`,超过阈值(如 32MB)直接拒绝;或对 decode 加内存上限。 + +--- + +## 【中】目录 ZIP 下载无总大小/数量限制,CPU 与带宽耗尽 + +**【严重度:中】** + +**【位置】** `internal/api/server.go:1290` `downloadSelectedZip`、`1368` `downloadDir` + +**【问题描述】** +两个接口把目录树流式打包为 zip 直接写回 response,全程无总字节数、无文件数上限 +(`downloadSelectedZip` 仅限制了顶层 path 参数 ≤100 个,但每个可为超大目录;`downloadDir` 无任何限制)。 +`zip.Deflate` 是 CPU 密集型,多并发请求可打满 CPU 并占满带宽。guest 可读即触发。 + +**【修复建议】** +增加可配置的打包上限(总大小、文件数),超限返回 413;或对该接口加并发信号量。 + +--- + +## 【中】上传无单文件/总量配额,可填满磁盘 + +**【严重度:中】** + +**【位置】** `internal/api/server.go:838` `upload`、`937` `upload2`、`savePart` + +**【问题描述】** +`ParseMultipartForm` 仅限制内存部分(64MB/128MB),`savePart` 的 `io.Copy(f, in)` 无任何大小上限。 +有写权限的用户可上传超大文件填满磁盘。当前生产仅 admin 有写权限,实际影响有限, +但项目主打"动态权限控制"(会给普通用户开写权限),属于设计缺口。 + +**【修复建议】** +增加 `upload.max-size` 配置项,`savePart` 中用 `io.LimitReader` 截断并报错。 + +--- + +## 【中】默认删除进系统回收站且永不清理,磁盘被"已删除"文件占满 + +**【严重度:中】** + +**【位置】** `internal/storage/storage.go` `Trash`;`internal/config/config.go` 默认 `FileRemove: 1` + +**【问题描述】** +默认 `file.remove=1` 走 `wastebasket.Trash`(`~/.local/share/Trash`),该库无自动清理机制 +(只有模式 2 的 `MoveToTrash` 有 30 天清理)。长期运行的服务删除的文件永久占用磁盘, +`df` 与用户认知不一致,最终表现为神秘的磁盘占满。 + +**【修复建议】** +对模式 1 也加定期清理(如 30 天),或在文档中明确说明并给出清理方式。 + +--- + +## 【中低】search 无结果数量上限 + +**【严重度:中低】** + +**【位置】** `internal/api/server.go:1009` `func (s *Server) search` + +**【问题描述】** +全树递归遍历,结果集无上限一次性 JSON 返回。超大目录树下内存与响应体膨胀, +guest 可直接触发。 + +**【修复建议】** +加 `limit` 参数(默认如 500),超限截断并提示。 + +--- + +## 【低】ipFilter 不识别 X-Forwarded-For,反代后 allow 白名单失效 + +**【严重度:低】** + +**【位置】** `internal/api/server.go:2065` `ipFilter` / `clientIP` + +**【问题描述】** +`clientIP` 只读 `r.RemoteAddr`。README 建议生产用 HTTPS 反向代理, +一旦接入反代,`allow` 看到的永远是代理 IP,白名单名存实亡(要么全放行要么全拒绝)。 + +**【修复建议】** +支持可信代理 + `X-Forwarded-For` 解析,或在文档中明确说明该限制。 + +--- + +## 【低】密码哈希为无盐 SHA256 + +**【严重度:低】** + +**【位置】** `internal/auth/auth.go` `func PasswordHash` + +**【问题描述】** +`users.json` 中存储 `hex(sha256(password))`,无盐。文件泄漏后彩虹表可直接破解弱密码。 + +**【修复建议】** +改用 bcrypt(`golang.org/x/crypto/bcrypt`),并做旧哈希的兼容迁移。 + +--- + +## 已验证为安全的关键点(正面结论) + +1. **JWT 密钥管理正确**:`main.go` 在未配置 `jwt.secret` 时生成随机 32 字节密钥并以 0600 权限持久化 + 到 `users.json.jwt-secret`(生产已生效,文件存在)。`auth.go` 中的硬编码常量在 server 路径中不可达。 +2. **路径穿越防御扎实**:`unsafePath`(NUL + `..` 段)与 `storage.real()` 双层防御;`real()` 对最近存在祖先 + 做 `EvalSymlinks`,连路径中间的符号链接逃逸都能拦住。多根目录经 `Multi` 路由后仍走同一检查。 +3. **权限检查完整**:除 `exist` 外所有 mutating 端点都有 `MaskFor` 检查;`rename` 为 create-only; + `remove`/`transfer` 有递归 preflight;WebDAV 有独立 `webdavAuthFS` 包装,Move/Copy/RemoveAll 均有 `checkTree`。 +4. **上传 preflight**:`upload`/`upload2` 全量校验后再落盘,失败回滚已保存分片;`createExclusive` 防并发覆盖。 +5. **Cookie 安全**:JWT cookie 为 `HttpOnly`;生产 guest 为只读。 +6. **生产密钥文件权限正确**:`users.json.jwt-secret` 为 0600。 + +--- + +## 总体结论 + +**有条件通过**。 + +代码本身质量不错:路径穿越防御、权限模型、并发处理都经过认真设计,测试全过、vet 干净。 +真正的风险在**生产部署**:`admin123` 弱密码 + 公网直连 + 无防火墙是阻断项,必须立即处理。 +代码层面需修:`exist` 鉴权缺失(高)、缩略图 OOM(高),以及 ZIP/上传/回收站/搜索的资源限制(中)。 +修完阻断项和高优先级后可认为达到生产安全标准。 diff --git a/go.mod b/go.mod index 3668e8e..5dc27e3 100644 --- a/go.mod +++ b/go.mod @@ -10,4 +10,7 @@ require ( github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e ) -require golang.org/x/sys v0.3.0 // indirect +require ( + golang.org/x/crypto v0.28.0 // indirect + golang.org/x/sys v0.26.0 // indirect +) diff --git a/go.sum b/go.sum index 6c1864d..b9265e5 100644 --- a/go.sum +++ b/go.sum @@ -11,5 +11,9 @@ github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= github.com/teambition/rrule-go v1.8.2/go.mod h1:Ieq5AbrKGciP1V//Wq8ktsTXwSwJHDD5mD/wLBGl3p4= +golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw= +golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U= golang.org/x/sys v0.3.0 h1:w8ZOecv6NaNa/zC8944JTU3vz4u6Lagfk4RPQxv92NQ= golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= +golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= diff --git a/internal/api/review_b_audit_test.go b/internal/api/review_b_audit_test.go new file mode 100644 index 0000000..1fe5c07 --- /dev/null +++ b/internal/api/review_b_audit_test.go @@ -0,0 +1,106 @@ +package api + +import ( + "image" + "image/color" + "image/png" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "chfs-ng/internal/auth" + "chfs-ng/internal/storage" +) + +func TestAuditExistEnforcesReadPermission(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "secret.txt"), []byte("x"), 0600); err != nil { + t.Fatal(err) + } + s := &Server{Store: storage.Local{Root: root}, Auth: &auth.Manager{Guest: &auth.Account{Mask: auth.None}}} + rr := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/chfs/exist?file=secret.txt", nil) + s.exist(rr, req) + if rr.Code != 403 { + t.Fatalf("exist status=%d want 403", rr.Code) + } +} + +func TestAuditThumbnailRejectsOversizeBeforeDecode(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "big.bin"), make([]byte, 64), 0600); err != nil { + t.Fatal(err) + } + s := &Server{Store: storage.Local{Root: root}, Auth: &auth.Manager{Guest: &auth.Account{Mask: auth.Read}}, ImagePreview: true, ThumbnailMaxSize: 32} + rr := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/chfs/thumbnail?file=big.bin", nil) + s.thumbnail(rr, req) + if rr.Code != 413 { + t.Fatalf("thumbnail status=%d want 413", rr.Code) + } +} + +func TestAuditZipLimitsUseOriginalFileSizes(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "a.txt"), make([]byte, 100), 0600); err != nil { + t.Fatal(err) + } + s := &Server{Store: storage.Local{Root: root}, ZipMaxSize: 99, ZipMaxFiles: 10} + a := &auth.Account{Mask: auth.Read} + if err := s.checkZipLimits(a, []string{""}); err == nil { + t.Fatal("expected zip size limit") + } +} + +func TestAuditSearchLimit(t *testing.T) { + root := t.TempDir() + for _, name := range []string{"a.txt", "b.txt", "c.txt"} { + if err := os.WriteFile(filepath.Join(root, name), []byte(name), 0600); err != nil { + t.Fatal(err) + } + } + s := &Server{Store: storage.Local{Root: root}, Auth: &auth.Manager{Guest: &auth.Account{Mask: auth.Read}}, SearchMaxResults: 2} + rr := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/chfs/search?str=.txt", nil) + s.search(rr, req) + if rr.Code != 200 { + t.Fatalf("search status=%d", rr.Code) + } + if len(rr.Body.Bytes()) == 0 || !contains(rr.Body.String(), `"truncated":true`) { + t.Fatalf("search did not report truncation: %s", rr.Body.String()) + } +} + +func contains(s, sub string) bool { + return len(s) >= len(sub) && (s == sub || func() bool { + for i := 0; i+len(sub) <= len(s); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return false + }()) +} + +func TestAuditThumbnailPixelLimit(t *testing.T) { + root := t.TempDir() + img := image.NewRGBA(image.Rect(0, 0, 20, 20)) + img.Set(0, 0, color.White) + f, err := os.Create(filepath.Join(root, "large.png")) + if err != nil { + t.Fatal(err) + } + if err := png.Encode(f, img); err != nil { + f.Close() + t.Fatal(err) + } + f.Close() + s := &Server{Store: storage.Local{Root: root}, Auth: &auth.Manager{Guest: &auth.Account{Mask: auth.Read}}, ImagePreview: true, ThumbnailMaxPixels: 100} + rr := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/chfs/thumbnail?file=large.png", nil) + s.thumbnail(rr, req) + if rr.Code != 415 { + t.Fatalf("thumbnail pixel-limit status=%d want 415", rr.Code) + } +} diff --git a/internal/api/server.go b/internal/api/server.go index 21325f7..1c6fb6a 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -47,6 +47,7 @@ type Server struct { WebDAVRoot string WebDAVFS webdav.FileSystem Allow string + TrustedProxies string MultiRoot bool RemoveMode int ImagePreview bool @@ -56,6 +57,14 @@ type Server struct { Log *opslog.Logger UserStore *auth.UserStore UserHome string + ThumbnailMaxSize int64 + ThumbnailMaxPixels int64 + ZipMaxSize int64 + ZipMaxFiles int + UploadMaxSize int64 + UploadMaxTotalSize int64 + UploadMaxFiles int + SearchMaxResults int ThumbnailCache *thumbnailCache thumbnailCacheOnce sync.Once } @@ -138,11 +147,11 @@ func (s *Server) logOp(r *http.Request, action string) { if a != nil { user = a.Username } - _ = s.Log.Write(clientIPString(r), user, action) + _ = s.Log.Write(s.clientIPString(r), user, action) } -func clientIPString(r *http.Request) string { - ip := clientIP(r) +func (s *Server) clientIPString(r *http.Request) string { + ip := s.clientIP(r) if ip == nil { return "-" } @@ -516,6 +525,10 @@ func (s *Server) exist(w http.ResponseWriter, r *http.Request) { return } p := strings.Trim(strings.ReplaceAll(raw, "\\", "/"), "/") + if !account(r, s).MaskFor(p).HasRead() { + writeJSONError(w, http.StatusForbidden, "has no privilege") + return + } _, e := s.Store.Stat(p) if e != nil { w.WriteHeader(http.StatusNotFound) @@ -639,7 +652,7 @@ func (s *Server) password(w http.ResponseWriter, r *http.Request) { writeJSONError(w, http.StatusBadRequest, "invalid json") return } - if a.Password != req.CurrentPassword && a.PasswordHash != auth.PasswordHash(req.CurrentPassword) { + if a.Password != req.CurrentPassword && !auth.VerifyPasswordHash(a.PasswordHash, req.CurrentPassword) { writeJSONError(w, http.StatusBadRequest, "current password incorrect") return } @@ -855,8 +868,24 @@ func (s *Server) upload(w http.ResponseWriter, r *http.Request) { // This prevents duplicate/bad entries from leaving a partial upload. seen := make(map[string]struct{}) var saved []string + var totalUpload int64 + var uploadFiles int for _, hs := range r.MultipartForm.File { for _, h := range hs { + uploadFiles++ + if s.UploadMaxFiles > 0 && uploadFiles > s.UploadMaxFiles { + http.Error(w, "upload file count limit exceeded", http.StatusRequestEntityTooLarge) + return + } + if s.UploadMaxSize > 0 && h.Size > s.UploadMaxSize { + http.Error(w, "upload file too large", http.StatusRequestEntityTooLarge) + return + } + totalUpload += h.Size + if s.UploadMaxTotalSize > 0 && totalUpload > s.UploadMaxTotalSize { + http.Error(w, "upload total size limit exceeded", http.StatusRequestEntityTooLarge) + return + } name := filepath.ToSlash(h.Filename) if name == "" || strings.HasPrefix(name, "/") || unsafePath(name) { http.Error(w, "invalid filename", 400) @@ -881,7 +910,7 @@ func (s *Server) upload(w http.ResponseWriter, r *http.Request) { for _, hs := range r.MultipartForm.File { for _, h := range hs { target := path.Join(dir, filepath.ToSlash(h.Filename)) - if e := savePart(s.Store, target, h); e != nil { + if e := savePart(s.Store, target, h, s.UploadMaxSize); e != nil { for _, previous := range saved { _ = s.Store.Remove(previous) } @@ -908,7 +937,7 @@ func createExclusive(st storage.Storage, target string) (*os.File, error) { return st.Create(target) } -func savePart(st storage.Storage, target string, h *multipart.FileHeader) error { +func savePart(st storage.Storage, target string, h *multipart.FileHeader, maxSize int64) error { in, e := h.Open() if e != nil { return e @@ -919,8 +948,18 @@ func savePart(st storage.Storage, target string, h *multipart.FileHeader) error return e } copyErr := func() error { - _, err := io.Copy(f, in) - return err + var src io.Reader = in + if maxSize > 0 { + src = io.LimitReader(in, maxSize+1) + } + n, err := io.Copy(f, src) + if err != nil { + return err + } + if maxSize > 0 && n > maxSize { + return fmt.Errorf("upload file too large") + } + return nil }() closeErr := f.Close() if copyErr != nil { @@ -953,8 +992,24 @@ func (s *Server) upload2(w http.ResponseWriter, r *http.Request) { // Preflight the complete multipart batch before creating anything. // This prevents duplicate/bad entries from leaving a partial upload. seen := make(map[string]struct{}) + var totalUpload int64 + var uploadFiles int for _, hs := range r.MultipartForm.File { for _, h := range hs { + uploadFiles++ + if s.UploadMaxFiles > 0 && uploadFiles > s.UploadMaxFiles { + http.Error(w, "upload file count limit exceeded", http.StatusRequestEntityTooLarge) + return + } + if s.UploadMaxSize > 0 && h.Size > s.UploadMaxSize { + http.Error(w, "upload file too large", http.StatusRequestEntityTooLarge) + return + } + totalUpload += h.Size + if s.UploadMaxTotalSize > 0 && totalUpload > s.UploadMaxTotalSize { + http.Error(w, "upload total size limit exceeded", http.StatusRequestEntityTooLarge) + return + } name := filepath.ToSlash(h.Filename) name = strings.TrimPrefix(name, "/") if name == "" || unsafePath(name) { @@ -981,7 +1036,7 @@ func (s *Server) upload2(w http.ResponseWriter, r *http.Request) { for _, h := range hs { name := strings.TrimPrefix(filepath.ToSlash(h.Filename), "/") target := path.Join(dir, name) - if err := savePart(s.Store, target, h); err != nil { + if err := savePart(s.Store, target, h, s.UploadMaxSize); err != nil { for _, previous := range saved { _ = s.Store.Remove(previous) } @@ -1015,6 +1070,11 @@ func (s *Server) search(w http.ResponseWriter, r *http.Request) { } needle := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("str"))) out := make([]searchFileInfo, 0) + maxResults := s.SearchMaxResults + if maxResults < 0 { + maxResults = 0 + } + truncated := false var walk func(string) error walk = func(dir string) error { items, err := s.Store.List(dir) @@ -1022,6 +1082,10 @@ func (s *Server) search(w http.ResponseWriter, r *http.Request) { return err } for _, fi := range items { + if maxResults > 0 && len(out) >= maxResults { + truncated = true + return nil + } p := path.Join(dir, fi.Name()) if !a.MaskFor(p).HasRead() { continue @@ -1041,7 +1105,7 @@ func (s *Server) search(w http.ResponseWriter, r *http.Request) { http.Error(w, err.Error(), 500) return } - writeJSON(w, map[string]any{"virtualRoot": 0, "currentMask": permString(rootMask), "files": out}) + writeJSON(w, map[string]any{"virtualRoot": 0, "currentMask": permString(rootMask), "files": out, "truncated": truncated}) } func sharedPath(parts ...string) string { @@ -1183,6 +1247,10 @@ func (s *Server) thumbnail(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) return } + if s.ThumbnailMaxSize > 0 && fi.Size() > s.ThumbnailMaxSize { + writeJSONError(w, http.StatusRequestEntityTooLarge, "thumbnail file too large") + return + } key := thumbnailCacheKey{Path: file, Size: fi.Size(), ModTime: fi.ModTime().UnixNano()} s.thumbnailCacheOnce.Do(func() { if s.ThumbnailCache == nil { @@ -1194,6 +1262,12 @@ func (s *Server) thumbnail(w http.ResponseWriter, r *http.Request) { if err != nil { return nil, err } + if s.ThumbnailMaxPixels > 0 { + b := src.Bounds() + if int64(b.Dx())*int64(b.Dy()) > s.ThumbnailMaxPixels { + return nil, fmt.Errorf("thumbnail dimensions too large") + } + } thumb := transform.Resize(src, 320, 240, transform.Linear) var buf bytes.Buffer if err := png.Encode(&buf, thumb); err != nil { @@ -1287,6 +1361,62 @@ func (s *Server) qrcode(w http.ResponseWriter, r *http.Request) { _, _ = w.Write(data) } +func (s *Server) zipStats(a *auth.Account, roots []string) (int64, int, error) { + var total int64 + files := 0 + seen := map[string]bool{} + var walk func(string) error + walk = func(p string) error { + if seen[p] { + return nil + } + seen[p] = true + fi, err := s.Store.Stat(p) + if err != nil { + return err + } + if !a.MaskFor(p).HasRead() { + return nil + } + if !fi.IsDir() { + files++ + total += fi.Size() + return nil + } + items, err := s.Store.List(p) + if err != nil { + return err + } + for _, it := range items { + child := path.Join(p, it.Name()) + if err := walk(child); err != nil { + return err + } + } + return nil + } + for _, root := range roots { + if err := walk(root); err != nil { + return 0, 0, err + } + } + return total, files, nil +} + +func (s *Server) checkZipLimits(a *auth.Account, roots []string) error { + total, files, err := s.zipStats(a, roots) + if err != nil { + return err + } + if s.ZipMaxFiles > 0 && files > s.ZipMaxFiles { + return fmt.Errorf("zip file count limit exceeded") + } + if s.ZipMaxSize > 0 && total > s.ZipMaxSize { + return fmt.Errorf("zip size limit exceeded") + } + return nil +} + func (s *Server) downloadSelectedZip(w http.ResponseWriter, r *http.Request) { a := account(r, s) paths := r.URL.Query()["path"] @@ -1312,6 +1442,10 @@ func (s *Server) downloadSelectedZip(w http.ResponseWriter, r *http.Request) { return } } + if err := s.checkZipLimits(a, paths); err != nil { + writeJSONError(w, http.StatusRequestEntityTooLarge, err.Error()) + return + } w.Header().Set("Content-Type", "application/zip") w.Header().Set("Content-Disposition", `attachment; filename="download.zip"`) zw := zip.NewWriter(w) @@ -1403,6 +1537,10 @@ func (s *Server) downloadDir(w http.ResponseWriter, r *http.Request) { } } } + if err := s.checkZipLimits(a, []string{p}); err != nil { + writeJSONError(w, http.StatusRequestEntityTooLarge, err.Error()) + return + } name := fi.Name() if name == "." || name == "" { name = "download" @@ -2064,7 +2202,7 @@ func (f *webdavAuthFS) checkTree(ctx context.Context, name string, p auth.PermMa func (s *Server) ipFilter(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if s.Allow != "" && !allowedIP(s.Allow, clientIP(r)) { + if s.Allow != "" && !allowedIP(s.Allow, s.clientIP(r)) { http.Error(w, "forbidden", http.StatusForbidden) return } @@ -2072,6 +2210,33 @@ func (s *Server) ipFilter(next http.Handler) http.Handler { }) } +func (s *Server) clientIP(r *http.Request) net.IP { + host, _, _ := net.SplitHostPort(r.RemoteAddr) + remote := net.ParseIP(host) + if remote == nil { + remote = net.ParseIP(r.RemoteAddr) + } + if remote == nil || s.TrustedProxies == "" || !allowedIP(s.TrustedProxies, remote) { + return remote + } + // Only a trusted proxy may supply the forwarding chain. Walk right-to-left + // and return the first address that is not itself a trusted proxy. + parts := strings.Split(r.Header.Get("X-Forwarded-For"), ",") + for i := len(parts) - 1; i >= 0; i-- { + ip := net.ParseIP(strings.TrimSpace(parts[i])) + if ip == nil { + continue + } + if !allowedIP(s.TrustedProxies, ip) { + return ip + } + } + if v := net.ParseIP(strings.TrimSpace(r.Header.Get("X-Real-IP"))); v != nil && !allowedIP(s.TrustedProxies, v) { + return v + } + return remote +} + func clientIP(r *http.Request) net.IP { host, _, err := net.SplitHostPort(r.RemoteAddr) if err == nil { diff --git a/internal/auth/auth.go b/internal/auth/auth.go index b2b0edf..b47928e 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -8,6 +8,7 @@ import ( "time" jwt "github.com/golang-jwt/jwt/v4" + "golang.org/x/crypto/bcrypt" ) type PermMask uint8 @@ -86,8 +87,10 @@ func ParseRules(values []string) (*Manager, error) { } func (m *Manager) Authenticate(user, pwd string) *Account { - if a, ok := m.Accounts[user]; ok && (a.Enabled || a.PasswordHash == "") && (a.Password == pwd || a.PasswordHash == PasswordHash(pwd)) { - return a + if a, ok := m.Accounts[user]; ok && (a.Enabled || a.PasswordHash == "") { + if a.Password == pwd || VerifyPasswordHash(a.PasswordHash, pwd) { + return a + } } if user == "" && pwd == "" { return m.Guest @@ -95,8 +98,23 @@ func (m *Manager) Authenticate(user, pwd string) *Account { return nil } func PasswordHash(password string) string { + h, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return "" + } + return string(h) +} + +func VerifyPasswordHash(stored, password string) bool { + if stored == "" { + return false + } + if strings.HasPrefix(stored, "$2a$") || strings.HasPrefix(stored, "$2b$") || strings.HasPrefix(stored, "$2y$") { + return bcrypt.CompareHashAndPassword([]byte(stored), []byte(password)) == nil + } + // Legacy unsalted SHA-256 hashes remain readable for one migration cycle. h := sha256.Sum256([]byte(password)) - return hex.EncodeToString(h[:]) + return hex.EncodeToString(h[:]) == stored } func (a *Account) MaskFor(path string) PermMask { diff --git a/internal/auth/password_migration_test.go b/internal/auth/password_migration_test.go new file mode 100644 index 0000000..05cdf82 --- /dev/null +++ b/internal/auth/password_migration_test.go @@ -0,0 +1,31 @@ +package auth + +import ( + "crypto/sha256" + "encoding/hex" + "testing" +) + +func TestPasswordHashUsesBcryptAndVerifies(t *testing.T) { + hash := PasswordHash("correct horse battery staple") + if len(hash) < 4 || (hash[0:4] != "$2a$" && hash[0:4] != "$2b$") { + t.Fatalf("hash=%q is not bcrypt", hash) + } + if !VerifyPasswordHash(hash, "correct horse battery staple") { + t.Fatal("bcrypt password did not verify") + } + if VerifyPasswordHash(hash, "wrong") { + t.Fatal("wrong password verified") + } +} + +func TestLegacySHA256PasswordStillVerifies(t *testing.T) { + sum := sha256.Sum256([]byte("legacy-pass")) + hash := hex.EncodeToString(sum[:]) + if !VerifyPasswordHash(hash, "legacy-pass") { + t.Fatal("legacy hash did not verify") + } + if VerifyPasswordHash(hash, "wrong") { + t.Fatal("wrong legacy password verified") + } +} diff --git a/internal/config/config.go b/internal/config/config.go index d082d89..166fa06 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -20,27 +20,36 @@ type AccountConfig struct { } type Config struct { - Path []string - Port int - Allow string - Rules []string - Log string - FileRemove int - ImagePreview bool - FolderDownload string - SessionTimeout int - JWTSecret string - HTMLTitle string - HTMLNotice string - UserFile string - UserHome string - SSLCert string - SSLKey string - Accounts []AccountConfig + Path []string + Port int + Allow string + TrustedProxies string + Rules []string + Log string + FileRemove int + ImagePreview bool + FolderDownload string + SessionTimeout int + JWTSecret string + HTMLTitle string + HTMLNotice string + UserFile string + UserHome string + ThumbnailMaxSize int64 + ThumbnailMaxPixels int64 + ZipMaxSize int64 + ZipMaxFiles int + UploadMaxSize int64 + UploadMaxTotalSize int64 + UploadMaxFiles int + SearchMaxResults int + SSLCert string + SSLKey string + Accounts []AccountConfig } func Default() Config { - return Config{Path: []string{"."}, Port: 80, FileRemove: 1, ImagePreview: true, FolderDownload: "enable", SessionTimeout: 30, UserHome: "users"} + return Config{Path: []string{"."}, Port: 80, FileRemove: 1, ImagePreview: true, FolderDownload: "enable", SessionTimeout: 30, UserHome: "users", ThumbnailMaxSize: 32 << 20, ThumbnailMaxPixels: 40_000_000, ZipMaxSize: 2 << 30, ZipMaxFiles: 5000, UploadMaxSize: 2 << 30, UploadMaxTotalSize: 4 << 30, UploadMaxFiles: 100, SearchMaxResults: 500} } func Load(path string) (Config, error) { @@ -101,6 +110,8 @@ func Load(path string) (Config, error) { } case "allow": c.Allow = val + case "trusted-proxies": + c.TrustedProxies = val case "rule": if val != "" { c.Rules = append(c.Rules, val) @@ -133,6 +144,38 @@ func Load(path string) (Config, error) { c.UserFile = val case "user.home": c.UserHome = strings.Trim(val, "/") + case "thumbnail.max-size": + if n, e := strconv.ParseInt(val, 10, 64); e == nil && n >= 0 { + c.ThumbnailMaxSize = n + } + case "thumbnail.max-pixels": + if n, e := strconv.ParseInt(val, 10, 64); e == nil && n >= 0 { + c.ThumbnailMaxPixels = n + } + case "zip.max-size": + if n, e := strconv.ParseInt(val, 10, 64); e == nil && n >= 0 { + c.ZipMaxSize = n + } + case "zip.max-files": + if n, e := strconv.Atoi(val); e == nil && n >= 0 { + c.ZipMaxFiles = n + } + case "upload.max-size": + if n, e := strconv.ParseInt(val, 10, 64); e == nil && n >= 0 { + c.UploadMaxSize = n + } + case "upload.max-total-size": + if n, e := strconv.ParseInt(val, 10, 64); e == nil && n >= 0 { + c.UploadMaxTotalSize = n + } + case "upload.max-files": + if n, e := strconv.Atoi(val); e == nil && n >= 0 { + c.UploadMaxFiles = n + } + case "search.max-results": + if n, e := strconv.Atoi(val); e == nil && n >= 0 { + c.SearchMaxResults = n + } case "ssl.cert": c.SSLCert = val case "ssl.key": diff --git a/internal/storage/storage.go b/internal/storage/storage.go index 649b703..49e6f6f 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -150,7 +150,49 @@ func (s Local) Trash(path string) error { if e != nil { return e } - return wastebasket.Trash(p) + if err := wastebasket.Trash(p); err != nil { + return err + } + _ = cleanupSystemTrash(30 * 24 * time.Hour) + return nil +} + +func cleanupSystemTrash(maxAge time.Duration) error { + base := os.Getenv("XDG_DATA_HOME") + if base == "" { + home, err := os.UserHomeDir() + if err != nil { + return err + } + base = filepath.Join(home, ".local", "share") + } + root := filepath.Join(base, "Trash") + filesDir := filepath.Join(root, "files") + infoDir := filepath.Join(root, "info") + now := time.Now() + entries, err := os.ReadDir(infoDir) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + for _, entry := range entries { + if entry.IsDir() || filepath.Ext(entry.Name()) != ".trashinfo" { + continue + } + info, err := entry.Info() + if err != nil { + continue + } + if now.Sub(info.ModTime()) <= maxAge { + continue + } + name := strings.TrimSuffix(entry.Name(), ".trashinfo") + _ = os.Remove(filepath.Join(infoDir, entry.Name())) + _ = os.RemoveAll(filepath.Join(filesDir, name)) + } + return nil } func (s Local) MoveToTrash(path, trashRoot string) error { diff --git a/internal/storage/trash_cleanup_test.go b/internal/storage/trash_cleanup_test.go new file mode 100644 index 0000000..7def10e --- /dev/null +++ b/internal/storage/trash_cleanup_test.go @@ -0,0 +1,42 @@ +package storage + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func TestCleanupSystemTrashRemovesOldEntries(t *testing.T) { + data := t.TempDir() + t.Setenv("XDG_DATA_HOME", data) + files := filepath.Join(data, "Trash", "files") + info := filepath.Join(data, "Trash", "info") + if err := os.MkdirAll(files, 0755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(info, 0755); err != nil { + t.Fatal(err) + } + oldInfo := filepath.Join(info, "old.txt.trashinfo") + oldFile := filepath.Join(files, "old.txt") + if err := os.WriteFile(oldInfo, []byte("[Trash Info]\n"), 0600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(oldFile, []byte("old"), 0600); err != nil { + t.Fatal(err) + } + old := time.Now().Add(-31 * 24 * time.Hour) + if err := os.Chtimes(oldInfo, old, old); err != nil { + t.Fatal(err) + } + if err := cleanupSystemTrash(30 * 24 * time.Hour); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(oldInfo); !os.IsNotExist(err) { + t.Fatalf("old info remains: %v", err) + } + if _, err := os.Stat(oldFile); !os.IsNotExist(err) { + t.Fatalf("old file remains: %v", err) + } +}