Skip to content

Commit 7cbee73

Browse files
authored
Merge pull request #6147 from thaJeztah/connhelper_quote
cli/connhelper: quote ssh arguments to prevent shell injection
2 parents ae6f8d0 + 88d1133 commit 7cbee73

File tree

7 files changed

+610
-15
lines changed

7 files changed

+610
-15
lines changed

cli/connhelper/connhelper.go

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -47,14 +47,19 @@ func getConnectionHelper(daemonURL string, sshFlags []string) (*ConnectionHelper
4747
}
4848
sshFlags = addSSHTimeout(sshFlags)
4949
sshFlags = disablePseudoTerminalAllocation(sshFlags)
50+
51+
remoteCommand := []string{"docker", "system", "dial-stdio"}
52+
socketPath := sp.Path
53+
if strings.Trim(sp.Path, "/") != "" {
54+
remoteCommand = []string{"docker", "--host=unix://" + socketPath, "system", "dial-stdio"}
55+
}
56+
sshArgs, err := sp.Command(sshFlags, remoteCommand...)
57+
if err != nil {
58+
return nil, err
59+
}
5060
return &ConnectionHelper{
5161
Dialer: func(ctx context.Context, network, addr string) (net.Conn, error) {
52-
args := []string{"docker"}
53-
if sp.Path != "" {
54-
args = append(args, "--host", "unix://"+sp.Path)
55-
}
56-
args = append(args, "system", "dial-stdio")
57-
return commandconn.New(ctx, "ssh", append(sshFlags, sp.Args(args...)...)...)
62+
return commandconn.New(ctx, "ssh", sshArgs...)
5863
},
5964
Host: "http://docker.example.com",
6065
}, nil
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
Copyright (c) 2016, Daniel Martí. All rights reserved.
2+
3+
Redistribution and use in source and binary forms, with or without
4+
modification, are permitted provided that the following conditions are
5+
met:
6+
7+
* Redistributions of source code must retain the above copyright
8+
notice, this list of conditions and the following disclaimer.
9+
* Redistributions in binary form must reproduce the above
10+
copyright notice, this list of conditions and the following disclaimer
11+
in the documentation and/or other materials provided with the
12+
distribution.
13+
* Neither the name of the copyright holder nor the names of its
14+
contributors may be used to endorse or promote products derived from
15+
this software without specific prior written permission.
16+
17+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
18+
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
19+
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
20+
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
21+
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
22+
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
23+
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
24+
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
25+
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
26+
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
27+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
// Package syntax is a fork of [mvdan.cc/sh/[email protected]/syntax].
2+
//
3+
// Copyright (c) 2016, Daniel Martí. All rights reserved.
4+
//
5+
// It is a reduced set of the package to only provide the [Quote] function,
6+
// and contains the [LICENSE], [quote.go] and [parser.go] files at the given
7+
// revision.
8+
//
9+
// [quote.go]: https://raw.githubusercontent.com/mvdan/sh/refs/tags/v3.10.0/syntax/quote.go
10+
// [parser.go]: https://raw.githubusercontent.com/mvdan/sh/refs/tags/v3.10.0/syntax/parser.go
11+
// [LICENSE]: https://raw.githubusercontent.com/mvdan/sh/refs/tags/v3.10.0/LICENSE
12+
// [mvdan.cc/sh/[email protected]/syntax]: https://pkg.go.dev/mvdan.cc/sh/[email protected]/syntax
13+
package syntax
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
// Copyright (c) 2016, Daniel Martí <[email protected]>
2+
// See LICENSE for licensing information
3+
4+
package syntax
5+
6+
// LangVariant describes a shell language variant to use when tokenizing and
7+
// parsing shell code. The zero value is [LangBash].
8+
type LangVariant int
9+
10+
const (
11+
// LangBash corresponds to the GNU Bash language, as described in its
12+
// manual at https://www.gnu.org/software/bash/manual/bash.html.
13+
//
14+
// We currently follow Bash version 5.2.
15+
//
16+
// Its string representation is "bash".
17+
LangBash LangVariant = iota
18+
19+
// LangPOSIX corresponds to the POSIX Shell language, as described at
20+
// https://pubs.opengroup.org/onlinepubs/9699919799/utilities/V3_chap02.html.
21+
//
22+
// Its string representation is "posix" or "sh".
23+
LangPOSIX
24+
25+
// LangMirBSDKorn corresponds to the MirBSD Korn Shell, also known as
26+
// mksh, as described at http://www.mirbsd.org/htman/i386/man1/mksh.htm.
27+
// Note that it shares some features with Bash, due to the shared
28+
// ancestry that is ksh.
29+
//
30+
// We currently follow mksh version 59.
31+
//
32+
// Its string representation is "mksh".
33+
LangMirBSDKorn
34+
35+
// LangBats corresponds to the Bash Automated Testing System language,
36+
// as described at https://github.com/bats-core/bats-core. Note that
37+
// it's just a small extension of the Bash language.
38+
//
39+
// Its string representation is "bats".
40+
LangBats
41+
42+
// LangAuto corresponds to automatic language detection,
43+
// commonly used by end-user applications like shfmt,
44+
// which can guess a file's language variant given its filename or shebang.
45+
//
46+
// At this time, [Variant] does not support LangAuto.
47+
LangAuto
48+
)
49+
50+
func (l LangVariant) String() string {
51+
switch l {
52+
case LangBash:
53+
return "bash"
54+
case LangPOSIX:
55+
return "posix"
56+
case LangMirBSDKorn:
57+
return "mksh"
58+
case LangBats:
59+
return "bats"
60+
case LangAuto:
61+
return "auto"
62+
}
63+
return "unknown shell language variant"
64+
}
65+
66+
// IsKeyword returns true if the given word is part of the language keywords.
67+
func IsKeyword(word string) bool {
68+
// This list has been copied from the bash 5.1 source code, file y.tab.c +4460
69+
switch word {
70+
case
71+
"!",
72+
"[[", // only if COND_COMMAND is defined
73+
"]]", // only if COND_COMMAND is defined
74+
"case",
75+
"coproc", // only if COPROCESS_SUPPORT is defined
76+
"do",
77+
"done",
78+
"else",
79+
"esac",
80+
"fi",
81+
"for",
82+
"function",
83+
"if",
84+
"in",
85+
"select", // only if SELECT_COMMAND is defined
86+
"then",
87+
"time", // only if COMMAND_TIMING is defined
88+
"until",
89+
"while",
90+
"{",
91+
"}":
92+
return true
93+
}
94+
return false
95+
}
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
// Copyright (c) 2021, Daniel Martí <[email protected]>
2+
// See LICENSE for licensing information
3+
4+
package syntax
5+
6+
import (
7+
"fmt"
8+
"strings"
9+
"unicode"
10+
"unicode/utf8"
11+
)
12+
13+
type QuoteError struct {
14+
ByteOffset int
15+
Message string
16+
}
17+
18+
func (e QuoteError) Error() string {
19+
return fmt.Sprintf("cannot quote character at byte %d: %s", e.ByteOffset, e.Message)
20+
}
21+
22+
const (
23+
quoteErrNull = "shell strings cannot contain null bytes"
24+
quoteErrPOSIX = "POSIX shell lacks escape sequences"
25+
quoteErrRange = "rune out of range"
26+
quoteErrMksh = "mksh cannot escape codepoints above 16 bits"
27+
)
28+
29+
// Quote returns a quoted version of the input string,
30+
// so that the quoted version is expanded or interpreted
31+
// as the original string in the given language variant.
32+
//
33+
// Quoting is necessary when using arbitrary literal strings
34+
// as words in a shell script or command.
35+
// Without quoting, one can run into syntax errors,
36+
// as well as the possibility of running unintended code.
37+
//
38+
// An error is returned when a string cannot be quoted for a variant.
39+
// For instance, POSIX lacks escape sequences for non-printable characters,
40+
// and no language variant can represent a string containing null bytes.
41+
// In such cases, the returned error type will be *QuoteError.
42+
//
43+
// The quoting strategy is chosen on a best-effort basis,
44+
// to minimize the amount of extra bytes necessary.
45+
//
46+
// Some strings do not require any quoting and are returned unchanged.
47+
// Those strings can be directly surrounded in single quotes as well.
48+
//
49+
//nolint:gocyclo // ignore "cyclomatic complexity 35 of func `Quote` is high (> 16) (gocyclo)"
50+
func Quote(s string, lang LangVariant) (string, error) {
51+
if s == "" {
52+
// Special case; an empty string must always be quoted,
53+
// as otherwise it expands to zero fields.
54+
return "''", nil
55+
}
56+
shellChars := false
57+
nonPrintable := false
58+
offs := 0
59+
for rem := s; len(rem) > 0; {
60+
r, size := utf8.DecodeRuneInString(rem)
61+
switch r {
62+
// Like regOps; token characters.
63+
case ';', '"', '\'', '(', ')', '$', '|', '&', '>', '<', '`',
64+
// Whitespace; might result in multiple fields.
65+
' ', '\t', '\r', '\n',
66+
// Escape sequences would be expanded.
67+
'\\',
68+
// Would start a comment unless quoted.
69+
'#',
70+
// Might result in brace expansion.
71+
'{',
72+
// Might result in tilde expansion.
73+
'~',
74+
// Might result in globbing.
75+
'*', '?', '[',
76+
// Might result in an assignment.
77+
'=':
78+
shellChars = true
79+
case '\x00':
80+
return "", &QuoteError{ByteOffset: offs, Message: quoteErrNull}
81+
}
82+
if r == utf8.RuneError || !unicode.IsPrint(r) {
83+
if lang == LangPOSIX {
84+
return "", &QuoteError{ByteOffset: offs, Message: quoteErrPOSIX}
85+
}
86+
nonPrintable = true
87+
}
88+
rem = rem[size:]
89+
offs += size
90+
}
91+
if !shellChars && !nonPrintable && !IsKeyword(s) {
92+
// Nothing to quote; avoid allocating.
93+
return s, nil
94+
}
95+
96+
// Single quotes are usually best,
97+
// as they don't require any escaping of characters.
98+
// If we have any invalid utf8 or non-printable runes,
99+
// use $'' so that we can escape them.
100+
// Note that we can't use double quotes for those.
101+
var b strings.Builder
102+
if nonPrintable {
103+
b.WriteString("$'")
104+
lastRequoteIfHex := false
105+
offs = 0
106+
for rem := s; len(rem) > 0; {
107+
nextRequoteIfHex := false
108+
r, size := utf8.DecodeRuneInString(rem)
109+
switch {
110+
case r == '\'', r == '\\':
111+
b.WriteByte('\\')
112+
b.WriteRune(r)
113+
case unicode.IsPrint(r) && r != utf8.RuneError:
114+
if lastRequoteIfHex && isHex(r) {
115+
b.WriteString("'$'")
116+
}
117+
b.WriteRune(r)
118+
case r == '\a':
119+
b.WriteString(`\a`)
120+
case r == '\b':
121+
b.WriteString(`\b`)
122+
case r == '\f':
123+
b.WriteString(`\f`)
124+
case r == '\n':
125+
b.WriteString(`\n`)
126+
case r == '\r':
127+
b.WriteString(`\r`)
128+
case r == '\t':
129+
b.WriteString(`\t`)
130+
case r == '\v':
131+
b.WriteString(`\v`)
132+
case r < utf8.RuneSelf, r == utf8.RuneError && size == 1:
133+
// \xXX, fixed at two hexadecimal characters.
134+
fmt.Fprintf(&b, "\\x%02x", rem[0])
135+
// Unfortunately, mksh allows \x to consume more hex characters.
136+
// Ensure that we don't allow it to read more than two.
137+
if lang == LangMirBSDKorn {
138+
nextRequoteIfHex = true
139+
}
140+
case r > utf8.MaxRune:
141+
// Not a valid Unicode code point?
142+
return "", &QuoteError{ByteOffset: offs, Message: quoteErrRange}
143+
case lang == LangMirBSDKorn && r > 0xFFFD:
144+
// From the CAVEATS section in R59's man page:
145+
//
146+
// mksh currently uses OPTU-16 internally, which is the same as
147+
// UTF-8 and CESU-8 with 0000..FFFD being valid codepoints.
148+
return "", &QuoteError{ByteOffset: offs, Message: quoteErrMksh}
149+
case r < 0x10000:
150+
// \uXXXX, fixed at four hexadecimal characters.
151+
fmt.Fprintf(&b, "\\u%04x", r)
152+
default:
153+
// \UXXXXXXXX, fixed at eight hexadecimal characters.
154+
fmt.Fprintf(&b, "\\U%08x", r)
155+
}
156+
rem = rem[size:]
157+
lastRequoteIfHex = nextRequoteIfHex
158+
offs += size
159+
}
160+
b.WriteString("'")
161+
return b.String(), nil
162+
}
163+
164+
// Single quotes without any need for escaping.
165+
if !strings.Contains(s, "'") {
166+
return "'" + s + "'", nil
167+
}
168+
169+
// The string contains single quotes,
170+
// so fall back to double quotes.
171+
b.WriteByte('"')
172+
for _, r := range s {
173+
switch r {
174+
case '"', '\\', '`', '$':
175+
b.WriteByte('\\')
176+
}
177+
b.WriteRune(r)
178+
}
179+
b.WriteByte('"')
180+
return b.String(), nil
181+
}
182+
183+
func isHex(r rune) bool {
184+
return (r >= '0' && r <= '9') ||
185+
(r >= 'a' && r <= 'f') ||
186+
(r >= 'A' && r <= 'F')
187+
}

0 commit comments

Comments
 (0)