Skip to content

fix(security): execute ScriptNotifier notification templates without a shell - #123

Open
dacj4n wants to merge 2 commits into
google:mainfrom
dacj4n:fix/quote-notifier-template-placeholders
Open

dacj4n wants to merge 2 commits into
google:mainfrom
dacj4n:fix/quote-notifier-template-placeholders

Conversation

@dacj4n

@dacj4n dacj4n commented Sep 17, 2026

Copy link
Copy Markdown

Summary

ScriptNotifier builds its command line by substituting placeholders with str.replace() and executed the result through a shell (shell=True). The template is operator-supplied, but the substituted values are task data: a task's goal reaches {title} and {message} (apps/admin_console/services/task_queue_service.py:788-796) and is fully controlled by whoever submits the task, which requires no authentication (POST /api/run).

Because the command line was handed to /bin/sh, that task data could be interpreted as shell syntax. The documented example (mcp_server/README.md:48) was not safe either: the notification message embeds single quotes around the goal, so inserting it into the template's quoted {message} placeholder closed that quote early and left the goal unquoted.

Reported through the Google OSS VRP.

Changes

  • mcp_server/notifiers/script.py — run the template as an argument vector instead of through a shell: shlex.split(cmd_template) resolves the operator's own quoting, placeholders are substituted per token, then the command is launched with shell=False. Task data can no longer contribute shell syntax because no shell is involved.
  • mcp_server/notifiers/script.py — document the behaviour in the class docstring.
  • mcp_server/README.md — note it in the notification-channel table.
  • tests/unit/mcp/test_notifiers.py — two regression tests: substituted values must arrive as exactly one literal argument each, and task data containing shell metacharacters must not execute.

Quoting the substituted values (e.g. shlex.quote) would not have been sufficient: the template may already wrap the placeholder in quotes, in which case the added quotes cancel out and the value is parsed as shell syntax again. I verified this — the first version of this patch used shlex.quote and the regression test caught it.

Compatibility

The intended feature is preserved: an operator can still configure a command and receive the notification data, and the documented usage keeps working unchanged — shlex.split resolves the operator's quoting, so my-script --title '{title}' --message '{message}' still passes both values as single arguments.

Two deliberate behaviour changes, both documented in the docstring and the README:

  • Shell features in the template no longer apply — pipes, &&, redirection, $VAR expansion and globbing. An operator relying on those should move that logic into their script and reference it as a single command.
  • If the configured command does not exist, notify() now returns False. Previously the shell reported the failure but notify() still returned True.

Verification

  • make typecheck — 0 errors, 0 warnings
  • uv run ruff check / ruff format --check on the changed files — clean
  • uv run pytest tests/unit/mcp/test_notifiers.py -v — 11 passed
  • Both new tests fail on main without this change (temporarily reverted the patch to confirm): the end-to-end test's failure output shows the injected command actually running, so it is a real regression test rather than an empty assertion
  • make test — pass/fail counts unchanged versus main (2107 → 2109 passed; the two additional passes are the new tests, the pre-existing unrelated failures are identical)

…a shell

ScriptNotifier substituted task data into an operator-supplied template and ran the result through a shell (subprocess.run(..., shell=True)). A task's goal reaches {title}/{message}, and POST /api/run requires no authentication, so an unauthenticated task submission could be interpreted as shell syntax and executed as the ARTEMIS server user (CWE-78).

Execute the template as an argument vector instead: shlex.split() resolves the operator's own quoting, placeholders are substituted per token, and the command is launched with shell=False, so task data can no longer contribute shell syntax.

Quoting the substituted values would not have been sufficient: the template may already wrap the placeholder in quotes, in which case the added quotes cancel out and the value is parsed as shell syntax again.

Behavior change: shell features in the template (pipes, &&, redirection, $VAR expansion, globbing) no longer apply; move that logic into the script itself. A missing command now makes notify() return False rather than True.

Adds two regression tests in tests/unit/mcp/test_notifiers.py; both fail on main without this change.
…preted

Document the behaviour change introduced by executing the template as an argument vector: shell features in the template no longer apply, so that logic belongs in the script itself. Also note that quotes around a placeholder no longer change how the value is passed.
@google-cla

google-cla Bot commented Sep 17, 2026

Copy link
Copy Markdown

Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA).

View this failed invocation of the CLA check for more information.

For the most up to date status, view the checks section at the bottom of the pull request.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant