diff --git a/CHANGELOG.md b/CHANGELOG.md index 2744e0f5b49..d81ac302f2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai - QA/Matrix: split Matrix live QA into a source-linked `qa-matrix` runner and keep repo-private `qa-*` surfaces out of packaged and published builds. (#66723) Thanks @gumadeiras. - Control UI/Overview: add a Model Auth status card showing OAuth token health and provider rate-limit pressure at a glance, with attention callouts when OAuth tokens are expiring or expired. Backed by a new `models.authStatus` gateway method that strips credentials and caches for 60s. (#66211) Thanks @omarshahine. - docs-i18n: add behavior baseline fixtures (#64073). Thanks @hxy91819 +- docs-i18n: harden behavior fixture path reads (#67046). Thanks @hxy91819 ### Fixes diff --git a/scripts/docs-i18n/behavior_baseline_test.go b/scripts/docs-i18n/behavior_baseline_test.go index 262574db9d6..63fbe0d8d05 100644 --- a/scripts/docs-i18n/behavior_baseline_test.go +++ b/scripts/docs-i18n/behavior_baseline_test.go @@ -3,8 +3,10 @@ package main import ( "context" "encoding/json" + "fmt" "os" "path/filepath" + "runtime" "strings" "testing" ) @@ -203,16 +205,74 @@ func readFixtureText(t *testing.T, path string) string { func readFixtureTextInDir(t *testing.T, dir, name string) string { t.Helper() + resolvedPath, err := resolveFixturePathInDir(dir, name) + if err != nil { + t.Fatal(err) + } + return readFixtureText(t, resolvedPath) +} + +func resolveFixturePathInDir(dir, name string) (string, error) { if filepath.IsAbs(name) { - t.Fatalf("absolute fixture paths are not allowed: %q", name) + return "", fmt.Errorf("absolute fixture paths are not allowed: %q", name) } clean := filepath.Clean(name) if clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) { - t.Fatalf("fixture path escapes dir: %q", name) + return "", fmt.Errorf("fixture path escapes dir: %q", name) } - return readFixtureText(t, filepath.Join(dir, clean)) + + joined := filepath.Join(dir, clean) + resolvedDir, err := filepath.EvalSymlinks(dir) + if err != nil { + return "", fmt.Errorf("EvalSymlinks(%q): %w", dir, err) + } + resolvedPath, err := filepath.EvalSymlinks(joined) + if err != nil { + return "", fmt.Errorf("EvalSymlinks(%q): %w", joined, err) + } + + rel, err := filepath.Rel(resolvedDir, resolvedPath) + if err != nil { + return "", fmt.Errorf("Rel(%q, %q): %w", resolvedDir, resolvedPath, err) + } + if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return "", fmt.Errorf("fixture path resolves outside dir: %q", name) + } + + return resolvedPath, nil } func normalizeBehaviorText(value string) string { return strings.TrimSpace(strings.ReplaceAll(value, "\r\n", "\n")) } + +func TestResolveFixturePathInDirRejectsSymlinkEscape(t *testing.T) { + t.Parallel() + + root := t.TempDir() + fixtureDir := filepath.Join(root, "fixture") + if err := os.MkdirAll(fixtureDir, 0o755); err != nil { + t.Fatalf("MkdirAll(%q): %v", fixtureDir, err) + } + + outsidePath := filepath.Join(root, "outside.txt") + if err := os.WriteFile(outsidePath, []byte("outside\n"), 0o644); err != nil { + t.Fatalf("WriteFile(%q): %v", outsidePath, err) + } + + linkPath := filepath.Join(fixtureDir, "outside-link.txt") + if err := os.Symlink(outsidePath, linkPath); err != nil { + if os.IsPermission(err) || runtime.GOOS == "windows" { + t.Skipf("symlink creation unavailable in this test environment: %v", err) + } + t.Fatalf("Symlink(%q, %q): %v", outsidePath, linkPath, err) + } + + _, err := resolveFixturePathInDir(fixtureDir, "outside-link.txt") + if err == nil { + t.Fatal("expected symlink escape to fail") + } + if !strings.Contains(err.Error(), "resolves outside dir") { + t.Fatalf("expected outside-dir error, got %v", err) + } +}