Files
orca/internal/runtime/podman.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

143 lines
4.0 KiB
Go

package runtime
import (
"context"
"fmt"
"strings"
"git.cloudinit.dev/coreci/orca/internal/sshpush"
)
// PodmanRuntime implements Runtime for the "podman" one_of. It runs
// `podman` on the peer over the SSH-push transport (P01). The
// transport is injected via the constructor (dependency injection).
//
// Container lifecycle:
//
// - Prepare: `podman pull <image>`
// - Start: `podman run -d --name orca-<alloc-id> <image> <command>`
// - Stop: `podman stop <name>` then `podman rm <name>`
// - Status: `podman inspect --format '{{.State.Running}}' <name>`
//
// The container name is `orca-<alloc-id>` (sanitized to lowercase +
// alnum). The runtime keeps no in-process state — each call is a fresh
// SSH exec against the peer.
type PodmanRuntime struct {
transport *sshpush.Transport
}
// NewPodmanRuntime returns a PodmanRuntime backed by the given transport.
func NewPodmanRuntime(t *sshpush.Transport) *PodmanRuntime {
return &PodmanRuntime{transport: t}
}
// containerName returns the deterministic container name for an alloc.
func containerName(alloc *Alloc) string {
id := strings.ToLower(alloc.ID)
id = strings.Map(func(r rune) rune {
if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '-' || r == '_' {
return r
}
return '-'
}, id)
return "orca-" + id
}
// Prepare pulls the image on the peer.
func (p *PodmanRuntime) Prepare(ctx context.Context, alloc *Alloc) error {
image, err := imageFor(alloc)
if err != nil {
return err
}
cmd := fmt.Sprintf("podman pull %q", image)
if _, err := p.transport.Exec(ctx, alloc.Node, cmd); err != nil {
return fmt.Errorf("podman: pull: %w", err)
}
return nil
}
// Start runs `podman run -d --name <name> <image> <command>`.
func (p *PodmanRuntime) Start(ctx context.Context, alloc *Alloc) (int, error) {
image, err := imageFor(alloc)
if err != nil {
return 0, err
}
cmdStr, _ := commandFor(alloc)
name := containerName(alloc)
cmd := fmt.Sprintf("podman run -d --name %s %q %s", shellQuote(name), image, shellQuote(cmdStr))
out, err := p.transport.Exec(ctx, alloc.Node, cmd)
if err != nil {
return 0, fmt.Errorf("podman: run: %w", err)
}
// The container ID is the first 12 chars of the printed hash. We
// don't keep it — Stop/Status use the name — but return a stable
// synthetic PID derived from the first 4 bytes of the hash for
// the interface contract.
cid := strings.TrimSpace(string(out))
return podmanCidToPID(cid), nil
}
// Stop stops and removes the container.
func (p *PodmanRuntime) Stop(ctx context.Context, alloc *Alloc) error {
name := containerName(alloc)
if _, err := p.transport.Exec(ctx, alloc.Node, fmt.Sprintf("podman stop %s", shellQuote(name))); err != nil {
return fmt.Errorf("podman: stop: %w", err)
}
if _, err := p.transport.Exec(ctx, alloc.Node, fmt.Sprintf("podman rm %s", shellQuote(name))); err != nil {
return fmt.Errorf("podman: rm: %w", err)
}
return nil
}
// Status inspects the container's running state.
func (p *PodmanRuntime) Status(ctx context.Context, alloc *Alloc) (State, error) {
name := containerName(alloc)
cmd := fmt.Sprintf("podman inspect --format '{{.State.Running}}' %s", shellQuote(name))
out, err := p.transport.Exec(ctx, alloc.Node, cmd)
if err != nil {
return StateFailed, fmt.Errorf("podman: inspect: %w", err)
}
v := strings.TrimSpace(string(out))
switch v {
case "true":
return StateRunning, nil
case "false":
return StateStopped, nil
default:
return StateFailed, fmt.Errorf("podman: unexpected inspect output %q", v)
}
}
// podmanCidToPID converts a container ID (hex hash) to a positive int
// PID for the interface contract. It reads up to 4 hex chars.
func podmanCidToPID(cid string) int {
if len(cid) < 1 {
return 1
}
n := len(cid)
if n > 4 {
n = 4
}
var pid int
for i := 0; i < n; i++ {
c := cid[i]
pid = (pid << 4) | int(hexVal(c))
}
if pid <= 0 {
pid = 1
}
return pid
}
func hexVal(c byte) byte {
switch {
case c >= '0' && c <= '9':
return c - '0'
case c >= 'a' && c <= 'f':
return c - 'a' + 10
case c >= 'A' && c <= 'F':
return c - 'A' + 10
}
return 0
}