Harden GUI security boundaries

This commit is contained in:
Snider 2026-04-15 17:19:11 +01:00
parent 50de8fd4e9
commit 86061a484a
7 changed files with 118 additions and 16 deletions

View file

@ -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
}

View file

@ -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)

View file

@ -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
}

View file

@ -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,
},

View file

@ -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)
}

View file

@ -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()

View file

@ -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)