fix(unit): drop PrivateTmp, ProtectHome, ProtectKernelModules and ProtectKernelLogs, which hide the loop mounts of container mode from Docker; test that the unit sets no option creating a mount namespace (GO-001)
- commit
- 63269d4a7636cbbcb9a8aa168540b3b83e307efb
- author
- brice · · 2026-10-12 20:09 UTC
- parent
- 3f47b6d
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +126 -41
M README.md +33 -22
| @@ -155,28 +155,39 @@ everything labelled as the runner's own. | |||
| 155 | 155 | ||
| 156 | 156 | **Sandboxing of the unit.** The runner has to be root (TAP devices, nftables, | |
| 157 | 157 | loop mounts, the jailer, Docker), so the unit only takes away what it does not | |
| 158 | - | use: `NoNewPrivileges`, `ProtectHome`, `PrivateTmp`, `ProtectClock`, | |
| 159 | - | `ProtectKernelModules`, `ProtectKernelLogs`, `RestrictSUIDSGID`, | |
| 160 | - | `LockPersonality`, `RestrictRealtime`, `SystemCallArchitectures=native`, | |
| 161 | - | `RestrictAddressFamilies` (UNIX, INET, INET6, NETLINK, VSOCK). | |
| 162 | - | Checked on a workstation (a user manager, no root): the micro-VM job test | |
| 163 | - | (`TestVMRunsAJob`: user and network namespaces, Firecracker, TAP, vsock) passes | |
| 164 | - | under every option above except `ProtectHome`, which hides the home directory | |
| 165 | - | where that test's tree lives; with it the binary itself was run (`version`) and | |
| 166 | - | `/home` is denied as intended. Not checked, because they need root on a real | |
| 167 | - | host: the jailer, loop mounts, nftables in the host namespace, Docker. **Deliberately absent**, and why: | |
| 168 | - | `Delegate=` (the runner manages no cgroup of its own: the jailer writes the | |
| 169 | - | VMs' cgroups as root, Docker has its own), `ProtectSystem=`, | |
| 170 | - | `ProtectControlGroups=`, `ProtectKernelTunables=` (the jailer writes cgroups, the | |
| 171 | - | runner turns on `net.ipv4.ip_forward`), `PrivateDevices=` (`/dev/kvm`, | |
| 172 | - | `/dev/net/tun`, `/dev/loop*`), `RestrictNamespaces=`, `PrivateNetwork=`, | |
| 173 | - | `CapabilityBoundingSet=` and `SystemCallFilter=` (network namespaces and | |
| 174 | - | devices, mounts, capabilities handed to the jailer and Docker), | |
| 175 | - | `MemoryDenyWriteExecute=` (Firecracker maps guest memory writable and | |
| 176 | - | executable), `UMask=` (the drivers create directories that the job's user must | |
| 177 | - | enter, with explicit modes; the state directory is made 0700 by the runner). The boundary of a job is its micro-VM or container, not this unit; | |
| 178 | - | the unit cannot be tested end to end without a rootful host, so the options that | |
| 179 | - | would only break it silently are left out. | |
| 158 | + | use, and only with options that **leave it in the host's mount namespace**: | |
| 159 | + | `NoNewPrivileges`, `ProtectClock`, `RestrictSUIDSGID`, `LockPersonality`, | |
| 160 | + | `RestrictRealtime`, `SystemCallArchitectures=native` and `RestrictAddressFamilies` | |
| 161 | + | (UNIX, INET, INET6, NETLINK, VSOCK). | |
| 162 | + | ||
| 163 | + | **Deliberately absent**, and why: | |
| 164 | + | ||
| 165 | + | - **Every option that gives the unit a mount namespace of its own:** `PrivateTmp`, | |
| 166 | + | `ProtectHome`, `ProtectSystem`, `ProtectKernelModules`, `ProtectKernelLogs`, | |
| 167 | + | `ProtectKernelTunables`, `ProtectControlGroups`, `PrivateDevices`, | |
| 168 | + | `ReadOnlyPaths`, `ReadWritePaths`, `InaccessiblePaths`, `TemporaryFileSystem`, | |
| 169 | + | `BindPaths`. In container mode the runner loop-mounts the scratch disk of each | |
| 170 | + | job and the Docker daemon, which lives in the host's namespace, binds that | |
| 171 | + | directory into the container; a mount made inside the unit's namespace is | |
| 172 | + | invisible to it and the container would get an empty directory. (`ProtectKernelModules` | |
| 173 | + | and `ProtectKernelLogs` look harmless but are implemented with mounts too.) The | |
| 174 | + | micro-VM mode would tolerate them, but one unit serves both modes, and a second | |
| 175 | + | unit for VM-only hardening is not worth maintaining. A test in `internal/host` | |
| 176 | + | fails if the unit sets any of them. | |
| 177 | + | - `Delegate=`: the runner manages no cgroup of its own (the jailer writes the | |
| 178 | + | VMs' cgroups as root, Docker has its own). | |
| 179 | + | - `RestrictNamespaces=`, `PrivateNetwork=`, `CapabilityBoundingSet=`, | |
| 180 | + | `SystemCallFilter=`: network namespaces and devices (TAP, veth), mounts, | |
| 181 | + | `net.ipv4.ip_forward`, capabilities handed to the jailer and Docker. | |
| 182 | + | - `MemoryDenyWriteExecute=`: Firecracker maps guest memory writable and executable. | |
| 183 | + | - `UMask=`: the drivers create directories that the job's user must enter, with | |
| 184 | + | explicit modes; the state directory is made 0700 by the runner. | |
| 185 | + | ||
| 186 | + | The boundary of a job is its micro-VM or container, not this unit. Checked on a | |
| 187 | + | workstation (a user manager, no root): the micro-VM job test (`TestVMRunsAJob`: | |
| 188 | + | user and network namespaces, Firecracker, TAP, vsock) passes under the options | |
| 189 | + | that remain. Not checked, because they need root on a real host: the jailer, loop | |
| 190 | + | mounts, nftables in the host namespace, Docker. | |
| 180 | 191 | ||
| 181 | 192 | ## The image | |
| 182 | 193 | ||
| @@ -155,28 +155,39 @@ everything labelled as the runner's own. | |||||
| 155 | 155 | ||||
| 156 | **Sandboxing of the unit.** The runner has to be root (TAP devices, nftables, | 156 | **Sandboxing of the unit.** The runner has to be root (TAP devices, nftables, | ||
| 157 | loop mounts, the jailer, Docker), so the unit only takes away what it does not | 157 | loop mounts, the jailer, Docker), so the unit only takes away what it does not | ||
| 158 | - | use: `NoNewPrivileges`, `ProtectHome`, `PrivateTmp`, `ProtectClock`, | 158 | + | use, and only with options that **leave it in the host's mount namespace**: |
| 159 | - | `ProtectKernelModules`, `ProtectKernelLogs`, `RestrictSUIDSGID`, | 159 | + | `NoNewPrivileges`, `ProtectClock`, `RestrictSUIDSGID`, `LockPersonality`, |
| 160 | - | `LockPersonality`, `RestrictRealtime`, `SystemCallArchitectures=native`, | 160 | + | `RestrictRealtime`, `SystemCallArchitectures=native` and `RestrictAddressFamilies` |
| 161 | - | `RestrictAddressFamilies` (UNIX, INET, INET6, NETLINK, VSOCK). | 161 | + | (UNIX, INET, INET6, NETLINK, VSOCK). |
| 162 | - | Checked on a workstation (a user manager, no root): the micro-VM job test | 162 | + | |
| 163 | - | (`TestVMRunsAJob`: user and network namespaces, Firecracker, TAP, vsock) passes | 163 | + | **Deliberately absent**, and why: |
| 164 | - | under every option above except `ProtectHome`, which hides the home directory | 164 | + | |
| 165 | - | where that test's tree lives; with it the binary itself was run (`version`) and | 165 | + | - **Every option that gives the unit a mount namespace of its own:** `PrivateTmp`, |
| 166 | - | `/home` is denied as intended. Not checked, because they need root on a real | 166 | + | `ProtectHome`, `ProtectSystem`, `ProtectKernelModules`, `ProtectKernelLogs`, |
| 167 | - | host: the jailer, loop mounts, nftables in the host namespace, Docker. **Deliberately absent**, and why: | 167 | + | `ProtectKernelTunables`, `ProtectControlGroups`, `PrivateDevices`, |
| 168 | - | `Delegate=` (the runner manages no cgroup of its own: the jailer writes the | 168 | + | `ReadOnlyPaths`, `ReadWritePaths`, `InaccessiblePaths`, `TemporaryFileSystem`, |
| 169 | - | VMs' cgroups as root, Docker has its own), `ProtectSystem=`, | 169 | + | `BindPaths`. In container mode the runner loop-mounts the scratch disk of each |
| 170 | - | `ProtectControlGroups=`, `ProtectKernelTunables=` (the jailer writes cgroups, the | 170 | + | job and the Docker daemon, which lives in the host's namespace, binds that |
| 171 | - | runner turns on `net.ipv4.ip_forward`), `PrivateDevices=` (`/dev/kvm`, | 171 | + | directory into the container; a mount made inside the unit's namespace is |
| 172 | - | `/dev/net/tun`, `/dev/loop*`), `RestrictNamespaces=`, `PrivateNetwork=`, | 172 | + | invisible to it and the container would get an empty directory. (`ProtectKernelModules` |
| 173 | - | `CapabilityBoundingSet=` and `SystemCallFilter=` (network namespaces and | 173 | + | and `ProtectKernelLogs` look harmless but are implemented with mounts too.) The |
| 174 | - | devices, mounts, capabilities handed to the jailer and Docker), | 174 | + | micro-VM mode would tolerate them, but one unit serves both modes, and a second |
| 175 | - | `MemoryDenyWriteExecute=` (Firecracker maps guest memory writable and | 175 | + | unit for VM-only hardening is not worth maintaining. A test in `internal/host` |
| 176 | - | executable), `UMask=` (the drivers create directories that the job's user must | 176 | + | fails if the unit sets any of them. |
| 177 | - | enter, with explicit modes; the state directory is made 0700 by the runner). The boundary of a job is its micro-VM or container, not this unit; | 177 | + | - `Delegate=`: the runner manages no cgroup of its own (the jailer writes the |
| 178 | - | the unit cannot be tested end to end without a rootful host, so the options that | 178 | + | VMs' cgroups as root, Docker has its own). |
| 179 | - | would only break it silently are left out. | 179 | + | - `RestrictNamespaces=`, `PrivateNetwork=`, `CapabilityBoundingSet=`, |
| 180 | + | `SystemCallFilter=`: network namespaces and devices (TAP, veth), mounts, | |||
| 181 | + | `net.ipv4.ip_forward`, capabilities handed to the jailer and Docker. | |||
| 182 | + | - `MemoryDenyWriteExecute=`: Firecracker maps guest memory writable and executable. | |||
| 183 | + | - `UMask=`: the drivers create directories that the job's user must enter, with | |||
| 184 | + | explicit modes; the state directory is made 0700 by the runner. | |||
| 185 | + | ||||
| 186 | + | The boundary of a job is its micro-VM or container, not this unit. Checked on a | |||
| 187 | + | workstation (a user manager, no root): the micro-VM job test (`TestVMRunsAJob`: | |||
| 188 | + | user and network namespaces, Firecracker, TAP, vsock) passes under the options | |||
| 189 | + | that remain. Not checked, because they need root on a real host: the jailer, loop | |||
| 190 | + | mounts, nftables in the host namespace, Docker. | |||
| 180 | 191 | ||||
| 181 | ## The image | 192 | ## The image | ||
| 182 | 193 | ||||
M contrib/ermite-runner.service +25 -19
| @@ -43,21 +43,16 @@ RuntimeDirectory=ermite-runner | |||
| 43 | 43 | TasksMax=infinity | |
| 44 | 44 | LimitNOFILE=1048576 | |
| 45 | 45 | ||
| 46 | - | # ---- sandboxing that keeps loop mounts, TAP, nftables, KVM and the jailer ---- | |
| 46 | + | # ---- sandboxing that leaves the unit in the host's mount namespace ---------- | |
| 47 | + | # Only options that do not give the unit a mount namespace of its own: in | |
| 48 | + | # container mode the runner loop-mounts the scratch disk of a job, and the Docker | |
| 49 | + | # daemon, which lives in the host's namespace, must see that mount to bind it | |
| 50 | + | # into the container. See the end of the file. | |
| 47 | 51 | NoNewPrivileges=yes | |
| 48 | - | # Nobody else's files, and no interactive logins' data. | |
| 49 | - | ProtectHome=yes | |
| 50 | - | # A private /tmp: the runner and the jailer do not share files with the host | |
| 51 | - | # through it (the sandbox directories live in the state directory). | |
| 52 | - | PrivateTmp=yes | |
| 53 | - | # The runner does not set the clock, load modules or change kernel logging. | |
| 52 | + | # The runner does not set the clock. | |
| 54 | 53 | ProtectClock=yes | |
| 55 | - | ProtectKernelModules=yes | |
| 56 | - | ProtectKernelLogs=yes | |
| 57 | 54 | # No setuid/setgid files created by the runner or its children. | |
| 58 | 55 | RestrictSUIDSGID=yes | |
| 59 | - | # Kernel tunables stay read-only for the unit... except where the runner needs | |
| 60 | - | # them: see the end of the file (net.ipv4.ip_forward, cgroups). | |
| 61 | 56 | LockPersonality=yes | |
| 62 | 57 | # The runner does not need real-time scheduling. | |
| 63 | 58 | RestrictRealtime=yes | |
| @@ -74,27 +69,38 @@ WantedBy=multi-user.target | |||
| 74 | 69 | ||
| 75 | 70 | # ---- deliberately absent ----------------------------------------------------- | |
| 76 | 71 | # | |
| 72 | + | # PrivateTmp=, ProtectHome=, ProtectSystem=, ProtectKernelModules=, | |
| 73 | + | # ProtectKernelLogs=, ProtectKernelTunables=, ProtectControlGroups=, | |
| 74 | + | # PrivateDevices=, ReadOnlyPaths=, ReadWritePaths=, InaccessiblePaths=, | |
| 75 | + | # TemporaryFileSystem=, BindPaths= | |
| 76 | + | # Each of these puts the unit in a mount namespace of its own | |
| 77 | + | # (systemd sets one up for ProtectKernelModules and | |
| 78 | + | # ProtectKernelLogs too: it hides /usr/lib/modules and | |
| 79 | + | # /proc/kmsg with mounts). In container mode the runner | |
| 80 | + | # loop-mounts the scratch disk of a job below the state | |
| 81 | + | # directory, and the Docker daemon, in the host's namespace, | |
| 82 | + | # cannot see a mount made inside the unit's: the bind | |
| 83 | + | # sources of the container would be empty directories. The | |
| 84 | + | # micro-VM mode would tolerate them, but one unit serves both | |
| 85 | + | # modes and a second unit for VM-only hardening is not worth | |
| 86 | + | # it. test: internal/host TestUnitHasNoMountNamespaceOptions. | |
| 87 | + | # | |
| 77 | 88 | # Delegate=yes The runner does not manage cgroups of its own. The jailer | |
| 78 | 89 | # creates the cgroups of the VMs below /sys/fs/cgroup itself | |
| 79 | 90 | # (it is root and writes there directly), and Docker has its | |
| 80 | 91 | # own. Delegation would hand a subtree to this unit that | |
| 81 | 92 | # nothing uses. | |
| 82 | - | # ProtectSystem= The runner writes to the state directory, /run, /sys/fs/cgroup, | |
| 83 | - | # /dev (mounts, TAP, loop devices), and Docker's data: a | |
| 84 | - | # read-only /usr is possible but would be the only gain, and | |
| 85 | - | # it breaks `ip`/`nft` helpers found by PATH on some distros. | |
| 86 | - | # PrivateDevices= The runner needs /dev/kvm, /dev/net/tun, /dev/loop*. | |
| 87 | - | # PrivateNetwork=, RestrictNamespaces=, ProtectKernelTunables=, | |
| 93 | + | # PrivateNetwork=, RestrictNamespaces=, | |
| 88 | 94 | # CapabilityBoundingSet=, MemoryDenyWriteExecute=, SystemCallFilter= | |
| 89 | 95 | # The runner creates network namespaces and devices (TAP, | |
| 90 | 96 | # veth, bridges) and mounts file systems, enables | |
| 91 | - | # net.ipv4.ip_forward, hands capabilities to the jailer and | |
| 97 | + | # net.ipv4.ip_forward (the jailer also writes cgroups, /dev/kvm, /dev/net/tun and | |
| 98 | + | # /dev/loop* are needed), hands capabilities to the jailer and | |
| 92 | 99 | # to Docker, and starts Firecracker (which maps guest memory | |
| 93 | 100 | # writable and executable). Each of these would break a path | |
| 94 | 101 | # that cannot be tested without a rootful host; the | |
| 95 | 102 | # boundary of a job is the micro-VM or the container, not | |
| 96 | 103 | # this unit. | |
| 97 | - | # ProtectControlGroups= The jailer writes the cgroups of the VMs. | |
| 98 | 104 | # UMask=0077 The state directory is made 0700 by the runner itself, and the | |
| 99 | 105 | # drivers create directories that the job's user must be able | |
| 100 | 106 | # to enter (the scratch disk's work and tmp directories are | |
| @@ -43,21 +43,16 @@ RuntimeDirectory=ermite-runner | |||||
| 43 | TasksMax=infinity | 43 | TasksMax=infinity | ||
| 44 | LimitNOFILE=1048576 | 44 | LimitNOFILE=1048576 | ||
| 45 | 45 | ||||
| 46 | - | # ---- sandboxing that keeps loop mounts, TAP, nftables, KVM and the jailer ---- | 46 | + | # ---- sandboxing that leaves the unit in the host's mount namespace ---------- |
| 47 | + | # Only options that do not give the unit a mount namespace of its own: in | |||
| 48 | + | # container mode the runner loop-mounts the scratch disk of a job, and the Docker | |||
| 49 | + | # daemon, which lives in the host's namespace, must see that mount to bind it | |||
| 50 | + | # into the container. See the end of the file. | |||
| 47 | NoNewPrivileges=yes | 51 | NoNewPrivileges=yes | ||
| 48 | - | # Nobody else's files, and no interactive logins' data. | 52 | + | # The runner does not set the clock. |
| 49 | - | ProtectHome=yes | |||
| 50 | - | # A private /tmp: the runner and the jailer do not share files with the host | |||
| 51 | - | # through it (the sandbox directories live in the state directory). | |||
| 52 | - | PrivateTmp=yes | |||
| 53 | - | # The runner does not set the clock, load modules or change kernel logging. | |||
| 54 | ProtectClock=yes | 53 | ProtectClock=yes | ||
| 55 | - | ProtectKernelModules=yes | |||
| 56 | - | ProtectKernelLogs=yes | |||
| 57 | # No setuid/setgid files created by the runner or its children. | 54 | # No setuid/setgid files created by the runner or its children. | ||
| 58 | RestrictSUIDSGID=yes | 55 | RestrictSUIDSGID=yes | ||
| 59 | - | # Kernel tunables stay read-only for the unit... except where the runner needs | |||
| 60 | - | # them: see the end of the file (net.ipv4.ip_forward, cgroups). | |||
| 61 | LockPersonality=yes | 56 | LockPersonality=yes | ||
| 62 | # The runner does not need real-time scheduling. | 57 | # The runner does not need real-time scheduling. | ||
| 63 | RestrictRealtime=yes | 58 | RestrictRealtime=yes | ||
| @@ -74,27 +69,38 @@ WantedBy=multi-user.target | |||||
| 74 | 69 | ||||
| 75 | # ---- deliberately absent ----------------------------------------------------- | 70 | # ---- deliberately absent ----------------------------------------------------- | ||
| 76 | # | 71 | # | ||
| 72 | + | # PrivateTmp=, ProtectHome=, ProtectSystem=, ProtectKernelModules=, | |||
| 73 | + | # ProtectKernelLogs=, ProtectKernelTunables=, ProtectControlGroups=, | |||
| 74 | + | # PrivateDevices=, ReadOnlyPaths=, ReadWritePaths=, InaccessiblePaths=, | |||
| 75 | + | # TemporaryFileSystem=, BindPaths= | |||
| 76 | + | # Each of these puts the unit in a mount namespace of its own | |||
| 77 | + | # (systemd sets one up for ProtectKernelModules and | |||
| 78 | + | # ProtectKernelLogs too: it hides /usr/lib/modules and | |||
| 79 | + | # /proc/kmsg with mounts). In container mode the runner | |||
| 80 | + | # loop-mounts the scratch disk of a job below the state | |||
| 81 | + | # directory, and the Docker daemon, in the host's namespace, | |||
| 82 | + | # cannot see a mount made inside the unit's: the bind | |||
| 83 | + | # sources of the container would be empty directories. The | |||
| 84 | + | # micro-VM mode would tolerate them, but one unit serves both | |||
| 85 | + | # modes and a second unit for VM-only hardening is not worth | |||
| 86 | + | # it. test: internal/host TestUnitHasNoMountNamespaceOptions. | |||
| 87 | + | # | |||
| 77 | # Delegate=yes The runner does not manage cgroups of its own. The jailer | 88 | # Delegate=yes The runner does not manage cgroups of its own. The jailer | ||
| 78 | # creates the cgroups of the VMs below /sys/fs/cgroup itself | 89 | # creates the cgroups of the VMs below /sys/fs/cgroup itself | ||
| 79 | # (it is root and writes there directly), and Docker has its | 90 | # (it is root and writes there directly), and Docker has its | ||
| 80 | # own. Delegation would hand a subtree to this unit that | 91 | # own. Delegation would hand a subtree to this unit that | ||
| 81 | # nothing uses. | 92 | # nothing uses. | ||
| 82 | - | # ProtectSystem= The runner writes to the state directory, /run, /sys/fs/cgroup, | 93 | + | # PrivateNetwork=, RestrictNamespaces=, |
| 83 | - | # /dev (mounts, TAP, loop devices), and Docker's data: a | |||
| 84 | - | # read-only /usr is possible but would be the only gain, and | |||
| 85 | - | # it breaks `ip`/`nft` helpers found by PATH on some distros. | |||
| 86 | - | # PrivateDevices= The runner needs /dev/kvm, /dev/net/tun, /dev/loop*. | |||
| 87 | - | # PrivateNetwork=, RestrictNamespaces=, ProtectKernelTunables=, | |||
| 88 | # CapabilityBoundingSet=, MemoryDenyWriteExecute=, SystemCallFilter= | 94 | # CapabilityBoundingSet=, MemoryDenyWriteExecute=, SystemCallFilter= | ||
| 89 | # The runner creates network namespaces and devices (TAP, | 95 | # The runner creates network namespaces and devices (TAP, | ||
| 90 | # veth, bridges) and mounts file systems, enables | 96 | # veth, bridges) and mounts file systems, enables | ||
| 91 | - | # net.ipv4.ip_forward, hands capabilities to the jailer and | 97 | + | # net.ipv4.ip_forward (the jailer also writes cgroups, /dev/kvm, /dev/net/tun and |
| 98 | + | # /dev/loop* are needed), hands capabilities to the jailer and | |||
| 92 | # to Docker, and starts Firecracker (which maps guest memory | 99 | # to Docker, and starts Firecracker (which maps guest memory | ||
| 93 | # writable and executable). Each of these would break a path | 100 | # writable and executable). Each of these would break a path | ||
| 94 | # that cannot be tested without a rootful host; the | 101 | # that cannot be tested without a rootful host; the | ||
| 95 | # boundary of a job is the micro-VM or the container, not | 102 | # boundary of a job is the micro-VM or the container, not | ||
| 96 | # this unit. | 103 | # this unit. | ||
| 97 | - | # ProtectControlGroups= The jailer writes the cgroups of the VMs. | |||
| 98 | # UMask=0077 The state directory is made 0700 by the runner itself, and the | 104 | # UMask=0077 The state directory is made 0700 by the runner itself, and the | ||
| 99 | # drivers create directories that the job's user must be able | 105 | # drivers create directories that the job's user must be able | ||
| 100 | # to enter (the scratch disk's work and tmp directories are | 106 | # to enter (the scratch disk's work and tmp directories are | ||
M internal/host/runner_test.go +68 -0
| @@ -1246,3 +1246,71 @@ func TestSaveStateMakesAnExistingDirectoryPrivate(t *testing.T) { | |||
| 1246 | 1246 | t.Fatalf("%v", fi.Mode()) | |
| 1247 | 1247 | } | |
| 1248 | 1248 | } | |
| 1249 | + | ||
| 1250 | + | // mountNamespaceOptions are the [Service] options that give a unit a mount | |
| 1251 | + | // namespace of its own. The unit must set none of them: in container mode the | |
| 1252 | + | // runner loop-mounts the scratch disk of a job; the Docker daemon, in the host's | |
| 1253 | + | // namespace, binds it into the container, and a mount made inside a private mount | |
| 1254 | + | // namespace of the unit is invisible to it (GO-001). Besides the options that say | |
| 1255 | + | // so (PrivateTmp, ProtectHome, ProtectSystem...), systemd builds a mount | |
| 1256 | + | // namespace for ProtectKernelModules and ProtectKernelLogs too. | |
| 1257 | + | var mountNamespaceOptions = []string{ | |
| 1258 | + | "PrivateTmp", "PrivateDevices", "PrivateMounts", "PrivateUsers", "ProtectHome", "ProtectSystem", | |
| 1259 | + | "ProtectKernelModules", "ProtectKernelLogs", "ProtectKernelTunables", "ProtectControlGroups", | |
| 1260 | + | "ProtectProc", "ProcSubset", "ProtectHostname", "ReadOnlyPaths", "ReadWritePaths", "InaccessiblePaths", | |
| 1261 | + | "ExecPaths", "NoExecPaths", "TemporaryFileSystem", "BindPaths", "BindReadOnlyPaths", "MountFlags", | |
| 1262 | + | "MountAPIVFS", "RootDirectory", "RootImage", "RootEphemeral", "BindLogSockets", "LogNamespace", | |
| 1263 | + | "PrivateIPC", "PrivatePIDs", "MountImages", "ExtensionImages", "ExtensionDirectories", | |
| 1264 | + | } | |
| 1265 | + | ||
| 1266 | + | // unitMountNamespaceOptions returns the lines of the [Service] section of a unit | |
| 1267 | + | // that set one of mountNamespaceOptions. Comments, which name the options on | |
| 1268 | + | // purpose, and other sections are ignored. | |
| 1269 | + | func unitMountNamespaceOptions(unit string) []string { | |
| 1270 | + | var found []string | |
| 1271 | + | inService := false | |
| 1272 | + | for _, line := range strings.Split(unit, "\n") { | |
| 1273 | + | line = strings.TrimSpace(line) | |
| 1274 | + | if strings.HasPrefix(line, "[") { | |
| 1275 | + | inService = line == "[Service]" | |
| 1276 | + | continue | |
| 1277 | + | } | |
| 1278 | + | if !inService || line == "" || strings.HasPrefix(line, "#") { | |
| 1279 | + | continue | |
| 1280 | + | } | |
| 1281 | + | key, _, _ := strings.Cut(line, "=") | |
| 1282 | + | for _, f := range mountNamespaceOptions { | |
| 1283 | + | if strings.EqualFold(strings.TrimSpace(key), f) { | |
| 1284 | + | found = append(found, line) | |
| 1285 | + | } | |
| 1286 | + | } | |
| 1287 | + | } | |
| 1288 | + | return found | |
| 1289 | + | } | |
| 1290 | + | ||
| 1291 | + | func TestUnitHasNoMountNamespaceOptions(t *testing.T) { | |
| 1292 | + | unit, err := os.ReadFile("../../contrib/ermite-runner.service") | |
| 1293 | + | if err != nil { | |
| 1294 | + | t.Skip(err) | |
| 1295 | + | } | |
| 1296 | + | if found := unitMountNamespaceOptions(string(unit)); len(found) > 0 { | |
| 1297 | + | t.Fatalf("the unit sets options that give it a mount namespace of its own, which hides the loop mounts from Docker: %q", found) | |
| 1298 | + | } | |
| 1299 | + | // The hardening that stays must still be there. | |
| 1300 | + | for _, want := range []string{"NoNewPrivileges=yes", "RestrictSUIDSGID=yes", "ProtectClock=yes", "RestrictAddressFamilies="} { | |
| 1301 | + | if !strings.Contains(string(unit), "\n"+want) { | |
| 1302 | + | t.Errorf("the unit lost %s", want) | |
| 1303 | + | } | |
| 1304 | + | } | |
| 1305 | + | } | |
| 1306 | + | ||
| 1307 | + | // The check sees the options in [Service] (also with spaces and another case), | |
| 1308 | + | // and ignores comments and other sections. | |
| 1309 | + | func TestUnitMountNamespaceCheckSeesOptions(t *testing.T) { | |
| 1310 | + | unit := "[Unit]\nPrivateTmp=yes\n[Service]\nPrivateTmp=yes\n protecthome = read-only\nProtectKernelModules=yes\n# ProtectSystem=strict\nNoNewPrivileges=yes\n[Install]\nProtectSystem=yes\n" | |
| 1311 | + | got := unitMountNamespaceOptions(unit) | |
| 1312 | + | want := []string{"PrivateTmp=yes", "protecthome = read-only", "ProtectKernelModules=yes"} | |
| 1313 | + | if strings.Join(got, "|") != strings.Join(want, "|") { | |
| 1314 | + | t.Fatalf("%q", got) | |
| 1315 | + | } | |
| 1316 | + | } | |
| @@ -1246,3 +1246,71 @@ func TestSaveStateMakesAnExistingDirectoryPrivate(t *testing.T) { | |||||
| 1246 | t.Fatalf("%v", fi.Mode()) | 1246 | t.Fatalf("%v", fi.Mode()) | ||
| 1247 | } | 1247 | } | ||
| 1248 | } | 1248 | } | ||
| 1249 | + | ||||
| 1250 | + | // mountNamespaceOptions are the [Service] options that give a unit a mount | |||
| 1251 | + | // namespace of its own. The unit must set none of them: in container mode the | |||
| 1252 | + | // runner loop-mounts the scratch disk of a job; the Docker daemon, in the host's | |||
| 1253 | + | // namespace, binds it into the container, and a mount made inside a private mount | |||
| 1254 | + | // namespace of the unit is invisible to it (GO-001). Besides the options that say | |||
| 1255 | + | // so (PrivateTmp, ProtectHome, ProtectSystem...), systemd builds a mount | |||
| 1256 | + | // namespace for ProtectKernelModules and ProtectKernelLogs too. | |||
| 1257 | + | var mountNamespaceOptions = []string{ | |||
| 1258 | + | "PrivateTmp", "PrivateDevices", "PrivateMounts", "PrivateUsers", "ProtectHome", "ProtectSystem", | |||
| 1259 | + | "ProtectKernelModules", "ProtectKernelLogs", "ProtectKernelTunables", "ProtectControlGroups", | |||
| 1260 | + | "ProtectProc", "ProcSubset", "ProtectHostname", "ReadOnlyPaths", "ReadWritePaths", "InaccessiblePaths", | |||
| 1261 | + | "ExecPaths", "NoExecPaths", "TemporaryFileSystem", "BindPaths", "BindReadOnlyPaths", "MountFlags", | |||
| 1262 | + | "MountAPIVFS", "RootDirectory", "RootImage", "RootEphemeral", "BindLogSockets", "LogNamespace", | |||
| 1263 | + | "PrivateIPC", "PrivatePIDs", "MountImages", "ExtensionImages", "ExtensionDirectories", | |||
| 1264 | + | } | |||
| 1265 | + | ||||
| 1266 | + | // unitMountNamespaceOptions returns the lines of the [Service] section of a unit | |||
| 1267 | + | // that set one of mountNamespaceOptions. Comments, which name the options on | |||
| 1268 | + | // purpose, and other sections are ignored. | |||
| 1269 | + | func unitMountNamespaceOptions(unit string) []string { | |||
| 1270 | + | var found []string | |||
| 1271 | + | inService := false | |||
| 1272 | + | for _, line := range strings.Split(unit, "\n") { | |||
| 1273 | + | line = strings.TrimSpace(line) | |||
| 1274 | + | if strings.HasPrefix(line, "[") { | |||
| 1275 | + | inService = line == "[Service]" | |||
| 1276 | + | continue | |||
| 1277 | + | } | |||
| 1278 | + | if !inService || line == "" || strings.HasPrefix(line, "#") { | |||
| 1279 | + | continue | |||
| 1280 | + | } | |||
| 1281 | + | key, _, _ := strings.Cut(line, "=") | |||
| 1282 | + | for _, f := range mountNamespaceOptions { | |||
| 1283 | + | if strings.EqualFold(strings.TrimSpace(key), f) { | |||
| 1284 | + | found = append(found, line) | |||
| 1285 | + | } | |||
| 1286 | + | } | |||
| 1287 | + | } | |||
| 1288 | + | return found | |||
| 1289 | + | } | |||
| 1290 | + | ||||
| 1291 | + | func TestUnitHasNoMountNamespaceOptions(t *testing.T) { | |||
| 1292 | + | unit, err := os.ReadFile("../../contrib/ermite-runner.service") | |||
| 1293 | + | if err != nil { | |||
| 1294 | + | t.Skip(err) | |||
| 1295 | + | } | |||
| 1296 | + | if found := unitMountNamespaceOptions(string(unit)); len(found) > 0 { | |||
| 1297 | + | t.Fatalf("the unit sets options that give it a mount namespace of its own, which hides the loop mounts from Docker: %q", found) | |||
| 1298 | + | } | |||
| 1299 | + | // The hardening that stays must still be there. | |||
| 1300 | + | for _, want := range []string{"NoNewPrivileges=yes", "RestrictSUIDSGID=yes", "ProtectClock=yes", "RestrictAddressFamilies="} { | |||
| 1301 | + | if !strings.Contains(string(unit), "\n"+want) { | |||
| 1302 | + | t.Errorf("the unit lost %s", want) | |||
| 1303 | + | } | |||
| 1304 | + | } | |||
| 1305 | + | } | |||
| 1306 | + | ||||
| 1307 | + | // The check sees the options in [Service] (also with spaces and another case), | |||
| 1308 | + | // and ignores comments and other sections. | |||
| 1309 | + | func TestUnitMountNamespaceCheckSeesOptions(t *testing.T) { | |||
| 1310 | + | unit := "[Unit]\nPrivateTmp=yes\n[Service]\nPrivateTmp=yes\n protecthome = read-only\nProtectKernelModules=yes\n# ProtectSystem=strict\nNoNewPrivileges=yes\n[Install]\nProtectSystem=yes\n" | |||
| 1311 | + | got := unitMountNamespaceOptions(unit) | |||
| 1312 | + | want := []string{"PrivateTmp=yes", "protecthome = read-only", "ProtectKernelModules=yes"} | |||
| 1313 | + | if strings.Join(got, "|") != strings.Join(want, "|") { | |||
| 1314 | + | t.Fatalf("%q", got) | |||
| 1315 | + | } | |||
| 1316 | + | } | |||