From afa8513d4b6cf218cea04f320621ae21a365c98b Mon Sep 17 00:00:00 2001 From: zhangwenjian Date: Sun, 27 Sep 2026 19:42:35 +0800 Subject: [PATCH] =?UTF-8?q?fix=F0=9F=90=9B:=20keep=20the=20code=20generato?= =?UTF-8?q?r's=20writes=20inside=20their=20roots?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The generator joins a table's package, table and business names into the paths it writes, and wrote them with os.MkdirAll and os.WriteFile. A name carrying "..", or a symlink under the target directory pointing elsewhere, took the write wherever it led. Every file is now written through os.Root: backend files under the working directory, frontend files under gen.frontpath. A path that resolves outside its root is refused, symlinks included, and the request reports the write failure without writing anything outside. --- app/other/apis/tools/gen.go | 59 ++++++++++------ app/other/apis/tools/gen_paths_test.go | 97 ++++++++++++++++++++++++++ 2 files changed, 135 insertions(+), 21 deletions(-) create mode 100644 app/other/apis/tools/gen_paths_test.go diff --git a/app/other/apis/tools/gen.go b/app/other/apis/tools/gen.go index 689aa7f5..ad64b1dc 100644 --- a/app/other/apis/tools/gen.go +++ b/app/other/apis/tools/gen.go @@ -319,22 +319,23 @@ func (e Gen) NOActionsGen(c *gin.Context, tab tools.SysTables) bool { // up - a flat gen/{BusinessName}.ts would let two tables in different // packages silently overwrite each other's translations, since // BusinessName only has a pattern check, no uniqueness check. + back, front := ".", config.GenConfig.FrontPath files := []struct { - path string - content []byte + root, name string + content []byte }{ - {"./app/" + tab.PackageName + "/models/" + tab.TBName + ".go", out[0]}, - {"./app/" + tab.PackageName + "/apis/" + tab.TBName + ".go", out[1]}, - {"./app/" + tab.PackageName + "/router/" + tab.TBName + ".go", out[2]}, - {config.GenConfig.FrontPath + "/api/" + tab.PackageName + "/" + tab.MLTBName + ".ts", out[3]}, - {config.GenConfig.FrontPath + "/views/" + tab.PackageName + "/" + tab.MLTBName + "/index.vue", out[4]}, - {"./app/" + tab.PackageName + "/service/dto/" + tab.TBName + ".go", out[5]}, - {"./app/" + tab.PackageName + "/service/" + tab.TBName + ".go", out[6]}, - {config.GenConfig.FrontPath + "/lang/zh-CN/gen/" + tab.PackageName + "/" + tab.BusinessName + ".ts", out[7]}, - {config.GenConfig.FrontPath + "/lang/en-US/gen/" + tab.PackageName + "/" + tab.BusinessName + ".ts", out[8]}, + {back, "app/" + tab.PackageName + "/models/" + tab.TBName + ".go", out[0]}, + {back, "app/" + tab.PackageName + "/apis/" + tab.TBName + ".go", out[1]}, + {back, "app/" + tab.PackageName + "/router/" + tab.TBName + ".go", out[2]}, + {front, "api/" + tab.PackageName + "/" + tab.MLTBName + ".ts", out[3]}, + {front, "views/" + tab.PackageName + "/" + tab.MLTBName + "/index.vue", out[4]}, + {back, "app/" + tab.PackageName + "/service/dto/" + tab.TBName + ".go", out[5]}, + {back, "app/" + tab.PackageName + "/service/" + tab.TBName + ".go", out[6]}, + {front, "lang/zh-CN/gen/" + tab.PackageName + "/" + tab.BusinessName + ".ts", out[7]}, + {front, "lang/en-US/gen/" + tab.PackageName + "/" + tab.BusinessName + ".ts", out[8]}, } for _, f := range files { - if err := writeGenerated(f.path, f.content); err != nil { + if err := writeGenerated(f.root, f.name, f.content); err != nil { log.Error(err) e.Error(500, err, fmt.Sprintf("生成文件写入失败!错误详情:%s", err.Error())) return false @@ -373,7 +374,7 @@ func (e Gen) genApiToFile(c *gin.Context, tab tools.SysTables) bool { e.Error(500, err, fmt.Sprintf("数据迁移模版渲染失败!错误详情:%s", err.Error())) return false } - if err := writeGenerated("./cmd/migrate/migration/version-local/"+i+"_migrate.go", out[0]); err != nil { + if err := writeGenerated(".", "cmd/migrate/migration/version-local/"+i+"_migrate.go", out[0]); err != nil { e.Logger.Error(err) e.Error(500, err, fmt.Sprintf("数据迁移文件写入失败!错误详情:%s", err.Error())) return false @@ -554,14 +555,30 @@ func renderAll(data any, tpls ...*template.Template) ([][]byte, error) { return out, nil } -// writeGenerated writes one generated file, creating its directory first, -// which pkg.FileCreate does not do. It replaced pkg.FileCreate here while -// that returned no error and, when the file could not be created, ended the -// process with log.Fatalln - one unwritable path took the whole server down -// with it. go-admin-core v2.11.0 made pkg.FileCreate return the error. -func writeGenerated(path string, content []byte) error { - if err := os.MkdirAll(filepath.Dir(path), os.ModePerm); err != nil { +// writeGenerated writes content to name, a slash-separated path under root, +// creating root and the directories in between. +// +// It goes through os.Root, so name cannot leave root: a ".." component, an +// absolute path, or a symlink under root that points outside it is refused +// rather than followed. root itself is configuration (the working directory, +// or gen.frontpath), and may be or contain a symlink. +// +// It replaced pkg.FileCreate here while that returned no error and, when the +// file could not be created, ended the process with log.Fatalln - one +// unwritable path took the whole server down with it. go-admin-core v2.11.0 +// made pkg.FileCreate return the error. +func writeGenerated(root, name string, content []byte) error { + if err := os.MkdirAll(root, os.ModePerm); err != nil { return err } - return os.WriteFile(path, content, 0o644) + r, err := os.OpenRoot(root) + if err != nil { + return err + } + defer r.Close() + name = filepath.FromSlash(name) + if err := r.MkdirAll(filepath.Dir(name), os.ModePerm); err != nil { + return err + } + return r.WriteFile(name, content, 0o644) } diff --git a/app/other/apis/tools/gen_paths_test.go b/app/other/apis/tools/gen_paths_test.go new file mode 100644 index 00000000..f5db9fc1 --- /dev/null +++ b/app/other/apis/tools/gen_paths_test.go @@ -0,0 +1,97 @@ +package tools + +import ( + "io/fs" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/gin-gonic/gin" +) + +// The generator builds the paths it writes to from three fields of a table's +// configuration. These tests hold that none of them can take a write outside +// the working directory or gen.frontpath, and that a refused write leaves +// nothing behind - inside the roots or outside them. + +// filesUnder lists every file below dir, or none when dir does not exist. +func filesUnder(t *testing.T, dir string) []string { + t.Helper() + var files []string + _ = filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error { + if err == nil && !d.IsDir() { + files = append(files, p) + } + return nil + }) + return files +} + +func TestNOActionsGenWritesEveryFileInsideItsRoots(t *testing.T) { + dir := genWorkspace(t) + + var ok bool + bodies := runGen(t, nil, func(c *gin.Context) { ok = Gen{}.NOActionsGen(c, genTable()) }, nil) + if !ok || len(bodies) != 0 { + t.Fatalf("ok=%v, responses %+v; want true and no error response", ok, bodies) + } + want := []string{ + "app/admin/models/gen_err.go", + "app/admin/apis/gen_err.go", + "app/admin/router/gen_err.go", + "app/admin/service/dto/gen_err.go", + "app/admin/service/gen_err.go", + "ui/api/admin/gen-err.ts", + "ui/views/admin/gen-err/index.vue", + "ui/lang/zh-CN/gen/admin/genErr.ts", + "ui/lang/en-US/gen/admin/genErr.ts", + } + for _, rel := range want { + if _, err := os.Stat(filepath.Join(dir, filepath.FromSlash(rel))); err != nil { + t.Errorf("%s was not written: %v", rel, err) + } + } + if got := len(writtenFiles(t, dir)); got != len(want) { + t.Errorf("wrote %d files, want %d: %v", got, len(want), writtenFiles(t, dir)) + } +} + +// A symlink under a root that points out of it is the case a name check +// cannot see: every component of the name is legal. os.Root refuses to follow +// it. The file planted where the write would land must survive unchanged. +func TestNOActionsGenDoesNotFollowASymlinkOutOfItsRoot(t *testing.T) { + for _, link := range []string{"app/admin", "ui/api"} { + t.Run(link, func(t *testing.T) { + dir := genWorkspace(t) + outside := t.TempDir() + planted := filepath.Join(outside, "models", "gen_err.go") + if err := os.MkdirAll(filepath.Dir(planted), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(planted, []byte("original"), 0o644); err != nil { + t.Fatal(err) + } + at := filepath.Join(dir, filepath.FromSlash(link)) + if err := os.MkdirAll(filepath.Dir(at), 0o755); err != nil { + t.Fatal(err) + } + if err := os.Symlink(outside, at); err != nil { + t.Fatal(err) + } + + var ok bool + bodies := runGen(t, nil, func(c *gin.Context) { ok = Gen{}.NOActionsGen(c, genTable()) }, nil) + + if ok || len(bodies) != 1 || bodies[0].Code != 500 || !strings.Contains(bodies[0].Msg, "生成文件写入失败") { + t.Fatalf("ok=%v, responses %+v; want false and one 500 for the write", ok, bodies) + } + if b, err := os.ReadFile(planted); err != nil || string(b) != "original" { + t.Errorf("the file outside the root reads %q (err %v), want it untouched", b, err) + } + if files := filesUnder(t, outside); len(files) != 1 { + t.Errorf("files outside the root: %v, want only the planted one", files) + } + }) + } +}