Skip to content

Commit 5f561e1

Browse files
fix(executor)!: give input artifacts without mode a default of 0600
Artifact repositories generally cannot store file permissions, so an artifact only kept the permissions it was created with if it was archived: a tarball restores the modes recorded in its headers, while an artifact stored with `archive: none` arrives with whatever mode its driver created the files with. The same artifact therefore landed with different permissions depending on how the template that produced it chose to store it. Give an artifact that does not set `mode` a well known set of permissions instead: 0600 for its files and 0700 for its directories, which have to stay traversable. This covers the whole artifact, as an artifact stored as-is can be a directory too. Symlinks are skipped, since os.Chmod follows them and untar preserves the symlinks within an artifact, so following one could change the permissions of a file outside it. Signed-off-by: Charles Cheng <chengxisheng777@gmail.com>
1 parent 5c51b07 commit 5f561e1

4 files changed

Lines changed: 140 additions & 0 deletions

File tree

docs/upgrading.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,31 @@ This variable controlled whether to write workflow updates back to the informer
2929
Alternative mechanisms now prevent reprocessing, making both behaviors unnecessary.
3030
If you have this variable set, it can be safely removed from your configuration.
3131

32+
### Input artifacts without `mode` now default to `0600`
33+
34+
An input artifact that does not set `mode` is now given a fixed set of permissions once it has been loaded: `0600` for its files and `0700` for its directories ([#14792](https://github.com/argoproj/argo-workflows/issues/14792)).
35+
This covers the whole artifact, not only its top level, since an artifact can be a directory.
36+
Symlinks within an artifact are left alone, and artifacts loaded by an artifact plugin keep their existing `0666` default.
37+
Setting `mode` still overrides all of this, and `recurseMode` continues to control how an explicit `mode` is applied.
38+
39+
Previously no default was applied, so the permissions an artifact ended up with depended on how it had been stored rather than on the template consuming it.
40+
A tarball (the default `archive` strategy) restores the modes recorded in its headers, so a file saved as `0644` was loaded as `0644`; the same file saved with `archive: none: {}` is stored without any permission metadata and was loaded as `0600`.
41+
Object stores generally cannot preserve filesystem permissions, and some artifact repositories (`http`, for example) have nowhere to record them at all, so the producing template's permissions were never something a consuming template could rely on.
42+
43+
Set `mode` (and `recurseMode` for a directory) explicitly on any input artifact that needs more than owner access.
44+
The two cases most likely to need it are a file that has to stay executable, and a `main` container that runs as a different user than the one that loaded the artifact — `0600` and `0700` are owner-only, so a differing `runAsUser` can no longer read the artifact:
45+
46+
```yaml
47+
inputs:
48+
artifacts:
49+
- name: my-script
50+
path: /tmp/my-script.sh
51+
mode: 0755
52+
```
53+
54+
Note that when an artifact's `path` falls inside a volume you mounted yourself, it is loaded into that volume rather than into the `input-artifacts` volume.
55+
For a `persistentVolumeClaim` the new permissions therefore outlive the pod, so a later step reading the file directly from the claim sees them too.
56+
3257
## Upgrading to v4.0.7 and v3.7.16
3358

3459
### Outputs of skipped and omitted steps and tasks now resolve

docs/walk-through/artifacts.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,9 @@ For example:
132132
It is good practice to specify the `mode` of the input file, to ensure your container code can always interact with it (reading/writing/executing) as expected.
133133
[This article](https://www.redhat.com/en/blog/linux-file-permissions-explained) explains file permissions and octal values.
134134

135+
An input artifact that does not specify `mode` is loaded with a fixed set of permissions: `0600` for its files and `0700` for its directories.
136+
Do not rely on the permissions the template that produced the artifact gave it: an artifact repository generally cannot store file permissions, so they only survive if the artifact was archived (see [Archive Strategy](#archive-strategy) above).
137+
135138
For example, to allow the user to execute `/bin/kubectl`, we set `mode: 0755`.
136139

137140
```yaml

workflow/executor/executor.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,11 @@ func (we *WorkflowExecutor) loadArtifact(ctx context.Context, pluginName wfv1.Ar
357357
logger.WithError(err).Error(ctx, "Failed to chmod plugin artifact")
358358
return err
359359
}
360+
} else {
361+
err = chmodDefault(artPath)
362+
if err != nil {
363+
return err
364+
}
360365
}
361366
return nil
362367
}
@@ -1331,6 +1336,49 @@ func unpack(srcPath string, destPath string, decompressor func(string, string) e
13311336
return nil
13321337
}
13331338

1339+
const (
1340+
// defaultArtifactFileMode is applied to the files of an input artifact that does not
1341+
// set `mode`.
1342+
defaultArtifactFileMode = 0o600
1343+
// defaultArtifactDirMode is defaultArtifactFileMode with the execute bit added, as a
1344+
// directory has to be traversable to be of any use.
1345+
defaultArtifactDirMode = 0o700
1346+
)
1347+
1348+
// chmodDefault gives an input artifact that does not set `mode` a well known set of
1349+
// permissions: defaultArtifactFileMode for its files and defaultArtifactDirMode for its
1350+
// directories. Without it an artifact keeps whatever permissions its archive strategy
1351+
// happened to carry - a tarball restores the modes recorded in its headers, while an
1352+
// artifact stored as-is only has the modes its driver created the files with, as object
1353+
// stores do not store permissions - so the same artifact arrives with different
1354+
// permissions depending on how the template that produced it chose to store it.
1355+
//
1356+
// This covers the whole artifact rather than just its root, because an artifact stored
1357+
// as-is can be a directory too. `recurseMode` is deliberately not consulted: it selects
1358+
// how an explicit `mode` is applied, and these are the defaults for its absence.
1359+
//
1360+
// Symlinks are skipped, as os.Chmod follows them and untar preserves the symlinks within
1361+
// an artifact, so following one could change the permissions of a file outside it.
1362+
func chmodDefault(artPath string) error {
1363+
err := filepath.WalkDir(artPath, func(path string, d fs.DirEntry, err error) error {
1364+
if err != nil {
1365+
return err
1366+
}
1367+
switch {
1368+
case d.IsDir():
1369+
return os.Chmod(path, defaultArtifactDirMode)
1370+
case d.Type().IsRegular():
1371+
return os.Chmod(path, defaultArtifactFileMode)
1372+
default:
1373+
return nil
1374+
}
1375+
})
1376+
if err != nil {
1377+
return argoerrs.InternalWrapError(err)
1378+
}
1379+
return nil
1380+
}
1381+
13341382
func chmod(artPath string, mode int32, recurse bool) error {
13351383
err := os.Chmod(artPath, os.FileMode(mode))
13361384
if err != nil {

workflow/executor/executor_test.go

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -483,6 +483,70 @@ func TestChmod(t *testing.T) {
483483
}
484484
}
485485

486+
func TestChmodDefault(t *testing.T) {
487+
if runtime.GOOS == "windows" {
488+
t.Skip("chmod does not work in windows")
489+
}
490+
491+
// A single file artifact, which is what `archive: none` gives you for a file. Its
492+
// mode is whatever the driver created it with.
493+
t.Run("single file", func(t *testing.T) {
494+
artPath := filepath.Join(t.TempDir(), "myfile.txt")
495+
require.NoError(t, os.WriteFile(artPath, []byte("test content\n"), 0o644))
496+
497+
require.NoError(t, chmodDefault(artPath))
498+
499+
info, err := os.Stat(artPath)
500+
require.NoError(t, err)
501+
assert.Equal(t, "-rw-------", info.Mode().String())
502+
})
503+
504+
// A directory artifact, as `untar` or a directory download leaves it. The whole
505+
// tree is covered, and directories keep the execute bit so they can be entered.
506+
t.Run("directory", func(t *testing.T) {
507+
root := t.TempDir()
508+
nested := filepath.Join(root, "nested")
509+
require.NoError(t, os.Mkdir(nested, 0o755))
510+
file := filepath.Join(nested, "script.sh")
511+
require.NoError(t, os.WriteFile(file, []byte("#!/bin/sh\n"), 0o755))
512+
require.NoError(t, os.Chmod(root, 0o755))
513+
514+
require.NoError(t, chmodDefault(root))
515+
516+
want := map[string]string{root: "drwx------", nested: "drwx------", file: "-rw-------"}
517+
for path, mode := range want {
518+
info, err := os.Stat(path)
519+
require.NoError(t, err)
520+
assert.Equal(t, mode, info.Mode().String(), path)
521+
}
522+
})
523+
524+
// os.Chmod follows symlinks, and untar preserves the symlinks in an artifact, so
525+
// following one would chmod a file outside the artifact.
526+
t.Run("does not follow symlinks out of the artifact", func(t *testing.T) {
527+
outside := filepath.Join(t.TempDir(), "outside.txt")
528+
require.NoError(t, os.WriteFile(outside, []byte("not part of the artifact\n"), 0o644))
529+
530+
root := t.TempDir()
531+
require.NoError(t, os.Symlink(outside, filepath.Join(root, "link")))
532+
533+
require.NoError(t, chmodDefault(root))
534+
535+
info, err := os.Stat(outside)
536+
require.NoError(t, err)
537+
assert.Equal(t, "-rw-r--r--", info.Mode().String())
538+
})
539+
540+
// A dangling symlink must not fail the artifact load, which did not chmod at all
541+
// before this default existed.
542+
t.Run("dangling symlink artifact", func(t *testing.T) {
543+
artPath := filepath.Join(t.TempDir(), "link")
544+
require.NoError(t, os.Symlink("/does/not/exist", artPath))
545+
546+
require.NoError(t, chmodDefault(artPath))
547+
})
548+
}
549+
486550
func TestSaveArtifacts(t *testing.T) {
487551
fakeClientset := fake.NewClientset()
488552
mockRuntimeExecutor := mocks.ContainerRuntimeExecutor{}

0 commit comments

Comments
 (0)