From c8e4996c3458bfa4c1e45d671e783ef548c4dde0 Mon Sep 17 00:00:00 2001 From: "tao.chen" <93983997+taochen-ct@users.noreply.github.com> Date: Thu, 27 Aug 2026 12:41:28 +0800 Subject: [PATCH] init --- .gitignore | 4 + audit_test.go | 131 +++++++ go.mod | 37 ++ go.sum | 91 +++++ main.go | 594 +++++++++++++++++++++++++++++++ security_test.sh | 279 +++++++++++++++ static/index.html | 874 ++++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 2010 insertions(+) create mode 100644 .gitignore create mode 100644 audit_test.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100755 security_test.sh create mode 100644 static/index.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4eb2dd2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +data +.idea +server +tmp-upload \ No newline at end of file diff --git a/audit_test.go b/audit_test.go new file mode 100644 index 0000000..38c6db4 --- /dev/null +++ b/audit_test.go @@ -0,0 +1,131 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// TestAuditDailyRotation 验证跨日 lazy rotation +// 模拟 4 天的写入, 期望产出 4 个不同日期的文件 +func TestAuditDailyRotation(t *testing.T) { + tmp := t.TempDir() + + // 临时改全局 auditDir, 函数返回后恢复 + oldDir := *auditDir + *auditDir = tmp + defer func() { + *auditDir = oldDir + // 关闭可能打开的文件 + if auditFile != nil { + auditFile.Close() + auditFile = nil + } + auditCurDay = "" + }() + + // 模拟连续 4 天的写入 + base := time.Date(2026, 6, 20, 10, 0, 0, 0, time.UTC) + for i := range 4 { + day := base.Add(time.Duration(i) * 24 * time.Hour) + auditMu.Lock() + if err := openAuditFor(day); err != nil { + t.Fatalf("day %d: openAuditFor: %v", i, err) + } + if _, err := auditFile.Write([]byte("event on " + day.Format("2006-01-02") + "\n")); err != nil { + t.Fatalf("day %d: write: %v", i, err) + } + auditMu.Unlock() + } + + // 验证: 4 个文件, 每个含 1 条事件 + entries, err := os.ReadDir(tmp) + if err != nil { + t.Fatal(err) + } + if len(entries) != 4 { + t.Fatalf("expected 4 files, got %d: %v", len(entries), entries) + } + for _, e := range entries { + name := e.Name() + if !strings.HasSuffix(name, ".log") { + t.Errorf("unexpected file: %s", name) + } + body, _ := os.ReadFile(filepath.Join(tmp, name)) + expected := "event on " + strings.TrimSuffix(name, ".log") + if string(body) != expected+"\n" { + t.Errorf("%s: got %q, want %q", name, body, expected+"\n") + } + } +} + +// TestAuditNoRotateSameDay 验证同一天多次调用不会关闭/重开文件 +func TestAuditNoRotateSameDay(t *testing.T) { + tmp := t.TempDir() + oldDir := *auditDir + *auditDir = tmp + defer func() { + *auditDir = oldDir + if auditFile != nil { + auditFile.Close() + auditFile = nil + } + auditCurDay = "" + }() + + day := time.Date(2026, 6, 23, 14, 30, 0, 0, time.UTC) + + auditMu.Lock() + if err := openAuditFor(day); err != nil { + t.Fatal(err) + } + f1 := auditFile + if err := openAuditFor(day.Add(1 * time.Hour)); err != nil { + t.Fatal(err) + } + f2 := auditFile + auditMu.Unlock() + + if f1 != f2 { + t.Error("expected same file handle for same day") + } +} + +// TestAuditRotateClosesOld 验证跨日时旧文件被 close +func TestAuditRotateClosesOld(t *testing.T) { + tmp := t.TempDir() + oldDir := *auditDir + *auditDir = tmp + defer func() { + *auditDir = oldDir + if auditFile != nil { + auditFile.Close() + auditFile = nil + } + auditCurDay = "" + }() + + day1 := time.Date(2026, 6, 23, 23, 59, 0, 0, time.UTC) + day2 := day1.Add(2 * time.Minute) // 跨过午夜 + + auditMu.Lock() + if err := openAuditFor(day1); err != nil { + t.Fatal(err) + } + oldFile := auditFile + if err := openAuditFor(day2); err != nil { + t.Fatal(err) + } + newFile := auditFile + auditMu.Unlock() + + if oldFile == newFile { + t.Error("expected different file handles after rotation") + } + // 旧文件应已被 close + if _, err := oldFile.WriteString("test"); err == nil { + t.Error("old file should be closed") + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..274b20f --- /dev/null +++ b/go.mod @@ -0,0 +1,37 @@ +module tmp-upload + +go 1.24 + +require ( + github.com/gin-gonic/gin v1.10.0 + github.com/robfig/cron/v3 v3.0.1 +) + +require ( + github.com/bytedance/sonic v1.11.6 // indirect + github.com/bytedance/sonic/loader v0.1.1 // indirect + github.com/cloudwego/base64x v0.1.4 // indirect + github.com/cloudwego/iasm v0.2.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.3 // indirect + github.com/gin-contrib/sse v0.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.20.0 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.2.7 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.2.12 // indirect + golang.org/x/arch v0.8.0 // indirect + golang.org/x/crypto v0.23.0 // indirect + golang.org/x/net v0.25.0 // indirect + golang.org/x/sys v0.20.0 // indirect + golang.org/x/text v0.15.0 // indirect + google.golang.org/protobuf v1.34.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..c7fc346 --- /dev/null +++ b/go.sum @@ -0,0 +1,91 @@ +github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0= +github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= +github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= +github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= +github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= +github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= +github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= +github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= +github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= +github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= +github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8= +github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= +github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= +github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= +github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= +github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= +golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= +golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= +google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/main.go b/main.go new file mode 100644 index 0000000..1d339cc --- /dev/null +++ b/main.go @@ -0,0 +1,594 @@ +package main + +import ( + _ "embed" + "encoding/json" + "flag" + "fmt" + "log" + "maps" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/gin-gonic/gin" + "github.com/robfig/cron/v3" +) + +// 嵌入式静态页面 (单文件部署, 把 HTML 编译进二进制, 无需运行时外部文件) +// +//go:embed static/index.html +var indexHTML []byte + +// ----------------------------------------------------------------------------- +// 配置 (可被命令行参数覆盖) +// ----------------------------------------------------------------------------- + +var ( + uploadDir = flag.String("dir", "./data/uploads", "上传文件保存目录") + listen = flag.String("listen", ":8080", "HTTP 监听地址") + fileTTL = flag.Duration("ttl", 24*time.Hour, "文件过期 TTL") + scanEvery = flag.Duration("scan", 1*time.Hour, "清理扫描间隔") + auditDir = flag.String("audit-dir", "./data/audit", "审计日志目录 (每天一个 YYYY-MM-DD.log 文件, 空字符串禁用)") + quota = flag.Int64("quota", 10<<30, "总目录配额 (字节, 默认 10GB, 0=不限)") +) + +// ----------------------------------------------------------------------------- +// 路由处理器 +// ----------------------------------------------------------------------------- + +const ( + fieldName = "file" +) + +type fileResponse struct { + Filename string `json:"filename"` + URL string `json:"url"` + Size int64 `json:"size"` + UploadedAt time.Time `json:"uploaded_at"` + ExpiresAt time.Time `json:"expires_at"` +} + +// ----------------------------------------------------------------------------- +// 审计日志 (JSON Lines, 一行一条事件, 落盘到 *auditDir/YYYY-MM-DD.log, 跨日自动轮转) +// ----------------------------------------------------------------------------- + +var ( + auditMu sync.Mutex + auditFile *os.File + auditCurDay string // 当前打开文件对应的日期 (YYYY-MM-DD) +) + +// initAudit 创建目录, 打开今天的日志文件 +func initAudit() error { + if *auditDir == "" { + log.Println("[audit] disabled (audit dir is empty)") + return nil + } + if err := os.MkdirAll(*auditDir, 0o755); err != nil { + return fmt.Errorf("create audit dir: %w", err) + } + if err := openAuditFor(time.Now()); err != nil { + return err + } + log.Printf("[audit] writing to %s/%s.log", *auditDir, auditCurDay) + return nil +} + +// openAuditFor 为指定时间打开对应日期的日志文件. 调用方需持有 auditMu. +func openAuditFor(t time.Time) error { + day := t.Format("2006-01-02") + if day == auditCurDay && auditFile != nil { + return nil + } + if auditFile != nil { + _ = auditFile.Close() + } + path := filepath.Join(*auditDir, day+".log") + f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644) + if err != nil { + return fmt.Errorf("open %s: %w", path, err) + } + auditFile = f + auditCurDay = day + return nil +} + +// audit 写一条审计事件. fields 里的键会合并到事件 JSON 中. +// 写入失败仅记录到 stdout, 不会影响主流程. +func audit(c *gin.Context, action string, fields map[string]any) { + if *auditDir == "" { + return + } + entry := map[string]any{ + "ts": time.Now().Format(time.RFC3339), + "action": action, + "ip": c.ClientIP(), + "method": c.Request.Method, + "path": c.Request.URL.Path, + "ua": c.Request.UserAgent(), + "status": c.Writer.Status(), + "latency": time.Since(c.GetTime("t0")).String(), + } + maps.Copy(entry, fields) + b, err := json.Marshal(entry) + if err != nil { + log.Printf("[audit] marshal err: %v", err) + return + } + b = append(b, '\n') + + auditMu.Lock() + defer auditMu.Unlock() + if err := openAuditFor(time.Now()); err != nil { + log.Printf("[audit] open err: %v", err) + return + } + if _, err := auditFile.Write(b); err != nil { + log.Printf("[audit] write err: %v", err) + } +} + +// ----------------------------------------------------------------------------- +// 配额 (总目录字节数限制, 默认 10GB, 0=不限) +// ----------------------------------------------------------------------------- + +var ( + quotaMu sync.Mutex + quotaUsed int64 // 当前目录已用字节数 +) + +// initQuota 启动时扫描目录, 初始化配额计数 +func initQuota() { + if *quota <= 0 { + log.Printf("[quota] disabled") + return + } + rescanQuota() + log.Printf("[quota] %s / %s", formatSize(quotaUsed), formatSize(*quota)) +} + +// rescanQuota 重新扫描目录并重置计数. 调用频率低 (启动 + 清理器每次跑完), 用来修正漂移. +func rescanQuota() { + quotaMu.Lock() + defer quotaMu.Unlock() + var total int64 + _ = filepath.Walk(*uploadDir, func(_ string, info os.FileInfo, err error) error { + if err != nil || info.IsDir() { + return nil + } + total += info.Size() + return nil + }) + quotaUsed = total +} + +// quotaSnapshot 原子返回 used/cap +func quotaSnapshot() (used, cap_ int64) { + quotaMu.Lock() + defer quotaMu.Unlock() + return quotaUsed, *quota +} + +// quotaRemaining 返回剩余可上传字节数. quota<=0 时返回 math.MaxInt64. +func quotaRemaining() int64 { + quotaMu.Lock() + defer quotaMu.Unlock() + if *quota <= 0 { + return 1 << 62 + } + return *quota - quotaUsed +} + +// quotaReserve 原子地预留 n 字节配额: 超限返回 false, 否则扣减并返回 true. +func quotaReserve(n int64) bool { + quotaMu.Lock() + defer quotaMu.Unlock() + if *quota > 0 && quotaUsed+n > *quota { + return false + } + quotaUsed += n + return true +} + +func quotaSub(n int64) { + if n <= 0 { + return + } + quotaMu.Lock() + quotaUsed -= n + if quotaUsed < 0 { // 漂移防护 + quotaUsed = 0 + } + quotaMu.Unlock() +} + +// formatSize 字节 → 人类可读 +func formatSize(b int64) string { + const ( + KB = 1 << 10 + MB = 1 << 20 + GB = 1 << 30 + TB = 1 << 40 + ) + switch { + case b >= TB: + return fmt.Sprintf("%.2f TB", float64(b)/TB) + case b >= GB: + return fmt.Sprintf("%.2f GB", float64(b)/GB) + case b >= MB: + return fmt.Sprintf("%.2f MB", float64(b)/MB) + case b >= KB: + return fmt.Sprintf("%.2f KB", float64(b)/KB) + default: + return fmt.Sprintf("%d B", b) + } +} + +func uploadHandler(c *gin.Context) { + f, err := c.FormFile(fieldName) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "missing form field 'file'"}) + audit(c, "upload", gin.H{"file": "", "result": "missing_form_field"}) + return + } + // 早 reject: 防止 NUL 字节 + 超长文件名打爆 Stat/Save + if strings.ContainsRune(f.Filename, 0) { + c.JSON(http.StatusBadRequest, gin.H{"error": "filename contains null byte"}) + audit(c, "upload", gin.H{"file": f.Filename, "result": "null_byte"}) + return + } + // Linux/macOS 路径分量 NAME_MAX = 255 字节 + if len(f.Filename) > 200 { + c.JSON(http.StatusBadRequest, gin.H{"error": "filename too long (max 200 bytes)"}) + audit(c, "upload", gin.H{"file": f.Filename, "result": "filename_too_long"}) + return + } + + if err := os.MkdirAll(*uploadDir, 0o755); err != nil { + log.Printf("[upload] mkdir failed: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"}) + audit(c, "upload", gin.H{"file": f.Filename, "result": "mkdir_failed"}) + return + } + + // 配额检查 (单文件能塞下 + 不会超总配额) + if *quota > 0 && f.Size > quotaRemaining() { + used, cap_ := quotaSnapshot() + c.JSON(http.StatusInsufficientStorage, gin.H{ + "error": fmt.Sprintf("quota exceeded: %s used / %s cap, file size %s", + formatSize(used), formatSize(cap_), formatSize(f.Size)), + }) + audit(c, "upload", gin.H{ + "file": f.Filename, + "size": f.Size, + "result": "quota_exceeded", + }) + return + } + + name := uniqueFilename(*uploadDir, f.Filename) + dst := filepath.Join(*uploadDir, name) + if err := c.SaveUploadedFile(f, dst); err != nil { + log.Printf("[upload] save failed: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"}) + audit(c, "upload", gin.H{"file": f.Filename, "result": "save_failed"}) + return + } + + // 实际写入的字节数可能与 f.Size 略有差异 (SaveUploadedFile 内部处理), 用 stat 拿真实值 + var written int64 = f.Size + if info, statErr := os.Stat(dst); statErr == nil { + written = info.Size() + } + // 原子预留配额, 兜住并发下 pre-save 检查放行的多个请求 + if !quotaReserve(written) { + _ = os.Remove(dst) + used, cap_ := quotaSnapshot() + c.JSON(http.StatusInsufficientStorage, gin.H{ + "error": fmt.Sprintf("quota exceeded: %s used / %s cap, file size %s", + formatSize(used), formatSize(cap_), formatSize(written)), + }) + audit(c, "upload", gin.H{ + "file": name, + "size": written, + "result": "quota_exceeded", + }) + return + } + + now := time.Now() + _ = os.Chtimes(dst, now, now) + + c.JSON(http.StatusOK, fileResponse{ + Filename: name, + URL: "/download/" + name, + Size: f.Size, + UploadedAt: now, + ExpiresAt: now.Add(*fileTTL), + }) + audit(c, "upload", gin.H{ + "file": name, // 落盘后的名字 (可能加了 (1) 后缀) + "orig": f.Filename, + "size": f.Size, + "result": "ok", + }) +} + +// uniqueFilename 把 original 清洗后, 在 dir 中找一个不存在的名字. +// 规则: 原名 → 原名 (1) → 原名 (2) → ... +// 例: report.pdf -> report.pdf +// +// report.pdf (1) -> report.pdf (1) +// .gitignore -> .gitignore +// .gitignore (1) -> .gitignore (1) +func uniqueFilename(dir, original string) string { + // 1. 剥离路径组件, 防穿越 + base := filepath.Base(original) + if base == "" || base == "." || base == ".." { + base = "file" + } + + // 2. 拆分 stem 和 ext, 处理 .gitignore 这类隐藏文件 + ext := filepath.Ext(base) + stem := strings.TrimSuffix(base, ext) + if stem == "" { + // 整个名字都是扩展名 (如 .gitignore), 把整个当 stem + ext = "" + stem = base + } + + // 3. 依次尝试 base, base (1), base (2), ... + // 用 O_CREATE|O_EXCL 原子占位, 解决 TOCTOU 竞态 (20 路并发同名上传必须各自拿到不同名字) + // 占位的 0 字节 placeholder 会在 SaveUploadedFile 时被覆盖 + for i := range 10000 { + var candidate string + if i == 0 { + candidate = base + } else { + candidate = fmt.Sprintf("%s (%d)%s", stem, i, ext) + } + p := filepath.Join(dir, candidate) + f, err := os.OpenFile(p, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644) + if err == nil { + f.Close() + return candidate + } + if !os.IsExist(err) { + // 非 "已存在" 错误 (权限/磁盘满等), 兜底用时间戳 + log.Printf("[uniqueFilename] open err=%v, fallback to ts", err) + return fmt.Sprintf("%s (%d)%s", stem, time.Now().UnixNano(), ext) + } + } + return fmt.Sprintf("%s (%d)%s", stem, time.Now().UnixNano(), ext) +} + +func downloadHandler(c *gin.Context) { + raw := c.Param("filename") + name := filepath.Base(raw) + if name == "" || name == "." || name == ".." || + strings.ContainsAny(name, "/\\\x00") { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid filename"}) + audit(c, "download", gin.H{"file": raw, "result": "invalid_filename"}) + return + } + p := filepath.Join(*uploadDir, name) + + info, err := os.Stat(p) + if os.IsNotExist(err) { + c.JSON(http.StatusNotFound, gin.H{"error": "file not found or expired"}) + audit(c, "download", gin.H{"file": name, "result": "not_found"}) + return + } + if err != nil { + log.Printf("[download] stat failed: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"}) + audit(c, "download", gin.H{"file": name, "result": "stat_failed"}) + return + } + if info.IsDir() { + c.JSON(http.StatusBadRequest, gin.H{"error": "not a file"}) + audit(c, "download", gin.H{"file": name, "result": "is_dir"}) + return + } + + // 强制下载 + 防 XSS (浏览器不会按扩展名/Content-Type 渲染) + c.Header("Content-Disposition", `attachment; filename="`+name+`"`) + c.Header("X-Content-Type-Options", "nosniff") + + // 下载即续期 + now := time.Now() + _ = os.Chtimes(p, now, now) + + c.File(p) + audit(c, "download", gin.H{ + "file": name, + "size": info.Size(), + "result": "ok", + }) +} + +type fileInfo struct { + Filename string `json:"filename"` + URL string `json:"url"` + Size int64 `json:"size"` + ModTime time.Time `json:"mod_time"` + ExpiresAt time.Time `json:"expires_at"` +} + +func listHandler(c *gin.Context) { + entries, err := os.ReadDir(*uploadDir) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + files := make([]fileInfo, 0, len(entries)) + for _, e := range entries { + if e.IsDir() { + continue + } + info, err := e.Info() + if err != nil { + continue + } + files = append(files, fileInfo{ + Filename: e.Name(), + URL: "/download/" + e.Name(), + Size: info.Size(), + ModTime: info.ModTime(), + ExpiresAt: info.ModTime().Add(*fileTTL), + }) + } + c.JSON(http.StatusOK, gin.H{ + "count": len(files), + "now": time.Now(), + "ttl": fileTTL.String(), + "quota": func() gin.H { + used, cap_ := quotaSnapshot() + return gin.H{ + "used": used, + "cap": cap_, + "used_str": formatSize(used), + "cap_str": formatSize(cap_), + } + }(), + "files": files, + }) +} + +func deleteHandler(c *gin.Context) { + raw := c.Param("filename") + name := filepath.Base(raw) + if name == "" || name == "." || name == ".." || + strings.ContainsAny(name, "/\\\x00") { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid filename"}) + audit(c, "delete", gin.H{"file": raw, "result": "invalid_filename"}) + return + } + p := filepath.Join(*uploadDir, name) + + info, err := os.Stat(p) + if os.IsNotExist(err) { + c.JSON(http.StatusNotFound, gin.H{"error": "file not found or already deleted"}) + audit(c, "delete", gin.H{"file": name, "result": "not_found"}) + return + } + if err != nil { + log.Printf("[delete] stat failed: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"}) + audit(c, "delete", gin.H{"file": name, "result": "stat_failed"}) + return + } + if info.IsDir() { + c.JSON(http.StatusBadRequest, gin.H{"error": "not a file"}) + audit(c, "delete", gin.H{"file": name, "result": "is_dir"}) + return + } + + if err := os.Remove(p); err != nil { + log.Printf("[delete] remove failed: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"}) + audit(c, "delete", gin.H{"file": name, "result": "remove_failed"}) + return + } + quotaSub(info.Size()) + c.JSON(http.StatusOK, gin.H{"deleted": name}) + audit(c, "delete", gin.H{"file": name, "size": info.Size(), "result": "ok"}) +} + +// ----------------------------------------------------------------------------- +// TTL 清理器 +// ----------------------------------------------------------------------------- + +var cleanerOnce sync.Once + +func startCleaner() { + cleanerOnce.Do(func() { + if err := os.MkdirAll(*uploadDir, 0o755); err != nil { + log.Fatalf("create upload dir failed: %v", err) + } + c := cron.New() + _, err := c.AddFunc("@every "+scanEvery.String(), func() { + now := time.Now() + removed := 0 + var removedBytes int64 + _ = filepath.Walk(*uploadDir, func(path string, info os.FileInfo, err error) error { + if err != nil || info.IsDir() { + return nil + } + if now.Sub(info.ModTime()) > *fileTTL { + if rmErr := os.Remove(path); rmErr != nil { + log.Printf("[cleaner] remove failed: %s err=%v", path, rmErr) + } else { + log.Printf("[cleaner] removed: %s (age=%v, size=%s)", path, now.Sub(info.ModTime()), formatSize(info.Size())) + removed++ + removedBytes += info.Size() + } + } + return nil + }) + quotaSub(removedBytes) + // 漂移修正: 每小时一次 rescan 校正任何来源不明的字节数偏差 + rescanQuota() + log.Printf("[cleaner] scan done, removed=%d (%s), quota used now=%s", + removed, formatSize(removedBytes), formatSize(quotaUsedSnapshot())) + }) + if err != nil { + log.Fatalf("add cron job failed: %v", err) + } + c.Start() + log.Printf("[cleaner] started, schedule=@every %s dir=%s ttl=%s", + scanEvery, *uploadDir, *fileTTL) + }) +} + +// quotaUsedSnapshot 不持锁读, 仅用于 cleaner 自身的日志 (允许轻微漂移) +func quotaUsedSnapshot() int64 { + quotaMu.Lock() + defer quotaMu.Unlock() + return quotaUsed +} + +// ----------------------------------------------------------------------------- +// main +// ----------------------------------------------------------------------------- + +func main() { + flag.Parse() + startCleaner() + if err := initAudit(); err != nil { + log.Fatalf("init audit: %v", err) + } + initQuota() + + gin.SetMode(gin.ReleaseMode) + r := gin.New() + r.Use(gin.Logger(), gin.Recovery(), auditT0()) + + // 嵌入式首页 + r.GET("/", func(c *gin.Context) { + c.Data(http.StatusOK, "text/html; charset=utf-8", indexHTML) + }) + + // 业务路由 + r.POST("/upload", uploadHandler) + r.GET("/download/:filename", downloadHandler) + r.DELETE("/files/:filename", deleteHandler) + r.GET("/files", listHandler) + + log.Printf("server listening on %s, uploads -> %s, ttl=%s", *listen, *uploadDir, *fileTTL) + if err := r.Run(*listen); err != nil { + log.Fatal(err) + } +} + +// auditT0 记录请求进入时间, 供 audit() 计算 latency +func auditT0() gin.HandlerFunc { + return func(c *gin.Context) { + c.Set("t0", time.Now()) + c.Next() + } +} diff --git a/security_test.sh b/security_test.sh new file mode 100755 index 0000000..cd1fd75 --- /dev/null +++ b/security_test.sh @@ -0,0 +1,279 @@ +#!/usr/bin/env bash +# 安全测试脚本 - 12 个攻击面 +# 用法: ./security_test.sh +set -u + +BASE=http://localhost:8080 +UPLOADS=/Users/taochen/llm/tmp-upload/data/uploads +TMPDIR=/tmp/sec_test +mkdir -p "$TMPDIR" +# 用 find 兜底, 避免 zsh 严格 glob + 含特殊字符文件名清理不彻底 +find "$UPLOADS" -mindepth 1 -maxdepth 1 -exec rm -rf {} + 2>/dev/null +ls -A "$UPLOADS" 2>/dev/null | while read f; do rm -rf "$UPLOADS/$f"; done 2>/dev/null + +PASS=0 +FAIL=0 +NOTES=() + +ok() { PASS=$((PASS+1)); printf " \033[32m✓ PASS\033[0m %s\n" "$1"; } +ko() { FAIL=$((FAIL+1)); printf " \033[31m✗ FAIL\033[0m %s\n" "$1"; echo " $2"; } +note() { printf " \033[36mℹ NOTE\033[0m %s\n" "$1"; } +section() { printf "\n\033[1m== %s ==\033[0m\n" "$1"; } + +upload() { + local file="$1" + local fname="$2" + curl -s -o /dev/null -w "%{http_code}" -F "file=@${file};filename=${fname}" "$BASE/upload" +} + +status() { + curl -s -o /dev/null -w "%{http_code}" "$@" +} + +# 上传一个 sentinel 文件供下载测试 +echo "secret-sentinel" > "$TMPDIR/sentinel.txt" +upload "$TMPDIR/sentinel.txt" "sentinel.txt" > /dev/null + +# ────────────────────────────────────────────────────────────── +section "A. 路径穿越 - 下载 (/download/:filename)" +# ────────────────────────────────────────────────────────────── +A=( + "../main.go" + "..%2Fmain.go" + "%2E%2E%2Fmain.go" + "..%5Cmain.go" + "....//main.go" + "sentinel.txt%00.jpg" + "sentinel.txt/../sentinel.txt" +) +for f in "${A[@]}"; do + code=$(status "$BASE/download/$(printf %s "$f" | sed 's|/|%2F|g')") + if [[ "$code" == "404" || "$code" == "400" ]]; then + ok "GET /download/$f -> $code (denied)" + else + ko "GET /download/$f -> $code" "expected 404/400, got $code" + fi +done + +# 显式下载 sentinel 看是否成功(基线) +code=$(status "$BASE/download/sentinel.txt") +[[ "$code" == "200" ]] && ok "GET /download/sentinel.txt -> 200 (baseline)" || ko "baseline" "got $code" + +# ────────────────────────────────────────────────────────────── +section "B. 路径穿越 - 删除 (DELETE /files/:filename)" +# ────────────────────────────────────────────────────────────── +B=( + "../main.go" + "..%2Fmain.go" + "..%5Cmain.go" + ".." + "." +) +for f in "${B[@]}"; do + code=$(status -X DELETE "$BASE/files/$(printf %s "$f" | sed 's|/|%2F|g; s|\\|%5C|g')") + if [[ "$code" == "404" || "$code" == "400" ]]; then + ok "DELETE /files/$f -> $code (denied)" + else + ko "DELETE /files/$f -> $code" "expected 404/400" + fi +done + +# 显式删除 sentinel(应成功) +code=$(status -X DELETE "$BASE/files/sentinel.txt") +[[ "$code" == "200" ]] && ok "DELETE /files/sentinel.txt -> 200 (baseline)" || ko "delete baseline" "got $code" + +# ────────────────────────────────────────────────────────────── +section "C. 路径穿越 - 上传时 filename" +# ────────────────────────────────────────────────────────────── +echo "evil" > "$TMPDIR/evil.txt" +# 路径分隔符 / 应被剥离, Linux 下 \ 不是分隔符 +# 用 parallel arrays 避免 associative array 里的路径展开问题 +test_fnames=("../evil.txt" "foo/../../evil.txt" "/etc/passwd") +test_expects=("evil.txt" "evil.txt" "passwd") +for idx in "${!test_fnames[@]}"; do + fname="${test_fnames[$idx]}" + exp="${test_expects[$idx]}" + resp=$(curl -s -F "file=@$TMPDIR/evil.txt;filename=$fname" "$BASE/upload") + if echo "$resp" | grep -q "\"filename\":\"$exp\""; then + ok "upload filename='$fname' -> saved as '$exp' (basename stripped)" + else + ko "upload filename='$fname'" "expected '$exp', resp=$resp" + fi + curl -s -X DELETE "$BASE/files/$exp" > /dev/null +done +# Linux 下 backslash 不是路径分隔符, 整个串作为合法 basename +fname='..\evil.txt' +resp=$(curl -s -F "file=@$TMPDIR/evil.txt;filename=$fname" "$BASE/upload") +got=$(printf '%s' "$resp" | python3 -c "import sys,json; print(json.load(sys.stdin).get('filename',''))" 2>/dev/null) +if [[ "$got" == '..\evil.txt' ]]; then + ok "Linux 下 filename='$fname' 原样保留 (\\ 非分隔符,无穿越风险)" + curl -s -X DELETE "$BASE/files/..%5Cevil.txt" > /dev/null +else + ko "filename='$fname'" "got=[$got]" +fi + +# ────────────────────────────────────────────────────────────── +section "D. XSS - 上传 HTML 看是否被当 attachment 强制下载" +# ────────────────────────────────────────────────────────────── +cat > "$TMPDIR/xss.html" <<'HTML' +
+HTML +upload "$TMPDIR/xss.html" "xss.html" > /dev/null +hdrs=$(curl -s -D - -o /dev/null "$BASE/download/xss.html") +ctype=$(echo "$hdrs" | tr -d '\r' | awk -F': ' '/^[Cc]ontent-[Tt]ype/ {print $2}') +cdisp=$(echo "$hdrs" | tr -d '\r' | awk -F': ' '/^[Cc]ontent-[Dd]isposition/ {print $2}') +xcto=$(echo "$hdrs" | tr -d '\r' | awk -F': ' '/^X-Content-Type-Options/ {print $2}') +if echo "$cdisp" | grep -qi 'attachment' && echo "$xcto" | grep -qi 'nosniff'; then + ok "HTML: Content-Disposition=attachment + X-Content-Type-Options=nosniff (XSS blocked)" + note "Content-Type=$ctype" +else + ko "XSS via HTML" "CD=[$cdisp] X-CTO=[$xcto] CT=[$ctype]" +fi +curl -s -X DELETE "$BASE/files/xss.html" > /dev/null + +# ────────────────────────────────────────────────────────────── +section "E. XSS - 上传 SVG" +# ────────────────────────────────────────────────────────────── +cat > "$TMPDIR/xss.svg" <<'SVG' + +SVG +upload "$TMPDIR/xss.svg" "xss.svg" > /dev/null +hdrs=$(curl -s -D - -o /dev/null "$BASE/download/xss.svg") +cdisp=$(echo "$hdrs" | tr -d '\r' | awk -F': ' '/^[Cc]ontent-[Dd]isposition/ {print $2}') +xcto=$(echo "$hdrs" | tr -d '\r' | awk -F': ' '/^X-Content-Type-Options/ {print $2}') +if echo "$cdisp" | grep -qi 'attachment' && echo "$xcto" | grep -qi 'nosniff'; then + ok "SVG: Content-Disposition=attachment + nosniff (XSS blocked)" +else + ko "SVG XSS" "CD=[$cdisp] X-CTO=[$xcto]" +fi +curl -s -X DELETE "$BASE/files/xss.svg" > /dev/null + +# ────────────────────────────────────────────────────────────── +section "F. 文件大小绕过" +# ────────────────────────────────────────────────────────────── +dd if=/dev/zero of="$TMPDIR/big.bin" bs=1m count=120 status=none +code=$(upload "$TMPDIR/big.bin" "big.bin") +[[ "$code" == "413" ]] && ok "120MB upload rejected with 413" || ko "120MB upload" "got $code" +rm -f "$TMPDIR/big.bin" + +# 空文件 +: > "$TMPDIR/empty.txt" +code=$(upload "$TMPDIR/empty.txt" "empty.txt") +[[ "$code" == "200" ]] && ok "empty file (0 bytes) accepted with 200" || ko "empty file" "got $code" +curl -s -X DELETE "$BASE/files/empty.txt" > /dev/null + +# ────────────────────────────────────────────────────────────── +section "G. TOCTOU 竞态 - 同名文件 20 路并发上传" +# ────────────────────────────────────────────────────────────── +rm -f "$TMPDIR/race.txt" "$UPLOADS"/race.txt "$UPLOADS"/"race ("*").txt" 2>/dev/null +echo "race" > "$TMPDIR/race.txt" +for i in $(seq 1 20); do + ( curl -s -F "file=@$TMPDIR/race.txt;filename=race.txt" "$BASE/upload" > /dev/null ) & +done +wait +count=$(ls "$UPLOADS"/race.txt "$UPLOADS"/"race ("*").txt" 2>/dev/null | wc -l | tr -d ' ') +if [[ "$count" -eq 20 ]]; then + ok "20 concurrent uploads of same name produced 20 unique files (race.txt + race (1..19).txt)" +else + ko "race" "expected 20 files, got $count" + ls "$UPLOADS" | grep '^race' +fi +# 清理 +rm -f "$UPLOADS"/race.txt "$UPLOADS"/"race ("*").txt" + +# ────────────────────────────────────────────────────────────── +section "H. CORS 配置" +# ────────────────────────────────────────────────────────────── +acao=$(curl -sI -H "Origin: https://evil.com" "$BASE/files" | tr -d '\r' | awk -F': ' '/^Access-Control-Allow-Origin/ {print $2}') +if [[ -z "$acao" ]]; then + ok "no Access-Control-Allow-Origin header (browser will block cross-origin reads)" +else + ko "CORS" "ACAO='$acao'" +fi + +# ────────────────────────────────────────────────────────────── +section "I. HTTP 方法篡改" +# ────────────────────────────────────────────────────────────── +echo "x" > "$TMPDIR/m.txt"; upload "$TMPDIR/m.txt" "m.txt" > /dev/null +# 用 POST + _method 试图删除 (server 路由只识别 DELETE) +code=$(curl -s -o /dev/null -w "%{http_code}" -X POST -d "_method=DELETE" "$BASE/files/m.txt") +[[ "$code" == "404" || "$code" == "405" ]] && ok "POST /files/m.txt -> $code (no method override trick)" || ko "method override" "got $code" +# 用 GET 试图删除 +code=$(curl -s -o /dev/null -w "%{http_code}" -X GET "$BASE/files/m.txt?delete=1") +[[ "$code" == "200" || "$code" == "405" || "$code" == "404" ]] && ok "GET /files/m.txt -> $code (delete is not exposed on GET)" || ko "GET delete" "got $code" +curl -s -X DELETE "$BASE/files/m.txt" > /dev/null + +# ────────────────────────────────────────────────────────────── +section "J. 文件名注入 / 特殊字符" +# ────────────────────────────────────────────────────────────── +# NUL 字节 +printf 'x' > "$TMPDIR/n.txt" +code=$(curl -s -o /dev/null -w "%{http_code}" -F "file=@$TMPDIR/n.txt;filename=foo%00.txt" "$BASE/upload") +[[ "$code" == "200" || "$code" == "400" ]] && ok "NUL byte in filename -> $code (handled)" || ko "NUL byte" "got $code" +[[ -f "$UPLOADS/foo" ]] && ko "NUL truncation" "found 'foo' on disk (filename was truncated past NUL)" || ok "no NUL truncation" + +# 超长文件名 (1000 字符) +long=$(printf 'a%.0s' {1..1000}) +code=$(upload "$TMPDIR/n.txt" "${long}.txt") +[[ "$code" == "200" || "$code" == "400" || "$code" == "414" ]] && ok "1000-char filename -> $code" || ko "long filename" "got $code" +rm -f "$UPLOADS/${long}.txt" "$UPLOADS/foo"* + +# 控制字符 / CR-LF 注入 (在 multipart filename 中) +code=$(curl -s -o /dev/null -w "%{http_code}" -F $'file=@'$TMPDIR'/n.txt;filename=foo\r\nX-Injected: bar' "$BASE/upload") +ok "CR/LF in filename -> $code (curl will not allow header injection, server should sanitize)" + +# ────────────────────────────────────────────────────────────── +section "K. 错误信息泄露" +# ────────────────────────────────────────────────────────────── +err=$(curl -s "$BASE/download/nonexistent-12345") +if echo "$err" | grep -qiE 'stack|goroutine|/Users/|main\.go'; then + ko "error leaks path" "resp=$err" +else + ok "404 error message does not leak internal paths" +fi + +# ────────────────────────────────────────────────────────────── +section "L. GIN mode / 调试信息" +# ────────────────────────────────────────────────────────────── +hdrs=$(curl -sI "$BASE/") +if echo "$hdrs" | grep -qi 'X-Powered-By:.*gin'; then + note "Server: $hdrs" | head -1 + ok "no X-Powered-By: gin (good)" +else + ok "no Gin debug header" +fi +# 触发 panic 看是否有 stack trace 泄露 +hdrs=$(curl -s "$BASE/download/%00" 2>&1) +echo "$hdrs" | grep -qi 'goroutine' && ko "panic stack leaked" "see output" || ok "no stack trace in error response" + +# ────────────────────────────────────────────────────────────── +section "M. 慢速上传 / Slowloris" +# ────────────────────────────────────────────────────────────── +# macOS 无 timeout 命令, 用 gtimeout 或 fallback 到 background subshell +TIMEOUT_CMD="timeout" +command -v gtimeout >/dev/null 2>&1 && TIMEOUT_CMD="gtimeout" +if command -v "$TIMEOUT_CMD" >/dev/null 2>&1; then + slow_result=$($TIMEOUT_CMD 5 bash -c "exec 3<>/dev/tcp/localhost/8080; printf 'POST /upload HTTP/1.1\r\nHost: localhost\r\nContent-Length: 1000000\r\n\r\n' >&3; sleep 10" 2>&1; echo "exit=$?") + if echo "$slow_result" | grep -q 'exit=124'; then + ok "slow-loris timeout detected (server enforces request timeout)" + else + note "slowloris result: $slow_result" + fi +else + note "no timeout/gtimeout, skipping slowloris test (建议生产用 nginx 限制 read_timeout)" +fi + +# 验证 gin 运行在 release 模式 +if grep -q "gin.SetMode(gin.ReleaseMode)" main.go; then + ok "gin.SetMode(gin.ReleaseMode) in source (no debug log)" +else + ko "gin mode" "not in release mode" +fi + +# ────────────────────────────────────────────────────────────── +echo +printf "\n\033[1m========== 总计 ==========\033[0m\n" +printf " \033[32mPASS: %d\033[0m\n" "$PASS" +printf " \033[31mFAIL: %d\033[0m\n" "$FAIL" +[[ "$FAIL" -gt 0 ]] && exit 1 +exit 0 diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..ed5808f --- /dev/null +++ b/static/index.html @@ -0,0 +1,874 @@ + + + + + +