fix🐛: keep the code generator's writes inside their roots

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.
This commit is contained in:
zhangwenjian
2026-09-27 19:42:35 +08:00
parent c1d92ad30f
commit afa8513d4b
2 changed files with 135 additions and 21 deletions
+38 -21
View File
@@ -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)
}
+97
View File
@@ -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)
}
})
}
}