Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions skillinject.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import (
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
Expand Down Expand Up @@ -262,6 +263,15 @@ func reconcilePluginFiles(f *fetcher, ctx context.Context, p *ManifestPlugin, ho
out := make([]Outcome, 0, len(p.Files))
for _, pf := range p.Files {
dst := filepath.Join(installDir, pf.Name)
// Reject path-traversal in pf.Name (e.g. "../../.ssh/authorized_keys")
if clean := filepath.Clean(dst); !strings.HasPrefix(clean, filepath.Clean(installDir)+string(os.PathSeparator)) && clean != filepath.Clean(installDir) {
out = append(out, Outcome{
Tool: p.ID, Kind: KindPluginFile, Path: dst,
Action: ActionError,
Err: fmt.Sprintf("path traversal: %q escapes install dir", pf.Name),
})
continue
}
body, err := f.fetchRepoFile(ctx, pf.Src)
if err != nil {
out = append(out, Outcome{
Expand Down
55 changes: 55 additions & 0 deletions zz_extra_branches_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -458,3 +458,58 @@ func TestReconcilePluginAllowList_MergeErrorSurfaced(t *testing.T) {
t.Errorf("expected non-empty Err on merge failure: %+v", out)
}
}

// TestReconcilePluginFiles_PathTraversalRejected verifies that a
// pf.Name containing "../" is rejected with an error Outcome instead
// of writing outside the install directory.
func TestReconcilePluginFiles_PathTraversalRejected(t *testing.T) {
t.Parallel()
home := t.TempDir()
installDir := filepath.Join(home, ".openclaw", "plugins", "pilot")

p := &ManifestPlugin{
ID: "pilot",
InstallPath: "~/.openclaw/plugins/pilot",
Files: []ManifestPluginFile{
{Name: "../../.ssh/authorized_keys", Src: "plugin/authorized_keys"},
{Name: "index.mjs", Src: "plugin/index.mjs"},
},
}

// Must set up a real fetcher so the loop doesn't fail on the
// good file before we reach the path traversal guard.
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/plugin/index.mjs" {
w.Write([]byte("export default {}\n"))
return
}
http.NotFound(w, r)
}))
t.Cleanup(srv.Close)

f := newFetcher(Config{RepoBaseURL: srv.URL + "/"})

out := reconcilePluginFiles(f, context.Background(), p, home)

// First entry (path traversal) should be an error.
if len(out) < 2 {
t.Fatalf("expected at least 2 outcomes, got %d: %+v", len(out), out)
}
first := out[0]
if first.Action != ActionError {
t.Errorf("first outcome action = %v, want ActionError", first.Action)
}
if !strings.Contains(first.Err, "path traversal") {
t.Errorf("first outcome error = %q, want 'path traversal'", first.Err)
}
// Second entry (clean filename) should succeed.
second := out[1]
if second.Action == ActionError {
t.Errorf("second outcome should not be error, got: %+v", second)
}
// Verify that the traversal file was NOT written.
traversalPath := filepath.Join(installDir, "../../.ssh/authorized_keys")
if _, err := os.Stat(traversalPath); err == nil {
t.Errorf("path traversal file was written at %s", traversalPath)
}
}
Loading