@@ -377,36 +377,61 @@ pub fn build_container_spec(sandbox: &DriverSandbox, config: &PodmanComputeConfi
377377 // proxy, and configure Landlock/seccomp. This matches the K8s
378378 // driver's runAsUser: 0.
379379 user : "0:0" . into ( ) ,
380- // The sandbox supervisor needs these capabilities during startup:
381- // SYS_ADMIN – seccomp filter installation, namespace creation, Landlock
382- // NET_ADMIN – network namespace veth setup, IP/route configuration
383- // SYS_PTRACE – reading /proc/<pid>/exe and ancestor walk for policy
384- // SYSLOG – reading /dev/kmsg for bypass-detection diagnostics
385- // SETUID – drop_privileges(): setuid() to the sandbox user
386- // SETGID – drop_privileges(): setgid() + initgroups() to the sandbox group
387- // DAC_READ_SEARCH – reading /proc/<pid>/fd/ across UIDs for process
388- // identity resolution. In rootless Podman the supervisor
389- // runs as UID 0 inside a user namespace while sandbox
390- // processes run as the sandbox user. The kernel's
391- // proc_fd_permission() calls generic_permission() which
392- // denies cross-UID access to the dr-x------ fd directory
393- // unless CAP_DAC_READ_SEARCH is present. Without it the
394- // proxy cannot determine which binary made each outbound
395- // connection and all traffic is denied.
396- // SETUID/SETGID are needed in rootless Podman: cap_drop:ALL removes them from
397- // the bounding set even though uid=0 owns the user namespace. Without them,
398- // setuid/setgid fail EPERM and the supervisor cannot drop to the sandbox user.
399- cap_drop : vec ! [ "ALL" . into( ) ] ,
380+ // Podman's default container capability set is already restricted:
381+ // CHOWN DAC_OVERRIDE FOWNER FSETID KILL SETGID SETUID SETPCAP
382+ // NET_BIND_SERVICE SYS_CHROOT SETFCAP
383+ // We add what the supervisor needs and drop what it doesn't.
384+ cap_drop : vec ! [
385+ // Not needed: standard file permission bits are sufficient; dropping
386+ // prevents the supervisor from bypassing DAC checks it shouldn't need.
387+ "DAC_OVERRIDE" . into( ) ,
388+ // Not needed: the supervisor does not create setuid/setgid executables.
389+ "FSETID" . into( ) ,
390+ // Not needed: the supervisor does not send signals to arbitrary processes.
391+ "KILL" . into( ) ,
392+ // Not needed: the supervisor does not bind privileged ports (<1024).
393+ "NET_BIND_SERVICE" . into( ) ,
394+ // Not in Podman's default set but explicitly denied in case the image
395+ // or runtime adds it; raw sockets are not required.
396+ "NET_RAW" . into( ) ,
397+ // Not needed: the supervisor does not manipulate file capabilities.
398+ "SETFCAP" . into( ) ,
399+ // Not needed: the supervisor does not manage its own capability bounding set.
400+ "SETPCAP" . into( ) ,
401+ // Not needed: the supervisor does not call chroot().
402+ "SYS_CHROOT" . into( ) ,
403+ ] ,
400404 cap_add : vec ! [
405+ // seccomp filter installation, namespace creation, Landlock setup.
401406 "SYS_ADMIN" . into( ) ,
407+ // Network namespace veth setup, IP/route configuration.
402408 "NET_ADMIN" . into( ) ,
409+ // Reading /proc/<pid>/exe and ancestor walk for process identity in policy.
403410 "SYS_PTRACE" . into( ) ,
411+ // Reading /dev/kmsg for bypass-detection diagnostics.
404412 "SYSLOG" . into( ) ,
405- "SETUID" . into( ) ,
406- "SETGID" . into( ) ,
413+ // Reading /proc/<pid>/fd/ across UIDs for process identity resolution.
414+ // In rootless Podman the supervisor runs as UID 0 inside a user namespace
415+ // while sandbox processes run as the sandbox user. The kernel's
416+ // proc_fd_permission() calls generic_permission() which denies cross-UID
417+ // access to the dr-x------ fd directory unless this cap is present.
418+ // Without it the proxy cannot determine which binary made each outbound
419+ // connection and all traffic is denied.
407420 "DAC_READ_SEARCH" . into( ) ,
408421 ] ,
409- // Disable the container-level seccomp profile. The sandbox supervisor
422+ // SETUID, SETGID, CHOWN, and FOWNER are intentionally kept from Podman's
423+ // default set and not dropped:
424+ // SETUID/SETGID – drop_privileges(): setuid()/setgid()/initgroups() to the
425+ // sandbox user. In rootless Podman cap_drop:ALL removes them
426+ // from the bounding set even though uid=0 owns the user
427+ // namespace — so we keep them by not dropping them explicitly.
428+ // CHOWN – prepare_filesystem(): chown(path, uid, gid) on newly
429+ // created read_write directories so the sandbox user can
430+ // write to them.
431+ // FOWNER – chown on files where the supervisor is not the owner
432+ // (e.g. pre-existing directories owned by another user).
433+ //
434+ // Disable the container-level seccomp profile. The sandbox supervisor The sandbox supervisor
410435 // installs its own policy-aware BPF seccomp filter at runtime via
411436 // seccompiler (two-phase: clone3 blocker + main filter). The runtime
412437 // filter is more restrictive than Podman's default — it blocks 20+
@@ -596,17 +621,46 @@ mod tests {
596621 let config = test_config ( ) ;
597622 let spec = build_container_spec ( & sandbox, & config) ;
598623
599- let cap_add = spec[ "cap_add" ]
624+ let added: Vec < & str > = spec[ "cap_add" ]
625+ . as_array ( )
626+ . expect ( "cap_add should be an array" )
627+ . iter ( )
628+ . filter_map ( |v| v. as_str ( ) )
629+ . collect ( ) ;
630+ assert ! ( added. contains( & "SYS_ADMIN" ) , "missing SYS_ADMIN" ) ;
631+ assert ! ( added. contains( & "NET_ADMIN" ) , "missing NET_ADMIN" ) ;
632+ assert ! ( added. contains( & "SYS_PTRACE" ) , "missing SYS_PTRACE" ) ;
633+ assert ! ( added. contains( & "SYSLOG" ) , "missing SYSLOG" ) ;
634+ assert ! (
635+ added. contains( & "DAC_READ_SEARCH" ) ,
636+ "missing DAC_READ_SEARCH"
637+ ) ;
638+
639+ // SETUID and SETGID are NOT in cap_add — they remain available from the
640+ // default bounding set because we no longer use cap_drop:ALL. Verify they
641+ // are also not explicitly dropped. Similarly CHOWN and FOWNER must not be
642+ // dropped because prepare_filesystem() calls chown() on newly created
643+ // read_write directories before the supervisor drops privileges.
644+ let dropped: Vec < & str > = spec[ "cap_drop" ]
600645 . as_array ( )
601- . expect ( "cap_add should be an array" ) ;
602- let caps: Vec < & str > = cap_add. iter ( ) . filter_map ( |v| v. as_str ( ) ) . collect ( ) ;
603- assert ! ( caps. contains( & "SYS_ADMIN" ) , "missing SYS_ADMIN" ) ;
604- assert ! ( caps. contains( & "NET_ADMIN" ) , "missing NET_ADMIN" ) ;
605- assert ! ( caps. contains( & "SYS_PTRACE" ) , "missing SYS_PTRACE" ) ;
606- assert ! ( caps. contains( & "SYSLOG" ) , "missing SYSLOG" ) ;
607- assert ! ( caps. contains( & "SETUID" ) , "missing SETUID" ) ;
608- assert ! ( caps. contains( & "SETGID" ) , "missing SETGID" ) ;
609- assert ! ( caps. contains( & "DAC_READ_SEARCH" ) , "missing DAC_READ_SEARCH" ) ;
646+ . expect ( "cap_drop should be an array" )
647+ . iter ( )
648+ . filter_map ( |v| v. as_str ( ) )
649+ . collect ( ) ;
650+ assert ! ( !dropped. contains( & "SETUID" ) , "SETUID must not be dropped" ) ;
651+ assert ! ( !dropped. contains( & "SETGID" ) , "SETGID must not be dropped" ) ;
652+ assert ! (
653+ !dropped. contains( & "CHOWN" ) ,
654+ "CHOWN must not be dropped (needed for prepare_filesystem chown)"
655+ ) ;
656+ assert ! (
657+ !dropped. contains( & "FOWNER" ) ,
658+ "FOWNER must not be dropped (needed for chown on non-owned files)"
659+ ) ;
660+ assert ! (
661+ !dropped. contains( & "ALL" ) ,
662+ "must not use cap_drop:ALL in rootless Podman"
663+ ) ;
610664 }
611665
612666 #[ test]
0 commit comments