package executor import ( config "github.com/OliveTin/OliveTin/internal/config" "github.com/OliveTin/OliveTin/internal/entities" "github.com/OliveTin/OliveTin/internal/fileupload" "github.com/OliveTin/OliveTin/internal/tpl" log "github.com/sirupsen/logrus" "fmt" "net/mail" "net/url" "regexp" "strings" "time" ) var ( typecheckRegex = map[string]string{ "very_dangerous_raw_string": "", "int": `^\d+$`, "unicode_identifier": `^[\w\-\.\_\d]+$`, "ascii": `^[a-zA-Z0-9]+$`, "ascii_identifier": `^[a-zA-Z0-9\-\._]+$`, "ascii_sentence": `^[a-zA-Z0-9\-\._, ]+$`, } ) // parseExecArray parses all exec arguments in the action. func parseExecArray(action *config.Action, templateArgs map[string]any, entity *entities.Entity) ([]string, error) { parsed := make([]string, len(action.Exec)) for i, segment := range action.Exec { out, err := parseExecSegment(segment, templateArgs, entity) if err != nil { return nil, err } parsed[i] = out } return parsed, nil } func prepareExecArguments(req *ExecutionRequest) error { if err := validateArguments(req); err != nil { return err } return finalizeFileUploadArguments(req) } func execRequestAction(req *ExecutionRequest) (*config.Action, error) { if req == nil || req.Binding == nil { return nil, fmt.Errorf("request or binding is nil") } if req.Binding.Action == nil { return nil, fmt.Errorf("action is nil") } return req.Binding.Action, nil } func parseActionExec(req *ExecutionRequest) ([]string, error) { action, err := execRequestAction(req) if err != nil { return nil, err } if err := prepareExecArguments(req); err != nil { return nil, err } tmpl := buildTemplateArgumentMap(req) parsed, err := parseExecArray(action, tmpl, req.Binding.Entity) if err != nil { return nil, err } logParsedExec(action, parsed, req.Arguments) return parsed, nil } func parseExecSegment(arg string, templateArgs map[string]any, entity *entities.Entity) (string, error) { return tpl.ParseTemplateWithActionContext(arg, entity, templateArgs) } func uploadRegistryFromExecutor(req *ExecutionRequest) *fileupload.Registry { if req == nil || req.executor == nil { return nil } return req.executor.UploadRegistry } func validateArguments(req *ExecutionRequest) error { action, err := execRequestAction(req) if err != nil { return err } reg := uploadRegistryFromExecutor(req) bindingID := req.Binding.ID for _, arg := range action.Arguments { if err := typecheckActionArgument(&arg, req.Arguments[arg.Name], action, reg, bindingID); err != nil { return err } log.WithFields(log.Fields{"name": arg.Name, "value": req.Arguments[arg.Name]}).Debugf("Arg assigned") } return nil } func logParsedExec(action *config.Action, parsed []string, values map[string]string) { redacted := redactExecArgs(parsed, action.Arguments, values) log.WithFields(log.Fields{"actionTitle": action.Title, "cmd": redacted}).Infof("Action parse args - After (Exec)") } func parseActionArguments(req *ExecutionRequest) (string, error) { log.WithFields(log.Fields{ "actionTitle": req.Binding.Action.Title, "cmd": req.Binding.Action.Shell, }).Infof("Action parse args - Before") if err := prepareExecArguments(req); err != nil { return "", err } tmpl := buildTemplateArgumentMap(req) parsedShellCommand, err := tpl.ParseTemplateWithActionContext(req.Binding.Action.Shell, req.Binding.Entity, tmpl) if err != nil { return "", err } redactedShellCommand := redactShellCommand(parsedShellCommand, req.Binding.Action.Arguments, req.Arguments) log.WithFields(log.Fields{ "actionTitle": req.Binding.Action.Title, "cmd": redactedShellCommand, }).Infof("Action parse args - After") return parsedShellCommand, nil } //gocyclo:ignore func redactShellCommand(shellCommand string, arguments []config.ActionArgument, argumentValues map[string]string) string { for _, arg := range arguments { if arg.Type == "password" || arg.Type == "file_upload" { argValue, exists := argumentValues[arg.Name] if !exists { log.Warnf("Redact shell command: Argument %s not found in values", arg.Name) continue } if argValue == "" { continue } shellCommand = strings.ReplaceAll(shellCommand, argValue, "") } } return shellCommand } //gocyclo:ignore func redactExecArgs(execArgs []string, arguments []config.ActionArgument, argumentValues map[string]string) []string { redacted := make([]string, len(execArgs)) for i, arg := range execArgs { redacted[i] = redactShellCommand(arg, arguments, argumentValues) } return redacted } func typecheckActionArgument(arg *config.ActionArgument, value string, action *config.Action, reg *fileupload.Registry, bindingID string) error { if arg.Type == "confirmation" { return nil } if arg.Name == "" { return fmt.Errorf("argument name cannot be empty") } if arg.Type == "file_upload" { return validateFileUploadArg(value, arg, reg, bindingID) } return typecheckActionArgumentFound(value, arg) } // ValidateArgument validates a single argument value using the same logic as the executor. // It applies mangling transformations and performs full validation including null checks, // choice validation, and type safety checks. func ValidateArgument(arg *config.ActionArgument, value string, action *config.Action, uploadRegistry *fileupload.Registry, bindingID string) error { if arg == nil { return fmt.Errorf("ValidateArgument: arg is nil") } if action == nil { return fmt.Errorf("ValidateArgument: action is nil") } // Apply mangling transformations mangledValue := MangleArgumentValue(arg, value, action.Title) // Use the same validation path as the executor return typecheckActionArgument(arg, mangledValue, action, uploadRegistry, bindingID) } func typecheckActionArgumentFound(value string, arg *config.ActionArgument) error { if value == "" { return typecheckNull(arg) } if len(arg.Choices) > 0 { return typecheckChoice(value, arg) } return TypeSafetyCheck(arg.Name, value, arg.Type) } // TypeSafetyCheck checks argument values match a specific type. The types are // defined in typecheckRegex, and, you guessed it, uses regex to check for allowed // characters. // //gocyclo:ignore func TypeSafetyCheck(name string, value string, argumentType string) error { switch argumentType { case "password": return nil case "file_upload": return nil case "raw_string_multiline": return nil case "checkbox": return nil case "email": return typeSafetyCheckEmail(value) case "url": return typeSafetyCheckUrl(value) case "datetime": return typeSafetyCheckDatetime(value) } return typeSafetyCheckRegex(name, value, argumentType) } func typecheckNull(arg *config.ActionArgument) error { if arg.RejectNull { return fmt.Errorf("null values are not allowed") } return nil } func typecheckChoice(value string, arg *config.ActionArgument) error { if arg.Entity != "" { return typecheckChoiceEntity(value, arg) } for _, choice := range arg.Choices { if value == choice.Value { return nil } } return fmt.Errorf("argument value is not one of the predefined choices") } func typecheckChoiceEntity(value string, arg *config.ActionArgument) error { templateChoice := arg.Choices[0].Value for _, ent := range entities.GetEntityInstances(arg.Entity) { choice := tpl.ParseTemplateOfActionBeforeExec(templateChoice, ent) if value == choice { return nil } } return fmt.Errorf("argument value cannot be found in entities") } func typeSafetyCheckEmail(value string) error { _, err := mail.ParseAddress(value) if err != nil { log.WithField("type", "email").Debugf("Email argument type check failed") return err } return nil } func typeSafetyCheckDatetime(value string) error { _, err := time.Parse("2006-01-02T15:04:05", value) if err != nil { return err } return nil } func typeSafetyCheckRegex(name string, value string, argumentType string) error { pattern := "" if strings.HasPrefix(argumentType, "regex:") { pattern = strings.Replace(argumentType, "regex:", "", 1) } else { found := false pattern, found = typecheckRegex[argumentType] if !found { return fmt.Errorf("argument type not implemented %v for arg: %v", argumentType, name) } } matches, _ := regexp.MatchString(pattern, value) if !matches { log.WithFields(log.Fields{ "name": name, "value": value, "type": argumentType, "pattern": pattern, }).Warn("Arg type check safety failure") return fmt.Errorf("invalid argument %v, doesn't match %v", name, argumentType) } return nil } func typeSafetyCheckUrl(value string) error { _, err := url.ParseRequestURI(value) return err } func checkShellArgumentSafety(action *config.Action) error { if action.Shell == "" { return nil } unsafe := map[string]struct{}{ "url": {}, "email": {}, "raw_string_multiline": {}, "very_dangerous_raw_string": {}, "password": {}, "file_upload": {}, } for _, arg := range action.Arguments { if _, bad := unsafe[arg.Type]; bad { return fmt.Errorf("unsafe argument type '%s' cannot be used with Shell execution. Use 'exec' instead. See https://docs.olivetin.app/action_execution/shellvsexec.html", arg.Type) } } return nil } func mangleInvalidArgumentValues(req *ExecutionRequest) { for _, arg := range req.Binding.Action.Arguments { if arg.Type == "datetime" { mangleInvalidDatetimeValues(req, &arg) } mangleCheckboxValues(req, &arg) } } func mangleCheckboxValues(req *ExecutionRequest, arg *config.ActionArgument) { if arg.Type != "checkbox" { return } log.Infof("Checking checkbox values for argument %s in action %s", arg.Name, req.Binding.Action.Title) for i, v := range arg.Choices { choice := &arg.Choices[i] if req.Arguments[arg.Name] == choice.Title { log.WithFields(log.Fields{ "arg": arg.Name, "choice": v, "oldValue": req.Arguments[arg.Name], "newValue": choice.Value, "actionTitle": req.Binding.Action.Title, }).Infof("Mangled checkbox value") req.Arguments[arg.Name] = choice.Value } } } func mangleInvalidDatetimeValues(req *ExecutionRequest, arg *config.ActionArgument) { value, exists := req.Arguments[arg.Name] if !exists || value == "" { return } timestamp, err := time.Parse("2006-01-02T15:04", value) if err == nil { log.WithFields(log.Fields{ "arg": arg.Name, "value": value, "actionTitle": req.Binding.Action.Title, }).Warnf("Mangled invalid datetime value without seconds to :00 seconds, this issue is commonly caused by Android browsers.") req.Arguments[arg.Name] = timestamp.Format("2006-01-02T15:04:05") } } // MangleArgumentValue applies mangling transformations to a single argument value. // This is used by the validation API to ensure the value matches what would be // used during actual execution. func MangleArgumentValue(arg *config.ActionArgument, value string, actionTitle string) string { if arg == nil { log.Debugf("MangleArgumentValue called with nil arg, returning value unchanged") return value } return mangleArgumentValueByType(arg, value, actionTitle) } func mangleArgumentValueByType(arg *config.ActionArgument, value string, actionTitle string) string { switch arg.Type { case "file_upload": return value case "datetime": return mangleDatetimeValue(arg, value, actionTitle) case "checkbox": return mangleCheckboxValue(arg, value, actionTitle) default: return value } } func mangleDatetimeValue(arg *config.ActionArgument, value string, actionTitle string) string { if arg == nil { log.Debugf("mangleDatetimeValue called with nil arg, returning value unchanged") return value } if value == "" { return value } timestamp, err := time.Parse("2006-01-02T15:04", value) if err != nil { return value } log.WithFields(log.Fields{ "arg": arg.Name, "value": value, "actionTitle": actionTitle, }).Warnf("Mangled invalid datetime value without seconds to :00 seconds, this issue is commonly caused by Android browsers.") return timestamp.Format("2006-01-02T15:04:05") } func mangleCheckboxValue(arg *config.ActionArgument, value string, actionTitle string) string { if arg == nil { log.Debugf("mangleCheckboxValue called with nil arg, returning value unchanged") return value } for _, choice := range arg.Choices { if value == choice.Title { log.WithFields(log.Fields{ "arg": arg.Name, "oldValue": value, "newValue": choice.Value, "actionTitle": actionTitle, }).Infof("Mangled checkbox value") return choice.Value } } return value }