fix: harden codex home boundaries
This commit is contained in:
@@ -2,6 +2,7 @@ package codexhome
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
@@ -31,9 +32,22 @@ func ResolveInside(home string, rel string) (string, error) {
|
||||
if IsForbidden(candidate, cleanHome) {
|
||||
return "", ErrForbiddenPath
|
||||
}
|
||||
if err := rejectSymlinkEscape(cleanHome, candidate); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return candidate, nil
|
||||
}
|
||||
|
||||
func ResolveAgentTOML(home string, fileName string) (string, error) {
|
||||
if filepath.IsAbs(fileName) || filepath.Dir(fileName) != "." || fileName == "" {
|
||||
return "", ErrOutsideCodexHome
|
||||
}
|
||||
if !strings.HasSuffix(strings.ToLower(fileName), ".toml") {
|
||||
return "", ErrForbiddenPath
|
||||
}
|
||||
return ResolveInside(home, filepath.Join("agents", fileName))
|
||||
}
|
||||
|
||||
func IsForbidden(path string, home string) bool {
|
||||
cleanHome, err := filepath.Abs(home)
|
||||
if err != nil {
|
||||
@@ -47,9 +61,73 @@ func IsForbidden(path string, home string) bool {
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
rel = filepath.ToSlash(rel)
|
||||
rel = strings.ToLower(filepath.ToSlash(rel))
|
||||
forbidden := map[string]bool{
|
||||
"auth.json": true,
|
||||
}
|
||||
return forbidden[rel]
|
||||
}
|
||||
|
||||
func rejectSymlinkEscape(home string, candidate string) error {
|
||||
evaluatedHome, err := evalExistingPath(home)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel, err := filepath.Rel(home, candidate)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rel == "." {
|
||||
return nil
|
||||
}
|
||||
currentLexical := home
|
||||
currentEvaluated := evaluatedHome
|
||||
for _, part := range strings.Split(rel, string(filepath.Separator)) {
|
||||
currentLexical = filepath.Join(currentLexical, part)
|
||||
currentEvaluated = filepath.Join(currentEvaluated, part)
|
||||
info, err := os.Lstat(currentLexical)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
continue
|
||||
}
|
||||
return err
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
currentEvaluated, err = filepath.EvalSymlinks(currentLexical)
|
||||
if err != nil {
|
||||
return ErrOutsideCodexHome
|
||||
}
|
||||
}
|
||||
if !isInside(evaluatedHome, currentEvaluated) {
|
||||
return ErrOutsideCodexHome
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func evalExistingPath(path string) (string, error) {
|
||||
evaluated, err := filepath.EvalSymlinks(path)
|
||||
if err == nil {
|
||||
return evaluated, nil
|
||||
}
|
||||
if !os.IsNotExist(err) {
|
||||
return "", err
|
||||
}
|
||||
parent := filepath.Dir(path)
|
||||
if parent == path {
|
||||
return filepath.Abs(path)
|
||||
}
|
||||
evaluatedParent, err := evalExistingPath(parent)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(evaluatedParent, filepath.Base(path)), nil
|
||||
}
|
||||
|
||||
func isInside(home string, path string) bool {
|
||||
rel, err := filepath.Rel(home, path)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)))
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package codexhome
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
@@ -25,6 +26,25 @@ func TestResolveInsideCodexHomeRejectsTraversal(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveInsideCodexHomeRejectsSymlinkEscape(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
home := filepath.Join(root, ".codex")
|
||||
external := filepath.Join(root, "external")
|
||||
if err := os.MkdirAll(home, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(external, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(external, filepath.Join(home, "agents")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := ResolveInside(home, "agents/x.toml")
|
||||
if err == nil {
|
||||
t.Fatal("expected symlink escape to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsForbiddenPathBlocksAuthJSON(t *testing.T) {
|
||||
home := filepath.Join(t.TempDir(), ".codex")
|
||||
path := filepath.Join(home, "auth.json")
|
||||
@@ -32,3 +52,42 @@ func TestIsForbiddenPathBlocksAuthJSON(t *testing.T) {
|
||||
t.Fatal("auth.json must be forbidden")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveInsideCodexHomeBlocksAuthJSONCaseInsensitive(t *testing.T) {
|
||||
home := filepath.Join(t.TempDir(), ".codex")
|
||||
_, err := ResolveInside(home, "AUTH.JSON")
|
||||
if err == nil {
|
||||
t.Fatal("AUTH.JSON must be forbidden")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAgentTOMLAllowsDirectAgentToml(t *testing.T) {
|
||||
home := filepath.Join(t.TempDir(), ".codex")
|
||||
got, err := ResolveAgentTOML(home, "product-manager.toml")
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveAgentTOML returned error: %v", err)
|
||||
}
|
||||
want := filepath.Join(home, "agents", "product-manager.toml")
|
||||
if got != want {
|
||||
t.Fatalf("path mismatch: got %q want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAgentTOMLRejectsUnsafeNames(t *testing.T) {
|
||||
tests := []string{
|
||||
"../auth.json",
|
||||
"auth.json",
|
||||
"sessions/demo.jsonl",
|
||||
"nested/demo.toml",
|
||||
"demo.txt",
|
||||
}
|
||||
home := filepath.Join(t.TempDir(), ".codex")
|
||||
for _, tt := range tests {
|
||||
t.Run(tt, func(t *testing.T) {
|
||||
_, err := ResolveAgentTOML(home, tt)
|
||||
if err == nil {
|
||||
t.Fatalf("expected %q to be rejected", tt)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user