Skip to content

Commit 0c36493

Browse files
authored
fix(security): build container command as argv, not sh -c (CWE-78); bind Mysterium UI to localhost (#51)
The deploy path built the container command as sh -c "<substituted>", interpolating user credentials into a shell string. Several catalog commands interpolate secrets (honeygain, iproyal, traffmonetizer, packetshare), so a credential containing shell metacharacters (; $() backticks | & quotes) or whitespace could break the deploy or inject commands into the container (CWE-78). buildCommandArgs now tokenizes the trusted maintainer template first (tokenizeCommand, a quote-aware splitter with no expansion), then substitutes env per token, and passes the result as config.Cmd with no shell -- a ${CRED} becomes exactly one inert argv element regardless of its content. Empty command -> nil (image default preserved). This also fixes a latent bug: argv is the intended '<entrypoint> -flag value' form, where the old sh -c passed 'sh -c <flags>' to the image entrypoint. Also binds Mysterium's UI/tequilapi to 127.0.0.1 (was 0.0.0.0, exposing the node control API to the LAN under host networking); host-net + NET_ADMIN kept for VPN NAT traversal. Verified: go build/vet/test -race green (tokenizer + injection unit tests); a live busybox demo confirmed the old sh -c ran an injected ; command while the argv form keeps it inert.
1 parent f944d6c commit 0c36493

3 files changed

Lines changed: 207 additions & 2 deletions

File tree

internal/runtime/runtime.go

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,7 @@ func (p *DockerProvider) Deploy(ctx context.Context, spec DeploySpec, progress f
156156
Hostname: fmt.Sprintf("cashpilot-%s", spec.Slug),
157157
}
158158
if svc.Docker.Command != "" {
159-
config.Cmd = []string{"sh", "-c", substitute(svc.Docker.Command, env)}
159+
config.Cmd = buildCommandArgs(svc.Docker.Command, env)
160160
}
161161

162162
hostConfig := &container.HostConfig{
@@ -599,6 +599,86 @@ func substitute(value string, env map[string]string) string {
599599
return out
600600
}
601601

602+
// buildCommandArgs turns a maintainer command template into the argv slice passed to
603+
// the container as Docker Cmd (which Docker appends to the image ENTRYPOINT, so argv
604+
// ["-email","x","-pass","y"] runs the entrypoint binary with those flags).
605+
//
606+
// SECURITY (fix S3, CWE-78): the template is tokenized FIRST — its token boundaries
607+
// are trusted because a template contains only static flags and ${VAR} placeholders,
608+
// never user data — and each resulting token is substituted individually. A ${VAR}
609+
// therefore expands into exactly ONE argv element even when the credential value
610+
// contains shell metacharacters (;, $(), backticks, quotes, &, |) or whitespace. The
611+
// argv is exec'd directly with no shell, so those characters are inert data and can
612+
// never be re-split or interpreted. This replaces the old sh -c "<substituted>" form,
613+
// where a credential holding shell syntax could break the deploy or inject commands
614+
// into the container. Returns nil for a template that tokenizes to nothing (so an
615+
// all-whitespace command leaves Cmd unset, keeping the image default, as before).
616+
func buildCommandArgs(template string, env map[string]string) []string {
617+
tokens := tokenizeCommand(template)
618+
if len(tokens) == 0 {
619+
return nil
620+
}
621+
args := make([]string, len(tokens))
622+
for i, tok := range tokens {
623+
args[i] = substitute(tok, env)
624+
}
625+
return args
626+
}
627+
628+
// tokenizeCommand splits a command template into argv tokens the way a POSIX shell
629+
// word-splits, but WITHOUT any expansion: runs of unquoted whitespace separate
630+
// tokens, while single-quoted ('...') and double-quoted ("...") groups are kept as
631+
// one token with the surrounding quotes stripped. Adjacent quoted and unquoted
632+
// segments concatenate into a single token (foo"a b" -> `fooa b`), and a quoted empty
633+
// string ("") yields an empty token. There is no backslash escaping and no ${VAR}
634+
// expansion here by design — expansion is applied per-token AFTER tokenizing (see
635+
// buildCommandArgs), so a value's own quotes/metacharacters can never change token
636+
// boundaries. Templates are maintainer-authored (static flags + ${VAR} placeholders),
637+
// so an unterminated quote is not expected; if one occurs the accumulated text is
638+
// still emitted as a final token rather than dropped.
639+
func tokenizeCommand(s string) []string {
640+
var tokens []string
641+
var cur strings.Builder
642+
started := false // a token is in progress (distinguishes "" from no token)
643+
inSingle := false
644+
inDouble := false
645+
for _, r := range s {
646+
switch {
647+
case inSingle:
648+
if r == '\'' {
649+
inSingle = false
650+
} else {
651+
cur.WriteRune(r)
652+
}
653+
case inDouble:
654+
if r == '"' {
655+
inDouble = false
656+
} else {
657+
cur.WriteRune(r)
658+
}
659+
case r == '\'':
660+
inSingle = true
661+
started = true
662+
case r == '"':
663+
inDouble = true
664+
started = true
665+
case r == ' ' || r == '\t' || r == '\n' || r == '\r':
666+
if started {
667+
tokens = append(tokens, cur.String())
668+
cur.Reset()
669+
started = false
670+
}
671+
default:
672+
cur.WriteRune(r)
673+
started = true
674+
}
675+
}
676+
if started {
677+
tokens = append(tokens, cur.String())
678+
}
679+
return tokens
680+
}
681+
602682
func envSlice(env map[string]string) []string {
603683
out := make([]string, 0, len(env))
604684
for key, value := range env {

internal/runtime/runtime_test.go

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"io"
99
"math"
1010
"os/exec"
11+
"reflect"
1112
goruntime "runtime"
1213
"strings"
1314
"sync"
@@ -99,6 +100,130 @@ func TestBuildEnvSubstitutesDefaultsAndOverrides(t *testing.T) {
99100
}
100101
}
101102

103+
// TestTokenizeCommandPlainFlags covers whitespace splitting of a plain flag list
104+
// (honeygain style): each flag and value becomes its own token and ${VAR}
105+
// placeholders are preserved intact for later per-token substitution.
106+
func TestTokenizeCommandPlainFlags(t *testing.T) {
107+
got := tokenizeCommand("-tou-accept -email ${HONEYGAIN_EMAIL} -pass ${HONEYGAIN_PASSWORD} -device ${HONEYGAIN_DEVICE_NAME}")
108+
want := []string{"-tou-accept", "-email", "${HONEYGAIN_EMAIL}", "-pass", "${HONEYGAIN_PASSWORD}", "-device", "${HONEYGAIN_DEVICE_NAME}"}
109+
if !reflect.DeepEqual(got, want) {
110+
t.Fatalf("tokenizeCommand = %#v, want %#v", got, want)
111+
}
112+
}
113+
114+
// TestTokenizeCommandEqualsJoinedFlags covers the -flag=${VAR} form (packetshare,
115+
// storj, mysterium): it must stay a single token so substitution keeps "-flag=" glued
116+
// to the value.
117+
func TestTokenizeCommandEqualsJoinedFlags(t *testing.T) {
118+
got := tokenizeCommand("-accept-tos -email=${PACKETSHARE_EMAIL} -password=${PACKETSHARE_PASSWORD}")
119+
want := []string{"-accept-tos", "-email=${PACKETSHARE_EMAIL}", "-password=${PACKETSHARE_PASSWORD}"}
120+
if !reflect.DeepEqual(got, want) {
121+
t.Fatalf("tokenizeCommand = %#v, want %#v", got, want)
122+
}
123+
}
124+
125+
// TestTokenizeCommandQuotedValuesWithSpaces covers quoting: single- and double-quoted
126+
// groups become one token with the quotes stripped, so a value containing spaces
127+
// (e.g. a device name "My Laptop") is not word-split into two args.
128+
func TestTokenizeCommandQuotedValuesWithSpaces(t *testing.T) {
129+
got := tokenizeCommand(`-device "My Laptop" -name 'Home Server' -x=${TOKEN}`)
130+
want := []string{"-device", "My Laptop", "-name", "Home Server", "-x=${TOKEN}"}
131+
if !reflect.DeepEqual(got, want) {
132+
t.Fatalf("tokenizeCommand = %#v, want %#v", got, want)
133+
}
134+
}
135+
136+
// TestTokenizeCommandCollapsesSurroundingWhitespace covers leading/trailing and
137+
// repeated whitespace (spaces and tabs) collapsing to token boundaries only.
138+
func TestTokenizeCommandCollapsesSurroundingWhitespace(t *testing.T) {
139+
got := tokenizeCommand(" start accept \t --token ${T} ")
140+
want := []string{"start", "accept", "--token", "${T}"}
141+
if !reflect.DeepEqual(got, want) {
142+
t.Fatalf("tokenizeCommand = %#v, want %#v", got, want)
143+
}
144+
if empty := tokenizeCommand(" \t "); len(empty) != 0 {
145+
t.Fatalf("tokenizeCommand(whitespace only) = %#v, want empty", empty)
146+
}
147+
}
148+
149+
// TestBuildCommandArgsCredentialWithShellMetacharsIsSingleArg is the core injection
150+
// regression for security fix S3 (CWE-78). A credential whose value contains shell
151+
// metacharacters (;, $(...), backticks, |, &, quotes) or a space must land in the
152+
// argv as exactly ONE element: the template is tokenized BEFORE substitution and the
153+
// argv is exec'd directly (no `sh -c`), so the metacharacters are inert data, never
154+
// split or expanded.
155+
func TestBuildCommandArgsCredentialWithShellMetacharsIsSingleArg(t *testing.T) {
156+
cases := []struct {
157+
name string
158+
pass string
159+
}{
160+
{"semicolon rm", "p; rm -rf /"},
161+
{"command substitution", "$(touch /pwned)"},
162+
{"backticks", "`id`"},
163+
{"embedded space", "hunter 2"},
164+
{"pipe and ampersand", "a | b & c"},
165+
{"embedded quotes", `a'b"c`},
166+
}
167+
for _, tc := range cases {
168+
t.Run(tc.name, func(t *testing.T) {
169+
// Generic placeholder names (not a real service's PASSWORD env, no email)
170+
// keep the secret scanner from flagging these fake fixtures; the code path
171+
// exercised is identical to a "-pass ${VAR}" service command.
172+
env := map[string]string{
173+
"ACCOUNT": "user-a",
174+
"CRED": tc.pass,
175+
"DEVICE": "device-1",
176+
}
177+
args := buildCommandArgs("-tou-accept -email ${ACCOUNT} -pass ${CRED} -device ${DEVICE}", env)
178+
want := []string{"-tou-accept", "-email", "user-a", "-pass", tc.pass, "-device", "device-1"}
179+
if !reflect.DeepEqual(args, want) {
180+
t.Fatalf("buildCommandArgs = %#v, want %#v (credential must be exactly one argv element, unsplit and unexpanded)", args, want)
181+
}
182+
// The credential must appear verbatim exactly once, as the argument to -pass.
183+
count, idx := 0, -1
184+
for i, a := range args {
185+
if a == tc.pass {
186+
count++
187+
idx = i
188+
}
189+
}
190+
if count != 1 {
191+
t.Fatalf("credential appears %d times in argv, want exactly 1: %#v", count, args)
192+
}
193+
if idx < 1 || args[idx-1] != "-pass" {
194+
t.Fatalf("credential is not the single argument to -pass: %#v", args)
195+
}
196+
})
197+
}
198+
}
199+
200+
// TestBuildCommandArgsEqualsJoinedCredentialIsSingleArg proves the -password=${VAR}
201+
// form (packetshare) also collapses to one argv element even when the value carries
202+
// shell metacharacters — "-password=" stays glued to the raw credential.
203+
func TestBuildCommandArgsEqualsJoinedCredentialIsSingleArg(t *testing.T) {
204+
env := map[string]string{
205+
"ACCOUNT": "user-a",
206+
"CRED": "x; rm -rf / $(reboot)",
207+
}
208+
args := buildCommandArgs("-accept-tos -email=${ACCOUNT} -password=${CRED}", env)
209+
want := []string{"-accept-tos", "-email=user-a", "-password=x; rm -rf / $(reboot)"}
210+
if !reflect.DeepEqual(args, want) {
211+
t.Fatalf("buildCommandArgs = %#v, want %#v", args, want)
212+
}
213+
}
214+
215+
// TestBuildCommandArgsEmptyTemplateReturnsNil covers the guard: an empty or
216+
// all-whitespace command template produces a nil argv, so Docker Cmd is left unset and
217+
// the image default is kept (matching the old empty-command behaviour).
218+
func TestBuildCommandArgsEmptyTemplateReturnsNil(t *testing.T) {
219+
if got := buildCommandArgs("", nil); got != nil {
220+
t.Fatalf("buildCommandArgs(\"\") = %#v, want nil", got)
221+
}
222+
if got := buildCommandArgs(" \t ", nil); got != nil {
223+
t.Fatalf("buildCommandArgs(whitespace) = %#v, want nil", got)
224+
}
225+
}
226+
102227
// TestCPUPercentTwoSampleDeltaYieldsExpectedPercent pins the arithmetic of the
103228
// two-sample fix with a known answer: cpuDelta = 1e9, systemDelta = 10e9,
104229
// onlineCPUs = 4 -> (1e9 / 10e9) * 4 * 100 = 40%.

services/bandwidth/mysterium.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ docker:
2222
- "4449:4449"
2323
volumes:
2424
- "mysterium-data:/var/lib/mysterium-node"
25-
command: "--ui.address=0.0.0.0 --tequilapi.address=0.0.0.0 service --agreed-terms-and-conditions"
25+
command: "--ui.address=127.0.0.1 --tequilapi.address=127.0.0.1 service --agreed-terms-and-conditions"
2626
network_mode: "host"
2727
cap_add: [NET_ADMIN]
2828
privileged: false

0 commit comments

Comments
 (0)