Skip to content

ftp: default to FTPS, contain restores, unpredictable temp names - #89

Open
poolpOrg wants to merge 1 commit into
integration/ftpfrom
sec/ftp-hardening
Open

poolpOrg wants to merge 1 commit into
integration/ftpfrom
sec/ftp-hardening

Conversation

@poolpOrg

Copy link
Copy Markdown
Contributor

Three issues in the FTP connector.

1. No transport security at all

func ConnectToFTP(host, username, password string) (*goftp.Client, error) {
	config := goftp.Config{User: username, Password: password, Timeout: 10 * time.Second}
	return goftp.DialConfig(config, host)
}

No TLSConfig, so FTPS was simply unreachable — credentials and every byte of backup data always went in the clear, with no config key to change it.

Adds a tls option (explicit / implicit / none) defaulting to explicit AUTH TLS, and tls_insecure_no_verify for certificate verification. Plain FTP now has to be asked for twice: tls=none is refused unless tls_insecure_no_verify=true acknowledges it. That's the same shape as the dav:// gate in webdav/connector.go — reusing the pattern this repo already settled on.

This changes the default, so existing plaintext configs will fail with an actionable error pointing at tls=none.

2. The exporter had no containment check

filepath.Join(p.Root(), record.Pathname) with nothing after it: a record pathname of /../../etc/x resolves above the restore root and is written there. contained() rejects it now.

It also switches filepathpath, since FTP paths are slash-separated regardless of client platform — filepath.Join was producing backslashes on Windows.

3. Predictable temp names

fmt.Sprintf("%s.tmp.%d", pathname, rand.Int()) with math/rand/v2 — neither seeded nor a CSPRNG. On a shared server the name can be precomputed and pre-created. Now crypto/rand.

Tests

common/options_test.go covers the TLS default, the double opt-in for cleartext, and rejected values. exporter/containment_test.go covers the traversal cases. go vet clean.

🤖 Generated with Claude Code

ConnectToFTP built a goftp.Config with only user, password and timeout: no
TLSConfig, so FTPS was unreachable and both credentials and backup data always
travelled in the clear, with nothing in the config to change that.

Add a tls option (explicit, implicit, none) defaulting to explicit AUTH TLS,
plus tls_insecure_no_verify for certificate verification. Plain FTP now has to
be asked for twice -- tls=none is refused unless tls_insecure_no_verify=true
acknowledges what it means -- which mirrors how the webdav connector gates
dav:// behind insecure=true.

The exporter also joined record pathnames onto the root with no containment
check, so a record pathname of "/../../etc/x" resolved above the restore root
and was written there; contained() rejects that now. It uses path rather than
filepath because FTP paths are slash-separated whatever the client platform,
which filepath.Join got wrong on Windows anyway.

writeAtomic() named its temporary file with math/rand/v2, neither seeded nor a
CSPRNG, so on a shared server the name could be precomputed and pre-created.
Draw the suffix from crypto/rand instead.
Comment thread ftp/common/common.go

// TLS selects the transport: "explicit" upgrades the control connection
// with AUTH TLS, "implicit" wraps it from the start, "none" is plain FTP.
TLS string

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It be nice to add const for possible values and/or a type for this.

type TLS string

const (
	TLSExplicit TLS = "explicit"
	TLSImplicit TLS = "implicit"
	TLSNone TLS = "none"
)

Comment thread ftp/common/hostport.go
@@ -0,0 +1,7 @@
package common

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NIT: we should rename this common package to something else. (config, ftp, conn, ...)

Comment thread ftp/common/common.go

if opts.TLS != "none" {
hostname := host
if h, _, err := splitHostPort(host); err == nil {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if h, _, err := splitHostPort(host); err == nil {
if h, _, err := net.SplitHostPort(host); err == nil {

So we can drop the hostport file

Comment thread ftp/common/hostport.go

import "net"

func splitHostPort(host string) (string, string, error) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As suggested before, we can just use net.SplitHostPort(host) and delete this file

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants