Skip to content

Commit 413fe6a

Browse files
committed
Restrict task names to a strict ASCII whitelist
Task names appear in on-disk checksum paths, terminal log lines and shell completion output. The previous validator only blocked path separators and '..', so a task file could still smuggle in control characters, terminal escapes, shell metacharacters or whitespace. Allow only ASCII letters, digits, '-', '_' and ':' (the namespace separator), and forbid leading/trailing/consecutive ':'.
1 parent 0ce2428 commit 413fe6a

2 files changed

Lines changed: 70 additions & 15 deletions

File tree

taskfile/parse.go

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,19 +12,29 @@ import (
1212

1313
const fileName = "gogo.yaml"
1414

15-
// validateTaskName rejects task names containing characters that could escape
16-
// the on-disk checksum directory or otherwise misbehave when joined into a
17-
// filesystem path. Namespace separators (':') and ordinary identifier
18-
// characters are allowed; '/', '\\' and '..' are not.
15+
// validateTaskName enforces a strict whitelist of characters allowed in task
16+
// names. Names appear in checksum file paths, log lines, and shell
17+
// completions; allowing arbitrary characters lets a malicious task file
18+
// inject control characters, terminal escapes, path components, or shell
19+
// metacharacters into those contexts.
20+
//
21+
// Allowed: ASCII letters, digits, '-', '_', and ':' (the namespace separator).
1922
func validateTaskName(name string) error {
2023
if name == "" {
2124
return errors.New("task name must not be empty")
2225
}
23-
if strings.ContainsAny(name, `/\`) {
24-
return fmt.Errorf("task name %q must not contain '/' or '\\'", name)
26+
for _, r := range name {
27+
switch {
28+
case 'a' <= r && r <= 'z':
29+
case 'A' <= r && r <= 'Z':
30+
case '0' <= r && r <= '9':
31+
case r == '-' || r == '_' || r == ':':
32+
default:
33+
return fmt.Errorf("task name %q contains disallowed character %q (allowed: letters, digits, '-', '_', ':')", name, r)
34+
}
2535
}
26-
if strings.Contains(name, "..") {
27-
return fmt.Errorf("task name %q must not contain '..'", name)
36+
if strings.HasPrefix(name, ":") || strings.HasSuffix(name, ":") || strings.Contains(name, "::") {
37+
return fmt.Errorf("task name %q must not start with, end with, or contain consecutive ':'", name)
2838
}
2939
return nil
3040
}

taskfile/parse_test.go

Lines changed: 52 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -759,14 +759,34 @@ flatten:
759759
}
760760

761761
func TestParseRejectsUnsafeTaskNames(t *testing.T) {
762-
// Names containing path separators or '..' could be used to escape the
763-
// .gogo/checksum/ directory when joined with checksumPath.
762+
// Task names appear in checksum file paths, log lines and shell
763+
// completions. We allow only letters, digits, '-', '_' and ':'; anything
764+
// else — path separators, control characters, terminal escapes, shell
765+
// metacharacters — must be rejected at parse time.
764766
cases := map[string]string{
765-
"slash": "foo/bar",
766-
"backslash": `foo\bar`,
767-
"parent": "..",
768-
"traversal": "../etc/passwd",
769-
"dotdot-mid": "foo..bar",
767+
"slash": "foo/bar",
768+
"backslash": `foo\bar`,
769+
"parent": "..",
770+
"traversal": "../etc/passwd",
771+
"dotdot-mid": "foo..bar",
772+
"space": "foo bar",
773+
"newline": "foo\nbar",
774+
"tab": "foo\tbar",
775+
"escape": "foo\x1b[31mred",
776+
"null": "foo\x00bar",
777+
"backtick": "foo`whoami`",
778+
"dollar": "$(whoami)",
779+
"semicolon": "foo;rm",
780+
"pipe": "foo|rm",
781+
"glob": "foo*",
782+
"question": "foo?",
783+
"quote": `foo"bar`,
784+
"singlequote": "foo'bar",
785+
"unicode": "foo\u202ebar",
786+
"leading-colon": ":build",
787+
"trailing-colon": "build:",
788+
"double-colon": "foo::bar",
789+
"dot": "foo.bar",
770790
}
771791
for label, name := range cases {
772792
t.Run(label, func(t *testing.T) {
@@ -782,6 +802,31 @@ func TestParseRejectsUnsafeTaskNames(t *testing.T) {
782802
}
783803
}
784804

805+
func TestParseAcceptsValidTaskNames(t *testing.T) {
806+
names := []string{
807+
"build",
808+
"build-all",
809+
"build_all",
810+
"_internal",
811+
"cli:build",
812+
"cli:utils:fmt",
813+
"go121",
814+
"BuildIt",
815+
}
816+
for _, name := range names {
817+
t.Run(name, func(t *testing.T) {
818+
dir := t.TempDir()
819+
writeFiles(t, dir, map[string]string{
820+
"gogo.yaml": fmt.Sprintf("version: \"1\"\ntasks:\n %q:\n cmd: echo hi\n", name),
821+
})
822+
823+
tf, err := Parse(dir)
824+
require.NoError(t, err)
825+
assert.Contains(t, tf.Tasks, name)
826+
})
827+
}
828+
}
829+
785830
func TestLoadWithFlattenRejectsUnsafeTaskName(t *testing.T) {
786831
dir := t.TempDir()
787832
writeFiles(t, dir, map[string]string{

0 commit comments

Comments
 (0)