Skip to content

Commit d361e2e

Browse files
authored
Enforce a stricter validation on the repo name for TAP 4 (#720)
Signed-off-by: Radoslav Dimitrov <radoslav@stacklok.com>
1 parent 29aae36 commit d361e2e

File tree

2 files changed

+156
-0
lines changed

2 files changed

+156
-0
lines changed

metadata/multirepo/multirepo.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,16 +19,29 @@ package multirepo
1919

2020
import (
2121
"encoding/json"
22+
"errors"
2223
"fmt"
2324
"os"
2425
"path/filepath"
26+
"regexp"
2527
"slices"
2628

2729
"github.com/theupdateframework/go-tuf/v2/metadata"
2830
"github.com/theupdateframework/go-tuf/v2/metadata/config"
2931
"github.com/theupdateframework/go-tuf/v2/metadata/updater"
3032
)
3133

34+
// ErrInvalidRepoName is returned when a repository name contains path traversal
35+
// components or is otherwise invalid for use as a directory name.
36+
var ErrInvalidRepoName = errors.New("invalid repository name")
37+
38+
// validRepoNamePattern defines the allowed characters for repository names.
39+
// Names must start with an alphanumeric character and may contain alphanumeric
40+
// characters, dots, hyphens, and underscores. This prevents path traversal
41+
// attacks while allowing typical repository naming conventions.
42+
// Examples: "sigstore-tuf-root", "staging", "repo.v2", "my_repo_1"
43+
var validRepoNamePattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]*$`)
44+
3245
// The following represent the map file described in TAP 4
3346
type Mapping struct {
3447
Paths []string `json:"paths"`
@@ -103,6 +116,13 @@ func New(config *MultiRepoConfig) (*MultiRepoClient, error) {
103116
TUFClients: map[string]*updater.Updater{},
104117
}
105118

119+
// validate repository names before using them as filesystem paths
120+
for repoName := range config.RepoMap.Repositories {
121+
if err := validateRepoName(repoName); err != nil {
122+
return nil, fmt.Errorf("repository %q: %w", repoName, err)
123+
}
124+
}
125+
106126
// create TUF clients for each repository listed in the map file
107127
if err := client.initTUFClients(); err != nil {
108128
return nil, err
@@ -363,3 +383,14 @@ func (cfg *MultiRepoConfig) EnsurePathsExist() error {
363383
}
364384
return nil
365385
}
386+
387+
// validateRepoName checks that a repository name is safe to use as a directory
388+
// component. Repository names must start with an alphanumeric character and
389+
// contain only alphanumeric characters, dots, hyphens, and underscores.
390+
// This prevents path traversal attacks when the repository name is used in filepath.Join.
391+
func validateRepoName(name string) error {
392+
if !validRepoNamePattern.MatchString(name) {
393+
return fmt.Errorf("%w: %q must start with alphanumeric and contain only alphanumeric, '.', '-', or '_' characters", ErrInvalidRepoName, name)
394+
}
395+
return nil
396+
}
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
// Copyright 2024 The Update Framework Authors
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License
14+
//
15+
// SPDX-License-Identifier: Apache-2.0
16+
//
17+
18+
package multirepo
19+
20+
import (
21+
"errors"
22+
"testing"
23+
)
24+
25+
func TestValidateRepoName(t *testing.T) {
26+
tests := []struct {
27+
name string
28+
input string
29+
wantErr bool
30+
}{
31+
// Valid names - must start with alphanumeric, contain only [a-zA-Z0-9._-]
32+
{"valid simple name", "my-repo", false},
33+
{"valid name with numbers", "repo123", false},
34+
{"valid starts with number", "123repo", false},
35+
{"valid name with dots", "my.repo.name", false},
36+
{"valid name with underscores", "my_repo_name", false},
37+
{"valid mixed", "sigstore-tuf-root", false},
38+
{"valid version style", "repo.v2.1", false},
39+
{"valid single char", "a", false},
40+
{"valid single number", "1", false},
41+
42+
// Invalid: empty
43+
{"empty name", "", true},
44+
45+
// Invalid: starts with non-alphanumeric
46+
{"starts with dot", ".hidden", true},
47+
{"starts with hyphen", "-repo", true},
48+
{"starts with underscore", "_repo", true},
49+
50+
// Invalid: traversal components
51+
{"single dot", ".", true},
52+
{"double dot", "..", true},
53+
54+
// Invalid: path separators
55+
{"unix path separator", "foo/bar", true},
56+
{"windows path separator", "foo\\bar", true},
57+
{"traversal with unix separator", "../escaped", true},
58+
{"traversal with windows separator", "..\\escaped", true},
59+
{"deep traversal", "../../etc/passwd", true},
60+
61+
// Invalid: absolute paths
62+
{"unix absolute path", "/etc/passwd", true},
63+
{"windows absolute path", "C:\\Windows", true},
64+
65+
// Invalid: special characters
66+
{"contains space", "my repo", true},
67+
{"contains at sign", "repo@org", true},
68+
{"contains colon", "repo:tag", true},
69+
{"contains hash", "repo#1", true},
70+
{"contains exclamation", "repo!", true},
71+
{"contains semicolon", "repo;rm", true},
72+
{"contains unicode", "репо", true},
73+
}
74+
75+
for _, tt := range tests {
76+
t.Run(tt.name, func(t *testing.T) {
77+
err := validateRepoName(tt.input)
78+
if (err != nil) != tt.wantErr {
79+
t.Errorf("validateRepoName(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr)
80+
}
81+
if err != nil && !errors.Is(err, ErrInvalidRepoName) {
82+
t.Errorf("validateRepoName(%q) error should wrap ErrInvalidRepoName, got %v", tt.input, err)
83+
}
84+
})
85+
}
86+
}
87+
88+
func TestNewRejectsInvalidRepoNames(t *testing.T) {
89+
tests := []struct {
90+
name string
91+
repoName string
92+
}{
93+
{"path traversal", "../escaped-repo"},
94+
{"starts with dot", ".hidden-repo"},
95+
{"contains slash", "foo/bar"},
96+
{"contains space", "my repo"},
97+
}
98+
99+
for _, tt := range tests {
100+
t.Run(tt.name, func(t *testing.T) {
101+
mapJSON := []byte(`{
102+
"repositories": {
103+
"` + tt.repoName + `": ["https://example.com/repo"]
104+
},
105+
"mapping": []
106+
}`)
107+
108+
rootBytes := []byte(`{"signatures":[],"signed":{}}`)
109+
110+
cfg, err := NewConfig(mapJSON, map[string][]byte{tt.repoName: rootBytes})
111+
if err != nil {
112+
t.Fatalf("NewConfig() unexpected error: %v", err)
113+
}
114+
115+
_, err = New(cfg)
116+
if err == nil {
117+
t.Fatalf("New() should reject repository name %q", tt.repoName)
118+
}
119+
120+
if !errors.Is(err, ErrInvalidRepoName) {
121+
t.Errorf("New() error should wrap ErrInvalidRepoName, got: %v", err)
122+
}
123+
})
124+
}
125+
}

0 commit comments

Comments
 (0)