Pārlūkot izejas kodu

security: GHSA-jf73-858c-54pg (MODERATE) View permission not being checked when returning dashboards

jamesread 4 mēneši atpakaļ
vecāks
revīzija
d7962710e7

+ 112 - 47
service/internal/api/api.go

@@ -70,20 +70,21 @@ func (api *oliveTinAPI) KillAction(ctx ctx.Context, req *connect.Request[apiv1.K
 	execReqLogEntry, ret.Found = api.executor.GetLog(req.Msg.ExecutionTrackingId)
 	execReqLogEntry, ret.Found = api.executor.GetLog(req.Msg.ExecutionTrackingId)
 
 
 	if !ret.Found {
 	if !ret.Found {
-		log.Warnf("Killing execution request not possible - not found by tracking ID: %v", req.Msg.ExecutionTrackingId)
-		return connect.NewResponse(ret), nil
+		return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("execution not found for tracking ID %s", req.Msg.ExecutionTrackingId))
 	}
 	}
 
 
-	log.Warnf("Killing execution request by tracking ID: %v", req.Msg.ExecutionTrackingId)
+	if execReqLogEntry.Binding == nil {
+		return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("log entry has no binding for tracking ID %s", req.Msg.ExecutionTrackingId))
+	}
 
 
 	action := execReqLogEntry.Binding.Action
 	action := execReqLogEntry.Binding.Action
 
 
 	if action == nil {
 	if action == nil {
-		log.Warnf("Killing execution request not possible - action not found: %v", execReqLogEntry.ActionTitle)
-		ret.Killed = false
-		return connect.NewResponse(ret), nil
+		return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("action not found for tracking ID %s", req.Msg.ExecutionTrackingId))
 	}
 	}
 
 
+	log.Warnf("Killing execution request by tracking ID: %v", req.Msg.ExecutionTrackingId)
+
 	user := auth.UserFromApiCall(ctx, req, api.cfg)
 	user := auth.UserFromApiCall(ctx, req, api.cfg)
 
 
 	api.killActionByTrackingId(user, action, execReqLogEntry, ret)
 	api.killActionByTrackingId(user, action, execReqLogEntry, ret)
@@ -205,42 +206,58 @@ func (api *oliveTinAPI) LocalUserLogin(ctx ctx.Context, req *connect.Request[api
 	return response, nil
 	return response, nil
 }
 }
 
 
-func (api *oliveTinAPI) StartActionAndWait(ctx ctx.Context, req *connect.Request[apiv1.StartActionAndWaitRequest]) (*connect.Response[apiv1.StartActionAndWaitResponse], error) {
-	args := make(map[string]string)
-
-	for _, arg := range req.Msg.Arguments {
-		args[arg.Name] = arg.Value
-	}
-
-	user := auth.UserFromApiCall(ctx, req, api.cfg)
-
+func (api *oliveTinAPI) startActionAndWaitRun(binding *executor.ActionBinding, args map[string]string, user *authpublic.AuthenticatedUser) (*executor.InternalLogEntry, bool) {
 	execReq := executor.ExecutionRequest{
 	execReq := executor.ExecutionRequest{
-		Binding:           api.executor.FindBindingByID(req.Msg.ActionId),
+		Binding:           binding,
 		TrackingID:        uuid.NewString(),
 		TrackingID:        uuid.NewString(),
 		Arguments:         args,
 		Arguments:         args,
 		AuthenticatedUser: user,
 		AuthenticatedUser: user,
 		Cfg:               api.cfg,
 		Cfg:               api.cfg,
 	}
 	}
-
 	wg, _ := api.executor.ExecRequest(&execReq)
 	wg, _ := api.executor.ExecRequest(&execReq)
 	wg.Wait()
 	wg.Wait()
+	return api.executor.GetLog(execReq.TrackingID)
+}
 
 
-	internalLogEntry, ok := api.executor.GetLog(execReq.TrackingID)
+func (api *oliveTinAPI) findBindingOrNotFound(actionId string) (*executor.ActionBinding, error) {
+	binding := api.executor.FindBindingByID(actionId)
+	if binding == nil || binding.Action == nil {
+		return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("action with ID %s not found", actionId))
+	}
+	return binding, nil
+}
 
 
-	if ok {
-		return connect.NewResponse(&apiv1.StartActionAndWaitResponse{
-			LogEntry: api.internalLogEntryToPb(internalLogEntry, user),
-		}), nil
-	} else {
-		return nil, fmt.Errorf("execution not found")
+func (api *oliveTinAPI) StartActionAndWait(ctx ctx.Context, req *connect.Request[apiv1.StartActionAndWaitRequest]) (*connect.Response[apiv1.StartActionAndWaitResponse], error) {
+	binding, err := api.findBindingOrNotFound(req.Msg.ActionId)
+	if err != nil {
+		return nil, err
+	}
+
+	args := make(map[string]string)
+	for _, arg := range req.Msg.Arguments {
+		args[arg.Name] = arg.Value
 	}
 	}
+	user := auth.UserFromApiCall(ctx, req, api.cfg)
+
+	internalLogEntry, ok := api.startActionAndWaitRun(binding, args, user)
+	if !ok {
+		return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("execution not found"))
+	}
+	return connect.NewResponse(&apiv1.StartActionAndWaitResponse{
+		LogEntry: api.internalLogEntryToPb(internalLogEntry, user),
+	}), nil
 }
 }
 
 
 func (api *oliveTinAPI) StartActionByGet(ctx ctx.Context, req *connect.Request[apiv1.StartActionByGetRequest]) (*connect.Response[apiv1.StartActionByGetResponse], error) {
 func (api *oliveTinAPI) StartActionByGet(ctx ctx.Context, req *connect.Request[apiv1.StartActionByGetRequest]) (*connect.Response[apiv1.StartActionByGetResponse], error) {
+	binding := api.executor.FindBindingByID(req.Msg.ActionId)
+	if binding == nil || binding.Action == nil {
+		return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("action with ID %s not found", req.Msg.ActionId))
+	}
+
 	args := make(map[string]string)
 	args := make(map[string]string)
 
 
 	execReq := executor.ExecutionRequest{
 	execReq := executor.ExecutionRequest{
-		Binding:           api.executor.FindBindingByID(req.Msg.ActionId),
+		Binding:           binding,
 		TrackingID:        uuid.NewString(),
 		TrackingID:        uuid.NewString(),
 		Arguments:         args,
 		Arguments:         args,
 		AuthenticatedUser: auth.UserFromApiCall(ctx, req, api.cfg),
 		AuthenticatedUser: auth.UserFromApiCall(ctx, req, api.cfg),
@@ -255,12 +272,17 @@ func (api *oliveTinAPI) StartActionByGet(ctx ctx.Context, req *connect.Request[a
 }
 }
 
 
 func (api *oliveTinAPI) StartActionByGetAndWait(ctx ctx.Context, req *connect.Request[apiv1.StartActionByGetAndWaitRequest]) (*connect.Response[apiv1.StartActionByGetAndWaitResponse], error) {
 func (api *oliveTinAPI) StartActionByGetAndWait(ctx ctx.Context, req *connect.Request[apiv1.StartActionByGetAndWaitRequest]) (*connect.Response[apiv1.StartActionByGetAndWaitResponse], error) {
+	binding := api.executor.FindBindingByID(req.Msg.ActionId)
+	if binding == nil || binding.Action == nil {
+		return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("action with ID %s not found", req.Msg.ActionId))
+	}
+
 	args := make(map[string]string)
 	args := make(map[string]string)
 
 
 	user := auth.UserFromApiCall(ctx, req, api.cfg)
 	user := auth.UserFromApiCall(ctx, req, api.cfg)
 
 
 	execReq := executor.ExecutionRequest{
 	execReq := executor.ExecutionRequest{
-		Binding:           api.executor.FindBindingByID(req.Msg.ActionId),
+		Binding:           binding,
 		TrackingID:        uuid.NewString(),
 		TrackingID:        uuid.NewString(),
 		Arguments:         args,
 		Arguments:         args,
 		AuthenticatedUser: user,
 		AuthenticatedUser: user,
@@ -276,9 +298,8 @@ func (api *oliveTinAPI) StartActionByGetAndWait(ctx ctx.Context, req *connect.Re
 		return connect.NewResponse(&apiv1.StartActionByGetAndWaitResponse{
 		return connect.NewResponse(&apiv1.StartActionByGetAndWaitResponse{
 			LogEntry: api.internalLogEntryToPb(internalLogEntry, user),
 			LogEntry: api.internalLogEntryToPb(internalLogEntry, user),
 		}), nil
 		}), nil
-	} else {
-		return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("execution not found"))
 	}
 	}
+	return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("execution not found"))
 }
 }
 
 
 func calculateRateLimitExpires(api *oliveTinAPI, logEntry *executor.InternalLogEntry) string {
 func calculateRateLimitExpires(api *oliveTinAPI, logEntry *executor.InternalLogEntry) string {
@@ -392,6 +413,8 @@ func (api *oliveTinAPI) ExecutionStatus(ctx ctx.Context, req *connect.Request[ap
 func (api *oliveTinAPI) Logout(ctx ctx.Context, req *connect.Request[apiv1.LogoutRequest]) (*connect.Response[apiv1.LogoutResponse], error) {
 func (api *oliveTinAPI) Logout(ctx ctx.Context, req *connect.Request[apiv1.LogoutRequest]) (*connect.Response[apiv1.LogoutResponse], error) {
 	user := auth.UserFromApiCall(ctx, req, api.cfg)
 	user := auth.UserFromApiCall(ctx, req, api.cfg)
 
 
+	auth.RevokeSessionForProvider(api.cfg, user.Provider, user.SID)
+
 	log.WithFields(log.Fields{
 	log.WithFields(log.Fields{
 		"username": user.Username,
 		"username": user.Username,
 		"provider": user.Provider,
 		"provider": user.Provider,
@@ -434,19 +457,38 @@ func (api *oliveTinAPI) GetActionBinding(ctx ctx.Context, req *connect.Request[a
 		return nil, err
 		return nil, err
 	}
 	}
 
 
-	binding := api.executor.FindBindingByID(req.Msg.BindingId)
+	resp, err := api.getActionBindingResponse(user, req.Msg.BindingId)
+	if err != nil {
+		return nil, err
+	}
+	return connect.NewResponse(resp), nil
+}
 
 
+func (api *oliveTinAPI) getActionBindingResponse(user *authpublic.AuthenticatedUser, bindingId string) (*apiv1.GetActionBindingResponse, error) {
+	binding := api.executor.FindBindingByID(bindingId)
 	if binding == nil {
 	if binding == nil {
-		return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("action with ID %s not found", req.Msg.BindingId))
+		return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("action with ID %s not found", bindingId))
 	}
 	}
-
-	return connect.NewResponse(&apiv1.GetActionBindingResponse{
+	if binding.Action == nil {
+		return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("action with ID %s not found", bindingId))
+	}
+	if !api.userCanViewAction(user, binding.Action) {
+		return nil, connect.NewError(connect.CodePermissionDenied, fmt.Errorf("permission denied"))
+	}
+	return &apiv1.GetActionBindingResponse{
 		Action: buildAction(binding, &DashboardRenderRequest{
 		Action: buildAction(binding, &DashboardRenderRequest{
 			cfg:               api.cfg,
 			cfg:               api.cfg,
 			AuthenticatedUser: user,
 			AuthenticatedUser: user,
 			ex:                api.executor,
 			ex:                api.executor,
 		}),
 		}),
-	}), nil
+	}, nil
+}
+
+func (api *oliveTinAPI) userCanViewAction(user *authpublic.AuthenticatedUser, action *config.Action) bool {
+	if user == nil {
+		return true
+	}
+	return acl.IsAllowedView(api.cfg, user, action)
 }
 }
 
 
 func (api *oliveTinAPI) GetDashboard(ctx ctx.Context, req *connect.Request[apiv1.GetDashboardRequest]) (*connect.Response[apiv1.GetDashboardResponse], error) {
 func (api *oliveTinAPI) GetDashboard(ctx ctx.Context, req *connect.Request[apiv1.GetDashboardRequest]) (*connect.Response[apiv1.GetDashboardResponse], error) {
@@ -646,7 +688,16 @@ error messages more quickly before starting the action.
 It uses the same validation logic as the executor, including mangling argument
 It uses the same validation logic as the executor, including mangling argument
 values (e.g., datetime formatting, checkbox title-to-value conversion).
 values (e.g., datetime formatting, checkbox title-to-value conversion).
 */
 */
+func (api *oliveTinAPI) argumentNotFoundForValidation(msg *apiv1.ValidateArgumentTypeRequest) bool {
+	arg, _ := api.findArgumentForValidation(msg.BindingId, msg.ArgumentName)
+	return arg == nil && (msg.BindingId != "" || msg.ArgumentName != "")
+}
+
 func (api *oliveTinAPI) ValidateArgumentType(ctx ctx.Context, req *connect.Request[apiv1.ValidateArgumentTypeRequest]) (*connect.Response[apiv1.ValidateArgumentTypeResponse], error) {
 func (api *oliveTinAPI) ValidateArgumentType(ctx ctx.Context, req *connect.Request[apiv1.ValidateArgumentTypeRequest]) (*connect.Response[apiv1.ValidateArgumentTypeResponse], error) {
+	if api.argumentNotFoundForValidation(req.Msg) {
+		return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("action or argument not found for binding ID %s", req.Msg.BindingId))
+	}
+
 	err := api.validateArgumentTypeInternal(req.Msg)
 	err := api.validateArgumentTypeInternal(req.Msg)
 	desc := ""
 	desc := ""
 	if err != nil {
 	if err != nil {
@@ -747,6 +798,13 @@ func (api *oliveTinAPI) DumpVars(ctx ctx.Context, req *connect.Request[apiv1.Dum
 	return connect.NewResponse(res), nil
 	return connect.NewResponse(res), nil
 }
 }
 
 
+func debugBindingActionTitle(binding *executor.ActionBinding) string {
+	if binding == nil || binding.Action == nil {
+		return ""
+	}
+	return binding.Action.Title
+}
+
 func (api *oliveTinAPI) DumpPublicIdActionMap(ctx ctx.Context, req *connect.Request[apiv1.DumpPublicIdActionMapRequest]) (*connect.Response[apiv1.DumpPublicIdActionMapResponse], error) {
 func (api *oliveTinAPI) DumpPublicIdActionMap(ctx ctx.Context, req *connect.Request[apiv1.DumpPublicIdActionMapRequest]) (*connect.Response[apiv1.DumpPublicIdActionMapResponse], error) {
 	res := &apiv1.DumpPublicIdActionMapResponse{}
 	res := &apiv1.DumpPublicIdActionMapResponse{}
 	res.Contents = make(map[string]*apiv1.DebugBinding)
 	res.Contents = make(map[string]*apiv1.DebugBinding)
@@ -761,7 +819,7 @@ func (api *oliveTinAPI) DumpPublicIdActionMap(ctx ctx.Context, req *connect.Requ
 
 
 	for k, v := range api.executor.MapActionBindings {
 	for k, v := range api.executor.MapActionBindings {
 		res.Contents[k] = &apiv1.DebugBinding{
 		res.Contents[k] = &apiv1.DebugBinding{
-			ActionTitle: v.Action.Title,
+			ActionTitle: debugBindingActionTitle(v),
 		}
 		}
 	}
 	}
 
 
@@ -1271,30 +1329,37 @@ func (api *oliveTinAPI) RestartAction(ctx ctx.Context, req *connect.Request[apiv
 		ExecutionTrackingId: req.Msg.ExecutionTrackingId,
 		ExecutionTrackingId: req.Msg.ExecutionTrackingId,
 	}
 	}
 
 
-	var execReqLogEntry *executor.InternalLogEntry
-
 	execReqLogEntry, found := api.executor.GetLog(req.Msg.ExecutionTrackingId)
 	execReqLogEntry, found := api.executor.GetLog(req.Msg.ExecutionTrackingId)
 
 
 	if !found {
 	if !found {
-		log.Warnf("Restarting execution request not possible - not found by tracking ID: %v", req.Msg.ExecutionTrackingId)
-		return connect.NewResponse(ret), nil
+		return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("execution not found for tracking ID %s", req.Msg.ExecutionTrackingId))
 	}
 	}
 
 
-	log.Warnf("Restarting execution request by tracking ID: %v", req.Msg.ExecutionTrackingId)
+	if execReqLogEntry.Binding == nil {
+		return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("log entry has no binding for tracking ID %s", req.Msg.ExecutionTrackingId))
+	}
 
 
 	action := execReqLogEntry.Binding.Action
 	action := execReqLogEntry.Binding.Action
 
 
 	if action == nil {
 	if action == nil {
-		log.Warnf("Restarting execution request not possible - action not found: %v", execReqLogEntry.ActionTitle)
-		return connect.NewResponse(ret), nil
+		return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("action not found for tracking ID %s", req.Msg.ExecutionTrackingId))
 	}
 	}
 
 
-	return api.StartAction(ctx, &connect.Request[apiv1.StartActionRequest]{
-		Msg: &apiv1.StartActionRequest{
-			BindingId:        execReqLogEntry.GetBindingId(),
-			UniqueTrackingId: req.Msg.ExecutionTrackingId,
-		},
-	})
+	authenticatedUser := auth.UserFromApiCall(ctx, req, api.cfg)
+
+	// TrackingID is deliberately not passed to the executor, so that it generates a new one for the restarted execution.
+	// This is because the old execution (identified by the old TrackingID) is already used.
+	execReq := executor.ExecutionRequest{
+		Binding:           execReqLogEntry.Binding,
+		Arguments:         make(map[string]string),
+		AuthenticatedUser: authenticatedUser,
+		Cfg:               api.cfg,
+	}
+
+	api.executor.ExecRequest(&execReq)
+
+	ret.ExecutionTrackingId = execReq.TrackingID
+	return connect.NewResponse(ret), nil
 }
 }
 
 
 func newServer(ex *executor.Executor) *oliveTinAPI {
 func newServer(ex *executor.Executor) *oliveTinAPI {

+ 33 - 18
service/internal/api/apiActions.go

@@ -28,18 +28,22 @@ func (rr *DashboardRenderRequest) findAction(title string) *apiv1.Action {
 	return rr.findActionForEntity(title, nil)
 	return rr.findActionForEntity(title, nil)
 }
 }
 
 
+func bindingMatchesTitleAndEntity(binding *executor.ActionBinding, title string, entity *entities.Entity) bool {
+	return binding != nil && binding.Action != nil && binding.Action.Title == title && matchesEntity(binding, entity)
+}
+
 func (rr *DashboardRenderRequest) findActionForEntity(title string, entity *entities.Entity) *apiv1.Action {
 func (rr *DashboardRenderRequest) findActionForEntity(title string, entity *entities.Entity) *apiv1.Action {
 	rr.ex.MapActionBindingsLock.RLock()
 	rr.ex.MapActionBindingsLock.RLock()
 	defer rr.ex.MapActionBindingsLock.RUnlock()
 	defer rr.ex.MapActionBindingsLock.RUnlock()
 
 
 	for _, binding := range rr.ex.MapActionBindings {
 	for _, binding := range rr.ex.MapActionBindings {
-		if binding.Action.Title != title {
+		if !bindingMatchesTitleAndEntity(binding, title, entity) {
 			continue
 			continue
 		}
 		}
-
-		if matchesEntity(binding, entity) {
-			return buildAction(binding, rr)
+		if !acl.IsAllowedView(rr.cfg, rr.AuthenticatedUser, binding.Action) {
+			return nil
 		}
 		}
+		return buildAction(binding, rr)
 	}
 	}
 
 
 	return nil
 	return nil
@@ -117,26 +121,37 @@ func getDefaultArgumentValue(cfgArg config.ActionArgument, entity *entities.Enti
 	return defaultValue
 	return defaultValue
 }
 }
 
 
-func buildAction(actionBinding *executor.ActionBinding, rr *DashboardRenderRequest) *apiv1.Action {
-	action := actionBinding.Action
+func formatRateLimitExpiry(expiryUnix int64) string {
+	if expiryUnix <= 0 {
+		return ""
+	}
+	return time.Unix(expiryUnix, 0).Format("2006-01-02 15:04:05")
+}
 
 
-	aclCanExec := acl.IsAllowedExec(rr.cfg, rr.AuthenticatedUser, action)
-	enabledExprCanExec := evaluateEnabledExpression(action, actionBinding.Entity)
+func actionFromBinding(actionBinding *executor.ActionBinding) (*executor.ActionBinding, *config.Action) {
+	if actionBinding == nil || actionBinding.Action == nil {
+		return nil, nil
+	}
+	return actionBinding, actionBinding.Action
+}
 
 
-	// Calculate rate limit expiry time
-	expiryUnix := rr.ex.GetTimeUntilAvailable(actionBinding)
-	datetimeRateLimitExpires := ""
-	if expiryUnix > 0 {
-		datetimeRateLimitExpires = time.Unix(expiryUnix, 0).Format("2006-01-02 15:04:05")
+func buildAction(actionBinding *executor.ActionBinding, rr *DashboardRenderRequest) *apiv1.Action {
+	binding, action := actionFromBinding(actionBinding)
+	if binding == nil {
+		return nil
 	}
 	}
 
 
+	aclCanExec := acl.IsAllowedExec(rr.cfg, rr.AuthenticatedUser, action)
+	enabledExprCanExec := evaluateEnabledExpression(action, binding.Entity)
+	datetimeRateLimitExpires := formatRateLimitExpiry(rr.ex.GetTimeUntilAvailable(binding))
+
 	btn := apiv1.Action{
 	btn := apiv1.Action{
-		BindingId:                actionBinding.ID,
-		Title:                    tpl.ParseTemplateOfActionBeforeExec(action.Title, actionBinding.Entity),
-		Icon:                     tpl.ParseTemplateOfActionBeforeExec(action.Icon, actionBinding.Entity),
+		BindingId:                binding.ID,
+		Title:                    tpl.ParseTemplateOfActionBeforeExec(action.Title, binding.Entity),
+		Icon:                     tpl.ParseTemplateOfActionBeforeExec(action.Icon, binding.Entity),
 		CanExec:                  aclCanExec && enabledExprCanExec,
 		CanExec:                  aclCanExec && enabledExprCanExec,
 		PopupOnStart:             action.PopupOnStart,
 		PopupOnStart:             action.PopupOnStart,
-		Order:                    int32(actionBinding.ConfigOrder),
+		Order:                    int32(binding.ConfigOrder),
 		Timeout:                  int32(action.Timeout),
 		Timeout:                  int32(action.Timeout),
 		DatetimeRateLimitExpires: datetimeRateLimitExpires,
 		DatetimeRateLimitExpires: datetimeRateLimitExpires,
 	}
 	}
@@ -147,7 +162,7 @@ func buildAction(actionBinding *executor.ActionBinding, rr *DashboardRenderReque
 			Title:                 cfgArg.Title,
 			Title:                 cfgArg.Title,
 			Type:                  cfgArg.Type,
 			Type:                  cfgArg.Type,
 			Description:           cfgArg.Description,
 			Description:           cfgArg.Description,
-			DefaultValue:          getDefaultArgumentValue(cfgArg, actionBinding.Entity),
+			DefaultValue:          getDefaultArgumentValue(cfgArg, binding.Entity),
 			Choices:               buildChoices(cfgArg),
 			Choices:               buildChoices(cfgArg),
 			Suggestions:           cfgArg.Suggestions,
 			Suggestions:           cfgArg.Suggestions,
 			SuggestionsBrowserKey: cfgArg.SuggestionsBrowserKey,
 			SuggestionsBrowserKey: cfgArg.SuggestionsBrowserKey,

+ 115 - 0
service/internal/api/api_test.go

@@ -6,6 +6,7 @@ import (
 
 
 	"connectrpc.com/connect"
 	"connectrpc.com/connect"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
 
 
 	log "github.com/sirupsen/logrus"
 	log "github.com/sirupsen/logrus"
 
 
@@ -335,3 +336,117 @@ func testWithEntity(t *testing.T, binding *executor.ActionBinding, rr *Dashboard
 	actionResult := buildAction(binding, rr)
 	actionResult := buildAction(binding, rr)
 	assert.Equal(t, expectedCanExec, actionResult.CanExec, message)
 	assert.Equal(t, expectedCanExec, actionResult.CanExec, message)
 }
 }
+
+// buildViewPermissionTestConfig returns config and users for GHSA view-permission tests:
+// one action "secret_action", ACL "restricted" (view:false) for user "low", ACL "full" (view:true) for user "admin".
+func buildViewPermissionTestConfig(t *testing.T) (*config.Config, *authpublic.AuthenticatedUser, *authpublic.AuthenticatedUser) {
+	t.Helper()
+	cfg := config.DefaultConfig()
+	cfg.DefaultPermissions.View = false
+	cfg.DefaultPermissions.Exec = false
+
+	cfg.Actions = append(cfg.Actions, &config.Action{
+		ID:    "secret_action",
+		Title: "Secret Action",
+		Shell: "echo sensitive",
+		Icon:  "🔒",
+	})
+
+	cfg.AccessControlLists = append(cfg.AccessControlLists,
+		&config.AccessControlList{
+			Name:             "restricted",
+			MatchUsernames:   []string{"low"},
+			AddToEveryAction: true,
+			Permissions:      config.PermissionsList{View: false, Exec: false, Logs: false, Kill: false},
+		},
+		&config.AccessControlList{
+			Name:             "full",
+			MatchUsernames:   []string{"admin"},
+			AddToEveryAction: true,
+			Permissions:      config.PermissionsList{View: true, Exec: true, Logs: true, Kill: true},
+		},
+	)
+
+	lowUser := &authpublic.AuthenticatedUser{Username: "low"}
+	lowUser.BuildUserAcls(cfg)
+	adminUser := &authpublic.AuthenticatedUser{Username: "admin"}
+	adminUser.BuildUserAcls(cfg)
+	return cfg, lowUser, adminUser
+}
+
+// TestViewPermissionExcludedFromDashboard (GHSA: view permission) asserts that when a user has view: false,
+// the default dashboard must not include that action. Covers GetDashboard not leaking action metadata.
+func TestViewPermissionExcludedFromDashboard(t *testing.T) {
+	cfg, lowUser, _ := buildViewPermissionTestConfig(t)
+	ex := executor.DefaultExecutor(cfg)
+	ex.RebuildActionMap()
+
+	rr := &DashboardRenderRequest{
+		AuthenticatedUser: lowUser,
+		cfg:               cfg,
+		ex:                ex,
+	}
+	db := buildDefaultDashboard(rr)
+
+	bindingIdsInDashboard := bindingIdsInDashboardContents(db.Contents)
+	assert.NotContains(t, bindingIdsInDashboard, "secret_action",
+		"user with view:false must not see action in dashboard; got bindingIds: %v", bindingIdsInDashboard)
+}
+
+// TestGetActionBindingDeniedWhenNoViewPermission (GHSA: view permission) asserts that GetActionBinding
+// returns permission denied for a user with view: false. Covers GetActionBinding not exposing action details.
+func TestGetActionBindingDeniedWhenNoViewPermission(t *testing.T) {
+	cfg, lowUser, _ := buildViewPermissionTestConfig(t)
+	ex := executor.DefaultExecutor(cfg)
+	ex.RebuildActionMap()
+	api := newServer(ex)
+
+	_, err := api.getActionBindingResponse(lowUser, "secret_action")
+	require.Error(t, err)
+	assert.Equal(t, connect.CodePermissionDenied, connect.CodeOf(err),
+		"user with view:false must get permission denied from GetActionBinding")
+}
+
+// TestViewPermissionAllowedSeesAction (GHSA: view permission) asserts that a user with view: true
+// still sees the action in the dashboard and can fetch it via GetActionBinding.
+func TestViewPermissionAllowedSeesAction(t *testing.T) {
+	cfg, _, adminUser := buildViewPermissionTestConfig(t)
+	ex := executor.DefaultExecutor(cfg)
+	ex.RebuildActionMap()
+	api := newServer(ex)
+
+	rr := &DashboardRenderRequest{
+		AuthenticatedUser: adminUser,
+		cfg:               cfg,
+		ex:                ex,
+	}
+	db := buildDefaultDashboard(rr)
+	bindingIdsInDashboard := bindingIdsInDashboardContents(db.Contents)
+	assert.Contains(t, bindingIdsInDashboard, "secret_action",
+		"user with view:true must see action in dashboard; got bindingIds: %v", bindingIdsInDashboard)
+
+	resp, err := api.getActionBindingResponse(adminUser, "secret_action")
+	require.NoError(t, err)
+	require.NotNil(t, resp)
+	require.NotNil(t, resp.Action)
+	assert.Equal(t, "secret_action", resp.Action.BindingId)
+}
+
+func bindingIdsInDashboardContents(contents []*apiv1.DashboardComponent) []string {
+	var ids []string
+	for _, c := range contents {
+		ids = append(ids, bindingIdsFromComponent(c)...)
+	}
+	return ids
+}
+
+func bindingIdsFromComponent(c *apiv1.DashboardComponent) []string {
+	if c == nil {
+		return nil
+	}
+	var ids []string
+	if c.Action != nil && c.Action.BindingId != "" {
+		ids = append(ids, c.Action.BindingId)
+	}
+	return append(ids, bindingIdsInDashboardContents(c.Contents)...)
+}

+ 9 - 1
service/internal/api/dashboards.go

@@ -4,6 +4,7 @@ import (
 	"sort"
 	"sort"
 
 
 	apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1"
 	apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1"
+	acl "github.com/OliveTin/OliveTin/internal/acl"
 	config "github.com/OliveTin/OliveTin/internal/config"
 	config "github.com/OliveTin/OliveTin/internal/config"
 	entities "github.com/OliveTin/OliveTin/internal/entities"
 	entities "github.com/OliveTin/OliveTin/internal/entities"
 	"github.com/OliveTin/OliveTin/internal/tpl"
 	"github.com/OliveTin/OliveTin/internal/tpl"
@@ -130,7 +131,7 @@ func buildDefaultDashboard(rr *DashboardRenderRequest) *apiv1.Dashboard {
 	}
 	}
 
 
 	for _, binding := range rr.ex.MapActionBindings {
 	for _, binding := range rr.ex.MapActionBindings {
-		if binding.Action.Hidden {
+		if binding == nil || binding.Action == nil || binding.Action.Hidden {
 			continue
 			continue
 		}
 		}
 
 
@@ -138,7 +139,14 @@ func buildDefaultDashboard(rr *DashboardRenderRequest) *apiv1.Dashboard {
 			continue
 			continue
 		}
 		}
 
 
+		if !acl.IsAllowedView(rr.cfg, rr.AuthenticatedUser, binding.Action) {
+			continue
+		}
+
 		action := buildAction(binding, rr)
 		action := buildAction(binding, rr)
+		if action == nil {
+			continue
+		}
 
 
 		fieldset.Contents = append(fieldset.Contents, &apiv1.DashboardComponent{
 		fieldset.Contents = append(fieldset.Contents, &apiv1.DashboardComponent{
 			Type:   "link",
 			Type:   "link",