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();