From fcca6ba9e0d70d109f2ae423e3655505279fd983 Mon Sep 17 00:00:00 2001 From: mesh-tools Date: Wed, 21 Oct 2026 09:22:14 +0000 Subject: [PATCH 1/2] Refuse an archive or a module directory that leads out of the repository through a link in the tree, so a repository cannot make the build pack files it does not hold (issue 1609) --- internal/builder/archive_symlink_test.go | 104 +++++++++++++++++++++++ internal/builder/builder.go | 43 +++++++++- 2 files changed, 146 insertions(+), 1 deletion(-) create mode 100644 internal/builder/archive_symlink_test.go diff --git a/internal/builder/archive_symlink_test.go b/internal/builder/archive_symlink_test.go new file mode 100644 index 00000000..cf5d622b --- /dev/null +++ b/internal/builder/archive_symlink_test.go @@ -0,0 +1,104 @@ +package builder + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// A link as a parent of an archive's `from` must not lead the build out of the module: the repository +// is somebody else's, and a build that followed it would pack, register and serve any directory the +// build account can read (issue 1609). +func TestAnArchiveWhoseFromGoesThroughALinkOutOfTheModuleIsRefused(t *testing.T) { + outside := t.TempDir() + if err := os.MkdirAll(filepath.Join(outside, "x"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(outside, "x", "secret"), []byte("s"), 0o644); err != nil { + t.Fatal(err) + } + tree := t.TempDir() + if err := os.Symlink(outside, filepath.Join(tree, "link")); err != nil { + t.Fatal(err) + } + r := &recorded{} + a := catalogue.Artifact{Name: "all", Kind: catalogue.ArtifactArchive, From: "link/x"} + _, err := one(context.Background(), nil, r, "m", tree, t.TempDir(), "c", "", a, nil, nil, "", Npmrc{}, nil, + nil, func(string, string, ...any) {}) + if err == nil { + t.Fatal("an archive was packed through a link out of the module") + } + realOutside, _ := filepath.EvalSymlinks(filepath.Join(outside, "x")) + realTree, _ := filepath.EvalSymlinks(tree) + for _, want := range []string{realOutside, realTree, "link/x"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("the refusal %q does not name %q", err, want) + } + } + if len(r.archives) != 0 { + t.Errorf("a refused archive left %d published", len(r.archives)) + } +} + +// A link that stays inside the module is still followed, and its archive keeps its digest. +func TestAnArchiveWhoseFromGoesThroughALinkInsideTheModuleIsPacked(t *testing.T) { + tree := t.TempDir() + if err := os.MkdirAll(filepath.Join(tree, "real", "x"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(tree, "real", "x", "f"), []byte("f"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Symlink("real", filepath.Join(tree, "link")); err != nil { + t.Fatal(err) + } + r := &recorded{} + a := catalogue.Artifact{Name: "all", Kind: catalogue.ArtifactArchive, From: "link/x"} + if _, err := one(context.Background(), nil, r, "m", tree, t.TempDir(), "c", "", a, nil, nil, "", Npmrc{}, nil, + nil, func(string, string, ...any) {}); err != nil { + t.Fatal(err) + } +} + +// A module directory that is itself a link out of the fetched tree is refused, naming both places. +func TestAModuleDirectoryThatIsALinkOutOfTheRepositoryIsRefused(t *testing.T) { + outside := t.TempDir() + if err := os.WriteFile(filepath.Join(outside, ManifestName), []byte("{}"), 0o644); err != nil { + t.Fatal(err) + } + tree := t.TempDir() + if err := os.MkdirAll(filepath.Join(tree, "modules"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.Symlink(outside, filepath.Join(tree, "modules", "evil")); err != nil { + t.Fatal(err) + } + _, err := inside(tree, "modules/evil") + if err == nil { + t.Fatal("a module directory behind a link out of the repository was accepted") + } + realOutside, _ := filepath.EvalSymlinks(outside) + realTree, _ := filepath.EvalSymlinks(tree) + for _, want := range []string{realOutside, realTree, "modules/evil"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("the refusal %q does not name %q", err, want) + } + } + // A link that stays inside the repository, and a module directory not there, are left as they were. + if err := os.MkdirAll(filepath.Join(tree, "real"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.Symlink("real", filepath.Join(tree, "modules", "fine")); err != nil { + t.Fatal(err) + } + if _, err := inside(tree, "modules/fine"); err != nil { + t.Errorf("a link inside the repository was refused: %v", err) + } + if _, err := inside(tree, "modules/none"); err != nil { + t.Errorf("an absent module directory was refused here: %v", err) + } +} diff --git a/internal/builder/builder.go b/internal/builder/builder.go index 153f414d..4e899c0b 100644 --- a/internal/builder/builder.go +++ b/internal/builder/builder.go @@ -943,6 +943,23 @@ func inside(tree, path string) (string, error) { return "", fmt.Errorf( "%q leaves the repository, and a build reads only its own tree", path) } + // A link in the fetched tree must not lead the module out of it either (issue 1609): compare where + // both really are. A module directory that does not exist is left to the read of its manifest. + realTree, err := filepath.EvalSymlinks(tree) + if err != nil { + return "", err + } + realWithin, err := filepath.EvalSymlinks(within) + if errors.Is(err, os.ErrNotExist) { + return within, nil + } + if err != nil { + return "", err + } + if rel, err := filepath.Rel(realTree, realWithin); err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return "", fmt.Errorf("%q leads out of the repository through a link: it resolves to %s, and the repository is %s", + path, realWithin, realTree) + } return within, nil } @@ -1326,7 +1343,10 @@ func one(ctx context.Context, run Runner, publish Publisher, } } else { src.ownHas(cleanEntry(a.From) + "/**") - body, err = pack(filepath.Join(tree, a.From)) + var root string + if root, err = archiveRoot(tree, a); err == nil { + body, err = pack(root) + } } if err != nil { return catalogue.Built{}, fmt.Errorf("%s: packing %s failed: %w", module, a.Name, err) @@ -1343,6 +1363,27 @@ func one(ctx context.Context, run Runner, publish Publisher, module, a.Name, a.Kind) } +// archiveRoot is the directory an archive artifact is packed from, refused when a link in the fetched +// tree leads out of the module. The repository is somebody else's: pack follows a link in a parent +// component of `from`, so without this a committed link makes the build pack, register and serve any +// directory the build account can read (issue 1609). Both are compared where they really are. +func archiveRoot(tree string, a catalogue.Artifact) (string, error) { + root := filepath.Join(tree, a.From) + realTree, err := filepath.EvalSymlinks(tree) + if err != nil { + return "", err + } + realRoot, err := filepath.EvalSymlinks(root) + if err != nil { + return "", err + } + if rel, err := filepath.Rel(realTree, realRoot); err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return "", fmt.Errorf("the archive %s is taken from %q, which lies outside the module: it resolves to %s, and the module is %s", + a.Name, a.From, realRoot, realTree) + } + return root, nil +} + // pack tars and gzips a directory. // // **Deterministically**: entries sorted, and no timestamps, uid, gid or original names carried -- 2.54.0 From 0caa8c6844d9a3701f18556d516e3e3b040d89b3 Mon Sep 17 00:00:00 2001 From: mesh-tools Date: Wed, 21 Oct 2026 09:46:00 +0000 Subject: [PATCH 2/2] Refuse a bundle whose output directory leads out of the module through a link, so a repository cannot make the build write into or pack files it does not hold (issue 1609) --- internal/builder/archive_symlink_test.go | 45 ++++++++++++++++++++++++ internal/builder/builder.go | 39 ++++++++++++++++++++ 2 files changed, 84 insertions(+) diff --git a/internal/builder/archive_symlink_test.go b/internal/builder/archive_symlink_test.go index cf5d622b..a6c2eae4 100644 --- a/internal/builder/archive_symlink_test.go +++ b/internal/builder/archive_symlink_test.go @@ -102,3 +102,48 @@ func TestAModuleDirectoryThatIsALinkOutOfTheRepositoryIsRefused(t *testing.T) { t.Errorf("an absent module directory was refused here: %v", err) } } + +// A repository that commits `.mesh-build` as a link must not make a bundle's build write into, or pack, +// a directory outside the module (issue 1609): the compile, the bundling and the pack each refuse it. +func TestABundleWhoseOutputDirectoryIsALinkOutOfTheModuleIsRefused(t *testing.T) { + outside := t.TempDir() + if err := os.WriteFile(filepath.Join(outside, "secret"), []byte("s"), 0o644); err != nil { + t.Fatal(err) + } + tree := t.TempDir() + if err := os.Symlink(outside, filepath.Join(tree, ".mesh-build")); err != nil { + t.Fatal(err) + } + chain, err := ToolchainFor("go") + if err != nil { + t.Fatal(err) + } + a := catalogue.Artifact{Name: "b", Kind: catalogue.ArtifactBundle, Language: "go", From: "cmd/b"} + ran := false + run := func(context.Context, string, string, ...string) (string, error) { ran = true; return "", nil } + realOutside, _ := filepath.EvalSymlinks(outside) + realTree, _ := filepath.EvalSymlinks(tree) + + check := func(what string, err error) { + t.Helper() + if err == nil { + t.Fatalf("%s went through a link out of the module", what) + } + for _, want := range []string{realOutside, realTree} { + if !strings.Contains(err.Error(), want) { + t.Errorf("%s: the refusal %q does not name %q", what, err, want) + } + } + } + _, err = compile(context.Background(), run, tree, chain, "base", a) + check("compile", err) + _, err = bundled(context.Background(), run, tree, chain, "base", a, nil) + check("bundled", err) + check("the pack", outsideTheModule(tree, filepath.Join(tree, Out(a.Name)))) + if ran { + t.Error("a command ran for a refused bundle") + } + if entries, _ := os.ReadDir(outside); len(entries) != 1 { + t.Errorf("a refused bundle wrote into the directory outside: %d entries", len(entries)) + } +} diff --git a/internal/builder/builder.go b/internal/builder/builder.go index 4e899c0b..d4997009 100644 --- a/internal/builder/builder.go +++ b/internal/builder/builder.go @@ -1295,6 +1295,9 @@ func one(ctx context.Context, run Runner, publish Publisher, } } say("bundle", "compiled, packing") + if err := outsideTheModule(tree, compiled); err != nil { + return catalogue.Built{}, fmt.Errorf("%s: packing %s failed: %w", module, a.Name, err) + } body, err := pack(compiled) if err != nil { return catalogue.Built{}, fmt.Errorf("%s: packing %s failed: %w", module, a.Name, err) @@ -1384,6 +1387,36 @@ func archiveRoot(tree string, a catalogue.Artifact) (string, error) { return root, nil } +// outsideTheModule refuses a path that, once every link in it is followed, is not inside the module's +// directory, naming where it really is and where the module is. For a path not there yet (an output +// directory about to be made) the deepest part that exists is the one followed. A repository commits +// its own links, so `.mesh-build` as a link would otherwise have a bundle's build write into, and pack, +// any directory the build account can read (issue 1609). +func outsideTheModule(tree, path string) error { + realTree, err := filepath.EvalSymlinks(tree) + if err != nil { + return err + } + real, rest := path, "" + for { + resolved, err := filepath.EvalSymlinks(real) + if err == nil { + real = filepath.Join(resolved, rest) + break + } + if !errors.Is(err, os.ErrNotExist) || filepath.Dir(real) == real { + return err + } + rest = filepath.Join(filepath.Base(real), rest) + real = filepath.Dir(real) + } + if rel, err := filepath.Rel(realTree, real); err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return fmt.Errorf("%s leads out of the module through a link: it resolves to %s, and the module is %s", + path, real, realTree) + } + return nil +} + // pack tars and gzips a directory. // // **Deterministically**: entries sorted, and no timestamps, uid, gid or original names carried @@ -1687,6 +1720,9 @@ func compile(ctx context.Context, run Runner, tree string, chain Toolchain, // a package in a third (ADR 0040). Compiling them all into one place would have them overwrite // each other and then be packed together, so each bundle compiles and packs alone. out := Out(a.Name) + if err := outsideTheModule(tree, filepath.Join(tree, out)); err != nil { + return "", err + } // **The output directory exists before the compiler is told about it.** `tsc --outDir` makes // one; `go build -o` writes a file into a directory and does not create it, failing with a @@ -2013,6 +2049,9 @@ func bundled(ctx context.Context, run Runner, tree string, chain Toolchain, base a catalogue.Artifact, launchers map[string]string) (string, error) { const within = "/app/modules/module" out, final := Out(a.Name), Out(a.Name)+bundledSuffix + if err := outsideTheModule(tree, filepath.Join(tree, final)); err != nil { + return "", err + } if err := os.RemoveAll(filepath.Join(tree, final)); err != nil { return "", err } -- 2.54.0