diff --git a/FeatherQuilld.Tests/Docker/WebSpaceContainerSecurityTests.cs b/FeatherQuilld.Tests/Docker/WebSpaceContainerSecurityTests.cs new file mode 100644 index 0000000..e844134 --- /dev/null +++ b/FeatherQuilld.Tests/Docker/WebSpaceContainerSecurityTests.cs @@ -0,0 +1,110 @@ +using Docker.DotNet.Models; +using FeatherQuilld.Utils.Config.Docker; +using FeatherQuilld.Utils.Docker; + +namespace FeatherQuilld.Tests.Docker; + +/// +/// Capability hardening of WebSpace containers. Dropping every capability looks harmless but +/// breaks any plate whose startup installs packages at boot - the built-in PHP bootstrap runs +/// apt-get + docker-php-ext-install and then Apache, all of which need SETUID/SETGID/CHOWN. +/// +public class WebSpaceContainerSecurityTests +{ + [Fact] + public void Default_DropsEverythingExceptWhatPackageInstallsNeed() + { + var host = new HostConfig(); + + WebSpaceRuntime.ApplyContainerSecurity(host, new DockerWebSpaceSecurityConfig()); + + Assert.Equal(new[] { "ALL" }, host.CapDrop); + Assert.Equal( + DockerWebSpaceSecurityConfig.RequiredByPackageInstalls.ToArray(), + host.CapAdd.ToArray()); + Assert.Equal(new[] { "no-new-privileges:true" }, host.SecurityOpt); + } + + [Fact] + public void Default_KeepsTheCapabilitiesThePhpBootstrapNeeds() + { + var kept = new DockerWebSpaceSecurityConfig().Capabilities; + + Assert.Contains("SETUID", kept); + Assert.Contains("SETGID", kept); + Assert.Contains("CHOWN", kept); + Assert.DoesNotContain("NET_RAW", kept); + Assert.DoesNotContain("SYS_ADMIN", kept); + Assert.DoesNotContain("ALL", kept); + } + + [Fact] + public void EmptyCapabilityList_DropsEveryCapability() + { + var host = new HostConfig(); + var security = new DockerWebSpaceSecurityConfig { Capabilities = [] }; + + WebSpaceRuntime.ApplyContainerSecurity(host, security); + + Assert.Equal(new[] { "ALL" }, host.CapDrop); + Assert.True(host.CapAdd is null || host.CapAdd.Count == 0); + Assert.Equal(new[] { "no-new-privileges:true" }, host.SecurityOpt); + } + + [Fact] + public void CustomCapabilityList_IsAppliedVerbatim() + { + var host = new HostConfig(); + var security = new DockerWebSpaceSecurityConfig { Capabilities = ["CHOWN", "SETUID"] }; + + WebSpaceRuntime.ApplyContainerSecurity(host, security); + + Assert.Equal(new[] { "ALL" }, host.CapDrop); + Assert.Equal(new[] { "CHOWN", "SETUID" }, host.CapAdd); + } + + [Fact] + public void DropCapabilitiesOff_LeavesTheEngineDefaultSet() + { + var host = new HostConfig(); + var security = new DockerWebSpaceSecurityConfig { DropCapabilities = false }; + + WebSpaceRuntime.ApplyContainerSecurity(host, security); + + Assert.True(host.CapDrop is null || host.CapDrop.Count == 0); + Assert.True(host.CapAdd is null || host.CapAdd.Count == 0); + Assert.Equal(new[] { "no-new-privileges:true" }, host.SecurityOpt); + } + + [Fact] + public void NoNewPrivilegesOff_LeavesSecurityOptUnset() + { + var host = new HostConfig(); + + WebSpaceRuntime.ApplyContainerSecurity( + host, + new DockerWebSpaceSecurityConfig { NoNewPrivileges = false }); + + Assert.True(host.SecurityOpt is null || host.SecurityOpt.Count == 0); + Assert.Equal(new[] { "ALL" }, host.CapDrop); + } + + [Fact] + public void MissingConfig_FallsBackToTheHardenedDefault() + { + var host = new HostConfig(); + + WebSpaceRuntime.ApplyContainerSecurity(host, null); + + Assert.Equal(new[] { "ALL" }, host.CapDrop); + Assert.Equal( + DockerWebSpaceSecurityConfig.RequiredByPackageInstalls.ToArray(), + host.CapAdd.ToArray()); + Assert.Equal(new[] { "no-new-privileges:true" }, host.SecurityOpt); + } + + [Fact] + public void NullHostConfig_Throws() => + Assert.Throws( + () => WebSpaceRuntime.ApplyContainerSecurity(null!, new DockerWebSpaceSecurityConfig())); +} diff --git a/Utils/Config/Docker/DockerConfig.cs b/Utils/Config/Docker/DockerConfig.cs index 9e2011e..f30e5b4 100644 --- a/Utils/Config/Docker/DockerConfig.cs +++ b/Utils/Config/Docker/DockerConfig.cs @@ -12,6 +12,11 @@ public class DockerConfig public bool UsePerformantInspect { get; set; } = true; public DockerRuntimeReconciliationConfig RuntimeReconciliation { get; set; } = new(); public string UsernsMode { get; set; } = ""; + + /// + /// Linux capability handling for WebSpace containers. + /// + public DockerWebSpaceSecurityConfig WebSpaceSecurity { get; set; } = new(); public List SystemIps { get; set; } = []; public bool EnableNativeKvm { get; set; } = true; public DockerLogConfig LogConfig { get; set; } = new(); @@ -92,3 +97,47 @@ public class DockerLogConfig ["mode"] = "non-blocking", }; } + +/// +/// Capability hardening for WebSpace containers. +/// +public class DockerWebSpaceSecurityConfig +{ + /// + /// Capabilities a WebPlate needs when its startup installs packages before serving: + /// the built-in PHP bootstrap (WebSpacePhpExtensions) runs + /// apt-get + docker-php-ext-install as root and then Apache. + /// apt drops privileges to _apt (SETUID/SETGID/SETGROUPS) and re-owns its list + /// directories (CHOWN/DAC_OVERRIDE/FOWNER), Apache binds :80 (NET_BIND_SERVICE) + /// and switches to www-data. Without these the container dies with + /// setgroups 0 failed - setgroups (1: Operation not permitted) and restarts in a loop. + /// + public static IReadOnlyList RequiredByPackageInstalls { get; } = + [ + "CHOWN", + "DAC_OVERRIDE", + "FOWNER", + "KILL", + "NET_BIND_SERVICE", + "SETGID", + "SETUID", + "SYS_CHROOT", + ]; + + /// + /// Drop every capability except . Switching this off leaves the + /// container with the Docker engine default set - only for plates that need more. + /// + public bool DropCapabilities { get; set; } = true; + + /// + /// Capabilities kept while is on. An empty list drops every + /// capability: strongest hardening, but plates that install packages at boot (PHP) need a + /// prebuilt image then. + /// + public List Capabilities { get; set; } = [.. RequiredByPackageInstalls]; + + /// Set no-new-privileges on the container. + public bool NoNewPrivileges { get; set; } = true; +} + diff --git a/Utils/Docker/WebSpaceRuntime.cs b/Utils/Docker/WebSpaceRuntime.cs index 037188f..98668df 100644 --- a/Utils/Docker/WebSpaceRuntime.cs +++ b/Utils/Docker/WebSpaceRuntime.cs @@ -655,16 +655,39 @@ private HostConfig BuildHostConfig( Memory = memoryBytes > 0 ? memoryBytes : 0, NanoCPUs = nanoCpus > 0 ? nanoCpus : 0, LogConfig = BuildLogConfig(), - CapDrop = ["ALL"], - SecurityOpt = ["no-new-privileges:true"], }; + ApplyContainerSecurity(hostConfig, _docker.WebSpaceSecurity); + if (!string.IsNullOrWhiteSpace(_docker.UsernsMode)) hostConfig.UsernsMode = _docker.UsernsMode.Trim(); return hostConfig; } + /// + /// Applies the container's capability hardening. Dropping every capability breaks plates + /// whose startup installs packages at boot (the PHP bootstrap runs apt-get + + /// docker-php-ext-install), so the config keeps the smallest set those need - see + /// . + /// + public static void ApplyContainerSecurity(HostConfig hostConfig, DockerWebSpaceSecurityConfig? security) + { + ArgumentNullException.ThrowIfNull(hostConfig); + + security ??= new DockerWebSpaceSecurityConfig(); + + if (security.DropCapabilities) + { + hostConfig.CapDrop = ["ALL"]; + if (security.Capabilities.Count > 0) + hostConfig.CapAdd = [.. security.Capabilities]; + } + + if (security.NoNewPrivileges) + hostConfig.SecurityOpt = ["no-new-privileges:true"]; + } + private LogConfig BuildLogConfig() { var cfg = _docker.LogConfig ?? new DockerLogConfig();