danielmiessler.fabric/internal/plugins/template/extension_executor.go
Authensor 3eba7a51d4 fix: shell-escape extension values to prevent command injection
The extension executor runs commands via 'sh -c' with user-controlled
values interpolated into the command string without escaping. A value
containing shell metacharacters (;, |, $(), backticks) is executed by
the shell, enabling command injection.

User input flows from content processed through a pattern into the
extension system via the InputSentinel, then into formatCommand which
interpolates it into the cmd_template, then into exec.Command('sh', '-c').
No escaping is applied at any point.

Fix: wrap all user-controlled values (value, numbered pipe-split values)
in single quotes with embedded-single-quote escaping before interpolation.
This ensures sh -c treats them as literal arguments, not shell syntax.

The existing tests pass unchanged because the inner sh strips the single
quotes, so the executed command receives the same argument values.

Regression test added: ShellInjectionBlocked verifies that input
'hello; touch /marker' does not create the marker file.

Signed-off-by: John Kearney <johndanielkearney@gmail.com>
2026-07-01 21:55:21 -05:00

216 lines
6.4 KiB
Go

package template
import (
"bytes"
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/danielmiessler/fabric/internal/i18n"
)
// ExtensionExecutor handles the secure execution of extensions
// It uses the registry to verify extensions before running them
type ExtensionExecutor struct {
registry *ExtensionRegistry
}
// NewExtensionExecutor creates a new executor instance
// It requires a registry to verify extensions
func NewExtensionExecutor(registry *ExtensionRegistry) *ExtensionExecutor {
return &ExtensionExecutor{
registry: registry,
}
}
// Execute runs an extension with the given operation and value string
// name: the registered name of the extension
// operation: the operation to perform
// value: the input value(s) for the operation
// In extension_executor.go
func (e *ExtensionExecutor) Execute(name, operation, value string) (string, error) {
// Get and verify extension from registry
ext, err := e.registry.GetExtension(name)
if err != nil {
return "", fmt.Errorf(i18n.T("extension_failed_get_extension"), err)
}
// Format the command using our template system
cmdStr, err := e.formatCommand(ext, operation, value)
if err != nil {
return "", fmt.Errorf(i18n.T("extension_failed_format_command"), err)
}
// Split the command string into command and arguments
cmdParts := strings.Fields(cmdStr)
if len(cmdParts) < 1 {
return "", errors.New(i18n.T("extension_empty_command"))
}
// Create command with the Executable and formatted arguments
cmd := exec.Command("sh", "-c", cmdStr)
//cmd := exec.Command(cmdParts[0], cmdParts[1:]...)
// Set up environment if specified
if len(ext.Env) > 0 {
cmd.Env = append(os.Environ(), ext.Env...)
}
// Execute based on output method
outputMethod := ext.GetOutputMethod()
if outputMethod == "file" {
return e.executeWithFile(cmd, ext)
}
return e.executeStdout(cmd, ext)
}
// formatCommand uses fabric's template system to format the command
// It creates a variables map for the template system using the input values
func (e *ExtensionExecutor) formatCommand(ext *ExtensionDefinition, operation string, value string) (string, error) {
// Get operation config
opConfig, exists := ext.Operations[operation]
if !exists {
return "", fmt.Errorf("%s", fmt.Sprintf(i18n.T("extension_operation_not_found"), operation, ext.Name))
}
// Shell-escape all user-controlled values to prevent command injection.
// The command string is ultimately passed to "sh -c", so any shell
// metacharacters (;, |, $(), backticks, etc.) in the value would be
// executed. Wrapping each value in single quotes and escaping embedded
// single quotes ensures the value is treated as a literal argument.
vars := make(map[string]string)
vars["executable"] = ext.Executable
vars["operation"] = operation
vars["value"] = shellEscape(value)
// Split on pipe for numbered variables
values := strings.Split(value, "|")
for i, val := range values {
vars[fmt.Sprintf("%d", i+1)] = shellEscape(val)
}
return ApplyTemplate(opConfig.CmdTemplate, vars, "")
}
// shellEscape wraps a string in single quotes for safe use in a shell command,
// escaping any embedded single quotes. This prevents command injection when
// untrusted input is passed as an argument to "sh -c".
func shellEscape(s string) string {
return "'" + strings.ReplaceAll(s, "'", "'\"'\"'") + "'"
}
// executeStdout runs the command and captures its stdout
func (e *ExtensionExecutor) executeStdout(cmd *exec.Cmd, ext *ExtensionDefinition) (string, error) {
var stdout bytes.Buffer
var stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
//debug output
fmt.Printf(i18n.T("extension_executing_command"), cmd.String())
if err := cmd.Run(); err != nil {
return "", fmt.Errorf(i18n.T("extension_execution_failed_stderr"), err, stderr.String())
}
return stdout.String(), nil
}
// executeWithFile runs the command and handles file-based output
func (e *ExtensionExecutor) executeWithFile(cmd *exec.Cmd, ext *ExtensionDefinition) (string, error) {
// Parse timeout - this is now a first-class field
timeout, err := time.ParseDuration(ext.Timeout)
if err != nil {
return "", fmt.Errorf(i18n.T("extension_invalid_timeout_format"), err)
}
// Create context with timeout
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
// Store the original environment
originalEnv := cmd.Env
// Create a new command with context. This might reset Env, depending on the Go version.
cmd = exec.CommandContext(ctx, cmd.Path, cmd.Args[1:]...)
// Restore the environment variables explicitly
cmd.Env = originalEnv
fileConfig := ext.GetFileConfig()
if fileConfig == nil {
return "", errors.New(i18n.T("extension_no_file_config"))
}
// Handle path from stdout case
if pathFromStdout, ok := fileConfig["path_from_stdout"].(bool); ok && pathFromStdout {
return e.handlePathFromStdout(cmd, ext)
}
// Handle fixed file case
workDir, _ := fileConfig["work_dir"].(string)
outputFile, _ := fileConfig["output_file"].(string)
if outputFile == "" {
return "", errors.New(i18n.T("extension_no_output_file"))
}
// Set working directory if specified
if workDir != "" {
cmd.Dir = workDir
}
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
if ctx.Err() == context.DeadlineExceeded {
return "", fmt.Errorf("%s", fmt.Sprintf(i18n.T("extension_execution_timed_out"), timeout))
}
return "", fmt.Errorf(i18n.T("extension_execution_failed_err"), err, stderr.String())
}
// Construct full file path
outputPath := outputFile
if workDir != "" {
outputPath = filepath.Join(workDir, outputFile)
}
content, err := os.ReadFile(outputPath)
if err != nil {
return "", fmt.Errorf(i18n.T("extension_failed_read_output_file"), err)
}
// Handle cleanup if enabled
if ext.IsCleanupEnabled() {
defer os.Remove(outputPath)
}
return string(content), nil
}
// Helper method to handle path from stdout case
func (e *ExtensionExecutor) handlePathFromStdout(cmd *exec.Cmd, ext *ExtensionDefinition) (string, error) {
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return "", fmt.Errorf(i18n.T("extension_failed_get_output_path"), err, stderr.String())
}
outputPath := strings.TrimSpace(stdout.String())
content, err := os.ReadFile(outputPath)
if err != nil {
return "", fmt.Errorf(i18n.T("extension_failed_read_output_file"), err)
}
if ext.IsCleanupEnabled() {
defer os.Remove(outputPath)
}
return string(content), nil
}