Skip to content

Commit b5d86c5

Browse files
authored
Merge pull request #15 from git-pkgs/validate-metadata-urls
Reject non-https download URLs from registry metadata
2 parents 2cdaa8e + 9792fba commit b5d86c5

2 files changed

Lines changed: 147 additions & 13 deletions

File tree

fetch/resolver.go

Lines changed: 38 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"errors"
66
"fmt"
7+
"net/url"
78
"strings"
89
"unicode"
910

@@ -14,6 +15,7 @@ import (
1415
var (
1516
ErrUnsupportedEcosystem = errors.New("unsupported ecosystem")
1617
ErrNoDownloadURL = errors.New("no download URL available")
18+
ErrUnsafeURL = errors.New("unsafe download URL from registry metadata")
1719
)
1820

1921
// Registry provides package metadata and URL information for artifact resolution.
@@ -140,21 +142,15 @@ func (r *Resolver) resolveFromMetadata(ctx context.Context, reg Registry, name,
140142
continue
141143
}
142144

143-
// Look for download URL in metadata
145+
// Look for download URL in metadata. These come from the
146+
// registry's API response, not from us, so they need checking
147+
// before anyone fetches them.
144148
if v.Metadata != nil {
145-
if url, ok := v.Metadata["download_url"].(string); ok && url != "" {
146-
return &ArtifactInfo{
147-
URL: url,
148-
Filename: filenameFromURL(url),
149-
Integrity: v.Integrity,
150-
}, nil
149+
if u, ok := v.Metadata["download_url"].(string); ok && u != "" {
150+
return artifactFromMetadataURL(u, v.Integrity)
151151
}
152-
if url, ok := v.Metadata["tarball"].(string); ok && url != "" {
153-
return &ArtifactInfo{
154-
URL: url,
155-
Filename: filenameFromURL(url),
156-
Integrity: v.Integrity,
157-
}, nil
152+
if u, ok := v.Metadata["tarball"].(string); ok && u != "" {
153+
return artifactFromMetadataURL(u, v.Integrity)
158154
}
159155
}
160156

@@ -164,6 +160,35 @@ func (r *Resolver) resolveFromMetadata(ctx context.Context, reg Registry, name,
164160
return nil, ErrNotFound
165161
}
166162

163+
func artifactFromMetadataURL(raw, integrity string) (*ArtifactInfo, error) {
164+
if err := checkMetadataURL(raw); err != nil {
165+
return nil, err
166+
}
167+
return &ArtifactInfo{
168+
URL: raw,
169+
Filename: filenameFromURL(raw),
170+
Integrity: integrity,
171+
}, nil
172+
}
173+
174+
// checkMetadataURL rejects download URLs from registry responses that
175+
// could direct the fetcher somewhere it shouldn't go. A compromised or
176+
// MITM'd registry could otherwise hand back file:// or a cloud metadata
177+
// endpoint.
178+
func checkMetadataURL(raw string) error {
179+
u, err := url.Parse(raw)
180+
if err != nil {
181+
return fmt.Errorf("%w: %v", ErrUnsafeURL, err)
182+
}
183+
if u.Scheme != "https" {
184+
return fmt.Errorf("%w: scheme %q", ErrUnsafeURL, u.Scheme)
185+
}
186+
if u.Hostname() == "" {
187+
return fmt.Errorf("%w: empty host", ErrUnsafeURL)
188+
}
189+
return nil
190+
}
191+
167192
func filenameFromURL(url string) string {
168193
if idx := strings.LastIndex(url, "/"); idx >= 0 {
169194
return url[idx+1:]

fetch/resolver_test.go

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@ package fetch
22

33
import (
44
"context"
5+
"errors"
56
"testing"
7+
8+
"github.com/git-pkgs/registries"
9+
"github.com/git-pkgs/registries/client"
610
)
711

812
func TestResolveWithoutRegistry(t *testing.T) {
@@ -118,6 +122,111 @@ func TestEncodeGoModule(t *testing.T) {
118122
}
119123
}
120124

125+
func TestCheckMetadataURL(t *testing.T) {
126+
tests := []struct {
127+
url string
128+
ok bool
129+
}{
130+
{"https://files.pythonhosted.org/packages/ab/cd/pkg-1.0.tar.gz", true},
131+
{"https://registry.npmjs.org/pkg/-/pkg-1.0.0.tgz", true},
132+
{"https://github.com/owner/repo/archive/v1.0.tar.gz", true},
133+
134+
{"http://files.pythonhosted.org/pkg.tar.gz", false},
135+
{"file:///etc/passwd", false},
136+
{"file://etc/passwd", false},
137+
{"gopher://evil.example/", false},
138+
{"ftp://ftp.example.com/pkg.tar.gz", false},
139+
{"javascript:alert(1)", false},
140+
{"https://", false},
141+
{"https:///path/only", false},
142+
{"//169.254.169.254/latest/meta-data/", false},
143+
{"169.254.169.254/latest/meta-data/", false},
144+
{"", false},
145+
{"\x00https://evil", false},
146+
}
147+
148+
for _, tt := range tests {
149+
err := checkMetadataURL(tt.url)
150+
if tt.ok && err != nil {
151+
t.Errorf("checkMetadataURL(%q) = %v, want nil", tt.url, err)
152+
}
153+
if !tt.ok {
154+
if err == nil {
155+
t.Errorf("checkMetadataURL(%q) = nil, want error", tt.url)
156+
} else if !errors.Is(err, ErrUnsafeURL) {
157+
t.Errorf("checkMetadataURL(%q) error not ErrUnsafeURL: %v", tt.url, err)
158+
}
159+
}
160+
}
161+
}
162+
163+
type fakeRegistry struct {
164+
versions []registries.Version
165+
}
166+
167+
func (f *fakeRegistry) Ecosystem() string { return "fake" }
168+
func (f *fakeRegistry) URLs() client.URLBuilder { return &client.BaseURLs{} } //nolint:ireturn // satisfying Registry interface
169+
func (f *fakeRegistry) FetchVersions(ctx context.Context, name string) ([]registries.Version, error) {
170+
return f.versions, nil
171+
}
172+
173+
func TestResolveFromMetadataRejectsUnsafeURL(t *testing.T) {
174+
tests := []struct {
175+
name string
176+
field string
177+
url string
178+
}{
179+
{"file scheme via download_url", "download_url", "file:///etc/passwd"},
180+
{"http via tarball", "tarball", "http://169.254.169.254/latest/meta-data/"},
181+
{"empty host via download_url", "download_url", "https:///just/a/path"},
182+
}
183+
184+
for _, tt := range tests {
185+
t.Run(tt.name, func(t *testing.T) {
186+
reg := &fakeRegistry{
187+
versions: []registries.Version{{
188+
Number: "1.0.0",
189+
Metadata: map[string]any{tt.field: tt.url},
190+
}},
191+
}
192+
r := NewResolver()
193+
r.RegisterRegistry(reg)
194+
195+
info, err := r.Resolve(context.Background(), "fake", "pkg", "1.0.0")
196+
if !errors.Is(err, ErrUnsafeURL) {
197+
t.Fatalf("Resolve = (%v, %v), want ErrUnsafeURL", info, err)
198+
}
199+
})
200+
}
201+
}
202+
203+
func TestResolveFromMetadataAcceptsSafeURL(t *testing.T) {
204+
want := "https://files.pythonhosted.org/packages/ab/cd/pkg-1.0.tar.gz"
205+
reg := &fakeRegistry{
206+
versions: []registries.Version{{
207+
Number: "1.0.0",
208+
Integrity: "sha256-deadbeef",
209+
Metadata: map[string]any{"download_url": want},
210+
}},
211+
}
212+
r := NewResolver()
213+
r.RegisterRegistry(reg)
214+
215+
info, err := r.Resolve(context.Background(), "fake", "pkg", "1.0.0")
216+
if err != nil {
217+
t.Fatalf("Resolve failed: %v", err)
218+
}
219+
if info.URL != want {
220+
t.Errorf("URL = %q, want %q", info.URL, want)
221+
}
222+
if info.Integrity != "sha256-deadbeef" {
223+
t.Errorf("Integrity = %q, want sha256-deadbeef", info.Integrity)
224+
}
225+
if info.Filename != "pkg-1.0.tar.gz" {
226+
t.Errorf("Filename = %q, want pkg-1.0.tar.gz", info.Filename)
227+
}
228+
}
229+
121230
func TestFilenameFromURL(t *testing.T) {
122231
tests := []struct {
123232
url string

0 commit comments

Comments
 (0)