Skip to content

Commit 44b96d5

Browse files
fix(sidecar): resolve handoff control files without following symlinks (#69)
* fix(sidecar): resolve handoff control files without following symlinks The per-volume handoff dir is writable by the sidecar UID (65534) so the sidecar can drop ready/error markers. A workload able to act as that UID could replace a control file (token/args/error) with a symlink; the privileged hostPID node plugin would then follow it (e.g. to /proc/1/root/<host-path>) during republish, giving host-side file create/truncate/chown and blind host reads. .volumes/ and <hash> are root-owned and cannot be swapped, so resolving each control file relative to a dirfd via openat2(RESOLVE_NO_SYMLINKS|RESOLVE_BENEATH) with fchown over the fd makes a planted symlink or magic-link fail (ELOOP) instead of escaping to the host. No handoff-protocol change. * test(webhook): add explicit return after t.Fatalf to satisfy staticcheck golangci-lint (pinned to 'latest', now v2.12.2) reports SA5011 on the sc.Env deref after the 'if sc == nil { t.Fatalf }' guard because it does not model t.Fatalf as terminating. Add an unreachable return to clear it. Pre-existing, unrelated to the handoff fix; bundled to unblock this PR. * chore(helm): default sidecar to hf-mount-fuse v0.9.1 v0.9.1 carries the repo-revision allowlist (huggingface/hf-mount#216). Bumps the chart default and the raw daemonset manifest from v0.7.2 so the CSI symlink fix ships paired with a sidecar that also rejects revision smuggling.
1 parent 850bb7b commit 44b96d5

5 files changed

Lines changed: 82 additions & 20 deletions

File tree

deploy/helm/hf-csi-driver/values.yaml

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,9 @@ hfMount:
88
image:
99
repository: ghcr.io/huggingface/hf-mount-fuse
1010
# Default for source installs; overwritten at release time with the latest hf-mount release tag.
11-
# v0.7.2 carries the inval_inode runtime-deadlock fix (huggingface/hf-mount#196,
12-
# fixes #195) — the root cause of the stranded-pod incident this PR's node-layer
13-
# sweep contains. See issue #48.
14-
tag: v0.7.2
11+
# v0.9.1 adds the repo-revision allowlist (blocks shell/URL metacharacters
12+
# reaching the mount args file) on top of prior fixes.
13+
tag: v0.9.1
1514
pullPolicy: IfNotPresent
1615
hostNetwork: true
1716
logFormat: ""

deploy/kubernetes/daemonset.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ spec:
3030
- --endpoint=unix:///csi/csi.sock
3131
- --node-id=$(CSI_NODE_NAME)
3232
- --cache-dir=/var/lib/hf-csi-driver/cache
33-
- --mount-image=ghcr.io/huggingface/hf-mount-fuse:v0.7.2
33+
- --mount-image=ghcr.io/huggingface/hf-mount-fuse:v0.9.1
3434
- --mount-pull-policy=IfNotPresent
3535
- --fuse-sweep-enabled=true
3636
- --fuse-sweep-interval=60s

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ go 1.25.0
44

55
require (
66
github.com/container-storage-interface/spec v1.12.0
7+
golang.org/x/sys v0.39.0
78
google.golang.org/grpc v1.79.1
89
google.golang.org/protobuf v1.36.11
910
k8s.io/api v0.35.3
@@ -50,7 +51,6 @@ require (
5051
golang.org/x/net v0.48.0 // indirect
5152
golang.org/x/oauth2 v0.34.0 // indirect
5253
golang.org/x/sync v0.19.0 // indirect
53-
golang.org/x/sys v0.39.0 // indirect
5454
golang.org/x/term v0.38.0 // indirect
5555
golang.org/x/text v0.32.0 // indirect
5656
golang.org/x/time v0.9.0 // indirect

pkg/driver/sidecar_mounter_linux.go

Lines changed: 75 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ package driver
55
import (
66
"crypto/sha256"
77
"fmt"
8+
"io"
89
"net"
910
"os"
1011
"path/filepath"
@@ -15,6 +16,7 @@ import (
1516
"unsafe"
1617

1718
"github.com/huggingface/hf-buckets-csi-driver/pkg/util"
19+
"golang.org/x/sys/unix"
1820
"k8s.io/klog/v2"
1921
)
2022

@@ -117,6 +119,64 @@ func sidecarEmptyDirPath(podUID string) string {
117119
return filepath.Join(kubeletPodsBase, podUID, "volumes", "kubernetes.io~empty-dir", emptyDirName)
118120
}
119121

122+
// The handoff dir (<emptyDir>/.volumes/<hash>) is writable by the sidecar UID
123+
// (65534), so a tenant acting as that UID could swap a control file for a
124+
// symlink into the host (e.g. /proc/1/root/...) that this privileged hostPID
125+
// plugin would then follow. The dir and its parents are root-owned and can't be
126+
// swapped, so we resolve each control file relative to a dirfd with
127+
// RESOLVE_NO_SYMLINKS|RESOLVE_BENEATH — a planted symlink then fails with ELOOP.
128+
129+
// openHandoffFile opens `name` (a single component) inside the root-owned
130+
// handoff dir, refusing symlink and magic-link resolution.
131+
func openHandoffFile(dir, name string, flags int, perm os.FileMode) (*os.File, error) {
132+
dirFd, err := unix.Open(dir, unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0)
133+
if err != nil {
134+
return nil, fmt.Errorf("open handoff dir %s: %w", dir, err)
135+
}
136+
defer func() { _ = unix.Close(dirFd) }()
137+
138+
how := unix.OpenHow{
139+
Flags: uint64(flags) | unix.O_CLOEXEC,
140+
Mode: uint64(perm.Perm()),
141+
Resolve: unix.RESOLVE_NO_SYMLINKS | unix.RESOLVE_BENEATH,
142+
}
143+
fd, err := unix.Openat2(dirFd, name, &how)
144+
if err != nil {
145+
return nil, fmt.Errorf("openat2 %s in %s: %w", name, dir, err)
146+
}
147+
return os.NewFile(uintptr(fd), filepath.Join(dir, name)), nil
148+
}
149+
150+
// writeHandoffFile writes a control file without following symlinks, chowning
151+
// to the sidecar UID over the fd (not by path) when chownSidecar is set.
152+
func writeHandoffFile(dir, name string, data []byte, perm os.FileMode, chownSidecar bool) error {
153+
f, err := openHandoffFile(dir, name, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, perm)
154+
if err != nil {
155+
return err
156+
}
157+
defer func() { _ = f.Close() }()
158+
if _, err := f.Write(data); err != nil {
159+
return fmt.Errorf("write %s: %w", name, err)
160+
}
161+
if chownSidecar {
162+
if err := f.Chown(65534, 65534); err != nil {
163+
return fmt.Errorf("chown %s: %w", name, err)
164+
}
165+
}
166+
return f.Close()
167+
}
168+
169+
// readHandoffFile reads a control file without following symlinks; a missing
170+
// file or an unresolvable planted symlink both surface as an error.
171+
func readHandoffFile(dir, name string) ([]byte, error) {
172+
f, err := openHandoffFile(dir, name, os.O_RDONLY, 0)
173+
if err != nil {
174+
return nil, err
175+
}
176+
defer func() { _ = f.Close() }()
177+
return io.ReadAll(f)
178+
}
179+
120180
// sidecarMount performs a FUSE mount via fd-passing to the sidecar container.
121181
//
122182
// Flow:
@@ -184,14 +244,15 @@ func sidecarMount(sourceType, sourceID, target string, opts MountOptions, volume
184244
// The emptyDir is tmpfs (medium: Memory) so the token never hits disk.
185245
// On republish, refreshSidecarToken overwrites this file with fresh credentials.
186246
if opts.TokenFile != "" {
247+
// opts.TokenFile is the kubelet-managed source (trusted); the destination
248+
// inside the 65534-writable handoff dir is what must not be a symlink.
187249
tokenDst := filepath.Join(volumeDir, "token")
188250
if data, err := os.ReadFile(opts.TokenFile); err == nil {
189-
if err := os.WriteFile(tokenDst, data, 0600); err != nil {
251+
// The sidecar runs as user 65534 (nobody) and needs to read the token.
252+
if err := writeHandoffFile(volumeDir, "token", data, 0600, true); err != nil {
190253
cleanup()
191254
return fmt.Errorf("write sidecar token: %w", err)
192255
}
193-
// The sidecar runs as user 65534 (nobody) and needs to read the token.
194-
_ = os.Chown(tokenDst, 65534, 65534)
195256
}
196257
opts.TokenFile = sidecarPath(tokenDst)
197258
}
@@ -207,7 +268,7 @@ func sidecarMount(sourceType, sourceID, target string, opts MountOptions, volume
207268
}
208269
// Prepend program name (required by clap's try_parse_from).
209270
argsContent := "hf-mount-fuse-sidecar\n" + strings.Join(args, "\n") + "\n"
210-
if err := os.WriteFile(filepath.Join(volumeDir, "args"), []byte(argsContent), 0644); err != nil {
271+
if err := writeHandoffFile(volumeDir, "args", []byte(argsContent), 0644, false); err != nil {
211272
cleanup()
212273
return fmt.Errorf("write args: %w", err)
213274
}
@@ -237,9 +298,10 @@ func sidecarMount(sourceType, sourceID, target string, opts MountOptions, volume
237298
return fmt.Errorf("listen on %s: %w", socketPath, err)
238299
}
239300

240-
// The sidecar runs as user 65534 (nobody). It needs write permission
241-
// on the socket file to connect.
242-
_ = os.Chown(filepath.Join(volumeDir, "s"), 65534, 65534)
301+
// The sidecar (user 65534) needs write permission on the socket to connect.
302+
// Lchown, not Chown, so a symlink planted at `s` by a tenant acting as 65534
303+
// can't redirect the chown to a host file.
304+
_ = os.Lchown(filepath.Join(volumeDir, "s"), 65534, 65534)
243305

244306
// --- Step 4: Async goroutine sends the fd when the sidecar connects ---
245307

@@ -332,11 +394,11 @@ func sidecarMount(sourceType, sourceID, target string, opts MountOptions, volume
332394
func checkSidecarHealth(podUID, volumeName string) error {
333395
tmpDir := sidecarEmptyDirPath(podUID)
334396
hash := fmt.Sprintf("%x", sha256.Sum256([]byte(volumeName)))[:12]
335-
errorPath := filepath.Join(tmpDir, ".volumes", hash, "error")
397+
volumeDir := filepath.Join(tmpDir, ".volumes", hash)
336398

337-
data, err := os.ReadFile(errorPath)
399+
data, err := readHandoffFile(volumeDir, "error")
338400
if err != nil {
339-
return nil // no error file = healthy
401+
return nil // no error file (or a symlink that won't resolve) = treat as healthy
340402
}
341403
msg := strings.TrimSpace(string(data))
342404
if msg == "" {
@@ -360,12 +422,11 @@ func cleanupSidecarSocket(volumeName string) {
360422
func refreshSidecarToken(podUID, volumeName, token string) {
361423
tmpDir := sidecarEmptyDirPath(podUID)
362424
hash := fmt.Sprintf("%x", sha256.Sum256([]byte(volumeName)))[:12]
363-
tokenPath := filepath.Join(tmpDir, ".volumes", hash, "token")
425+
volumeDir := filepath.Join(tmpDir, ".volumes", hash)
364426

365-
if err := os.WriteFile(tokenPath, []byte(token), 0600); err != nil {
366-
klog.Warningf("Sidecar token refresh: cannot write %s: %v", tokenPath, err)
427+
if err := writeHandoffFile(volumeDir, "token", []byte(token), 0600, true); err != nil {
428+
klog.Warningf("Sidecar token refresh: cannot write token in %s: %v", volumeDir, err)
367429
} else {
368-
_ = os.Chown(tokenPath, 65534, 65534)
369430
klog.V(4).Infof("Refreshed sidecar token for volume %s", volumeName)
370431
}
371432
}

pkg/webhook/sidecar_test.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,7 @@ func TestInjectSidecar_PassesRaisedGraceToSidecar(t *testing.T) {
257257
}
258258
if sc == nil {
259259
t.Fatalf("sidecar container %q was not injected", SidecarContainerName)
260+
return
260261
}
261262

262263
graceEnv, found := "", false
@@ -287,6 +288,7 @@ func TestInjectSidecar_PassesLogFormatToSidecar(t *testing.T) {
287288
}
288289
if sc == nil {
289290
t.Fatalf("sidecar container %q was not injected", SidecarContainerName)
291+
return
290292
}
291293

292294
for _, e := range sc.Env {

0 commit comments

Comments
 (0)