security: remediate review B findings

This commit is contained in:
kory
2026-10-03 01:37:02 +08:00
parent ca4d54d5b3
commit 56b05b11d9
11 changed files with 674 additions and 35 deletions
+1 -1
View File
@@ -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) addr := ":" + itoa(cfg.Port)
log.Printf("chfs-ng listening on %s root=%s removeMode=%d", addr, filepath.Clean(root), cfg.FileRemove) log.Printf("chfs-ng listening on %s root=%s removeMode=%d", addr, filepath.Clean(root), cfg.FileRemove)
if cfg.SSLCert != "" && cfg.SSLKey != "" { if cfg.SSLCert != "" && cfg.SSLKey != "" {
+185
View File
@@ -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://<host>: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=<path>` 返回 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/上传/回收站/搜索的资源限制(中)。
修完阻断项和高优先级后可认为达到生产安全标准。
+4 -1
View File
@@ -10,4 +10,7 @@ require (
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e 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
)
+4
View File
@@ -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 h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= 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= 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 h1:w8ZOecv6NaNa/zC8944JTU3vz4u6Lagfk4RPQxv92NQ=
golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 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=
+106
View File
@@ -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)
}
}
+176 -11
View File
@@ -47,6 +47,7 @@ type Server struct {
WebDAVRoot string WebDAVRoot string
WebDAVFS webdav.FileSystem WebDAVFS webdav.FileSystem
Allow string Allow string
TrustedProxies string
MultiRoot bool MultiRoot bool
RemoveMode int RemoveMode int
ImagePreview bool ImagePreview bool
@@ -56,6 +57,14 @@ type Server struct {
Log *opslog.Logger Log *opslog.Logger
UserStore *auth.UserStore UserStore *auth.UserStore
UserHome string UserHome string
ThumbnailMaxSize int64
ThumbnailMaxPixels int64
ZipMaxSize int64
ZipMaxFiles int
UploadMaxSize int64
UploadMaxTotalSize int64
UploadMaxFiles int
SearchMaxResults int
ThumbnailCache *thumbnailCache ThumbnailCache *thumbnailCache
thumbnailCacheOnce sync.Once thumbnailCacheOnce sync.Once
} }
@@ -138,11 +147,11 @@ func (s *Server) logOp(r *http.Request, action string) {
if a != nil { if a != nil {
user = a.Username user = a.Username
} }
_ = s.Log.Write(clientIPString(r), user, action) _ = s.Log.Write(s.clientIPString(r), user, action)
} }
func clientIPString(r *http.Request) string { func (s *Server) clientIPString(r *http.Request) string {
ip := clientIP(r) ip := s.clientIP(r)
if ip == nil { if ip == nil {
return "-" return "-"
} }
@@ -516,6 +525,10 @@ func (s *Server) exist(w http.ResponseWriter, r *http.Request) {
return return
} }
p := strings.Trim(strings.ReplaceAll(raw, "\\", "/"), "/") 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) _, e := s.Store.Stat(p)
if e != nil { if e != nil {
w.WriteHeader(http.StatusNotFound) w.WriteHeader(http.StatusNotFound)
@@ -639,7 +652,7 @@ func (s *Server) password(w http.ResponseWriter, r *http.Request) {
writeJSONError(w, http.StatusBadRequest, "invalid json") writeJSONError(w, http.StatusBadRequest, "invalid json")
return 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") writeJSONError(w, http.StatusBadRequest, "current password incorrect")
return 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. // This prevents duplicate/bad entries from leaving a partial upload.
seen := make(map[string]struct{}) seen := make(map[string]struct{})
var saved []string var saved []string
var totalUpload int64
var uploadFiles int
for _, hs := range r.MultipartForm.File { for _, hs := range r.MultipartForm.File {
for _, h := range hs { 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 := filepath.ToSlash(h.Filename)
if name == "" || strings.HasPrefix(name, "/") || unsafePath(name) { if name == "" || strings.HasPrefix(name, "/") || unsafePath(name) {
http.Error(w, "invalid filename", 400) 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 _, hs := range r.MultipartForm.File {
for _, h := range hs { for _, h := range hs {
target := path.Join(dir, filepath.ToSlash(h.Filename)) 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 { for _, previous := range saved {
_ = s.Store.Remove(previous) _ = s.Store.Remove(previous)
} }
@@ -908,7 +937,7 @@ func createExclusive(st storage.Storage, target string) (*os.File, error) {
return st.Create(target) 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() in, e := h.Open()
if e != nil { if e != nil {
return e return e
@@ -919,8 +948,18 @@ func savePart(st storage.Storage, target string, h *multipart.FileHeader) error
return e return e
} }
copyErr := func() error { copyErr := func() error {
_, err := io.Copy(f, in) var src io.Reader = in
return err 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() closeErr := f.Close()
if copyErr != nil { 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. // Preflight the complete multipart batch before creating anything.
// This prevents duplicate/bad entries from leaving a partial upload. // This prevents duplicate/bad entries from leaving a partial upload.
seen := make(map[string]struct{}) seen := make(map[string]struct{})
var totalUpload int64
var uploadFiles int
for _, hs := range r.MultipartForm.File { for _, hs := range r.MultipartForm.File {
for _, h := range hs { 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 := filepath.ToSlash(h.Filename)
name = strings.TrimPrefix(name, "/") name = strings.TrimPrefix(name, "/")
if name == "" || unsafePath(name) { if name == "" || unsafePath(name) {
@@ -981,7 +1036,7 @@ func (s *Server) upload2(w http.ResponseWriter, r *http.Request) {
for _, h := range hs { for _, h := range hs {
name := strings.TrimPrefix(filepath.ToSlash(h.Filename), "/") name := strings.TrimPrefix(filepath.ToSlash(h.Filename), "/")
target := path.Join(dir, name) 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 { for _, previous := range saved {
_ = s.Store.Remove(previous) _ = 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"))) needle := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("str")))
out := make([]searchFileInfo, 0) out := make([]searchFileInfo, 0)
maxResults := s.SearchMaxResults
if maxResults < 0 {
maxResults = 0
}
truncated := false
var walk func(string) error var walk func(string) error
walk = func(dir string) error { walk = func(dir string) error {
items, err := s.Store.List(dir) items, err := s.Store.List(dir)
@@ -1022,6 +1082,10 @@ func (s *Server) search(w http.ResponseWriter, r *http.Request) {
return err return err
} }
for _, fi := range items { for _, fi := range items {
if maxResults > 0 && len(out) >= maxResults {
truncated = true
return nil
}
p := path.Join(dir, fi.Name()) p := path.Join(dir, fi.Name())
if !a.MaskFor(p).HasRead() { if !a.MaskFor(p).HasRead() {
continue continue
@@ -1041,7 +1105,7 @@ func (s *Server) search(w http.ResponseWriter, r *http.Request) {
http.Error(w, err.Error(), 500) http.Error(w, err.Error(), 500)
return 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 { func sharedPath(parts ...string) string {
@@ -1183,6 +1247,10 @@ func (s *Server) thumbnail(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r) http.NotFound(w, r)
return 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()} key := thumbnailCacheKey{Path: file, Size: fi.Size(), ModTime: fi.ModTime().UnixNano()}
s.thumbnailCacheOnce.Do(func() { s.thumbnailCacheOnce.Do(func() {
if s.ThumbnailCache == nil { if s.ThumbnailCache == nil {
@@ -1194,6 +1262,12 @@ func (s *Server) thumbnail(w http.ResponseWriter, r *http.Request) {
if err != nil { if err != nil {
return nil, err 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) thumb := transform.Resize(src, 320, 240, transform.Linear)
var buf bytes.Buffer var buf bytes.Buffer
if err := png.Encode(&buf, thumb); err != nil { 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) _, _ = 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) { func (s *Server) downloadSelectedZip(w http.ResponseWriter, r *http.Request) {
a := account(r, s) a := account(r, s)
paths := r.URL.Query()["path"] paths := r.URL.Query()["path"]
@@ -1312,6 +1442,10 @@ func (s *Server) downloadSelectedZip(w http.ResponseWriter, r *http.Request) {
return 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-Type", "application/zip")
w.Header().Set("Content-Disposition", `attachment; filename="download.zip"`) w.Header().Set("Content-Disposition", `attachment; filename="download.zip"`)
zw := zip.NewWriter(w) 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() name := fi.Name()
if name == "." || name == "" { if name == "." || name == "" {
name = "download" 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 { func (s *Server) ipFilter(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 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) http.Error(w, "forbidden", http.StatusForbidden)
return 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 { func clientIP(r *http.Request) net.IP {
host, _, err := net.SplitHostPort(r.RemoteAddr) host, _, err := net.SplitHostPort(r.RemoteAddr)
if err == nil { if err == nil {
+21 -3
View File
@@ -8,6 +8,7 @@ import (
"time" "time"
jwt "github.com/golang-jwt/jwt/v4" jwt "github.com/golang-jwt/jwt/v4"
"golang.org/x/crypto/bcrypt"
) )
type PermMask uint8 type PermMask uint8
@@ -86,8 +87,10 @@ func ParseRules(values []string) (*Manager, error) {
} }
func (m *Manager) Authenticate(user, pwd string) *Account { 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)) { if a, ok := m.Accounts[user]; ok && (a.Enabled || a.PasswordHash == "") {
return a if a.Password == pwd || VerifyPasswordHash(a.PasswordHash, pwd) {
return a
}
} }
if user == "" && pwd == "" { if user == "" && pwd == "" {
return m.Guest return m.Guest
@@ -95,8 +98,23 @@ func (m *Manager) Authenticate(user, pwd string) *Account {
return nil return nil
} }
func PasswordHash(password string) string { 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)) h := sha256.Sum256([]byte(password))
return hex.EncodeToString(h[:]) return hex.EncodeToString(h[:]) == stored
} }
func (a *Account) MaskFor(path string) PermMask { func (a *Account) MaskFor(path string) PermMask {
+31
View File
@@ -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")
}
}
+61 -18
View File
@@ -20,27 +20,36 @@ type AccountConfig struct {
} }
type Config struct { type Config struct {
Path []string Path []string
Port int Port int
Allow string Allow string
Rules []string TrustedProxies string
Log string Rules []string
FileRemove int Log string
ImagePreview bool FileRemove int
FolderDownload string ImagePreview bool
SessionTimeout int FolderDownload string
JWTSecret string SessionTimeout int
HTMLTitle string JWTSecret string
HTMLNotice string HTMLTitle string
UserFile string HTMLNotice string
UserHome string UserFile string
SSLCert string UserHome string
SSLKey string ThumbnailMaxSize int64
Accounts []AccountConfig ThumbnailMaxPixels int64
ZipMaxSize int64
ZipMaxFiles int
UploadMaxSize int64
UploadMaxTotalSize int64
UploadMaxFiles int
SearchMaxResults int
SSLCert string
SSLKey string
Accounts []AccountConfig
} }
func Default() Config { 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) { func Load(path string) (Config, error) {
@@ -101,6 +110,8 @@ func Load(path string) (Config, error) {
} }
case "allow": case "allow":
c.Allow = val c.Allow = val
case "trusted-proxies":
c.TrustedProxies = val
case "rule": case "rule":
if val != "" { if val != "" {
c.Rules = append(c.Rules, val) c.Rules = append(c.Rules, val)
@@ -133,6 +144,38 @@ func Load(path string) (Config, error) {
c.UserFile = val c.UserFile = val
case "user.home": case "user.home":
c.UserHome = strings.Trim(val, "/") 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": case "ssl.cert":
c.SSLCert = val c.SSLCert = val
case "ssl.key": case "ssl.key":
+43 -1
View File
@@ -150,7 +150,49 @@ func (s Local) Trash(path string) error {
if e != nil { if e != nil {
return e 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 { func (s Local) MoveToTrash(path, trashRoot string) error {
+42
View File
@@ -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)
}
}