Skip to content

Commit 3c86c2c

Browse files
authored
Merge pull request #14 from netfoundry/security_review_fixes
Security review fixes
2 parents aa4ecd1 + e99041b commit 3c86c2c

11 files changed

Lines changed: 632 additions & 174 deletions

File tree

client/sftp.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616
"os"
1717
"path"
1818
"path/filepath"
19+
"strings"
1920
"time"
2021

2122
"github.com/pkg/sftp"
@@ -48,6 +49,11 @@ func RunSFTP(
4849
cfg := &ssh.ClientConfig{
4950
User: user,
5051
Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)},
52+
// Host key verification is intentionally skipped. The connection arrives
53+
// over a Ziti overlay that enforces mutual TLS using the controller's
54+
// PKI — the host's Ziti identity is cryptographically proven before any
55+
// SSH bytes are exchanged. A traditional known-hosts check would be
56+
// redundant and weaker than the guarantee Ziti already provides.
5157
HostKeyCallback: ssh.InsecureIgnoreHostKey(), //nolint:gosec
5258
}
5359

@@ -272,6 +278,9 @@ func downloadDir(client *sftp.Client, remoteDir, localDir string, preserve, quie
272278
}
273279

274280
for _, entry := range entries {
281+
if err := safeName(entry.Name()); err != nil {
282+
return fmt.Errorf("remote dir %q: %w", remoteDir, err)
283+
}
275284
rPath := path.Join(remoteDir, entry.Name())
276285
lPath := filepath.Join(localDir, entry.Name())
277286
if entry.IsDir() {
@@ -342,6 +351,19 @@ func downloadFile(client *sftp.Client, remotePath, localPath string, preserve, q
342351
return nil
343352
}
344353

354+
// safeName rejects remote-supplied entry names that could escape the local
355+
// download target via path traversal (e.g. "..", "../etc/passwd", or names
356+
// embedding a path separator).
357+
func safeName(name string) error {
358+
if name == "" || name == "." || name == ".." {
359+
return fmt.Errorf("rejected unsafe remote entry name %q", name)
360+
}
361+
if strings.ContainsAny(name, `/\`) {
362+
return fmt.Errorf("rejected unsafe remote entry name %q", name)
363+
}
364+
return nil
365+
}
366+
345367
// ---------------------------------------------------------------------------
346368
// Progress reporting
347369
// ---------------------------------------------------------------------------

cmd/ziti-scp/main.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -277,7 +277,7 @@ func runSign(p signParams) error {
277277
}
278278

279279
certPath := deriveCertPath(privKeyPath)
280-
if err := os.WriteFile(certPath, []byte(certLine+"\n"), 0644); err != nil {
280+
if err := config.AtomicWriteFile(certPath, []byte(certLine+"\n"), 0644); err != nil {
281281
return fmt.Errorf("write certificate to %q: %w", certPath, err)
282282
}
283283

@@ -322,7 +322,7 @@ func runEnroll(jwtPath, outPath string) error {
322322
if err != nil {
323323
return fmt.Errorf("marshal identity config: %w", err)
324324
}
325-
if err := os.WriteFile(outPath, cfgJSON, 0600); err != nil {
325+
if err := config.AtomicWriteFile(outPath, cfgJSON, 0600); err != nil {
326326
return fmt.Errorf("write identity file %q: %w", outPath, err)
327327
}
328328

cmd/ziti-ssh-ca/main.go

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"bufio"
1313
"encoding/json"
1414
"fmt"
15+
"io"
1516
"log/slog"
1617
"net"
1718
"net/url"
@@ -205,7 +206,7 @@ func runEnroll(jwtPath, outPath string) error {
205206
if err != nil {
206207
return fmt.Errorf("marshal identity config: %w", err)
207208
}
208-
if err := os.WriteFile(outPath, cfgJSON, 0600); err != nil {
209+
if err := config.AtomicWriteFile(outPath, cfgJSON, 0600); err != nil {
209210
return fmt.Errorf("write identity file %q: %w", outPath, err)
210211
}
211212

@@ -379,7 +380,15 @@ func handleConn(conn net.Conn, signer ssh.Signer, caPubBytes []byte, principal,
379380
log = log.With("derived_principal", effectivePrincipal)
380381
}
381382

382-
reader := bufio.NewReader(conn)
383+
// Bound the connection lifetime: 30 s is generous for a single-line request.
384+
// The size cap (4 KiB) is well above any real SSH public key but prevents a
385+
// slow-sender from holding the connection indefinitely.
386+
if err := conn.SetDeadline(time.Now().Add(30 * time.Second)); err != nil {
387+
log.Error("set deadline", "err", err)
388+
return
389+
}
390+
const maxRequestBytes = 4096
391+
reader := bufio.NewReader(io.LimitReader(conn, maxRequestBytes))
383392
line, err := reader.ReadString('\n')
384393
if err != nil {
385394
log.Error("read request", "err", err)

cmd/ziti-ssh-host/main.go

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
package main
1414

1515
import (
16+
"bytes"
1617
"crypto/tls"
1718
"crypto/x509"
1819
"encoding/json"
@@ -253,7 +254,7 @@ func runEnroll(jwtPath, identityFile string) error {
253254
if err != nil {
254255
return fmt.Errorf("marshal identity config: %w", err)
255256
}
256-
if err := os.WriteFile(identityFile, cfgJSON, 0600); err != nil {
257+
if err := config.AtomicWriteFile(identityFile, cfgJSON, 0600); err != nil {
257258
return fmt.Errorf("write identity file %q: %w", identityFile, err)
258259
}
259260
slog.Info("identity enrolled", "path", identityFile)
@@ -456,8 +457,22 @@ func intCAFromController(controllerURL string, rootPool *x509.CertPool) (*x509.C
456457
"selfSigned", cert.Subject.String() == cert.Issuer.String())
457458
}
458459

460+
if len(state.PeerCertificates) == 0 {
461+
return nil, fmt.Errorf("no certificates in TLS chain from %q", host)
462+
}
463+
leaf := state.PeerCertificates[0]
464+
// Walk the chain and pick the cert whose Subject matches the leaf's Issuer
465+
// field. Raw-bytes comparison avoids encoding differences that string
466+
// formatting can obscure, and correctly identifies the signing intermediate
467+
// even when the chain contains cross-signed or multiple intermediate certs.
468+
for _, cert := range state.PeerCertificates[1:] {
469+
if cert.IsCA && bytes.Equal(cert.RawSubject, leaf.RawIssuer) {
470+
return cert, nil
471+
}
472+
}
473+
// Fallback to the original heuristic for unusual chain orderings.
459474
for _, cert := range state.PeerCertificates {
460-
if cert.IsCA && cert.Subject.String() != cert.Issuer.String() {
475+
if cert.IsCA && !bytes.Equal(cert.RawSubject, cert.RawIssuer) {
461476
return cert, nil
462477
}
463478
}
@@ -674,11 +689,40 @@ func loadPermissionsConfig(zitiCtx ziti.Context, serviceName string) (*host.Perm
674689
Permissions: make(map[string]host.IdentityPermissions, len(wire.Permissions)),
675690
}
676691
for identity, entry := range wire.Permissions {
692+
if entry.SudoersRule != "" {
693+
if err := host.ValidateSudoersRule(entry.SudoersRule); err != nil {
694+
return nil, fmt.Errorf("service %q config: identity %q: %w", serviceName, identity, err)
695+
}
696+
}
677697
pc.Permissions[identity] = host.IdentityPermissions{
678698
Groups: entry.Groups,
679699
SudoersRule: entry.SudoersRule,
680700
}
681701
}
702+
// Validate that no two explicit (non-glob) config keys derive to the same
703+
// Linux username. A collision allows one Ziti identity to connect as another
704+
// identity's Linux account, potentially inheriting elevated permissions.
705+
// Glob patterns are skipped — they match many identities and have no single
706+
// derived username. Runtime collision detection in UserManager.EnsureUser
707+
// handles the glob case.
708+
derived := make(map[string]string, len(pc.Permissions))
709+
var collisions []string
710+
for identity := range pc.Permissions {
711+
if strings.ContainsAny(identity, "*?") {
712+
continue
713+
}
714+
uname := ca.DeriveUsername(identity)
715+
if prev, exists := derived[uname]; exists {
716+
collisions = append(collisions, fmt.Sprintf("%q and %q both derive to %q", prev, identity, uname))
717+
} else {
718+
derived[uname] = identity
719+
}
720+
}
721+
if len(collisions) > 0 {
722+
return nil, fmt.Errorf("service %q config has username collisions (fix or remove one of each pair): %s",
723+
serviceName, strings.Join(collisions, "; "))
724+
}
725+
682726
slog.Info("loaded ziti-ssh-host.v1 config", "service", serviceName, "identities", len(pc.Permissions))
683727
return pc, nil
684728
}
@@ -922,7 +966,7 @@ func runProxy(identityFile string, sshServices []string, mode string, zitiTimeou
922966
"username", username,
923967
"groups", perms.Groups,
924968
"has_sudoers", perms.SudoersRule != "")
925-
return mgr.EnsureUser(username, perms)
969+
return mgr.EnsureUser(zitiIdentity, username, perms)
926970
},
927971
OnDisconnect: func(zitiIdentity string) {
928972
username := ca.DeriveUsername(zitiIdentity)
@@ -958,6 +1002,10 @@ func runProxy(identityFile string, sshServices []string, mode string, zitiTimeou
9581002
go func() {
9591003
sig := <-sigCh
9601004
slog.Info("received signal, stopping proxy listeners", "signal", sig)
1005+
// Restore default signal handling so a second SIGINT/SIGTERM exits
1006+
// immediately if the drain hangs (instead of being silently swallowed
1007+
// by the now-drained channel).
1008+
signal.Reset(syscall.SIGTERM, syscall.SIGINT)
9611009
if _, err := daemon.SdNotify(false, "STOPPING=1"); err != nil {
9621010
slog.Debug("sd_notify STOPPING failed", "err", err)
9631011
}

cmd/ziti-ssh/main.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -357,7 +357,7 @@ func runSign(p signParams) error {
357357
}
358358

359359
certPath := deriveCertPath(privKeyPath)
360-
if err := os.WriteFile(certPath, []byte(certLine+"\n"), 0644); err != nil {
360+
if err := config.AtomicWriteFile(certPath, []byte(certLine+"\n"), 0644); err != nil {
361361
return fmt.Errorf("write certificate to %q: %w", certPath, err)
362362
}
363363

@@ -882,7 +882,7 @@ func runEnroll(jwtPath, outPath string) error {
882882
if err != nil {
883883
return fmt.Errorf("marshal identity config: %w", err)
884884
}
885-
if err := os.WriteFile(outPath, cfgJSON, 0600); err != nil {
885+
if err := config.AtomicWriteFile(outPath, cfgJSON, 0600); err != nil {
886886
return fmt.Errorf("write identity file %q: %w", outPath, err)
887887
}
888888

config/atomic.go

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
package config
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"path/filepath"
7+
)
8+
9+
// AtomicWriteFile writes data to dest atomically: it creates a sibling temp
10+
// file, writes and fsyncs the data, sets the file mode, renames it over dest,
11+
// and fsyncs the parent directory. This ensures that a concurrent reader never
12+
// sees a partial write and that the new content survives a crash immediately
13+
// after rename.
14+
func AtomicWriteFile(dest string, data []byte, mode os.FileMode) error {
15+
dir := filepath.Dir(dest)
16+
tmp, err := os.CreateTemp(dir, ".tmp-*")
17+
if err != nil {
18+
return fmt.Errorf("create temp file in %q: %w", dir, err)
19+
}
20+
tmpName := tmp.Name()
21+
committed := false
22+
defer func() {
23+
if !committed {
24+
_ = tmp.Close()
25+
_ = os.Remove(tmpName)
26+
}
27+
}()
28+
29+
if _, err := tmp.Write(data); err != nil {
30+
return fmt.Errorf("write temp file: %w", err)
31+
}
32+
if err := tmp.Sync(); err != nil {
33+
return fmt.Errorf("sync temp file: %w", err)
34+
}
35+
if err := tmp.Chmod(mode); err != nil {
36+
return fmt.Errorf("chmod temp file: %w", err)
37+
}
38+
if err := tmp.Close(); err != nil {
39+
return fmt.Errorf("close temp file: %w", err)
40+
}
41+
42+
if err := os.Rename(tmpName, dest); err != nil {
43+
return fmt.Errorf("rename temp file to %q: %w", dest, err)
44+
}
45+
committed = true
46+
47+
// Sync the directory so the rename is durable.
48+
if d, err := os.Open(dir); err == nil {
49+
_ = d.Sync()
50+
_ = d.Close()
51+
}
52+
return nil
53+
}

config/ztapis.go

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -46,13 +46,5 @@ func PersistZtAPIs(identityFile string, urls []string) error {
4646
mode = fi.Mode()
4747
}
4848

49-
tmp := identityFile + ".tmp"
50-
if err := os.WriteFile(tmp, out, mode); err != nil {
51-
return fmt.Errorf("write temp identity file: %w", err)
52-
}
53-
if err := os.Rename(tmp, identityFile); err != nil {
54-
os.Remove(tmp)
55-
return fmt.Errorf("rename temp identity file: %w", err)
56-
}
57-
return nil
49+
return AtomicWriteFile(identityFile, out, mode)
5850
}

0 commit comments

Comments
 (0)