diff --git a/lib/sshutils/sftp/parse.go b/lib/sshutils/sftp/parse.go index 8e0e941da8b6d..6ab4c46301437 100644 --- a/lib/sshutils/sftp/parse.go +++ b/lib/sshutils/sftp/parse.go @@ -17,39 +17,19 @@ limitations under the License. package sftp import ( - "regexp" + "strings" "github.com/gravitational/trace" "github.com/gravitational/teleport/lib/utils" ) -var reSFTP = regexp.MustCompile( - // optional username, note that outside group - // is a non-capturing as it includes @ signs we don't want - `(?:(?P.+)@)?` + - // either some stuff in brackets - [ipv6] - // or some stuff without brackets and colons - `(?P` + - // this says: [stuff in brackets that is not brackets] - loose definition of the IP address - `(?:\[[^@\[\]]+\])` + - // or - `|` + - // some stuff without brackets or colons to make sure the OR condition - // is not ambiguous - `(?:[^@\[\:\]]+)` + - `)` + - // after colon, there is a path that could consist technically of - // any char including empty which stands for the implicit home directory - `:(?P.*)`, -) - -// Destination is SCP destination to copy to or from +// Destination is a remote SFTP destination to copy to or from. type Destination struct { // Login is an optional login username Login string // Host is a host to copy to/from - Host utils.NetAddr + Host *utils.NetAddr // Path is a path to copy to/from. // An empty path name is valid, and it refers to the user's default directory (usually // the user's home directory). @@ -57,24 +37,105 @@ type Destination struct { Path string } -// ParseSCPDestination takes a string representing a remote resource for SFTP -// to download/upload, like "user@host:/path/to/resource.txt" and parses it into +// ParseDestination takes a string representing a remote resource for SFTP +// to download/upload in the form "[user@]host:[path]" and parses it into // a structured form. // // See https://tools.ietf.org/html/draft-ietf-secsh-filexfer-09#page-14, 'File Names' // section about details on file names. -func ParseDestination(s string) (*Destination, error) { - out := reSFTP.FindStringSubmatch(s) - if len(out) < 4 { - return nil, trace.BadParameter("failed to parse %q, try form user@host:/path", s) +func ParseDestination(input string) (*Destination, error) { + firstColonIdx := strings.Index(input, ":") + // if there are no colons, no path is specified + if firstColonIdx == -1 { + return nil, trace.BadParameter("%q is missing a path, use form [user@]host:[path]", input) } - addr, err := utils.ParseAddr(out[2]) - if err != nil { - return nil, trace.Wrap(err) + hostStartIdx := strings.LastIndex(input[:firstColonIdx], "@") + // if a login exists and the path begins right after the login ends, + // no host is specified + if hostStartIdx != -1 && hostStartIdx+1 == firstColonIdx { + return nil, trace.BadParameter("%q is missing a host, use form [user@]host:[path]", input) + } + + var login string + // If at least one '@' exists and is before the first ':', get the + // login. Otherwise, either there are no '@' or all '@' are after + // the first ':' (where the host or path starts), so no login was + // specified. + if hostStartIdx != -1 { + login = input[:hostStartIdx] + // increment so that we won't try to parse the host starting at '@' + hostStartIdx++ + } else { + hostStartIdx = 0 + } + + // the path will start after the first colon, unless the host is an + // IPv6 address + pathStartIdx := firstColonIdx + 1 + var host *utils.NetAddr + // if the host begins with '[', it is most likely an IPv6 address, + // so attempt to parse it as such + afterLogin := input[hostStartIdx:] + if afterLogin[0] == '[' { + ipv6Host, hostEndIdx, err := parseIPv6Host(input, hostStartIdx) + if err != nil { + return nil, trace.Wrap(err) + } + if ipv6Host != nil { + host = ipv6Host + pathStartIdx = hostEndIdx + } + } + + // the host could not be parsed as an IPv6 address, try parsing it raw + if host == nil { + var err error + host, err = utils.ParseAddr(input[hostStartIdx:firstColonIdx]) + if err != nil { + return nil, trace.Wrap(err) + } + } + + // if there is nothing after the host the path defaults to "." + path := "." + if len(input) > pathStartIdx { + path = input[pathStartIdx:] + } + + return &Destination{ + Login: login, + Host: host, + Path: path, + }, nil +} + +// parseIPv6Host returns the parsed host in input and the index of input +// where the host ends. parseIPv6Host assumes the host contained in +// input starts with '['. +func parseIPv6Host(input string, start int) (*utils.NetAddr, int, error) { + hostStr := input[start:] + // if there is only one ':' in the entire input, the host isn't + // an IPv6 address + if strings.Count(hostStr, ":") == 1 { + return nil, 0, trace.BadParameter("%q has an invalid host, host cannot contain '[' unless it is an IPv6 address", input) } - path := out[3] - if path == "" { - path = "." + // if there's no closing ']', this isn't a valid IPv6 address + rbraceIdx := strings.Index(hostStr, "]") + if rbraceIdx == -1 { + return nil, 0, trace.BadParameter("%q has an invalid host, host cannot contain '[' or ':' unless it is an IPv6 address", input) } - return &Destination{Login: out[1], Host: *addr, Path: path}, nil + // if there's nothing after ']' then the path is missing + if len(hostStr) <= rbraceIdx+2 { + return nil, 0, trace.BadParameter("%q is missing a path, use form [user@]host:[path]", input) + } + + maybeAddr := hostStr[:rbraceIdx+1] + host, err := utils.ParseAddr(maybeAddr) + if err != nil { + return nil, 0, trace.Wrap(err) + } + + // the host ends after the login + the IPv6 address + // (including the trailing ']') and a ':' + return host, start + rbraceIdx + 1 + 1, nil } diff --git a/lib/sshutils/sftp/parse_test.go b/lib/sshutils/sftp/parse_test.go index c86bd68527958..e0d8db6f36265 100644 --- a/lib/sshutils/sftp/parse_test.go +++ b/lib/sshutils/sftp/parse_test.go @@ -17,6 +17,7 @@ limitations under the License. package sftp import ( + "fmt" "testing" "github.com/google/go-cmp/cmp" @@ -25,60 +26,243 @@ import ( "github.com/gravitational/teleport/lib/utils" ) -func TestDestinationParsing(t *testing.T) { +func TestParseDestination(t *testing.T) { t.Parallel() testCases := []struct { - comment string - in string - dest Destination - err error + name string + in string + dest Destination + errCheck require.ErrorAssertionFunc }{ { - comment: "full spec of the remote destination", - in: "root@remote.host:/etc/nginx.conf", - dest: Destination{Login: "root", Host: utils.NetAddr{Addr: "remote.host", AddrNetwork: "tcp"}, Path: "/etc/nginx.conf"}, + name: "full spec of the remote destination", + in: "root@remote.host:/etc/nginx.conf", + dest: Destination{ + Login: "root", + Host: &utils.NetAddr{ + Addr: "remote.host", + AddrNetwork: "tcp", + }, + Path: "/etc/nginx.conf", + }, }, { - comment: "spec with just the remote host", - in: "remote.host:/etc/nginx.co:nf", - dest: Destination{Host: utils.NetAddr{Addr: "remote.host", AddrNetwork: "tcp"}, Path: "/etc/nginx.co:nf"}, + name: "spec with just the remote host", + in: "remote.host:/etc/nginx.co:nf", + dest: Destination{ + Host: &utils.NetAddr{ + Addr: "remote.host", + AddrNetwork: "tcp", + }, + Path: "/etc/nginx.co:nf", + }, }, { - comment: "ipv6 remote destination address", - in: "[::1]:/etc/nginx.co:nf", - dest: Destination{Host: utils.NetAddr{Addr: "[::1]", AddrNetwork: "tcp"}, Path: "/etc/nginx.co:nf"}, + name: "ipv6 remote destination address", + in: "[::1]:/etc/nginx.co:nf", + dest: Destination{ + Host: &utils.NetAddr{ + Addr: "[::1]", + AddrNetwork: "tcp", + }, + Path: "/etc/nginx.co:nf", + }, }, { - comment: "full spec of the remote destination using ipv4 address", - in: "root@123.123.123.123:/var/www/html/", - dest: Destination{Login: "root", Host: utils.NetAddr{Addr: "123.123.123.123", AddrNetwork: "tcp"}, Path: "/var/www/html/"}, + name: "full spec of the remote destination using ipv4 address", + in: "root@123.123.123.123:/var/www/html/", + dest: Destination{ + Login: "root", + Host: &utils.NetAddr{ + Addr: "123.123.123.123", + AddrNetwork: "tcp", + }, + Path: "/var/www/html/", + }, }, { - comment: "target location using wildcard", - in: "myusername@myremotehost.com:/home/hope/*", - dest: Destination{Login: "myusername", Host: utils.NetAddr{Addr: "myremotehost.com", AddrNetwork: "tcp"}, Path: "/home/hope/*"}, + name: "target location using wildcard", + in: "myusername@myremotehost.com:/home/hope/*", + dest: Destination{ + Login: "myusername", + Host: &utils.NetAddr{ + Addr: "myremotehost.com", + AddrNetwork: "tcp", + }, + Path: "/home/hope/*", + }, }, { - comment: "complex login", - in: "complex@example.com@remote.com:/anything.txt", - dest: Destination{Login: "complex@example.com", Host: utils.NetAddr{Addr: "remote.com", AddrNetwork: "tcp"}, Path: "/anything.txt"}, + name: "complex login", + in: "complex@example.com@remote.com:/anything.txt", + dest: Destination{ + Login: "complex@example.com", + Host: &utils.NetAddr{ + Addr: "remote.com", + AddrNetwork: "tcp", + }, + Path: "/anything.txt", + }, }, { - comment: "implicit user's home directory", - in: "root@remote.host:", - dest: Destination{Login: "root", Host: utils.NetAddr{Addr: "remote.host", AddrNetwork: "tcp"}, Path: "."}, + name: "implicit user's home directory", + in: "root@remote.host:", + dest: Destination{ + Login: "root", + Host: &utils.NetAddr{ + Addr: "remote.host", + AddrNetwork: "tcp", + }, + Path: ".", + }, + }, + { + name: "no login and '@' in path", + in: "remote.host:/some@file", + dest: Destination{ + Host: &utils.NetAddr{ + Addr: "remote.host", + AddrNetwork: "tcp", + }, + Path: "/some@file", + }, + }, + { + name: "no login, '@' and ':' in path", + in: "remote.host:/some@remote:file", + dest: Destination{ + Host: &utils.NetAddr{ + Addr: "remote.host", + AddrNetwork: "tcp", + }, + Path: "/some@remote:file", + }, + }, + { + name: "complex login, IPv6 addr and ':' in path", + in: "complex@user@[::1]:/remote:file", + dest: Destination{ + Login: "complex@user", + Host: &utils.NetAddr{ + Addr: "[::1]", + AddrNetwork: "tcp", + }, + Path: "/remote:file", + }, + }, + { + name: "filename with timestamp", + in: "user@server.com:/tmp/user-2022-03-10T09:49:23-98cd2a03/file.txt", + dest: Destination{ + Login: "user", + Host: &utils.NetAddr{ + Addr: "server.com", + AddrNetwork: "tcp", + }, + Path: "/tmp/user-2022-03-10T09:49:23-98cd2a03/file.txt", + }, + }, + { + name: "filename with '@' suffix", + in: "user@server:file@", + dest: Destination{ + Login: "user", + Host: &utils.NetAddr{ + Addr: "server", + AddrNetwork: "tcp", + }, + Path: "file@", + }, + }, + { + name: "filename with IPv6 address", + in: "user@server:file[::1]name", + dest: Destination{ + Login: "user", + Host: &utils.NetAddr{ + Addr: "server", + AddrNetwork: "tcp", + }, + Path: "file[::1]name", + }, + }, + { + name: "IPv6 address and filename with IPv6 address", + in: "user@[::1]:file[::1]name", + dest: Destination{ + Login: "user", + Host: &utils.NetAddr{ + Addr: "[::1]", + AddrNetwork: "tcp", + }, + Path: "file[::1]name", + }, + }, + { + name: "IPv6 address and filename with IPv6 address and '@'s", + in: "user@[::1]:file@[::1]@name", + dest: Destination{ + Login: "user", + Host: &utils.NetAddr{ + Addr: "[::1]", + AddrNetwork: "tcp", + }, + Path: "file@[::1]@name", + }, + }, + { + name: "missing path", + in: "user@server", + errCheck: func(t require.TestingT, err error, i ...interface{}) { + require.EqualError(t, err, fmt.Sprintf("%q is missing a path, use form [user@]host:[path]", i[0])) + }, + }, + { + name: "missing host", + in: "user@:/foo", + errCheck: func(t require.TestingT, err error, i ...interface{}) { + require.EqualError(t, err, fmt.Sprintf("%q is missing a host, use form [user@]host:[path]", i[0])) + }, + }, + { + name: "invalid IPv6 addr, only one colon", + in: "[user]@[:", + errCheck: func(t require.TestingT, err error, i ...interface{}) { + require.EqualError(t, err, fmt.Sprintf("%q has an invalid host, host cannot contain '[' unless it is an IPv6 address", i[0])) + }, + }, + { + name: "invalid IPv6 addr, only one colon", + in: "[user]@[::1:file", + errCheck: func(t require.TestingT, err error, i ...interface{}) { + require.EqualError(t, err, fmt.Sprintf("%q has an invalid host, host cannot contain '[' or ':' unless it is an IPv6 address", i[0])) + }, + }, + { + name: "missing path with IPv6 addr", + in: "[user]@[::1]", + errCheck: func(t require.TestingT, err error, i ...interface{}) { + require.EqualError(t, err, fmt.Sprintf("%q is missing a path, use form [user@]host:[path]", i[0])) + }, }, } + for _, tt := range testCases { - t.Run(tt.comment, func(t *testing.T) { + t.Run(tt.name, func(t *testing.T) { resp, err := ParseDestination(tt.in) - if tt.err != nil { - require.IsType(t, err, tt.err) - return + if tt.errCheck == nil { + require.NoError(t, err) + require.Empty(t, cmp.Diff(resp, &tt.dest)) + } else { + tt.errCheck(t, err, tt.in) } - require.NoError(t, err) - require.Empty(t, cmp.Diff(resp, &tt.dest)) }) } } + +func FuzzParseDestination(f *testing.F) { + f.Fuzz(func(t *testing.T, input string) { + _, _ = ParseDestination(input) + }) +} diff --git a/lib/utils/addr.go b/lib/utils/addr.go index 7d0986d8b5d81..4b362a69a0af2 100644 --- a/lib/utils/addr.go +++ b/lib/utils/addr.go @@ -181,7 +181,7 @@ func ParseAddr(a string) (*NetAddr, error) { case "http", "https": return &NetAddr{Addr: u.Host, AddrNetwork: u.Scheme, Path: u.Path}, nil default: - return nil, trace.BadParameter("'%v': unsupported scheme: '%v'", a, u.Scheme) + return nil, trace.BadParameter("%q: unsupported scheme: %q", a, u.Scheme) } }