{"record":{"id":"66c5842e1ccf1824","repo":"sipeed/picoclaw","slug":"credential-file-path-escapes-config-directory","errorCode":null,"errorMessage":"credential: file:// path escapes config directory","messagePattern":"credential: file:// path escapes config directory","errorType":"validation","errorClass":null,"httpStatus":null,"severity":"error","filePath":"pkg/credential/credential.go","lineNumber":133,"sourceCode":"\n\tif strings.HasPrefix(raw, FileScheme) {\n\t\tfileName := strings.TrimSpace(strings.TrimPrefix(raw, FileScheme))\n\t\tif fileName == \"\" {\n\t\t\treturn \"\", fmt.Errorf(\"credential: file:// reference has no filename\")\n\t\t}\n\n\t\tbaseDir := r.resolvedConfigDir\n\t\tif baseDir == \"\" {\n\t\t\tbaseDir = r.configDir\n\t\t}\n\t\tkeyPath := filepath.Join(baseDir, fileName)\n\t\t// Resolve symlinks before enforcing containment to prevent escaping via symlinks.\n\t\trealKeyPath, err := filepath.EvalSymlinks(keyPath)\n\t\tif err != nil {\n\t\t\treturn \"\", fmt.Errorf(\"credential: failed to resolve credential file path %q: %w\", keyPath, err)\n\t\t}\n\t\tif !isWithinDir(realKeyPath, baseDir) {\n\t\t\treturn \"\", fmt.Errorf(\"credential: file:// path escapes config directory\")\n\t\t}\n\t\tdata, err := os.ReadFile(realKeyPath)\n\t\tif err != nil {\n\t\t\treturn \"\", fmt.Errorf(\"credential: failed to read credential file %q: %w\", realKeyPath, err)\n\t\t}\n\n\t\tvalue := strings.TrimSpace(string(data))\n\t\tif value == \"\" {\n\t\t\treturn \"\", fmt.Errorf(\"credential: credential file %q is empty\", realKeyPath)\n\t\t}\n\n\t\treturn value, nil\n\t}\n\n\tif strings.HasPrefix(raw, EncScheme) {\n\t\treturn resolveEncrypted(raw)\n\t}\n","sourceCodeStart":115,"sourceCodeEnd":151,"githubUrl":"https://github.com/sipeed/picoclaw/blob/49183d7e8daed0dba89ddbb6fcb60089401d9680/pkg/credential/credential.go#L115-L151","documentation":"Returned by Resolver.Resolve as a deliberate security guard: after resolving symlinks, the real path of the credential file is not inside the resolver's base config directory. Because EvalSymlinks ran first, this catches both lexical escapes (../ in the filename) and symlink-based escapes (a link inside configDir pointing outside). This is fail-closed: no credential is read, and the error is returned immediately.","triggerScenarios":"`file://../../etc/passwd` or `file://../shared/keys/openai.key` (lexical traversal after join+resolve lands outside baseDir); or a legit-looking `file://openai.key` where openai.key is a symlink to ~/.ssh/id_rsa or /run/secrets/... — EvalSymlinks exposes the real target outside the dir and the guard fires.","commonSituations":"Trying to reuse existing secrets (Docker/Kubernetes mounted at /run/secrets, ~/.ssh) by symlinking them into the config dir; sharing a keys directory between projects via ../; or hardening scans probing with traversal payloads. The design requires secrets to physically live inside the config dir.","solutions":["Copy (not symlink) the credential file into the config dir and reference it plainly: `file://openai.key`","If you need mounted secrets, have provisioning materialize the file inside the config dir (e.g. initContainer/sidecar copy step) rather than linking out","Remove any symlinks inside the config dir that point outside it — they will always be rejected","Never construct file:// values from user input without rejecting `..` and absolute-ish components first"],"exampleFix":"# before: symlink escape\nln -s /run/secrets/api_key config/openai.key   # always rejected\n\n# after: copy into config dir\ncp /run/secrets/api_key config/openai.key && chmod 600 config/openai.key","handlingStrategy":"validation","validationCode":"// Reject traversal before calling Resolve.\nfunc safeFileRef(filename string) error {\n\tfilename = strings.TrimSpace(filename)\n\tif filename == \"\" || strings.Contains(filename, \"..\") || filepath.IsAbs(filename) {\n\t\treturn fmt.Errorf(\"unsafe credential filename %q\", filename)\n\t}\n\treturn nil\n}","typeGuard":"func isContainedFilename(name string) bool {\n\tname = strings.TrimSpace(name)\n\treturn name != \"\" && !strings.Contains(name, \"..\") && !filepath.IsAbs(name)\n}","tryCatchPattern":"if _, err := resolver.Resolve(raw); err != nil {\n\tif strings.Contains(err.Error(), \"escapes config directory\") {\n\t\t// never retry with sanitized input automatically; surface for human review (security)\n\t}\n\treturn err\n}","preventionTips":["Never symlink secrets from outside into the config dir — copy them","Treat an escapes-config-directory error as a security finding, not a transient failure","Provision mounted secrets by copying into the config dir at deploy time"],"tags":["go","credentials","security","path-traversal","file-scheme"],"backgroundTag":null,"analyzedSha":"49183d7e8daed0dba89ddbb6fcb60089401d9680","analyzedAt":"2026-08-15T21:55:41.315Z","schemaVersion":2},"datasetVersion":"2026-08-15T22:17:37.221Z"}