diff --git a/internal/stage/publish.go b/internal/stage/publish.go index ec9c104..9ee83e2 100644 --- a/internal/stage/publish.go +++ b/internal/stage/publish.go @@ -978,7 +978,7 @@ func collectPublishPreviousFiles(ctx context.Context, previousDir string) ([]pub func openPublishRunFiles(runRoot string, files []publishUploadFile) ([]*os.File, error) { sources := make([]*os.File, 0, len(files)) for _, candidate := range files { - file, err := openPublishOutputSource(filepath.Join(runRoot, filepath.FromSlash(candidate.SourceRelativePath))) + file, err := openPublishSourceUnderRoot(runRoot, candidate.SourceRelativePath) if err != nil { closePublishSources(sources) return nil, err @@ -993,6 +993,56 @@ func openPublishRunFiles(runRoot string, files []publishUploadFile) ([]*os.File, return sources, nil } +func openPublishSourceUnderRoot(rootPath, relativePath string) (*os.File, error) { + relativePath, err := pathsafe.NormalizeRelativeDestination(relativePath) + if err != nil { + return nil, fmt.Errorf("validate publish source path %q: %w", relativePath, err) + } + + root, err := os.OpenRoot(rootPath) + if err != nil { + return nil, fmt.Errorf("open publish source root %q: %w", rootPath, err) + } + openedRoots := []*os.Root{root} + defer func() { + for index := len(openedRoots) - 1; index >= 0; index-- { + _ = openedRoots[index].Close() + } + }() + + parts := strings.Split(relativePath, "/") + current := root + for _, part := range parts[:len(parts)-1] { + info, err := current.Lstat(part) + if err != nil { + return nil, fmt.Errorf("inspect publish source ancestor %q: %w", part, err) + } + if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { + return nil, fmt.Errorf("publish source ancestor %q is not a directory without symlinks", part) + } + next, err := current.OpenRoot(part) + if err != nil { + return nil, fmt.Errorf("open publish source ancestor %q: %w", part, err) + } + openedRoots = append(openedRoots, next) + current = next + } + + leaf := parts[len(parts)-1] + info, err := current.Lstat(leaf) + if err != nil { + return nil, fmt.Errorf("inspect publish source %q: %w", relativePath, err) + } + if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { + return nil, fmt.Errorf("publish source %q is not a regular file without symlinks", relativePath) + } + file, err := current.Open(leaf) + if err != nil { + return nil, fmt.Errorf("open publish source %q: %w", relativePath, err) + } + return file, nil +} + func verifyPublishSourceChecksum(file *os.File, relativePath, expected string) error { if strings.TrimSpace(expected) == "" { return nil diff --git a/internal/stage/publish_test.go b/internal/stage/publish_test.go index 893254c..e09fdbd 100644 --- a/internal/stage/publish_test.go +++ b/internal/stage/publish_test.go @@ -263,7 +263,7 @@ func TestPublishRejectsSymlinkedRunRootBeforeUploading(t *testing.T) { assertNoPublishUploads(t, env.ObjectStore.(*storage.FakeBackend), "") } -func TestPublishUsesOrdinarySourceAncestorPathSemantics(t *testing.T) { +func TestPublishRejectsDeclaredSourceWithSymlinkedAncestor(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("symlink creation requires privileges on Windows") } @@ -278,9 +278,11 @@ func TestPublishUsesOrdinarySourceAncestorPathSemantics(t *testing.T) { t.Fatal(err) } - if _, err := (publishStage{}).Run(context.Background(), env, m); err != nil { - t.Fatalf("Run() error = %v", err) + _, err := (publishStage{}).Run(context.Background(), env, m) + if err == nil || !strings.Contains(err.Error(), "ancestor") { + t.Fatalf("Run() error = %v, want rejected symlinked ancestor", err) } + assertNoPublishUploads(t, env.ObjectStore.(*storage.FakeBackend), "outside secret") } func TestPublishRejectsDeclaredSourceReplacement(t *testing.T) {