mirror of
https://github.com/danielmiessler/fabric.git
synced 2026-09-10 07:36:44 -04:00
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>
216 lines
6.4 KiB
Go
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
|
|
}
|