{"record":{"id":"64aca86005c1b4a2","repo":"hashicorp/nomad","slug":"artifact-includes-symlink-that-resolves-outside-of","errorCode":null,"errorMessage":"artifact includes symlink that resolves outside of sandbox","messagePattern":"artifact includes symlink that resolves outside of sandbox","errorType":"exception","errorClass":"ErrSandboxEscape","httpStatus":null,"severity":"critical","filePath":"client/allocrunner/taskrunner/getter/util.go","lineNumber":33,"sourceCode":"\t\"path/filepath\"\n\t\"runtime\"\n\t\"sort\"\n\t\"strings\"\n\t\"unicode\"\n\n\t\"github.com/hashicorp/go-getter\"\n\t\"github.com/hashicorp/nomad/client/interfaces\"\n\t\"github.com/hashicorp/nomad/helper/subproc\"\n\t\"github.com/hashicorp/nomad/helper/users\"\n\t\"github.com/hashicorp/nomad/nomad/structs\"\n)\n\nconst (\n\t// githubPrefixSSH is the prefix for downloading via git using ssh from GitHub.\n\tgithubPrefixSSH = \"git@github.com:\"\n)\n\nvar ErrSandboxEscape = errors.New(\"artifact includes symlink that resolves outside of sandbox\")\n\nfunc getURL(taskEnv interfaces.EnvReplacer, artifact *structs.TaskArtifact) (string, error) {\n\tsource := taskEnv.ReplaceEnv(artifact.GetterSource)\n\n\t// fixup GitHub SSH URL such as git@github.com:hashicorp/nomad.git\n\tgitSSH := false\n\tif strings.HasPrefix(source, githubPrefixSSH) {\n\t\tgitSSH = true\n\t\tsource = source[len(githubPrefixSSH):]\n\t}\n\n\tu, err := url.Parse(source)\n\tif err != nil {\n\t\treturn \"\", &Error{\n\t\t\tURL:         artifact.GetterSource,\n\t\t\tErr:         fmt.Errorf(\"failed to parse source URL %q: %v\", artifact.GetterSource, err),\n\t\t\tRecoverable: false,\n\t\t}","sourceCodeStart":15,"sourceCodeEnd":51,"githubUrl":"https://github.com/hashicorp/nomad/blob/482b49bf1aec006f089bcfc7e632d8f6ac303e5e/client/allocrunner/taskrunner/getter/util.go#L15-L51","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","solutions":["Treat the artifact source as untrusted/compromised: remove or rebuild the artifact so it contains no symlinks resolving outside its own root","Use retries: false plus the artifact's optional/chroot settings appropriately, and re-download from a trusted mirror","If the symlink is legitimate and points outside, restructure the artifact (relative in-sandbox links) or split it into multiple artifact stanzas","Upgrade Nomad if you believe a safe archive is being flagged — sandbox link validation has had hardening fixes across versions"],"exampleFix":"// before: artifact references a file outside the sandbox via symlink\nsource = \"https://example.com/bad.tar.gz\" // contains symlink /etc/passwd -> ../../etc/passwd\n// after: rebuild artifact with in-root relative symlinks only\ntar -czf good.tar.gz --transform 's|^/etc/passwd|./passwd|' app/\n// and verify before publishing:\ntar -tvzf good.tar.gz | grep -E '^l'  # inspect every link target","handlingStrategy":"try-catch","validationCode":"// Go: pre-scan an untrusted archive before handing it to the getter\nfunc archiveHasSuspiciousLinks(tarPath string) error {\n\tr, _ := os.Open(tarPath); defer r.Close()\n\treturn tar.NewReader(r).Iterate(func(h *tar.Header) error {\n\t\tif h.Typeflag == tar.TypeSymlink {\n\t\t\ttarget := filepath.Join(filepath.Dir(h.Name), h.Linkname)\n\t\t\tif !strings.HasPrefix(filepath.Clean(target), \"./\") {\n\t\t\t\treturn fmt.Errorf(\"symlink %s -> %s escapes artifact root\", h.Name, h.Linkname)\n\t\t\t}\n\t\t}\n\t\treturn nil\n\t})\n}","typeGuard":"// Sentinel check helper\nfunc IsSandboxEscape(err error) bool {\n\treturn errors.Is(err, getter.ErrSandboxEscape)\n}","tryCatchPattern":"// Go has no try/catch; use errors.Is on the exported sentinel\nif err := sbox.Get(env, artifact, \"nobody\"); err != nil {\n\tif errors.Is(err, ErrSandboxEscape) {\n\t\t// quarantine artifact, alert, do not retry blindly\n\t\treturn fmt.Errorf(\"artifact %q rejected: %w\", artifact.GetterSource, err)\n\t}\n\treturn err\n}","preventionTips":["Build artifacts with relative in-root symlinks only; never ship absolute links","Inspect archives (tar -tvf, unzip -l) for symlinks before publishing","Pin and verify artifact sources (checksums/signed releases) so tampered archives are caught","Run untrusted-artifact clients with minimal host privileges as defense in depth"],"tags":["security","sandbox","symlink","artifact"],"backgroundTag":"symlink-path-escape","analyzedSha":"482b49bf1aec006f089bcfc7e632d8f6ac303e5e","analyzedAt":"2026-09-04T07:54:14.808Z","contentChangedAt":"2026-09-04T07:54:14.808Z","schemaVersion":2},"datasetVersion":"2026-09-08T10:18:20.063Z"}