0ae798d24f
* fix(installer): confine tarball extraction with os.Root Lexical containment of tar entry names is not enough on its own: an archive can ship a symlink whose target passes the check (`a` -> `.` resolves to destDir itself) and then write through it with a later entry (`a/x` -> `..`) that really lands outside destDir. Confirmed by extracting a crafted tarball — it wrote a file into destDir's parent. Anchor extraction at an os.Root opened on destDir so the OS-level path walk refuses to follow a link out of the tree, and keep the lexical checks for clear up-front errors. Adds a regression test for the two-hop symlink chain. Fixes CodeQL alert #19 (go/zipslip). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(installer): reject archive symlinks under symlinked parents Root.Symlink follows symlinked parent components, so an entry named "a/x" is created at "x" when "a" is a symlink to ".". The link still lands inside destDir, but one level shallower than its archive path — which changes what a relative target means. "a/x" -> ".." passes a check computed against parent "a" and then resolves to destDir's parent from its real home, leaving an escaping symlink in the install tree. Nothing can be written through it during extraction (os.Root refuses to follow it), but it persists for any later consumer that walks the tree with ordinary os calls. Require every parent component of a symlink entry to be a real directory, so an entry's archive path and its on-disk location agree — the assumption the existing target check already depended on. Reported by the Codex reviewer on #1934. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Kandev Agent <agent@kandev.dev> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
567 lines
15 KiB
Go
567 lines
15 KiB
Go
package installer
|
|
|
|
import (
|
|
"archive/tar"
|
|
"bytes"
|
|
"compress/gzip"
|
|
"errors"
|
|
"io"
|
|
"io/fs"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
type roundTripFunc func(*http.Request) (*http.Response, error)
|
|
|
|
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
|
return f(req)
|
|
}
|
|
|
|
func stubGithubTarballDownload(t *testing.T, archive []byte) func() int {
|
|
t.Helper()
|
|
requestCount := 0
|
|
originalClient := http.DefaultClient
|
|
http.DefaultClient = &http.Client{Transport: roundTripFunc(func(_ *http.Request) (*http.Response, error) {
|
|
requestCount++
|
|
return &http.Response{
|
|
StatusCode: http.StatusOK,
|
|
Body: io.NopCloser(bytes.NewReader(archive)),
|
|
Header: make(http.Header),
|
|
}, nil
|
|
})}
|
|
t.Cleanup(func() { http.DefaultClient = originalClient })
|
|
return func() int { return requestCount }
|
|
}
|
|
|
|
func TestGithubTarballStrategyInstallRepairsPartialInstall(t *testing.T) {
|
|
installDir := t.TempDir()
|
|
target := runtime.GOOS + "-" + runtime.GOARCH
|
|
binaryPath := filepath.Join(installDir, "tool-1.0.0-"+target, "bin", "tool")
|
|
if err := os.MkdirAll(filepath.Dir(binaryPath), 0o755); err != nil {
|
|
t.Fatalf("create partial install: %v", err)
|
|
}
|
|
if err := os.WriteFile(binaryPath, []byte("partial"), 0o755); err != nil {
|
|
t.Fatalf("write partial binary: %v", err)
|
|
}
|
|
|
|
archive := tarGzWithFiles(t, map[string]string{
|
|
"tool-1.0.0-" + target + "/bin/tool": "complete",
|
|
"tool-1.0.0-" + target + "/lib/runtime": "runtime",
|
|
})
|
|
requestCount := stubGithubTarballDownload(t, archive)
|
|
|
|
strategy := NewGithubTarballStrategy(installDir, "tool", GithubTarballConfig{
|
|
Owner: "owner",
|
|
Repo: "repo",
|
|
Version: "1.0.0",
|
|
AssetPattern: "tool-{version}-{os}-{arch}.tar.gz",
|
|
BinaryPath: "tool-{version}-{os}-{arch}/bin/tool",
|
|
Targets: map[string]string{
|
|
runtime.GOOS + "/" + runtime.GOARCH: target,
|
|
},
|
|
}, testLogger())
|
|
|
|
if _, err := strategy.Install(t.Context()); err != nil {
|
|
t.Fatalf("Install() error = %v", err)
|
|
}
|
|
if requestCount() != 1 {
|
|
t.Fatalf("download request count = %d, want 1", requestCount())
|
|
}
|
|
if _, err := os.Stat(filepath.Join(installDir, "tool-1.0.0-"+target, "lib", "runtime")); err != nil {
|
|
t.Fatalf("partial install was not repaired: %v", err)
|
|
}
|
|
|
|
if _, err := strategy.Install(t.Context()); err != nil {
|
|
t.Fatalf("second Install() error = %v", err)
|
|
}
|
|
if requestCount() != 1 {
|
|
t.Fatalf("download request count after completed install = %d, want 1", requestCount())
|
|
}
|
|
}
|
|
|
|
func TestGithubTarballStrategyInstallPreservesMissingBinaryError(t *testing.T) {
|
|
installDir := t.TempDir()
|
|
target := runtime.GOOS + "-" + runtime.GOARCH
|
|
archive := tarGzWithFiles(t, map[string]string{
|
|
"tool-1.0.0-" + target + "/lib/runtime": "runtime",
|
|
})
|
|
stubGithubTarballDownload(t, archive)
|
|
|
|
strategy := NewGithubTarballStrategy(installDir, "tool", GithubTarballConfig{
|
|
Owner: "owner",
|
|
Repo: "repo",
|
|
Version: "1.0.0",
|
|
AssetPattern: "tool-{version}-{os}-{arch}.tar.gz",
|
|
BinaryPath: "tool-{version}-{os}-{arch}/bin/tool",
|
|
Targets: map[string]string{
|
|
runtime.GOOS + "/" + runtime.GOARCH: target,
|
|
},
|
|
}, testLogger())
|
|
|
|
_, err := strategy.Install(t.Context())
|
|
if !errors.Is(err, fs.ErrNotExist) {
|
|
t.Fatalf("Install() error = %v, want wrapped fs.ErrNotExist", err)
|
|
}
|
|
}
|
|
|
|
func TestGithubTarballStrategyInstallReportsCacheInspectionError(t *testing.T) {
|
|
installDir := t.TempDir()
|
|
target := runtime.GOOS + "-" + runtime.GOARCH
|
|
binaryPath := filepath.Join(installDir, "tool-1.0.0-"+target, "bin", "tool")
|
|
if err := os.MkdirAll(filepath.Dir(binaryPath), 0o755); err != nil {
|
|
t.Fatalf("create install directory: %v", err)
|
|
}
|
|
if err := os.Symlink("tool", binaryPath); err != nil {
|
|
t.Fatalf("create symlink loop: %v", err)
|
|
}
|
|
|
|
strategy := NewGithubTarballStrategy(installDir, "tool", GithubTarballConfig{
|
|
Owner: "owner",
|
|
Repo: "repo",
|
|
Version: "1.0.0",
|
|
AssetPattern: "tool-{version}-{os}-{arch}.tar.gz",
|
|
BinaryPath: "tool-{version}-{os}-{arch}/bin/tool",
|
|
Targets: map[string]string{
|
|
runtime.GOOS + "/" + runtime.GOARCH: target,
|
|
},
|
|
}, testLogger())
|
|
|
|
_, err := strategy.Install(t.Context())
|
|
if err == nil || !strings.Contains(err.Error(), "failed to inspect installed binary") {
|
|
t.Fatalf("Install() error = %v, want cache inspection error", err)
|
|
}
|
|
}
|
|
|
|
func tarGzWithFiles(t *testing.T, files map[string]string) []byte {
|
|
t.Helper()
|
|
|
|
var buf bytes.Buffer
|
|
gzWriter := gzip.NewWriter(&buf)
|
|
tarWriter := tar.NewWriter(gzWriter)
|
|
for name, content := range files {
|
|
if err := tarWriter.WriteHeader(&tar.Header{
|
|
Name: name,
|
|
Mode: 0o755,
|
|
Size: int64(len(content)),
|
|
}); err != nil {
|
|
t.Fatalf("write tar header: %v", err)
|
|
}
|
|
if _, err := tarWriter.Write([]byte(content)); err != nil {
|
|
t.Fatalf("write tar content: %v", err)
|
|
}
|
|
}
|
|
if err := tarWriter.Close(); err != nil {
|
|
t.Fatalf("close tar writer: %v", err)
|
|
}
|
|
if err := gzWriter.Close(); err != nil {
|
|
t.Fatalf("close gzip writer: %v", err)
|
|
}
|
|
return buf.Bytes()
|
|
}
|
|
|
|
func TestSanitizeTarPath(t *testing.T) {
|
|
destDir := "/install"
|
|
|
|
tests := []struct {
|
|
name string
|
|
path string
|
|
wantErr bool
|
|
wantClean string
|
|
}{
|
|
{
|
|
name: "simple path",
|
|
path: "code-server/bin/code-server",
|
|
wantErr: false,
|
|
wantClean: "code-server/bin/code-server",
|
|
},
|
|
{
|
|
name: "nested path",
|
|
path: "code-server-4.96.4-macos-arm64/bin/code-server",
|
|
wantErr: false,
|
|
wantClean: "code-server-4.96.4-macos-arm64/bin/code-server",
|
|
},
|
|
{
|
|
name: "path traversal with dotdot",
|
|
path: "../etc/passwd",
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "absolute path",
|
|
path: "/etc/passwd",
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "current directory",
|
|
path: ".",
|
|
wantErr: false,
|
|
wantClean: ".",
|
|
},
|
|
{
|
|
name: "hidden traversal via dotdot in middle",
|
|
path: "foo/../../etc/passwd",
|
|
wantErr: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
clean, err := sanitizeTarPath(tt.path, destDir)
|
|
if tt.wantErr {
|
|
if err == nil {
|
|
t.Errorf("expected error for path %q, got nil", tt.path)
|
|
}
|
|
return
|
|
}
|
|
if err != nil {
|
|
t.Errorf("unexpected error for path %q: %v", tt.path, err)
|
|
return
|
|
}
|
|
if clean != tt.wantClean {
|
|
t.Errorf("expected clean path %q, got %q", tt.wantClean, clean)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestExpandTemplate(t *testing.T) {
|
|
s := &GithubTarballStrategy{
|
|
config: GithubTarballConfig{
|
|
Version: "4.96.4",
|
|
},
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
tmpl string
|
|
target string
|
|
expected string
|
|
}{
|
|
{
|
|
name: "asset pattern",
|
|
tmpl: "code-server-{version}-{os}-{arch}.tar.gz",
|
|
target: "macos-arm64",
|
|
expected: "code-server-4.96.4-macos-arm64.tar.gz",
|
|
},
|
|
{
|
|
name: "binary path pattern",
|
|
tmpl: "code-server-{version}-{os}-{arch}/bin/code-server",
|
|
target: "linux-amd64",
|
|
expected: "code-server-4.96.4-linux-amd64/bin/code-server",
|
|
},
|
|
{
|
|
name: "no placeholders",
|
|
tmpl: "binary",
|
|
target: "linux-amd64",
|
|
expected: "binary",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
result := s.expandTemplate(tt.tmpl, tt.target)
|
|
if result != tt.expected {
|
|
t.Errorf("expected %q, got %q", tt.expected, result)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestExtractTarGz(t *testing.T) {
|
|
// Create a tar.gz archive in memory with a file and a directory
|
|
var buf bytes.Buffer
|
|
gzWriter := gzip.NewWriter(&buf)
|
|
tarWriter := tar.NewWriter(gzWriter)
|
|
|
|
// Add a directory
|
|
_ = tarWriter.WriteHeader(&tar.Header{
|
|
Name: "myapp/",
|
|
Typeflag: tar.TypeDir,
|
|
Mode: 0o755,
|
|
})
|
|
|
|
// Add a file
|
|
content := []byte("#!/bin/sh\necho hello")
|
|
_ = tarWriter.WriteHeader(&tar.Header{
|
|
Name: "myapp/bin/hello",
|
|
Typeflag: tar.TypeReg,
|
|
Mode: 0o755,
|
|
Size: int64(len(content)),
|
|
})
|
|
_, _ = tarWriter.Write(content)
|
|
|
|
_ = tarWriter.Close()
|
|
_ = gzWriter.Close()
|
|
|
|
// Extract into temp dir
|
|
destDir := t.TempDir()
|
|
if err := extractTarGz(&buf, destDir); err != nil {
|
|
t.Fatalf("extractTarGz failed: %v", err)
|
|
}
|
|
|
|
// Verify the file was extracted
|
|
extractedPath := filepath.Join(destDir, "myapp", "bin", "hello")
|
|
data, err := os.ReadFile(extractedPath)
|
|
if err != nil {
|
|
t.Fatalf("failed to read extracted file: %v", err)
|
|
}
|
|
if string(data) != string(content) {
|
|
t.Errorf("expected %q, got %q", string(content), string(data))
|
|
}
|
|
}
|
|
|
|
func TestExtractTarGz_RejectsTraversal(t *testing.T) {
|
|
var buf bytes.Buffer
|
|
gzWriter := gzip.NewWriter(&buf)
|
|
tarWriter := tar.NewWriter(gzWriter)
|
|
|
|
// Add a malicious entry
|
|
content := []byte("malicious")
|
|
_ = tarWriter.WriteHeader(&tar.Header{
|
|
Name: "../etc/passwd",
|
|
Typeflag: tar.TypeReg,
|
|
Mode: 0o644,
|
|
Size: int64(len(content)),
|
|
})
|
|
_, _ = tarWriter.Write(content)
|
|
|
|
_ = tarWriter.Close()
|
|
_ = gzWriter.Close()
|
|
|
|
destDir := t.TempDir()
|
|
err := extractTarGz(&buf, destDir)
|
|
if err == nil {
|
|
t.Error("expected error for path traversal, got nil")
|
|
}
|
|
}
|
|
|
|
func TestExtractTarGz_SymlinkEscapeBlocked(t *testing.T) {
|
|
var buf bytes.Buffer
|
|
gzWriter := gzip.NewWriter(&buf)
|
|
tarWriter := tar.NewWriter(gzWriter)
|
|
|
|
// Add a symlink that escapes the dest dir
|
|
_ = tarWriter.WriteHeader(&tar.Header{
|
|
Name: "escape",
|
|
Typeflag: tar.TypeSymlink,
|
|
Linkname: "../../../../etc/passwd",
|
|
})
|
|
|
|
_ = tarWriter.Close()
|
|
_ = gzWriter.Close()
|
|
|
|
destDir := t.TempDir()
|
|
err := extractTarGz(&buf, destDir)
|
|
if err == nil {
|
|
t.Error("expected error for symlink escape, got nil")
|
|
}
|
|
}
|
|
|
|
func TestExtractTarGz_SymlinkWithRelativeDotDot(t *testing.T) {
|
|
// Legitimate relative symlinks using ".." (like npm .bin links) should be allowed
|
|
// as long as the resolved target stays within destDir.
|
|
var buf bytes.Buffer
|
|
gzWriter := gzip.NewWriter(&buf)
|
|
tarWriter := tar.NewWriter(gzWriter)
|
|
|
|
// Create directory structure: pkg/node_modules/.bin/ and pkg/node_modules/semver/bin/
|
|
for _, dir := range []string{
|
|
"pkg/node_modules/.bin/",
|
|
"pkg/node_modules/semver/bin/",
|
|
} {
|
|
_ = tarWriter.WriteHeader(&tar.Header{
|
|
Name: dir,
|
|
Typeflag: tar.TypeDir,
|
|
Mode: 0o755,
|
|
})
|
|
}
|
|
|
|
// Create the target file
|
|
content := []byte("#!/bin/sh\necho semver")
|
|
_ = tarWriter.WriteHeader(&tar.Header{
|
|
Name: "pkg/node_modules/semver/bin/semver.js",
|
|
Typeflag: tar.TypeReg,
|
|
Mode: 0o755,
|
|
Size: int64(len(content)),
|
|
})
|
|
_, _ = tarWriter.Write(content)
|
|
|
|
// Create symlink: .bin/semver -> ../semver/bin/semver.js (stays within destDir)
|
|
_ = tarWriter.WriteHeader(&tar.Header{
|
|
Name: "pkg/node_modules/.bin/semver",
|
|
Typeflag: tar.TypeSymlink,
|
|
Linkname: "../semver/bin/semver.js",
|
|
})
|
|
|
|
_ = tarWriter.Close()
|
|
_ = gzWriter.Close()
|
|
|
|
destDir := t.TempDir()
|
|
if err := extractTarGz(&buf, destDir); err != nil {
|
|
t.Fatalf("extractTarGz should allow relative '..' symlinks within destDir, got: %v", err)
|
|
}
|
|
|
|
// Verify the symlink was created
|
|
linkPath := filepath.Join(destDir, "pkg/node_modules/.bin/semver")
|
|
linkTarget, err := os.Readlink(linkPath)
|
|
if err != nil {
|
|
t.Fatalf("failed to read symlink: %v", err)
|
|
}
|
|
if linkTarget != "../semver/bin/semver.js" {
|
|
t.Errorf("expected symlink target '../semver/bin/semver.js', got %q", linkTarget)
|
|
}
|
|
}
|
|
|
|
func TestExtractTarGz_SymlinkDotDotEscapeBlocked(t *testing.T) {
|
|
// A ".." symlink that escapes destDir must still be blocked by the resolved-path check.
|
|
var buf bytes.Buffer
|
|
gzWriter := gzip.NewWriter(&buf)
|
|
tarWriter := tar.NewWriter(gzWriter)
|
|
|
|
_ = tarWriter.WriteHeader(&tar.Header{
|
|
Name: "pkg/",
|
|
Typeflag: tar.TypeDir,
|
|
Mode: 0o755,
|
|
})
|
|
|
|
// Symlink that uses ".." to escape the destDir
|
|
_ = tarWriter.WriteHeader(&tar.Header{
|
|
Name: "pkg/escape",
|
|
Typeflag: tar.TypeSymlink,
|
|
Linkname: "../../etc/passwd",
|
|
})
|
|
|
|
_ = tarWriter.Close()
|
|
_ = gzWriter.Close()
|
|
|
|
destDir := t.TempDir()
|
|
err := extractTarGz(&buf, destDir)
|
|
if err == nil {
|
|
t.Error("expected error for '..' symlink escaping destDir, got nil")
|
|
}
|
|
}
|
|
|
|
func TestExtractTarGz_WriteThroughSymlinkChainBlocked(t *testing.T) {
|
|
// Lexical containment alone lets an archive escape in two hops: "a" -> "."
|
|
// resolves to destDir itself (contained), so "a/x" -> ".." *looks* like it
|
|
// lands on destDir while it really lands on destDir's parent. A later entry
|
|
// written through a/x then escapes. Extraction must refuse the write.
|
|
var buf bytes.Buffer
|
|
gzWriter := gzip.NewWriter(&buf)
|
|
tarWriter := tar.NewWriter(gzWriter)
|
|
|
|
_ = tarWriter.WriteHeader(&tar.Header{
|
|
Name: "a",
|
|
Typeflag: tar.TypeSymlink,
|
|
Linkname: ".",
|
|
})
|
|
_ = tarWriter.WriteHeader(&tar.Header{
|
|
Name: "a/x",
|
|
Typeflag: tar.TypeSymlink,
|
|
Linkname: "..",
|
|
})
|
|
content := []byte("PWNED")
|
|
_ = tarWriter.WriteHeader(&tar.Header{
|
|
Name: "a/x/pwned.txt",
|
|
Typeflag: tar.TypeReg,
|
|
Mode: 0o644,
|
|
Size: int64(len(content)),
|
|
})
|
|
_, _ = tarWriter.Write(content)
|
|
|
|
_ = tarWriter.Close()
|
|
_ = gzWriter.Close()
|
|
|
|
parent := t.TempDir()
|
|
destDir := filepath.Join(parent, "install")
|
|
if err := os.MkdirAll(destDir, 0o755); err != nil {
|
|
t.Fatalf("failed to create destDir: %v", err)
|
|
}
|
|
|
|
if err := extractTarGz(&buf, destDir); err == nil {
|
|
t.Error("expected error for write through an escaping symlink chain, got nil")
|
|
}
|
|
|
|
if _, err := os.Lstat(filepath.Join(parent, "pwned.txt")); !os.IsNotExist(err) {
|
|
t.Errorf("archive escaped destDir: pwned.txt written to %s (lstat err: %v)", parent, err)
|
|
}
|
|
}
|
|
|
|
func TestExtractTarGz_SymlinkUnderSymlinkedParentRejected(t *testing.T) {
|
|
// A symlink entry whose parent is itself a symlink lands one level
|
|
// shallower than its archive path, so a relative target validated against
|
|
// the archive path resolves somewhere else on disk: "a/x" -> ".." checks
|
|
// out against parent "a" but really points at destDir's parent once it is
|
|
// created at "x". Nothing can be written through it (os.Root refuses to
|
|
// follow it), but it must not be left in the install tree either.
|
|
var buf bytes.Buffer
|
|
gzWriter := gzip.NewWriter(&buf)
|
|
tarWriter := tar.NewWriter(gzWriter)
|
|
|
|
_ = tarWriter.WriteHeader(&tar.Header{
|
|
Name: "a",
|
|
Typeflag: tar.TypeSymlink,
|
|
Linkname: ".",
|
|
})
|
|
_ = tarWriter.WriteHeader(&tar.Header{
|
|
Name: "a/x",
|
|
Typeflag: tar.TypeSymlink,
|
|
Linkname: "..",
|
|
})
|
|
|
|
_ = tarWriter.Close()
|
|
_ = gzWriter.Close()
|
|
|
|
parent := t.TempDir()
|
|
destDir := filepath.Join(parent, "install")
|
|
if err := os.MkdirAll(destDir, 0o755); err != nil {
|
|
t.Fatalf("failed to create destDir: %v", err)
|
|
}
|
|
|
|
if err := extractTarGz(&buf, destDir); err == nil {
|
|
t.Error("expected error for symlink created under a symlinked parent, got nil")
|
|
}
|
|
|
|
// The link must not exist at its real (shallower) location either.
|
|
shallow := filepath.Join(destDir, "x")
|
|
if _, err := os.Lstat(shallow); !os.IsNotExist(err) {
|
|
target, _ := os.Readlink(shallow)
|
|
t.Errorf("escaping symlink left in install tree: %s -> %q (lstat err: %v)", shallow, target, err)
|
|
}
|
|
}
|
|
|
|
func TestResolveTarget_Unsupported(t *testing.T) {
|
|
s := &GithubTarballStrategy{
|
|
config: GithubTarballConfig{
|
|
Targets: map[string]string{
|
|
"linux/amd64": "linux-amd64",
|
|
},
|
|
},
|
|
}
|
|
// resolveTarget uses runtime.GOOS/GOARCH — if the test platform isn't in Targets,
|
|
// it should return an error. We can't guarantee this, so just verify the method works.
|
|
_, err := s.resolveTarget()
|
|
// The result depends on the test platform; just verify no panic.
|
|
_ = err
|
|
}
|
|
|
|
func TestBuildURL(t *testing.T) {
|
|
s := &GithubTarballStrategy{
|
|
config: GithubTarballConfig{
|
|
Owner: "coder",
|
|
Repo: "code-server",
|
|
Version: "4.96.4",
|
|
AssetPattern: "code-server-{version}-{os}-{arch}.tar.gz",
|
|
},
|
|
}
|
|
|
|
url := s.buildURL("macos-arm64")
|
|
expected := "https://github.com/coder/code-server/releases/download/v4.96.4/code-server-4.96.4-macos-arm64.tar.gz"
|
|
if url != expected {
|
|
t.Errorf("expected %q, got %q", expected, url)
|
|
}
|
|
}
|