Harden GUI security boundaries
This commit is contained in:
parent
50de8fd4e9
commit
86061a484a
7 changed files with 118 additions and 16 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue