From 860928c5e41c066bb8209994939f757be3fb6852 Mon Sep 17 00:00:00 2001 From: Kevin Date: Wed, 1 Apr 2026 23:52:25 -0500 Subject: [PATCH] fix(exec): enforce permissions for non-root exec user Drop elevated process capabilities when exec is requested with an explicit non-root user. Add a CLI regression test that validates /etc/shadow access is denied for exec -u nobody. Refs: #1352 --- .../Container/ProcessConfiguration.swift | 18 ++++++++++ .../Server/SandboxService.swift | 8 +++++ .../Subcommands/Containers/TestCLIExec.swift | 35 +++++++++++++++++++ 3 files changed, 61 insertions(+) diff --git a/Sources/ContainerResource/Container/ProcessConfiguration.swift b/Sources/ContainerResource/Container/ProcessConfiguration.swift index 856b0dbaa..6390937f2 100644 --- a/Sources/ContainerResource/Container/ProcessConfiguration.swift +++ b/Sources/ContainerResource/Container/ProcessConfiguration.swift @@ -68,6 +68,24 @@ public struct ProcessConfiguration: Sendable, Codable { return name } } + + /// Returns true when the user specification explicitly resolves to a non-root user. + /// Unknown named users are treated as non-root for safer default behavior. + public var isExplicitlyNonRoot: Bool { + switch self { + case .id(let uid, _): + return uid != 0 + case .raw(let userString): + let primaryToken = userString + .split(separator: ":", maxSplits: 1, omittingEmptySubsequences: false) + .first + .map(String.init) ?? "" + if let uid = UInt32(primaryToken) { + return uid != 0 + } + return primaryToken.lowercased() != "root" + } + } } public init( diff --git a/Sources/Services/ContainerSandboxService/Server/SandboxService.swift b/Sources/Services/ContainerSandboxService/Server/SandboxService.swift index db8acd03c..1d9a1de31 100644 --- a/Sources/Services/ContainerSandboxService/Server/SandboxService.swift +++ b/Sources/Services/ContainerSandboxService/Server/SandboxService.swift @@ -956,6 +956,10 @@ public actor SandboxService { username: "" ) } + + if process.user.isExplicitlyNonRoot { + czConfig.process.capabilities = Containerization.LinuxCapabilities() + } } private nonisolated func configureProcessConfig(config: ProcessConfiguration, stdio: [FileHandle?], containerConfig: ContainerConfiguration) @@ -1003,6 +1007,10 @@ public actor SandboxService { ) } + if config.user.isExplicitlyNonRoot { + proc.capabilities = Containerization.LinuxCapabilities() + } + return proc } diff --git a/Tests/CLITests/Subcommands/Containers/TestCLIExec.swift b/Tests/CLITests/Subcommands/Containers/TestCLIExec.swift index 503b97ee7..6e0b92cbf 100644 --- a/Tests/CLITests/Subcommands/Containers/TestCLIExec.swift +++ b/Tests/CLITests/Subcommands/Containers/TestCLIExec.swift @@ -136,4 +136,39 @@ class TestCLIExecCommand: CLITest { } } } + + @Test func testExecUserRespectsFilePermissions() throws { + let name = getTestName() + try doLongRun(name: name) + defer { + try? doStop(name: name) + } + + let (_, idOutput, idError, idStatus) = try run(arguments: [ + "exec", + "-u", + "nobody", + name, + "id", + ]) + try #require(idStatus == 0, "id should run as nobody: stderr=\(idError)") + #expect(idOutput.contains("uid=65534"), "expected uid 65534 for nobody, got: \(idOutput)") + + let (_, output, error, status) = try run(arguments: [ + "exec", + "-u", + "nobody", + name, + "cat", + "/etc/shadow", + ]) + + #expect(status != 0, "expected permission failure, got success with output: \(output)") + + let combined = "\(output)\n\(error)" + #expect( + combined.localizedCaseInsensitiveContains("permission denied"), + "expected permission denied error, got: \(combined)" + ) + } }