diff --git a/pkg/browser/service.go b/pkg/browser/service.go index ce2a289d..4126f44b 100644 --- a/pkg/browser/service.go +++ b/pkg/browser/service.go @@ -2,8 +2,12 @@ package browser import ( "context" + "net/url" + "path/filepath" + "strings" core "dappco.re/go/core" + coreerr "dappco.re/go/core/log" ) type Options struct{} @@ -15,13 +19,21 @@ type Service struct { func (s *Service) OnStartup(_ context.Context) core.Result { openURL := func(_ context.Context, opts core.Options) core.Result { - if err := s.platform.OpenURL(opts.String("url")); err != nil { + parsedURL, err := validatedOpenURL(opts.String("url")) + if err != nil { + return core.Result{Value: err, OK: false} + } + if err := s.platform.OpenURL(parsedURL); err != nil { return core.Result{Value: err, OK: false} } return core.Result{OK: true} } openFile := func(_ context.Context, opts core.Options) core.Result { - if err := s.platform.OpenFile(opts.String("path")); err != nil { + path, err := validatedOpenFilePath(opts.String("path")) + if err != nil { + return core.Result{Value: err, OK: false} + } + if err := s.platform.OpenFile(path); err != nil { return core.Result{Value: err, OK: false} } return core.Result{OK: true} @@ -36,3 +48,36 @@ func (s *Service) OnStartup(_ context.Context) core.Result { func (s *Service) HandleIPCEvents(_ *core.Core, _ core.Message) core.Result { return core.Result{OK: true} } + +func validatedOpenURL(raw string) (string, error) { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return "", coreerr.E("browser.openURL", "url is required", nil) + } + parsed, err := url.ParseRequestURI(trimmed) + if err != nil { + return "", coreerr.E("browser.openURL", "invalid url", err) + } + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return "", coreerr.E("browser.openURL", "unsupported url scheme: "+parsed.Scheme, nil) + } + if parsed.Host == "" { + return "", coreerr.E("browser.openURL", "url host is required", nil) + } + return parsed.String(), nil +} + +func validatedOpenFilePath(raw string) (string, error) { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return "", coreerr.E("browser.openFile", "path is required", nil) + } + if strings.ContainsRune(trimmed, '\x00') { + return "", coreerr.E("browser.openFile", "path contains a null byte", nil) + } + cleaned := filepath.Clean(trimmed) + if !filepath.IsAbs(cleaned) { + return "", coreerr.E("browser.openFile", "path must be absolute", nil) + } + return cleaned, nil +} diff --git a/pkg/browser/service_test.go b/pkg/browser/service_test.go index c025b5d5..1e7d08d3 100644 --- a/pkg/browser/service_test.go +++ b/pkg/browser/service_test.go @@ -56,6 +56,17 @@ func TestTaskOpenURL_Good(t *testing.T) { assert.Equal(t, "https://example.com", mp.lastURL) } +func TestTaskOpenURL_Bad_Scheme(t *testing.T) { + mp := &mockPlatform{} + _, c := newTestBrowserService(t, mp) + + r := c.Action("browser.openURL").Run(context.Background(), core.NewOptions( + core.Option{Key: "url", Value: "javascript:alert(1)"}, + )) + assert.False(t, r.OK) + assert.Empty(t, mp.lastURL) +} + func TestTaskOpenURL_Bad_PlatformError(t *testing.T) { mp := &mockPlatform{urlErr: core.NewError("browser not found")} _, c := newTestBrowserService(t, mp) @@ -77,6 +88,17 @@ func TestTaskOpenFile_Good(t *testing.T) { assert.Equal(t, "/tmp/readme.txt", mp.lastPath) } +func TestTaskOpenFile_Bad_RelativePath(t *testing.T) { + mp := &mockPlatform{} + _, c := newTestBrowserService(t, mp) + + r := c.Action("browser.openFile").Run(context.Background(), core.NewOptions( + core.Option{Key: "path", Value: "relative/readme.txt"}, + )) + assert.False(t, r.OK) + assert.Empty(t, mp.lastPath) +} + func TestTaskOpenFile_Bad_PlatformError(t *testing.T) { mp := &mockPlatform{fileErr: core.NewError("file not found")} _, c := newTestBrowserService(t, mp) diff --git a/pkg/contextmenu/service.go b/pkg/contextmenu/service.go index 61eb85cc..486bffbd 100644 --- a/pkg/contextmenu/service.go +++ b/pkg/contextmenu/service.go @@ -3,9 +3,10 @@ package contextmenu import ( "context" + "sync" - coreerr "dappco.re/go/core/log" core "dappco.re/go/core" + coreerr "dappco.re/go/core/log" ) type Options struct{} @@ -13,6 +14,7 @@ type Options struct{} type Service struct { *core.ServiceRuntime[Options] platform Platform + mu sync.RWMutex registeredMenus map[string]ContextMenuDef } @@ -39,6 +41,8 @@ func (s *Service) OnStartup(_ context.Context) core.Result { func (s *Service) OnShutdown(_ context.Context) core.Result { // Destroy all registered menus on shutdown to release platform resources + s.mu.Lock() + defer s.mu.Unlock() for name := range s.registeredMenus { _ = s.platform.Remove(name) } @@ -66,6 +70,8 @@ func (s *Service) handleQuery(_ *core.Core, q core.Query) core.Result { } func (s *Service) queryGet(q QueryGet) *ContextMenuDef { + s.mu.RLock() + defer s.mu.RUnlock() menu, ok := s.registeredMenus[q.Name] if !ok { return nil @@ -74,6 +80,8 @@ func (s *Service) queryGet(q QueryGet) *ContextMenuDef { } func (s *Service) queryList() map[string]ContextMenuDef { + s.mu.RLock() + defer s.mu.RUnlock() result := make(map[string]ContextMenuDef, len(s.registeredMenus)) for k, v := range s.registeredMenus { result[k] = v @@ -82,6 +90,8 @@ func (s *Service) queryList() map[string]ContextMenuDef { } func (s *Service) taskAdd(t TaskAdd) error { + s.mu.Lock() + defer s.mu.Unlock() // If menu already exists, remove it first (replace semantics) if _, exists := s.registeredMenus[t.Name]; exists { _ = s.platform.Remove(t.Name) @@ -105,6 +115,8 @@ func (s *Service) taskAdd(t TaskAdd) error { } func (s *Service) taskRemove(t TaskRemove) error { + s.mu.Lock() + defer s.mu.Unlock() if _, exists := s.registeredMenus[t.Name]; !exists { return ErrorMenuNotFound } @@ -119,6 +131,8 @@ func (s *Service) taskRemove(t TaskRemove) error { } func (s *Service) taskUpdate(t TaskUpdate) error { + s.mu.Lock() + defer s.mu.Unlock() if _, exists := s.registeredMenus[t.Name]; !exists { return ErrorMenuNotFound } @@ -144,6 +158,8 @@ func (s *Service) taskUpdate(t TaskUpdate) error { } func (s *Service) taskDestroy(t TaskDestroy) error { + s.mu.Lock() + defer s.mu.Unlock() if _, exists := s.registeredMenus[t.Name]; !exists { return ErrorMenuNotFound } diff --git a/pkg/display/display.go b/pkg/display/display.go index 52f36121..2f610d02 100644 --- a/pkg/display/display.go +++ b/pkg/display/display.go @@ -2,6 +2,7 @@ package display import ( "context" + "net/url" "runtime" "sync" @@ -1345,12 +1346,13 @@ func (s *Service) handleOpenFile() { if !ok || len(paths) == 0 { return } + fileURL := "/#/developer/editor?file=" + url.QueryEscape(paths[0]) _ = s.Core().Action("window.open").Run(context.Background(), core.NewOptions( core.Option{Key: "task", Value: window.TaskOpenWindow{ Window: &window.Window{ Name: "editor", Title: paths[0] + " - Editor", - URL: "/#/developer/editor?file=" + paths[0], + URL: fileURL, Width: 1200, Height: 800, }, diff --git a/pkg/keybinding/service.go b/pkg/keybinding/service.go index 2a113b71..ad810bb8 100644 --- a/pkg/keybinding/service.go +++ b/pkg/keybinding/service.go @@ -3,9 +3,10 @@ package keybinding import ( "context" + "sync" - coreerr "dappco.re/go/core/log" core "dappco.re/go/core" + coreerr "dappco.re/go/core/log" ) type Options struct{} @@ -13,6 +14,7 @@ type Options struct{} type Service struct { *core.ServiceRuntime[Options] platform Platform + mu sync.RWMutex registeredBindings map[string]BindingInfo } @@ -49,6 +51,8 @@ func (s *Service) handleQuery(_ *core.Core, q core.Query) core.Result { } func (s *Service) queryList() []BindingInfo { + s.mu.RLock() + defer s.mu.RUnlock() result := make([]BindingInfo, 0, len(s.registeredBindings)) for _, info := range s.registeredBindings { result = append(result, info) @@ -57,6 +61,8 @@ func (s *Service) queryList() []BindingInfo { } func (s *Service) taskAdd(t TaskAdd) error { + s.mu.Lock() + defer s.mu.Unlock() if _, exists := s.registeredBindings[t.Accelerator]; exists { return ErrorAlreadyRegistered } @@ -77,6 +83,8 @@ func (s *Service) taskAdd(t TaskAdd) error { } func (s *Service) taskRemove(t TaskRemove) error { + s.mu.Lock() + defer s.mu.Unlock() if _, exists := s.registeredBindings[t.Accelerator]; !exists { return coreerr.E("keybinding.taskRemove", "not registered: "+t.Accelerator, ErrorNotRegistered) } @@ -95,7 +103,10 @@ func (s *Service) taskRemove(t TaskRemove) error { // // c.Action("keybinding.process").Run(ctx, core.NewOptions(core.Option{Key:"task", Value:keybinding.TaskProcess{Accelerator:"Ctrl+S"}})) func (s *Service) taskProcess(t TaskProcess) error { - if _, exists := s.registeredBindings[t.Accelerator]; !exists { + s.mu.RLock() + _, exists := s.registeredBindings[t.Accelerator] + s.mu.RUnlock() + if !exists { return coreerr.E("keybinding.taskProcess", "not registered: "+t.Accelerator, ErrorNotRegistered) } diff --git a/pkg/webview/service.go b/pkg/webview/service.go index 5111262f..ce590d59 100644 --- a/pkg/webview/service.go +++ b/pkg/webview/service.go @@ -6,6 +6,7 @@ import ( "context" "encoding/base64" "strconv" + "strings" "sync" "time" @@ -85,6 +86,10 @@ func Register(optionFns ...func(*Options)) func(*core.Core) core.Result { // defaultNewConn creates real go-webview connections. func defaultNewConn(options Options) func(string, string) (connector, error) { return func(debugURL, windowName string) (connector, error) { + windowName = strings.TrimSpace(windowName) + if windowName == "" { + return nil, core.E("webview.connect", "window name is required", nil) + } // Enumerate targets, match by title/URL containing window name targets, err := gowebview.ListTargets(debugURL) if err != nil { @@ -97,17 +102,8 @@ func defaultNewConn(options Options) func(string, string) (connector, error) { break } } - // Fallback: first page target if wsURL == "" { - for _, t := range targets { - if t.Type == "page" { - wsURL = t.WebSocketDebuggerURL - break - } - } - } - if wsURL == "" { - return nil, core.E("webview.connect", "no page target found", nil) + return nil, core.E("webview.connect", "no page target matched window name", nil) } wv, err := gowebview.New( gowebview.WithDebugURL(debugURL), @@ -198,6 +194,10 @@ func (s *Service) HandleIPCEvents(_ *core.Core, msg core.Message) core.Result { // getConn returns the connector for a window, creating it if needed. func (s *Service) getConn(windowName string) (connector, error) { + windowName = strings.TrimSpace(windowName) + if windowName == "" { + return nil, core.E("webview.getConn", "window name is required", nil) + } s.mu.RLock() if conn, ok := s.connections[windowName]; ok { s.mu.RUnlock() diff --git a/pkg/webview/service_test.go b/pkg/webview/service_test.go index 59ceef2a..99cbe589 100644 --- a/pkg/webview/service_test.go +++ b/pkg/webview/service_test.go @@ -202,6 +202,12 @@ func TestTaskEvaluate_Good(t *testing.T) { assert.Equal(t, 42, r.Value) } +func TestTaskEvaluate_Bad_EmptyWindow(t *testing.T) { + _, c := newTestService(t, &mockConnector{evalResult: 42}) + r := taskRun(c, "webview.evaluate", TaskEvaluate{Window: " ", Script: "21*2"}) + assert.False(t, r.OK) +} + func TestTaskClick_Good(t *testing.T) { mock := &mockConnector{} _, c := newTestService(t, mock)