Преглед изворни кода

fix: close shellAfterCompleted output injection bypass, and docs consistency
Rewrite all output/exitCode template forms to env refs and omit them
from template args so command output cannot reach sh -c.
(GHSA-vc6p)

jamesread пре 3 недеља
родитељ
комит
e5d29b68da

+ 5 - 3
docs/modules/ROOT/pages/action_execution/aftercompletion.adoc

@@ -13,13 +13,15 @@ actions:
     shellAfterCompleted: "apprise -c /config/apprise.yml -t 'Notification: Backup script completed' -b \"$(printf 'Backup completed with exit code %s. Log: %s' {{ exitCode }} {{ output }})\""
     shellAfterCompleted: "apprise -c /config/apprise.yml -t 'Notification: Backup script completed' -b \"$(printf 'Backup completed with exit code %s. Log: %s' {{ exitCode }} {{ output }})\""
 ----
 ----
 
 
-When running shellAfterCompleted, you *cannot* use argument values - they are not passed to the command. However the following special arguments are defined;
+When running shellAfterCompleted, you *cannot* use user-defined argument values - they are not passed to the command. However the following special arguments are defined;
 
 
-* `{{ exitCode }}` - The exit code of the previous shell command. OliveTin substitutes this with the `EXITCODE` environment variable when running `shellAfterCompleted`, so shell metacharacters in the value cannot break quoting.
-* `{{ output }}` - The standard output of the previous shell command. OliveTin substitutes this with the `OUTPUT` environment variable when running `shellAfterCompleted`, so shell metacharacters in command output cannot be executed. You can also reference `$OUTPUT` directly in your `shellAfterCompleted` command. Do not place these placeholders inside single-quoted shell arguments; single quotes prevent `$OUTPUT` and `$EXITCODE` from expanding after substitution.
+* `{{ exitCode }}` / `{{ .Arguments.exitCode }}` - The exit code of the previous command. OliveTin rewrites these placeholders to the quoted `"$EXITCODE"` environment reference when running `shellAfterCompleted`, so shell metacharacters in the value cannot break quoting.
+* `{{ output }}` / `{{ .Arguments.output }}` - The standard output of the previous command. OliveTin rewrites these placeholders to the quoted `"$OUTPUT"` environment reference, so shell metacharacters in command output cannot be executed. You can also reference `$OUTPUT` directly in your `shellAfterCompleted` command. Do not place these placeholders inside single-quoted shell arguments; single quotes prevent `$OUTPUT` and `$EXITCODE` from expanding after substitution.
 * `{{ .Arguments.ot_executionTrackingId }}` - The unique execution tracking id for this execution (version 3k; in 2k use `{{ ot_executionTrackingId }}`)
 * `{{ .Arguments.ot_executionTrackingId }}` - The unique execution tracking id for this execution (version 3k; in 2k use `{{ ot_executionTrackingId }}`)
 * `{{ .Arguments.ot_username }}` - The username of the user who started the execution (version 3k; in 2k use `{{ ot_username }}`). May be `guest` or `cron` for unauthenticated or automated runs.
 * `{{ .Arguments.ot_username }}` - The username of the user who started the execution (version 3k; in 2k use `{{ ot_username }}`). May be `guest` or `cron` for unauthenticated or automated runs.
 
 
+Webhooks cannot use `shellAfterCompleted` (or `shell:`). Use `exec:` for webhook-triggered actions without an after-completion shell. See xref:action_execution/shellvsexec.adoc[Shell vs Exec].
+
 You can only use a single `shellAfterCompleted`, so use it for notifications, or similar. It would be an antipattern to use this do run 2 commands making up a mini script.
 You can only use a single `shellAfterCompleted`, so use it for notifications, or similar. It would be an antipattern to use this do run 2 commands making up a mini script.
 
 
 The official OliveTin container images from version 2024.03.24 onwards include the fantastic apprise tool, which makes chat notifications on many protocols very easy.
 The official OliveTin container images from version 2024.03.24 onwards include the fantastic apprise tool, which makes chat notifications on many protocols very easy.

+ 27 - 12
docs/modules/ROOT/pages/action_execution/onwebhook.adoc

@@ -7,7 +7,7 @@ OliveTin provides a dedicated webhook endpoint at `/webhooks` that can receive w
 
 
 == Basic Configuration
 == Basic Configuration
 
 
-To configure an action to run on a webhook, add the `execOnWebhook` property to your action:
+To configure an action to run on a webhook, add the `execOnWebhook` property to your action. Webhook-triggered actions **must** use `exec:` (not `shell:` or `shellAfterCompleted`).
 
 
 [source,yaml]
 [source,yaml]
 .`config.yaml`
 .`config.yaml`
@@ -15,7 +15,8 @@ To configure an action to run on a webhook, add the `execOnWebhook` property to
 actions:
 actions:
   - title: Deploy Application
   - title: Deploy Application
     id: deploy
     id: deploy
-    shell: /opt/scripts/deploy.sh
+    exec:
+      - /opt/scripts/deploy.sh
     execOnWebhook:
     execOnWebhook:
       - matchHeaders:
       - matchHeaders:
           X-Event-Type: deploy
           X-Event-Type: deploy
@@ -51,7 +52,9 @@ Match webhooks based on HTTP header values:
 ----
 ----
 actions:
 actions:
   - title: Process Event
   - title: Process Event
-    shell: echo "Processing event"
+    exec:
+      - echo
+      - "Processing event"
     execOnWebhook:
     execOnWebhook:
       - matchHeaders:
       - matchHeaders:
           X-Event-Type: my-event
           X-Event-Type: my-event
@@ -68,7 +71,9 @@ Match webhooks based on URL query parameters:
 ----
 ----
 actions:
 actions:
   - title: Process Request
   - title: Process Request
-    shell: echo "Processing request for {{ service }}"
+    exec:
+      - echo
+      - "Processing request for {{ service }}"
     arguments:
     arguments:
       - name: service
       - name: service
         type: ascii
         type: ascii
@@ -88,7 +93,9 @@ Match webhooks based on values in the JSON request body using JSONPath expressio
 ----
 ----
 actions:
 actions:
   - title: Handle Push Event
   - title: Handle Push Event
-    shell: echo "Push to {{ branch }}"
+    exec:
+      - echo
+      - "Push to {{ branch }}"
     arguments:
     arguments:
       - name: branch
       - name: branch
         type: ascii
         type: ascii
@@ -112,7 +119,9 @@ Header and query parameter values can use regex patterns by prefixing with `rege
 ----
 ----
 actions:
 actions:
   - title: Handle Multiple Events
   - title: Handle Multiple Events
-    shell: echo "Handling event"
+    exec:
+      - echo
+      - "Handling event"
     execOnWebhook:
     execOnWebhook:
       - matchHeaders:
       - matchHeaders:
           X-Event-Type: "regex:^(push|pull_request|release)$"
           X-Event-Type: "regex:^(push|pull_request|release)$"
@@ -126,7 +135,9 @@ You can combine multiple match criteria. All criteria must match for the webhook
 ----
 ----
 actions:
 actions:
   - title: Production Deploy
   - title: Production Deploy
-    shell: /opt/scripts/deploy.sh production
+    exec:
+      - /opt/scripts/deploy.sh
+      - production
     execOnWebhook:
     execOnWebhook:
       - matchHeaders:
       - matchHeaders:
           X-Event-Type: deploy
           X-Event-Type: deploy
@@ -143,9 +154,10 @@ You can extract values from the webhook payload and pass them as arguments to yo
 ----
 ----
 actions:
 actions:
   - title: Deploy Version
   - title: Deploy Version
-    shell: |
-      echo "Deploying version {{ version }} to {{ environment }}"
-      /opt/scripts/deploy.sh "{{ version }}" "{{ environment }}"
+    exec:
+      - /opt/scripts/deploy.sh
+      - "{{ version }}"
+      - "{{ environment }}"
     arguments:
     arguments:
       - name: version
       - name: version
         type: ascii
         type: ascii
@@ -176,7 +188,9 @@ For example, to access the `X-Request-Id` header in your action:
 ----
 ----
 actions:
 actions:
   - title: Log Request
   - title: Log Request
-    shell: echo "Request ID: {{ webhook_header_x-request-id }}"
+    exec:
+      - echo
+      - "Request ID: {{ webhook_header_x-request-id }}"
     arguments:
     arguments:
       - name: webhook_header_x-request-id
       - name: webhook_header_x-request-id
         type: ascii
         type: ascii
@@ -278,7 +292,8 @@ An action can have multiple webhook configurations. The action will be triggered
 ----
 ----
 actions:
 actions:
   - title: Deploy
   - title: Deploy
-    shell: /opt/scripts/deploy.sh
+    exec:
+      - /opt/scripts/deploy.sh
     execOnWebhook:
     execOnWebhook:
       - matchHeaders:
       - matchHeaders:
           X-Event-Type: deploy-manual
           X-Event-Type: deploy-manual

+ 15 - 3
docs/modules/ROOT/pages/action_execution/shellvsexec.adoc

@@ -1,13 +1,25 @@
 = Shell vs Exec
 = Shell vs Exec
 
 
-OliveTin supports two different methods to run commands: `shell` and `exec`. The difference between these two is that "shell" accepts strings, and will wrap that whole command in a shell with "bash -c". Exec uses a syscall directly to execute commands.
+OliveTin supports two different methods to run commands: `shell` and `exec`. The difference between these two is that "shell" accepts a single string and runs it via the system shell (`sh -c` on Unix; `cmd /C` on Windows). Exec passes an argument vector directly to the operating system without invoking a shell.
 
 
 * **Shell** is more flexible, because it allows you to chain commands (eg, using &&) and redirect or pipe output (eg: ">" or "|").
 * **Shell** is more flexible, because it allows you to chain commands (eg, using &&) and redirect or pipe output (eg: ">" or "|").
 * **Exec** is more secure, because it does not invoke a shell, and thus avoids shell injection attacks.
 * **Exec** is more secure, because it does not invoke a shell, and thus avoids shell injection attacks.
 
 
-Shell can be safe and secure with simple argument types (like ascii_identifier), but some argument types like URL can contain basically any character - /, :, ?, &, etc - which can lead to shell injection vulnerabilities while still being a valid URL.
+Shell can be safe and secure with simple argument types (like `ascii_identifier`), but some argument types like `url` can contain characters such as `/`, `:`, `?`, and `&` which can lead to shell injection vulnerabilities while still being a valid URL.
 
 
-OliveTin will try and prevent you from using dangerous characters in shell commands (eg, URL is no longer permitted with Shell).
+OliveTin blocks unsafe argument types from being used with `shell:` (for example `url`, `email`, `password`, `regex:...`, and raw string types). See xref:args/types.adoc#shell-blocked-arg-types[Types that cannot be used with shell]. Prefer `exec:` when in doubt.
+
+[#shell-entity-env-trust]
+== Entity and `.Env` values are not shell-sanitized
+
+User-supplied **argument** values are type-checked (and some types are blocked with `shell`) to reduce shell injection risk. That protection does **not** apply to:
+
+* Entity fields — `{{ .CurrentEntity.field }}` (and legacy forms such as `{{ server.hostname }}`)
+* Process environment — `{{ .Env.VAR_NAME }}`
+
+Those values are substituted into `shell` / `shellAfterCompleted` as-is. OliveTin assumes they are **server-controlled** (entity files and the OliveTin process environment under the operator's control). The author of the config is responsible for ensuring that data is trustworthy, or for using `exec` and careful quoting when it might not be.
+
+Webhooks cannot use `shell:` or `shellAfterCompleted`; webhook-triggered actions must use `exec:` only. See xref:action_execution/onwebhook.adoc[Execute on webhook].
 
 
 The way that you specify these two types of execution is different - `shell` expects a single string, while `exec` expects a list of strings (the first being the command, the rest being the arguments).
 The way that you specify these two types of execution is different - `shell` expects a single string, while `exec` expects a list of strings (the first being the command, the rest being the arguments).
 
 

+ 2 - 0
docs/modules/ROOT/pages/advanced_configuration/config_envs.adoc

@@ -51,4 +51,6 @@ actions:
 
 
 `.Env` uses the same Go template context as other action variables (e.g. `.Arguments`, `.CurrentEntity`, `.OliveTin`). The map is built from the process environment when OliveTin starts; values are read at template execution time. If a variable is missing, the template engine will report a missing-key error (with `missingkey=error`), so use defaulting when a variable might be unset, e.g. `{{ or .Env.OPTIONAL_VAR "default" }}`. For template functions such as JSON encoding, see xref:args/templates.adoc#json-encoding[JSON encoding with Json].
 `.Env` uses the same Go template context as other action variables (e.g. `.Arguments`, `.CurrentEntity`, `.OliveTin`). The map is built from the process environment when OliveTin starts; values are read at template execution time. If a variable is missing, the template engine will report a missing-key error (with `missingkey=error`), so use defaulting when a variable might be unset, e.g. `{{ or .Env.OPTIONAL_VAR "default" }}`. For template functions such as JSON encoding, see xref:args/templates.adoc#json-encoding[JSON encoding with Json].
 
 
+`.Env` values are **not** sanitized for shell safety. When you use them in `shell` or `shellAfterCompleted`, OliveTin assumes the process environment is server-controlled and that you accept responsibility for those values. See xref:action_execution/shellvsexec.adoc#shell-entity-env-trust[Entity and .Env values are not shell-sanitized].
+
 This feature addresses the need to use environment variables in templates without changing the config loader (see link:https://github.com/OliveTin/OliveTin/issues/840[GitHub issue #840]).
 This feature addresses the need to use environment variables in templates without changing the config loader (see link:https://github.com/OliveTin/OliveTin/issues/840[GitHub issue #840]).

+ 2 - 0
docs/modules/ROOT/pages/args/input_checkbox.adoc

@@ -3,6 +3,8 @@
 
 
 The `checkbox` type argument is a simple checkbox that can be used to toggle a boolean value. It can be especially useful to pass flags to your actions.
 The `checkbox` type argument is a simple checkbox that can be used to toggle a boolean value. It can be especially useful to pass flags to your actions.
 
 
+Define `choices` for the checked/unchecked values. A checkbox **without** choices is not allowed with `shell:` (use `exec:` instead).
+
 [source,yaml]
 [source,yaml]
 ----
 ----
 actions:
 actions:

+ 3 - 3
docs/modules/ROOT/pages/args/input_checklist.adoc

@@ -1,7 +1,7 @@
 [#checklist]
 [#checklist]
 = Input: Checklist
 = Input: Checklist
 
 
-The `checklist` type argument renders multiple checkboxes from predefined `choices`. Users can select one or more options, and the selected values are passed to your action as a comma-separated string.
+The `checklist` type argument renders multiple checkboxes from predefined `choices`. Users can select one or more options, and the selected values are passed to your action as a **JSON array string** (for example `["documents","photos"]`). Legacy comma-separated values are rejected.
 
 
 [source,yaml]
 [source,yaml]
 ----
 ----
@@ -53,7 +53,7 @@ arguments:
 
 
 == Choice values
 == Choice values
 
 
-Choice `value` fields must not contain commas, because commas are used to join multiple selections together.
+Choice `value` fields may contain commas; selections are encoded as JSON, not joined with commas.
 
 
 Each `title` is shown in the web interface. If a submitted segment matches a choice `title`, OliveTin maps it to the corresponding `value` before validation, matching the behaviour of xref:args/input_checkbox.adoc[checkbox] arguments with choices.
 Each `title` is shown in the web interface. If a submitted segment matches a choice `title`, OliveTin maps it to the corresponding `value` before validation, matching the behaviour of xref:args/input_checkbox.adoc[checkbox] arguments with choices.
 
 
@@ -80,4 +80,4 @@ entities:
     name: container
     name: container
 ----
 ----
 
 
-OliveTin expands the template once per entity instance and renders each result as a checkbox. Selected values are still passed as a comma-separated string.
+OliveTin expands the template once per entity instance and renders each result as a checkbox. Selected values are still passed as a JSON array string.

+ 7 - 2
docs/modules/ROOT/pages/args/input_textarea.adoc

@@ -3,19 +3,24 @@
 
 
 OliveTin supports multi-line text inputs, which can be useful for longer messages or scripts. You should set your argument `type` to `raw_string_multiline` to use these.
 OliveTin supports multi-line text inputs, which can be useful for longer messages or scripts. You should set your argument `type` to `raw_string_multiline` to use these.
 
 
-As the name implies, textareas are raw, and are NOT validated by any regex.
+As the name implies, textareas are raw, and are NOT validated by any regex. For that reason they **cannot** be used with `shell:` — use `exec:` so the value is a separate argv element.
 
 
 [source,yaml]
 [source,yaml]
 .`config.yaml`
 .`config.yaml`
 ----
 ----
 actions:
 actions:
   - title: Save text to file
   - title: Save text to file
-    shell: echo "$CONTENT" > file
+    exec:
+      - /bin/sh
+      - -c
+      - echo "$CONTENT" > file
     arguments:
     arguments:
       - type: raw_string_multiline
       - type: raw_string_multiline
         name: content
         name: content
 ----
 ----
 
 
+In that `exec:` example, `$CONTENT` comes from the process environment (OliveTin exports each argument as an uppercase env var), not from shell-string interpolation of the argument into `shell:`.
+
 This renders like this;
 This renders like this;
 
 
 image::args/textarea/multiline-text.png[]
 image::args/textarea/multiline-text.png[]

+ 5 - 4
docs/modules/ROOT/pages/args/password.adoc

@@ -1,9 +1,9 @@
 = Password
 = Password
 
 
-Sometimes you want to mask the input you pass, and a password field is useful for this. 
+Sometimes you want to mask the input you pass, and a password field is useful for this.
 
 
 [WARNING]
 [WARNING]
-Passwords are passed to the OliveTin server in cleartext (unless you're using HTTPS), and are just treated as a string on the server side. 
+Passwords are passed to the OliveTin server in cleartext (unless you're using HTTPS), and are just treated as a string on the server side. Password arguments are **not** type-checked for allowed characters, and they **cannot** be used with `shell:` — use `exec:` so the value is passed as a separate argument instead of being interpolated into a shell string.
 
 
 [source,yaml]
 [source,yaml]
 .`config.yaml`
 .`config.yaml`
@@ -11,9 +11,10 @@ Passwords are passed to the OliveTin server in cleartext (unless you're using HT
 actions:
 actions:
   - title: echo a message
   - title: echo a message
     icon: smile
     icon: smile
-    shell: echo {{ my_password }}
+    exec:
+      - echo
+      - "{{ my_password }}"
     arguments:
     arguments:
       - name: my_password
       - name: my_password
         type: password
         type: password
 ----
 ----
-

+ 7 - 1
docs/modules/ROOT/pages/args/regex.adoc

@@ -5,13 +5,17 @@ OliveTin version 2024.02.081 and above support custom regex patterns for argumen
 
 
 NOTE: The regex pattern should be enclosed in single quotes, otherwise you will probably get a YAML error when starting OliveTin.
 NOTE: The regex pattern should be enclosed in single quotes, otherwise you will probably get a YAML error when starting OliveTin.
 
 
+Custom `regex:...` argument types **cannot** be used with `shell:`. Use `exec:` instead (see xref:action_execution/shellvsexec.adoc[Shell vs Exec]).
+
 [source,yaml]
 [source,yaml]
 .`config.yaml`
 .`config.yaml`
 ----
 ----
 actions:
 actions:
   - title: echo a message
   - title: echo a message
     icon: smile
     icon: smile
-    shell: echo "{{ message }}"
+    exec:
+      - echo
+      - "{{ message }}"
     arguments:
     arguments:
       - name: message
       - name: message
         type: 'regex:^\w\w\w$'
         type: 'regex:^\w\w\w$'
@@ -22,4 +26,6 @@ The site http://regex101.com is a good place to test your regex patterns. OliveT
 . **Regex in the browser** (which probably uses PCRE or Perl Compatible Regular Expressions) - this is so that the browser can give you a nice validation message. This is ignored when it reaches the server though, or if you are using the API directly. Select "PCRE" on the regex101 site when testing.
 . **Regex in the browser** (which probably uses PCRE or Perl Compatible Regular Expressions) - this is so that the browser can give you a nice validation message. This is ignored when it reaches the server though, or if you are using the API directly. Select "PCRE" on the regex101 site when testing.
 . **Regex on the server** (which uses Golang's regex engine) - this is the one that actually validates the input. Select "Golang" on the regex101 site when testing.
 . **Regex on the server** (which uses Golang's regex engine) - this is the one that actually validates the input. Select "Golang" on the regex101 site when testing.
 
 
+On the server, the pattern you provide is anchored to the full string (`^(?:…)$`), so a partial match is not enough.
+
 You cannot specify different regex patterns for the browser and server. The regex pattern you create will need to be compatible with both types of regex engine.
 You cannot specify different regex patterns for the browser and server. The regex pattern you create will need to be compatible with both types of regex engine.

+ 2 - 0
docs/modules/ROOT/pages/args/templates.adoc

@@ -10,6 +10,8 @@ In OliveTin 3k, use dotted names for template context variables:
 * `{{ .Env.VAR_NAME }}` — process environment (see xref:advanced_configuration/config_envs.adoc#using-env-in-template-replacements[Using .Env in template replacements])
 * `{{ .Env.VAR_NAME }}` — process environment (see xref:advanced_configuration/config_envs.adoc#using-env-in-template-replacements[Using .Env in template replacements])
 * `{{ .OliveTin.Build.Version }}` and related build/runtime fields
 * `{{ .OliveTin.Build.Version }}` and related build/runtime fields
 
 
+IMPORTANT: Unlike argument values, `.CurrentEntity` and `.Env` are **not** sanitized for shell safety when used in `shell` or `shellAfterCompleted`. They are treated as server-controlled data; the config author is responsible for that trust. See xref:action_execution/shellvsexec.adoc#shell-entity-env-trust[Entity and .Env values are not shell-sanitized].
+
 In OliveTin 2k, argument and execution-request placeholders used the shorter form (for example, `{{ message }}` instead of `{{ .Arguments.message }}`).
 In OliveTin 2k, argument and execution-request placeholders used the shorter form (for example, `{{ message }}` instead of `{{ .Arguments.message }}`).
 
 
 [#json-encoding]
 [#json-encoding]

+ 35 - 19
docs/modules/ROOT/pages/args/types.adoc

@@ -7,29 +7,45 @@ A full list of argument types are below;
 [%header,cols="1,0,2"]
 [%header,cols="1,0,2"]
 |===
 |===
 | Type                        | Rendered as                       | Allowed values
 | Type                        | Rendered as                       | Allowed values
-| (default)                   | xref:args/input.adoc[Textbox]           | If a `type:` is not set, and `choices:` is empty, then ascii will be used, and a warning will be logged. It is recommended that you set the type explicitly, rather than relying on defaults.
-| ascii                       | xref:args/input.adoc[Textbox]           | a-z (case insensitive), 0-9, but no spaces or punctuation
-| ascii_identifier            | xref:args/input.adoc[Textbox]           | Like a DNS name, a-Z (case insensitive), 0-9, `-`, `.`, and `_`.
+| (default)                   | xref:args/input.adoc[Textbox]           | If a `type:` is not set, and `choices:` is empty, then `ascii` will be used, and a config warning is reported. It is recommended that you set the type explicitly, rather than relying on defaults.
+| ascii                       | xref:args/input.adoc[Textbox]           | `a-z`, `A-Z`, `0-9` only. No spaces or punctuation.
+| ascii_identifier            | xref:args/input.adoc[Textbox]           | `a-z`, `A-Z`, `0-9`, `-`, `.`, and `_`.
 | dnsname                     | xref:args/input.adoc[Textbox]           | A DNS hostname (RFC 1123). Short names (e.g. `webserver`) and FQDNs (e.g. `webserver.example.com`). Letters/digits/hyphens only, no underscores. Optional trailing dot allowed.
 | dnsname                     | xref:args/input.adoc[Textbox]           | A DNS hostname (RFC 1123). Short names (e.g. `webserver`) and FQDNs (e.g. `webserver.example.com`). Letters/digits/hyphens only, no underscores. Optional trailing dot allowed.
 | shell_safe_identifier       | xref:args/input.adoc[Textbox]           | Like an ascii identifier, but also allows `@` and `+`. Useful for shell-safe usernames and email-style identifiers.
 | shell_safe_identifier       | xref:args/input.adoc[Textbox]           | Like an ascii identifier, but also allows `@` and `+`. Useful for shell-safe usernames and email-style identifiers.
-| ascii_sentence              | xref:args/input.adoc[Textbox]           | a-z (case insensitive), 0-9, with spaces, `.` and `,`.
-| unicode_identifier          | xref:args/input.adoc[Textbox]           | Like an ascii identifier, but allows unicode characters. This is useful for languages that use non-ascii characters, such as Chinese, Japanese, etc.
-| email                       | xref:args/input.adoc[Textbox]           | An email address.
-| password                    | xref:args/password.adoc[Password]       | A password, which is hidden when typed.
-| very_dangerous_raw_string   | xref:args/input.adoc[Textbox]           | Anything. This is **incredibly dangerous**, as effectively people can type anything they like, including executing additional commands beyond what you specify. Absolutely should not be used unless your OliveTin instance can only be used by people you trust entirely.
-| regex:...                   | xref:args/input.adoc[Textbox]           | Version 2024.03.081 and above support custom regex patterns. See xref:args/regex.adoc[Custom regex arguments].
-| int                         | xref:args/input.adoc[Textbox]           | Any number, made up of the characters 0 to 9. Negative numbers are not supported.
-| url                         | xref:args/input.adoc[Textbox]           | A URL (e.g. https://example.com). Accepts any scheme, including `file://` and `ftp://`. See warning below.
+| ascii_sentence              | xref:args/input.adoc[Textbox]           | `a-z`, `A-Z`, `0-9`, spaces, `.`, `,`, `-`, and `_`.
+| unicode_identifier          | xref:args/input.adoc[Textbox]           | Same character class as Go's `\w` plus `-` and `.` (ASCII letters, digits, and `_`, plus `-` and `.`). Despite the name, non-ASCII letters are **not** accepted by the current server check.
+| email                       | xref:args/input.adoc[Textbox]           | An email address (parsed with Go's `mail.ParseAddress`).
+| password                    | xref:args/password.adoc[Password]       | Any string (not type-checked). Hidden in the UI. **Not allowed with `shell:`** — use `exec:`.
+| very_dangerous_raw_string   | xref:args/input.adoc[Textbox]           | Anything. This is **incredibly dangerous**, as effectively people can type anything they like, including executing additional commands beyond what you specify. Absolutely should not be used unless your OliveTin instance can only be used by people you trust entirely. **Not allowed with `shell:`** — use `exec:`.
+| regex:...                   | xref:args/input.adoc[Textbox]           | Custom regex patterns. See xref:args/regex.adoc[Custom regex arguments]. **Not allowed with `shell:`** — use `exec:`.
+| int                         | xref:args/input.adoc[Textbox]           | Digits `0-9` only. Negative numbers are not supported.
+| url                         | xref:args/input.adoc[Textbox]           | A URL with scheme `http` or `https` only (e.g. `https://example.com`). **Not allowed with `shell:`** — use `exec:`.
+| datetime                    | xref:args/input_datetime.adoc[Date & Time] | A local datetime in the form `YYYY-MM-DDTHH:MM:SS` (seconds may be mangled to `:00` when browsers omit them).
 | confirmation                | xref:args/input_confirmation.adoc[Confirmation] | A UI gate that requires a checkbox before starting. Usually unnamed (nothing is substituted). If named, the value is only `0` or `1`.
 | confirmation                | xref:args/input_confirmation.adoc[Confirmation] | A UI gate that requires a checkbox before starting. Usually unnamed (nothing is substituted). If named, the value is only `0` or `1`.
-| checklist                   | xref:args/input_checklist.adoc[Checklist]     | Multiple checkboxes from predefined choices. Selected values are passed as a comma-separated string.
-| n/a, but `choices` used     | xref:args/input_dropdown.adoc[Dropdown]         | A "hidden" argument that makes the action require a confirmation before launching.
-| raw_string_multiline        | xref:args/input_textarea.adoc[Textarea]         | Anything. This is **dangerous**, as effectively people can type anything they like
+| checkbox                    | xref:args/input_checkbox.adoc[Checkbox] | Typically used with `choices` for on/off flag values. A checkbox **without** choices is **not allowed with `shell:`** — use `exec:` or define choices.
+| checklist                   | xref:args/input_checklist.adoc[Checklist]     | Multiple checkboxes from predefined choices. Selected values are passed as a JSON array string (e.g. `["documents","photos"]`).
+| n/a, but `choices` used     | xref:args/input_dropdown.adoc[Dropdown]         | Predefined choices shown as a dropdown. The submitted value must match one of the choice values (or an entity-expanded choice).
+| raw_string_multiline        | xref:args/input_textarea.adoc[Textarea]         | Anything (not type-checked). **Dangerous**, and **not allowed with `shell:`** — use `exec:`.
 |===
 |===
 
 
-[WARNING]
-.Security risk: URL argument type
-====
-The `url` argument type does not restrict the URL scheme. Users can enter `file://` (local filesystem) URLs, `ftp://`, or other schemes. If the argument value is passed directly to curl, wget, or similar tools, a malicious or mistaken input could read local files, access internal services, or trigger unwanted network requests.
+[#shell-blocked-arg-types]
+== Types that cannot be used with `shell:`
+
+When an action uses `shell:` (including with arguments substituted into the command string), OliveTin rejects these argument types and asks you to use `exec:` instead:
+
+* `url`
+* `email`
+* `password`
+* `raw_string_multiline`
+* `very_dangerous_raw_string`
+* `html` (internal/display-oriented; skips normal type checks)
+* any custom `regex:...` type
+* `checkbox` when it has **no** `choices`
 
 
-If your action might be used by untrusted users, validate or filter the URL in your script (e.g. allow only `https://`) before using the value.
+See xref:action_execution/shellvsexec.adoc[Shell vs Exec].
+
+[NOTE]
+.URL schemes
+====
+The `url` type accepts only `http` and `https`. Schemes such as `file://`, `ftp://`, and `gopher://` are rejected by the server.
 ====
 ====

+ 2 - 0
docs/modules/ROOT/pages/entities/intro.adoc

@@ -11,6 +11,8 @@ Entities are just loaded from files on disk, OliveTin will also watch these file
 
 
 Entity data files can contain any fields you need. Those values are available in action templates as `{{ .CurrentEntity.field }}` — for example, `{{ .CurrentEntity.status }}` or `{{ .CurrentEntity.hostname }}`.
 Entity data files can contain any fields you need. Those values are available in action templates as `{{ .CurrentEntity.field }}` — for example, `{{ .CurrentEntity.status }}` or `{{ .CurrentEntity.hostname }}`.
 
 
+Entity field values are **not** sanitized for shell safety. If you substitute them into `shell` or `shellAfterCompleted`, OliveTin assumes the entity files are server-controlled and that you accept responsibility for that data. See xref:action_execution/shellvsexec.adoc#shell-entity-env-trust[Entity and .Env values are not shell-sanitized].
+
 To control which fields appear in the Entities page table and entity details view, configure `properties` on the entity definition in `config.yaml`. See xref:entities/properties.adoc[Entity properties] for details.
 To control which fields appear in the Entities page table and entity details view, configure `properties` on the entity definition in `config.yaml`. See xref:entities/properties.adoc[Entity properties] for details.
 
 
 [source,yaml]
 [source,yaml]

+ 7 - 5
docs/modules/ROOT/pages/reference/reference_themes_for_users.adoc

@@ -8,11 +8,11 @@ You can look for themes on the link:http://www.olivetin.app/themes/[OliveTin The
 There are 3 ways to install a theme;
 There are 3 ways to install a theme;
 
 
 
 
-If running inside a Docker container: 
+If running inside a Docker container:
 
 
 1. Use the `olivetin-get-theme` command to easily Git clone the theme into your `custom-webui/themes/` directory.
 1. Use the `olivetin-get-theme` command to easily Git clone the theme into your `custom-webui/themes/` directory.
 
 
-If running without using containers: 
+If running without using containers:
 
 
 1. Download the theme .zip and copy it across to your `custom-webui/themes/` directory.
 1. Download the theme .zip and copy it across to your `custom-webui/themes/` directory.
 2. Git Clone the theme into your `custom-webui/themes/` directory.
 2. Git Clone the theme into your `custom-webui/themes/` directory.
@@ -22,11 +22,14 @@ If running without using containers:
 
 
 The default OliveTin configuration comes with an action to get new OliveTin themes. If you deleted it from your configuration, you can add it back in by adding the following to your `config.yaml` file;
 The default OliveTin configuration comes with an action to get new OliveTin themes. If you deleted it from your configuration, you can add it back in by adding the following to your `config.yaml` file;
 
 
-[source,bash]
+[source,yaml]
 ----
 ----
 actions:
 actions:
   - title: Get OliveTin Theme
   - title: Get OliveTin Theme
-    shell: olivetin-get-theme {{ themeGitRepo }} {{ themeFolderName }}
+    exec:
+      - olivetin-get-theme
+      - "{{ themeGitRepo }}"
+      - "{{ themeFolderName }}"
     icon: theme
     icon: theme
     arguments:
     arguments:
       - name: themeGitRepo
       - name: themeGitRepo
@@ -93,4 +96,3 @@ body {
 Profit.
 Profit.
 
 
 Check out xref:reference/reference_themes_for_developers.adoc[Themes for Developers] for more information on how to develop themes.
 Check out xref:reference/reference_themes_for_developers.adoc[Themes for Developers] for more information on how to develop themes.
-

+ 24 - 13
service/internal/executor/executor.go

@@ -1290,21 +1290,32 @@ func shellAfterCompletedAction(req *ExecutionRequest) (*config.Action, bool) {
 	return req.Binding.Action, true
 	return req.Binding.Action, true
 }
 }
 
 
+// Matches legacy and modern template forms for shellAfterCompleted output/exitCode,
+// including optional .Arguments. prefix and flexible whitespace. These must become
+// quoted env refs before template execution so command output cannot inject into sh -c.
+var (
+	shellAfterOutputRef   = regexp.MustCompile(`\{\{\s*(?:\.Arguments\.)?output\s*\}\}`)
+	shellAfterExitCodeRef = regexp.MustCompile(`\{\{\s*(?:\.Arguments\.)?exitCode\s*\}\}`)
+)
+
 func substituteShellAfterCompletedEnvRefs(command string) string {
 func substituteShellAfterCompletedEnvRefs(command string) string {
-	replacements := []struct{ old, new string }{
-		{"{{ output }}", `"$OUTPUT"`},
-		{"{{output}}", `"$OUTPUT"`},
-		{"{{ exitCode }}", `"$EXITCODE"`},
-		{"{{exitCode}}", `"$EXITCODE"`},
-		{"{{ exitCode}}", `"$EXITCODE"`},
-		{"{{exitCode }}", `"$EXITCODE"`},
-	}
+	// $$ is required: regexp replacements treat $ as submatch expansion.
+	command = shellAfterOutputRef.ReplaceAllString(command, `"$$OUTPUT"`)
+	command = shellAfterExitCodeRef.ReplaceAllString(command, `"$$EXITCODE"`)
+	return command
+}
 
 
-	for _, replacement := range replacements {
-		command = strings.ReplaceAll(command, replacement.old, replacement.new)
+// shellAfterTemplateArgs omits output/exitCode so templates cannot expand them
+// raw. Those values are only provided as OUTPUT/EXITCODE process environment.
+func shellAfterTemplateArgs(args map[string]string) map[string]string {
+	templateArgs := make(map[string]string, len(args))
+	for name, value := range args {
+		if name == "output" || name == "exitCode" {
+			continue
+		}
+		templateArgs[name] = value
 	}
 	}
-
-	return command
+	return templateArgs
 }
 }
 
 
 func parseShellAfterCompletedCommand(req *ExecutionRequest, commandTemplate string, args map[string]string) (string, error) {
 func parseShellAfterCompletedCommand(req *ExecutionRequest, commandTemplate string, args map[string]string) (string, error) {
@@ -1338,7 +1349,7 @@ func buildShellAfterCommand(ctx context.Context, req *ExecutionRequest, stdout,
 	}
 	}
 
 
 	commandTemplate := substituteShellAfterCompletedEnvRefs(action.ShellAfterCompleted)
 	commandTemplate := substituteShellAfterCompletedEnvRefs(action.ShellAfterCompleted)
-	finalParsedCommand, err := parseShellAfterCompletedCommand(req, commandTemplate, args)
+	finalParsedCommand, err := parseShellAfterCompletedCommand(req, commandTemplate, shellAfterTemplateArgs(args))
 	if err != nil {
 	if err != nil {
 		return nil, nil, err
 		return nil, nil, err
 	}
 	}

+ 84 - 0
service/internal/executor/executor_test.go

@@ -449,6 +449,90 @@ func TestShellAfterCompletedUsesOutputEnvSafely(t *testing.T) {
 	assert.True(t, os.IsNotExist(err), "shellAfterCompleted must not execute injected commands from output")
 	assert.True(t, os.IsNotExist(err), "shellAfterCompleted must not execute injected commands from output")
 }
 }
 
 
+func TestShellAfterCompletedBlocksArgumentsOutputInjection(t *testing.T) {
+	payload := func(injectedPath string) string {
+		return "x; touch " + injectedPath + "; #"
+	}
+
+	cases := []struct {
+		name string
+		sac  string
+	}{
+		{"legacy", "printf %s {{ output }}"},
+		{"legacy compact", "printf %s {{output}}"},
+		{"legacy extra spaces", "printf %s {{  output  }}"},
+		{"modern Arguments", "printf %s {{ .Arguments.output }}"},
+		{"modern compact", "printf %s {{.Arguments.output}}"},
+		{"modern exitCode still env", "printf %s {{ .Arguments.exitCode }}"},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			cfg := config.DefaultConfig()
+			e := DefaultExecutor(cfg)
+			injectedPath := filepath.Join(t.TempDir(), "injected")
+			mainPayload := payload(injectedPath)
+
+			a1 := &config.Action{
+				Title:               "sac-injection-" + tc.name,
+				Shell:               "printf %s \"" + mainPayload + "\"",
+				ShellAfterCompleted: tc.sac,
+			}
+			cfg.Actions = append(cfg.Actions, a1)
+			cfg.Sanitize()
+			e.RebuildActionMap()
+
+			req := ExecutionRequest{
+				AuthenticatedUser: auth.UserFromSystem(cfg, "cron"),
+				Cfg:               cfg,
+				Binding:           e.FindBindingWithNoEntity(a1),
+			}
+			wg, _ := e.ExecRequest(&req)
+			wg.Wait()
+
+			_, err := os.Stat(injectedPath)
+			assert.True(t, os.IsNotExist(err), "shellAfterCompleted must not execute injected commands via %q", tc.sac)
+		})
+	}
+}
+
+func TestSubstituteShellAfterCompletedEnvRefs(t *testing.T) {
+	cases := []struct {
+		in   string
+		want string
+	}{
+		{`printf %s {{ output }}`, `printf %s "$OUTPUT"`},
+		{`printf %s {{output}}`, `printf %s "$OUTPUT"`},
+		{`printf %s {{  output  }}`, `printf %s "$OUTPUT"`},
+		{`printf %s {{ .Arguments.output }}`, `printf %s "$OUTPUT"`},
+		{`printf %s {{.Arguments.output}}`, `printf %s "$OUTPUT"`},
+		{`echo {{ exitCode }}`, `echo "$EXITCODE"`},
+		{`echo {{ .Arguments.exitCode }}`, `echo "$EXITCODE"`},
+		{`echo {{  .Arguments.exitCode  }}`, `echo "$EXITCODE"`},
+	}
+
+	for _, tc := range cases {
+		assert.Equal(t, tc.want, substituteShellAfterCompletedEnvRefs(tc.in))
+	}
+}
+
+func TestShellAfterTemplateArgsOmitsOutputAndExitCode(t *testing.T) {
+	args := map[string]string{
+		"output":                 "evil; id",
+		"exitCode":               "1",
+		"ot_username":            "alice",
+		"ot_executionTrackingId": "track-1",
+	}
+
+	templateArgs := shellAfterTemplateArgs(args)
+
+	assert.NotContains(t, templateArgs, "output")
+	assert.NotContains(t, templateArgs, "exitCode")
+	assert.Equal(t, "alice", templateArgs["ot_username"])
+	assert.Equal(t, "track-1", templateArgs["ot_executionTrackingId"])
+	assert.Equal(t, "evil; id", args["output"], "env args map must keep output for OUTPUT=")
+}
+
 func TestFilterToDefinedArgumentsOnly(t *testing.T) {
 func TestFilterToDefinedArgumentsOnly(t *testing.T) {
 	req := newExecRequest()
 	req := newExecRequest()
 	req.Binding.Action = &config.Action{
 	req.Binding.Action = &config.Action{