mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-25 07:00:48 +09:00
fix(skills): harden managed skill path validation
This commit is contained in:
@@ -101,10 +101,10 @@ func (h *ContainerdHandler) UpsertSkills(c echo.Context) error {
|
|||||||
|
|
||||||
for _, raw := range req.Skills {
|
for _, raw := range req.Skills {
|
||||||
parsed := skillset.ParseFile(raw, "")
|
parsed := skillset.ParseFile(raw, "")
|
||||||
if !skillset.IsValidName(parsed.Name) {
|
dirPath, dirErr := skillset.ManagedSkillDirForName(parsed.Name)
|
||||||
|
if dirErr != nil {
|
||||||
return echo.NewHTTPError(http.StatusBadRequest, "skill must have a valid name in YAML frontmatter")
|
return echo.NewHTTPError(http.StatusBadRequest, "skill must have a valid name in YAML frontmatter")
|
||||||
}
|
}
|
||||||
dirPath := path.Join(skillset.ManagedDir(), parsed.Name)
|
|
||||||
if err := client.Mkdir(ctx, dirPath); err != nil {
|
if err := client.Mkdir(ctx, dirPath); err != nil {
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("mkdir failed: %v", err))
|
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("mkdir failed: %v", err))
|
||||||
}
|
}
|
||||||
@@ -149,10 +149,10 @@ func (h *ContainerdHandler) DeleteSkills(c echo.Context) error {
|
|||||||
|
|
||||||
for _, name := range req.Names {
|
for _, name := range req.Names {
|
||||||
skillName := strings.TrimSpace(name)
|
skillName := strings.TrimSpace(name)
|
||||||
if !skillset.IsValidName(skillName) {
|
managedDir, dirErr := skillset.ManagedSkillDirForName(skillName)
|
||||||
|
if dirErr != nil {
|
||||||
return echo.NewHTTPError(http.StatusBadRequest, "invalid skill name")
|
return echo.NewHTTPError(http.StatusBadRequest, "invalid skill name")
|
||||||
}
|
}
|
||||||
managedDir := path.Join(skillset.ManagedDir(), skillName)
|
|
||||||
if _, statErr := client.Stat(ctx, managedDir); statErr != nil {
|
if _, statErr := client.Stat(ctx, managedDir); statErr != nil {
|
||||||
return fsHTTPError(statErr)
|
return fsHTTPError(statErr)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -205,6 +205,24 @@ func TestDeleteSkillsAPIRejectsExternalOnlySkill(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUpsertSkillsAPIRejectsTraversalName(t *testing.T) {
|
||||||
|
env := newSkillsTestEnv(t)
|
||||||
|
|
||||||
|
_, err := env.callJSON(t, http.MethodPost, "/bots/:bot_id/container/skills", SkillsUpsertRequest{
|
||||||
|
Skills: []string{"---\nname: ..\ndescription: Escape\n---\n\n# Escape"},
|
||||||
|
}, env.handler.UpsertSkills)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected upserting traversal skill name to fail")
|
||||||
|
}
|
||||||
|
var httpErr *echo.HTTPError
|
||||||
|
if !errors.As(err, &httpErr) {
|
||||||
|
t.Fatalf("expected echo.HTTPError, got %T", err)
|
||||||
|
}
|
||||||
|
if httpErr.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("upsert traversal status = %d, want 400", httpErr.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLoadSkillsUsesEffectiveSetAndPromptReflectsOverrideFallback(t *testing.T) {
|
func TestLoadSkillsUsesEffectiveSetAndPromptReflectsOverrideFallback(t *testing.T) {
|
||||||
env := newSkillsTestEnv(t)
|
env := newSkillsTestEnv(t)
|
||||||
managedPath := path.Join(skillset.ManagedDir(), "alpha", "SKILL.md")
|
managedPath := path.Join(skillset.ManagedDir(), "alpha", "SKILL.md")
|
||||||
|
|||||||
@@ -102,6 +102,19 @@ func ManagedDir() string {
|
|||||||
return ManagedDirPath
|
return ManagedDirPath
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ManagedSkillDirForName(name string) (string, error) {
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
if !IsValidName(name) {
|
||||||
|
return "", bridge.ErrBadRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
dirPath := path.Clean(path.Join(ManagedDirPath, name))
|
||||||
|
if dirPath == ManagedDirPath || !strings.HasPrefix(dirPath, ManagedDirPath+"/") {
|
||||||
|
return "", bridge.ErrBadRequest
|
||||||
|
}
|
||||||
|
return dirPath, nil
|
||||||
|
}
|
||||||
|
|
||||||
func ContainerEnv() []string {
|
func ContainerEnv() []string {
|
||||||
return []string{
|
return []string{
|
||||||
"HOME=" + config.DefaultDataMount,
|
"HOME=" + config.DefaultDataMount,
|
||||||
@@ -184,7 +197,10 @@ func ApplyAction(ctx context.Context, client fileClient, req ActionRequest) erro
|
|||||||
return bridge.ErrBadRequest
|
return bridge.ErrBadRequest
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
dirPath := path.Join(ManagedDirPath, target.Name)
|
dirPath, err := ManagedSkillDirForName(target.Name)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if err := client.Mkdir(ctx, dirPath); err != nil {
|
if err := client.Mkdir(ctx, dirPath); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -247,6 +263,12 @@ func IsValidName(name string) bool {
|
|||||||
if name == "" {
|
if name == "" {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
if name == "." || name == ".." {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(name, ".") || strings.Contains(name, "..") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
for _, r := range name {
|
for _, r := range name {
|
||||||
switch {
|
switch {
|
||||||
case r >= 'a' && r <= 'z':
|
case r >= 'a' && r <= 'z':
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ package skills
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/memohai/memoh/internal/workspace/bridge"
|
||||||
pb "github.com/memohai/memoh/internal/workspace/bridgepb"
|
pb "github.com/memohai/memoh/internal/workspace/bridgepb"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -107,6 +109,62 @@ func TestApplyActionAdoptAndDisable(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestApplyActionAdoptRejectsInvalidManagedName(t *testing.T) {
|
||||||
|
client := newFakeClient()
|
||||||
|
externalPath := pathJoin("/data/.agents/skills", "escape", "SKILL.md")
|
||||||
|
client.listings["/data/.agents/skills"] = []*pb.FileEntry{{Path: "escape", IsDir: true}}
|
||||||
|
client.files[externalPath] = "---\nname: ..\ndescription: Escape\n---\n\n# Escape"
|
||||||
|
|
||||||
|
err := ApplyAction(context.Background(), client, ActionRequest{
|
||||||
|
Action: ActionAdopt,
|
||||||
|
TargetPath: externalPath,
|
||||||
|
})
|
||||||
|
if !errors.Is(err, bridge.ErrBadRequest) {
|
||||||
|
t.Fatalf("adopt err = %v, want ErrBadRequest", err)
|
||||||
|
}
|
||||||
|
if _, ok := client.files[pathJoin(ManagedDirPath, "..", "SKILL.md")]; ok {
|
||||||
|
t.Fatalf("unexpected managed write for invalid adopted name")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsValidNameRejectsTraversalPatterns(t *testing.T) {
|
||||||
|
for _, name := range []string{
|
||||||
|
"",
|
||||||
|
".",
|
||||||
|
"..",
|
||||||
|
".hidden",
|
||||||
|
"alpha..beta",
|
||||||
|
"../escape",
|
||||||
|
"alpha/../beta",
|
||||||
|
} {
|
||||||
|
if IsValidName(name) {
|
||||||
|
t.Fatalf("IsValidName(%q) = true, want false", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, name := range []string{"alpha", "alpha-beta", "alpha_beta", "alpha.beta"} {
|
||||||
|
if !IsValidName(name) {
|
||||||
|
t.Fatalf("IsValidName(%q) = false, want true", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManagedSkillDirForNameRejectsEscapingNames(t *testing.T) {
|
||||||
|
for _, name := range []string{".", "..", ".alpha", "alpha..beta"} {
|
||||||
|
if _, err := ManagedSkillDirForName(name); !errors.Is(err, bridge.ErrBadRequest) {
|
||||||
|
t.Fatalf("ManagedSkillDirForName(%q) err = %v, want ErrBadRequest", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dirPath, err := ManagedSkillDirForName("alpha.beta")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ManagedSkillDirForName(valid) returned error: %v", err)
|
||||||
|
}
|
||||||
|
if dirPath != pathJoin(ManagedDirPath, "alpha.beta") {
|
||||||
|
t.Fatalf("ManagedSkillDirForName(valid) = %q, want %q", dirPath, pathJoin(ManagedDirPath, "alpha.beta"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestDiscoveryRootsMatchCurrentPolicy(t *testing.T) {
|
func TestDiscoveryRootsMatchCurrentPolicy(t *testing.T) {
|
||||||
roots := DiscoveryRoots()
|
roots := DiscoveryRoots()
|
||||||
want := []Root{
|
want := []Root{
|
||||||
|
|||||||
Reference in New Issue
Block a user