Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 97 additions & 36 deletions lib/sshutils/sftp/parse.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,64 +17,125 @@ 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<username>.+)@)?` +
// either some stuff in brackets - [ipv6]
// or some stuff without brackets and colons
`(?P<host>` +
// 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<path>.*)`,
)

// 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).
// See https://tools.ietf.org/html/draft-ietf-secsh-filexfer-09#page-14, 'File Names'
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) {
Comment thread
russjones marked this conversation as resolved.
firstColonIdx := strings.Index(input, ":")
Comment thread
capnspacehook marked this conversation as resolved.
// 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], "@")
Comment thread
jentfoo marked this conversation as resolved.
// 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)
Comment thread
jentfoo marked this conversation as resolved.
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
}
Loading