security: (MED) GHSA-fwhj-785h-43hh Crash on NPE by calling APIs with invalid bindings or log references

This commit is contained in:
jamesread 2026-03-04 22:51:58 +00:00
parent 235493e471
commit bb14c5da3e
3 changed files with 111 additions and 61 deletions

View File

@ -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 nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("execution not found for tracking ID %s", req.Msg.ExecutionTrackingId))
return connect.NewResponse(ret), nil
} }
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) return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("action not found for tracking ID %s", req.Msg.ExecutionTrackingId))
ret.Killed = false
return connect.NewResponse(ret), nil
} }
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) { func (api *oliveTinAPI) startActionAndWaitRun(binding *executor.ActionBinding, args map[string]string, user *authpublic.AuthenticatedUser) (*executor.InternalLogEntry, bool) {
args := make(map[string]string)
for _, arg := range req.Msg.Arguments {
args[arg.Name] = arg.Value
}
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,
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 { 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{ return connect.NewResponse(&apiv1.StartActionAndWaitResponse{
LogEntry: api.internalLogEntryToPb(internalLogEntry, user), LogEntry: api.internalLogEntryToPb(internalLogEntry, user),
}), nil }), nil
} else {
return nil, fmt.Errorf("execution not found")
}
} }
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 {
@ -436,7 +457,7 @@ func (api *oliveTinAPI) GetActionBinding(ctx ctx.Context, req *connect.Request[a
binding := api.executor.FindBindingByID(req.Msg.BindingId) binding := api.executor.FindBindingByID(req.Msg.BindingId)
if binding == nil { if binding == nil || binding.Action == 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", req.Msg.BindingId))
} }
@ -646,7 +667,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 +777,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 +798,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),
} }
} }
@ -1267,28 +1304,26 @@ func serializeEntityFields(data any) map[string]string {
} }
func (api *oliveTinAPI) RestartAction(ctx ctx.Context, req *connect.Request[apiv1.RestartActionRequest]) (*connect.Response[apiv1.StartActionResponse], error) { func (api *oliveTinAPI) RestartAction(ctx ctx.Context, req *connect.Request[apiv1.RestartActionRequest]) (*connect.Response[apiv1.StartActionResponse], error) {
ret := &apiv1.StartActionResponse{
ExecutionTrackingId: req.Msg.ExecutionTrackingId,
}
var execReqLogEntry *executor.InternalLogEntry 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 nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("execution not found for tracking ID %s", req.Msg.ExecutionTrackingId))
return connect.NewResponse(ret), nil
} }
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 nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("action not found for tracking ID %s", req.Msg.ExecutionTrackingId))
return connect.NewResponse(ret), nil
} }
log.Warnf("Restarting execution request by tracking ID: %v", req.Msg.ExecutionTrackingId)
return api.StartAction(ctx, &connect.Request[apiv1.StartActionRequest]{ return api.StartAction(ctx, &connect.Request[apiv1.StartActionRequest]{
Msg: &apiv1.StartActionRequest{ Msg: &apiv1.StartActionRequest{
BindingId: execReqLogEntry.GetBindingId(), BindingId: execReqLogEntry.GetBindingId(),

View File

@ -28,19 +28,20 @@ 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) return buildAction(binding, rr)
} }
}
return nil return nil
} }
@ -117,26 +118,37 @@ func getDefaultArgumentValue(cfgArg config.ActionArgument, entity *entities.Enti
return defaultValue return defaultValue
} }
func formatRateLimitExpiry(expiryUnix int64) string {
if expiryUnix <= 0 {
return ""
}
return time.Unix(expiryUnix, 0).Format("2006-01-02 15:04:05")
}
func actionFromBinding(actionBinding *executor.ActionBinding) (*executor.ActionBinding, *config.Action) {
if actionBinding == nil || actionBinding.Action == nil {
return nil, nil
}
return actionBinding, actionBinding.Action
}
func buildAction(actionBinding *executor.ActionBinding, rr *DashboardRenderRequest) *apiv1.Action { func buildAction(actionBinding *executor.ActionBinding, rr *DashboardRenderRequest) *apiv1.Action {
action := actionBinding.Action binding, action := actionFromBinding(actionBinding)
if binding == nil {
aclCanExec := acl.IsAllowedExec(rr.cfg, rr.AuthenticatedUser, action) return nil
enabledExprCanExec := evaluateEnabledExpression(action, actionBinding.Entity)
// 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")
} }
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, BindingId: binding.ID,
Title: tpl.ParseTemplateOfActionBeforeExec(action.Title, actionBinding.Entity), Title: tpl.ParseTemplateOfActionBeforeExec(action.Title, binding.Entity),
Icon: tpl.ParseTemplateOfActionBeforeExec(action.Icon, actionBinding.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 +159,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,

View File

@ -130,7 +130,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
} }
@ -139,6 +139,9 @@ func buildDefaultDashboard(rr *DashboardRenderRequest) *apiv1.Dashboard {
} }
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",