@@ -436,6 +436,24 @@ func BaseDir() string {
436436 return filepath .Join (home , ".homebutler" , "apps" )
437437}
438438
439+ // ValidateAppName checks that an app name is safe for use in file paths.
440+ // It rejects names containing path separators or traversal sequences.
441+ func ValidateAppName (name string ) error {
442+ if name == "" {
443+ return fmt .Errorf ("app name must not be empty" )
444+ }
445+ if strings .Contains (name , "/" ) || strings .Contains (name , "\\ " ) || strings .Contains (name , ".." ) {
446+ return fmt .Errorf ("invalid app name %q: must not contain '/', '\\ ', or '..'" , name )
447+ }
448+ // After joining, the result must still be under BaseDir.
449+ joined := filepath .Join (BaseDir (), name )
450+ rel , err := filepath .Rel (BaseDir (), joined )
451+ if err != nil || strings .HasPrefix (rel , ".." ) {
452+ return fmt .Errorf ("invalid app name %q: path escapes base directory" , name )
453+ }
454+ return nil
455+ }
456+
439457// AppDir returns the directory for a specific app.
440458func AppDir (appName string ) string {
441459 return filepath .Join (BaseDir (), appName )
@@ -579,8 +597,15 @@ func IsSpecialWarning(appName string) string {
579597 return ""
580598}
581599
582- // isPortInUse checks if a port is in use (cross-platform).
600+ // portInUseBy checks if a port is in use (cross-platform).
583601func portInUseBy (port string ) string {
602+ // Validate port is purely numeric to prevent shell injection.
603+ for _ , c := range port {
604+ if c < '0' || c > '9' {
605+ return ""
606+ }
607+ }
608+
584609 // Try lsof (macOS/Linux) — gives process name
585610 out , err := util .RunCmd ("sh" , "-c" ,
586611 fmt .Sprintf ("lsof -i :%s -sTCP:LISTEN 2>/dev/null | grep LISTEN | head -1 || true" , port ))
@@ -675,6 +700,8 @@ func Install(app App, opts InstallOptions) error {
675700 defer f .Close ()
676701
677702 if err := tmpl .Execute (f , ctx ); err != nil {
703+ f .Close ()
704+ os .Remove (composeFile )
678705 return fmt .Errorf ("failed to render compose file: %w" , err )
679706 }
680707
@@ -694,6 +721,9 @@ func Install(app App, opts InstallOptions) error {
694721
695722// Uninstall stops the app and removes its containers.
696723func Uninstall (appName string ) error {
724+ if err := ValidateAppName (appName ); err != nil {
725+ return err
726+ }
697727 appDir := GetInstalledPath (appName )
698728 composeFile := filepath .Join (appDir , "docker-compose.yml" )
699729
@@ -711,6 +741,9 @@ func Uninstall(appName string) error {
711741
712742// Purge removes the app directory including data.
713743func Purge (appName string ) error {
744+ if err := ValidateAppName (appName ); err != nil {
745+ return err
746+ }
714747 appDir := GetInstalledPath (appName )
715748 if err := Uninstall (appName ); err != nil {
716749 return err
@@ -733,6 +766,9 @@ func Purge(appName string) error {
733766
734767// Status checks if the installed app is running.
735768func Status (appName string ) (string , error ) {
769+ if err := ValidateAppName (appName ); err != nil {
770+ return "" , err
771+ }
736772 appDir := GetInstalledPath (appName )
737773 composeFile := filepath .Join (appDir , "docker-compose.yml" )
738774
0 commit comments