hashicorp/nomad · critical · ErrSandboxEscape

artifact includes symlink that resolves outside of sandbox

Error message

artifact includes symlink that resolves outside of sandbox

What it means

Nomad's artifact getter runs downloads inside a sandbox rooted at the task's allocation directory. After extraction, it verifies every symlink target stays inside that sandbox root; if any symlink resolves outside it, the getter aborts and returns the exported sentinel ErrSandboxEscape ('artifact includes symlink that resolves outside of sandbox'). This prevents a malicious artifact from escaping the chroot-like sandbox and reading or overwriting host files.

Source

Thrown at client/allocrunner/taskrunner/getter/util.go:33

	"path/filepath"
	"runtime"
	"sort"
	"strings"
	"unicode"

	"github.com/hashicorp/go-getter"
	"github.com/hashicorp/nomad/client/interfaces"
	"github.com/hashicorp/nomad/helper/subproc"
	"github.com/hashicorp/nomad/helper/users"
	"github.com/hashicorp/nomad/nomad/structs"
)

const (
	// githubPrefixSSH is the prefix for downloading via git using ssh from GitHub.
	githubPrefixSSH = "git@github.com:"
)

var ErrSandboxEscape = errors.New("artifact includes symlink that resolves outside of sandbox")

func getURL(taskEnv interfaces.EnvReplacer, artifact *structs.TaskArtifact) (string, error) {
	source := taskEnv.ReplaceEnv(artifact.GetterSource)

	// fixup GitHub SSH URL such as git@github.com:hashicorp/nomad.git
	gitSSH := false
	if strings.HasPrefix(source, githubPrefixSSH) {
		gitSSH = true
		source = source[len(githubPrefixSSH):]
	}

	u, err := url.Parse(source)
	if err != nil {
		return "", &Error{
			URL:         artifact.GetterSource,
			Err:         fmt.Errorf("failed to parse source URL %q: %v", artifact.GetterSource, err),
			Recoverable: false,
		}

View on GitHub (pinned to 482b49bf1a)

Solutions

  1. Treat the artifact source as untrusted/compromised: remove or rebuild the artifact so it contains no symlinks resolving outside its own root
  2. Use retries: false plus the artifact's optional/chroot settings appropriately, and re-download from a trusted mirror
  3. If the symlink is legitimate and points outside, restructure the artifact (relative in-sandbox links) or split it into multiple artifact stanzas
  4. Upgrade Nomad if you believe a safe archive is being flagged — sandbox link validation has had hardening fixes across versions

Example fix

// before: artifact references a file outside the sandbox via symlink
source = "https://example.com/bad.tar.gz" // contains symlink /etc/passwd -> ../../etc/passwd
// after: rebuild artifact with in-root relative symlinks only
tar -czf good.tar.gz --transform 's|^/etc/passwd|./passwd|' app/
// and verify before publishing:
tar -tvzf good.tar.gz | grep -E '^l'  # inspect every link target
Defensive patterns

Strategy: try-catch

Validate before calling

// Go: pre-scan an untrusted archive before handing it to the getter
func archiveHasSuspiciousLinks(tarPath string) error {
	r, _ := os.Open(tarPath); defer r.Close()
	return tar.NewReader(r).Iterate(func(h *tar.Header) error {
		if h.Typeflag == tar.TypeSymlink {
			target := filepath.Join(filepath.Dir(h.Name), h.Linkname)
			if !strings.HasPrefix(filepath.Clean(target), "./") {
				return fmt.Errorf("symlink %s -> %s escapes artifact root", h.Name, h.Linkname)
			}
		}
		return nil
	})
}

Type guard

// Sentinel check helper
func IsSandboxEscape(err error) bool {
	return errors.Is(err, getter.ErrSandboxEscape)
}

Try / catch

// Go has no try/catch; use errors.Is on the exported sentinel
if err := sbox.Get(env, artifact, "nobody"); err != nil {
	if errors.Is(err, ErrSandboxEscape) {
		// quarantine artifact, alert, do not retry blindly
		return fmt.Errorf("artifact %q rejected: %w", artifact.GetterSource, err)
	}
	return err
}

Prevention

When it happens

Trigger: Calling sbox.Get(env, artifact, user) (or the getter pipeline it backs) where the downloaded archive (tar/zip/git repo) contains a symlink or hardlink whose resolved target is not within the sandbox directory — e.g. a link to /etc/passwd, ../.., or an absolute path outside the alloc dir. The check fires in util.go:474 when isWithin is false during post-extraction link verification.

Common situations: Consuming a third-party artifact (tarball, git repo, GitHub release) that contains malicious or careless symlinks — e.g. build outputs linking to absolute paths on the build machine, or a deliberately weaponized archive (symlink-traversal attack). Also seen when an artifact was repackaged preserving absolute symlinks.

Related errors


AI-assisted analysis of hashicorp/nomad@482b49bf1a (2026-09-04). Data as JSON: /api/errors/64aca86005c1b4a2. Report an issue: GitHub.