Security Considerations

Learn how to configure permissions securely and avoid common risks when executing shell commands in Tauri v2 with the Shell plugin

The Shell plugin gives your Tauri app the power to run system commands, launch external programs, and interact with the operating system. That power carries real risk: a single misconfigured permission or a string of unsanitized user input can let an attacker execute arbitrary code on the user's machine. This document walks through the security model, the most dangerous pitfalls, and the practices that keep your application safe. Pair this with Understanding Capabilities if a call is rejected.

How the shell permission model restricts command execution

Tauri v2 never allows shell access by default. Every command you want to run must be explicitly declared in a capability file. This design prevents a compromised frontend from spawning arbitrary processes without the developer's knowledge.

The Shell plugin exposes several permission identifiers. The two most common for running commands are:

  • shell:allow-spawn – allows spawning a child process without waiting for it.
  • shell:allow-execute – allows running a command and collecting its output.

Each of these can be scoped to specific commands. Without a scope, the permission alone is useless. You must define a list of allowed commands, their exact binary paths, and any argument constraints.

A minimal capability entry that allows running git with only the --version flag looks like this:

src-tauri/capabilities/default.json
{
  "identifier": "shell:allow-execute",
  "allow": [
    {
      "name": "git",
      "cmd": "/usr/bin/git",
      "args": [
        "--version"
      ],
      "sidecar": false
    }
  ]
}

The cmd field must be an absolute path or a bare command name that Tauri resolves against the system PATH at runtime. The args array defines an allowlist of fixed arguments. If you need dynamic arguments, you can use a validator pattern instead.

Correctly scoped:

When a capability defines a narrow allowlist like this, Tauri will reject any command that uses a different binary, a different argument, or even an extra flag. This is the safest starting point.

For dynamic arguments, you supply a validator regex that each user-provided argument must match. The following example allows echo with any single word as the message:

src-tauri/capabilities/default.json
{
  "identifier": "shell:allow-execute",
  "allow": [
    {
      "name": "echo-command",
      "cmd": "echo",
      "args": [
        {
          "validator": "\\S+"
        }
      ],
      "sidecar": false
    }
  ]
}

A validator does not make the argument safe for all contexts. It only restricts the shape of the string. You are still responsible for preventing injection, as discussed later.

Validators are not sanitizers:

A regex validator checks the format of an argument but does not escape special characters. A string that passes \S+ can still contain shell metacharacters like ;, |, or $ if the underlying command interprets them.

Recognizing the main categories of shell API risk

Before you lock down a configuration, it helps to understand what you are protecting against. The risks fall into three broad categories, each with a different root cause and a different fix.

Command injection through unsanitized input

This is the most common and most severe class of vulnerability. It happens when user-supplied data is passed directly to a shell interpreter or when command arguments are built by concatenating strings without proper escaping.

Imagine a feature that lets the user type a note and then runs echo to process it. A naive implementation in the frontend might look like this:

// Dangerous: concatenation builds a shell command string
import { Command } from '@tauri-apps/plugin-shell';
async function echoNote(userInput: string) {
  const command = Command.create('exec-sh', ['-c', `echo ${userInput}`]);
  return await command.execute();
}

If the user types hello; rm -rf /, the shell executes both commands. Tauri's scoping will not stop this because the scope only checks the command name (exec-sh) and the validator on the -c argument. It has no way to know that the content after echo is malicious.

Direct shell execution with user data is a critical risk:

Never pass unsanitized user input to sh -c, cmd /C, or any other shell interpreter. The shell will parse metacharacters, subshells, and pipes, making injection trivial.

The correct approach is to run the command directly, passing arguments as separate items in the array. Tauri's Command API spawns the process without invoking a shell when you omit the -c wrapper.

// Safe: arguments are passed directly to the echo binary, no shell interpretation
import { Command } from '@tauri-apps/plugin-shell';
async function echoNote(userInput: string) {
  const command = Command.create('echo', [userInput]);
  return await command.execute();
}

This still requires that the argument passes the validator in the capability. If the validator is restrictive enough (for example, only alphanumeric characters), the risk is negligible. On Windows, remember that echo is not a standalone binary but a built-in of cmd. You would need to either use cmd /C echo ... (which reintroduces the shell) or use a different program altogether. The platform difference is a common source of misconfiguration.

Windows built-ins require a shell:

On Windows, commands like echo, dir, or type are not separate executables. You must call cmd /C to use them, which means you are back in a shell context. Avoid built-ins when user input is involved. Instead, write a small sidecar binary or use a more predictable command.

Environment variable manipulation

When you spawn a process, it inherits the environment of the Tauri app, which includes system PATH, user home directory, and any variables set by the system. An attacker who can influence these variables before the app launches can redirect which binary gets executed.

Consider a capability that allows ls without an absolute path:

{
  "name": "ls",
  "cmd": "ls",
  "args": [],
  "sidecar": false
}

If the PATH environment variable has been tampered with to include a directory containing a malicious ls executable, that binary runs instead of /bin/ls. Tauri resolves the command using the current environment.

The mitigation is to always use an absolute path in the cmd field, and if the command is user-facing, ensure the environment is clean before spawning. For Rust-side execution, you can set clear_env(true) on the command builder:

use tauri_plugin_shell::ShellExt;
let output = app.shell()
    .command("/usr/bin/ls")
    .args(["-la"])
    .clear_env(true)
    .env("PATH", "/usr/bin:/bin")
    .output()
    .await
    .unwrap();

This removes all inherited variables and sets only what the command needs.

Environment hygiene is extra defense:

Even if you use absolute paths, cleaning the environment prevents the command from being affected by unexpected variables like LD_PRELOAD on Linux or DYLD_INSERT_LIBRARIES on macOS, which can inject code into a process at load time.

Temporary file attacks and path traversal

If your app writes data to temporary files and later feeds those files to a command, you open a window for an attacker to replace the file between write and read. This is especially dangerous in publicly writable directories like /tmp on Unix systems.

A classic scenario: an app writes a script to /tmp/script.sh, then runs sh /tmp/script.sh. If the attacker can delete the file and replace it with a symlink to something else before execution, they control what gets run.

The defense is to create a private temporary directory with mktemp -d (or the Rust equivalent), set restrictive permissions with umask, and write the file inside that directory. Never write executable content to shared locations.

use std::env::temp_dir;
use std::fs::{self, File};
use std::os::unix::fs::PermissionsExt;
fn safe_temp_script() -> std::io::Result<()> {
    let dir = temp_dir().join(format!("myapp_{}", std::process::id()));
    fs::create_dir(&dir)?;
    // restrict permissions on the directory
    fs::set_permissions(&dir, fs::Permissions::from_mode(0o700))?;
    let script_path = dir.join("script.sh");
    let mut file = File::create(&script_path)?;
    // write script content
    // then execute from the secure location
    Ok(())
}

Path traversal is a related threat. If your command accepts a filename argument derived from user input, an attacker might pass ../../etc/passwd to read sensitive files. Validators in the capability can prevent patterns like .., but you should also resolve paths against a safe base directory on the Rust side and reject anything that escapes it.

Building a defense-in-depth configuration

Security for the Shell API isn't a single checkbox. It's a series of layered decisions that together shrink the attack surface.

Whitelist exact commands with absolute paths

Define every allowed command with its full path. Never use a bare name unless you control the environment tightly and the platform guarantees the binary location (e.g., /usr/bin/git on macOS/Linux). This single choice eliminates PATH hijacking entirely.

Constrain arguments to the minimum necessary

If a command only needs to be run with one or two fixed flags, list them literally in the args array. If you must accept user input, attach a validator that is as strict as possible. For instance, if the input should be a filename, a validator like ^[a-zA-Z0-9_.-]+$ prevents directory traversal characters.

Prefer direct command execution over shell wrapping

The pattern Command.create('exec-sh', ['-c', someString]) is the single most dangerous call in the API. It hands user-controlled data directly to a shell parser. If your feature can be implemented by running the target command directly with argument arrays, do that instead. The only times sh -c is genuinely needed are when you need shell features like piping, redirection, or built-in commands—and those should be treated as high-risk, high-scrutiny code paths.

Offload complex logic to sidecar binaries

A sidecar is an executable you bundle with your app and invoke through the Shell plugin with "sidecar": true in the scope. Because you control the sidecar's source code, you can implement all the risky logic (input parsing, file access, environment setup) in a language with strong type safety, and expose a simple, narrow interface to the frontend. The sidecar can accept structured input (like JSON on stdin) and avoid shell interpretation entirely.

src-tauri/capabilities/default.json
{
  "identifier": "shell:allow-execute",
  "allow": [
    {
      "name": "my-sidecar",
      "cmd": "binaries/my-sidecar",
      "args": [],
      "sidecar": true
    }
  ]
}

The sidecar binary lives in src-tauri/binaries/ (configurable) and is executed with its name relative to that directory. Tauri treats it as a trusted component, but you must still ensure the sidecar itself does not do anything dangerous with the input it receives.

Validate and sanitize all input from the frontend

Even with narrow validators in the capability, treat every piece of data that crosses the WebView boundary as potentially hostile. On the Rust side, where you have full control, add an extra layer of validation before passing arguments to a command. This can be as simple as checking the length, character set, and absence of path separators, or as thorough as parsing the input into a known structure and re-serializing it.

fn sanitize_filename(input: &str) -> Result<String, String> {
    if input.contains('/') || input.contains('\\') || input.contains("..") {
        return Err("Invalid filename".into());
    }
    Ok(input.to_string())
}

Handle command output carefully

The output of a command can contain data an attacker embedded in a file or response. If you display that output in the frontend as HTML, it becomes a cross-site scripting vector. Apply output encoding appropriate for your rendering context. The frontend should treat command output as untrusted plain text, never as executable code or markup.

A complete secure configuration example

Here is a realistic capability entry that allows running ffprobe (a media inspection tool) with a single filename argument, restricted to alphanumeric characters, dots, hyphens, and underscores:

src-tauri/capabilities/default.json
{
  "$schema": "../gen/schemas/desktop-schema.json",
  "identifier": "media-capability",
  "description": "Allows ffprobe to inspect user-supplied media files",
  "windows": ["main"],
  "permissions": [
    {
      "identifier": "shell:allow-execute",
      "allow": [
        {
          "name": "ffprobe",
          "cmd": "/usr/bin/ffprobe",
          "args": [
            "-v",
            "quiet",
            "-print_format",
            "json",
            "-show_format",
            "-show_streams",
            {
              "validator": "^[a-zA-Z0-9_.-]+$"
            }
          ],
          "sidecar": false
        }
      ]
    }
  ]
}

The frontend call passes the user-chosen filename as the only dynamic argument:

import { Command } from '@tauri-apps/plugin-shell';
async function inspectMedia(filename: string) {
  const command = Command.create('ffprobe', [
    '-v', 'quiet',
    '-print_format', 'json',
    '-show_format',
    '-show_streams',
    filename
  ]);
  const output = await command.execute();
  console.log(output.stdout);
}

Notice that -v quiet, -print_format json, -show_format, and -show_streams are all fixed in the capability. The only dynamic parameter is the filename, and its validator prevents path traversal characters. The command runs directly, without a shell, so even if a malicious filename passed the validator, it could not inject additional flags or commands.

Why this works:

The capability restricts the binary to an absolute path, locks most arguments to known safe values, and uses a strict regex on the sole user-supplied argument. The frontend uses the direct Command.create form with an arguments array, bypassing shell interpretation. Even if the validator is circumvented—which would require a Tauri scope bypass—the Rust-side process is ffprobe, not a shell, so arbitrary command injection is impossible.

Summary

The Shell API bridges your web frontend to the full power of the operating system. Securing it is a matter of denying everything by default, then carefully opening only the specific commands and arguments your feature genuinely needs. Use absolute paths, constrain arguments with strict validators, keep user input out of shell interpreters, and push risky logic into sidecar binaries you control.