Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 110 additions & 0 deletions FeatherQuilld.Tests/Docker/WebSpaceContainerSecurityTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
using Docker.DotNet.Models;
using FeatherQuilld.Utils.Config.Docker;
using FeatherQuilld.Utils.Docker;

namespace FeatherQuilld.Tests.Docker;

/// <summary>
/// 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.
/// </summary>
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<ArgumentNullException>(
() => WebSpaceRuntime.ApplyContainerSecurity(null!, new DockerWebSpaceSecurityConfig()));
}
49 changes: 49 additions & 0 deletions Utils/Config/Docker/DockerConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ public class DockerConfig
public bool UsePerformantInspect { get; set; } = true;
public DockerRuntimeReconciliationConfig RuntimeReconciliation { get; set; } = new();
public string UsernsMode { get; set; } = "";

/// <summary>
/// Linux capability handling for WebSpace containers.
/// </summary>
public DockerWebSpaceSecurityConfig WebSpaceSecurity { get; set; } = new();
public List<string> SystemIps { get; set; } = [];
public bool EnableNativeKvm { get; set; } = true;
public DockerLogConfig LogConfig { get; set; } = new();
Expand Down Expand Up @@ -92,3 +97,47 @@ public class DockerLogConfig
["mode"] = "non-blocking",
};
}

/// <summary>
/// Capability hardening for WebSpace containers.
/// </summary>
public class DockerWebSpaceSecurityConfig
{
/// <summary>
/// Capabilities a WebPlate needs when its startup installs packages before serving:
/// the built-in PHP bootstrap (<c>WebSpacePhpExtensions</c>) runs
/// <c>apt-get</c> + <c>docker-php-ext-install</c> as root and then Apache.
/// apt drops privileges to <c>_apt</c> (SETUID/SETGID/SETGROUPS) and re-owns its list
/// directories (CHOWN/DAC_OVERRIDE/FOWNER), Apache binds <c>:80</c> (NET_BIND_SERVICE)
/// and switches to www-data. Without these the container dies with
/// <c>setgroups 0 failed - setgroups (1: Operation not permitted)</c> and restarts in a loop.
/// </summary>
public static IReadOnlyList<string> RequiredByPackageInstalls { get; } =
[
"CHOWN",
"DAC_OVERRIDE",
"FOWNER",
"KILL",
"NET_BIND_SERVICE",
"SETGID",
"SETUID",
"SYS_CHROOT",
];

/// <summary>
/// Drop every capability except <see cref="Capabilities"/>. Switching this off leaves the
/// container with the Docker engine default set - only for plates that need more.
/// </summary>
public bool DropCapabilities { get; set; } = true;

/// <summary>
/// Capabilities kept while <see cref="DropCapabilities"/> is on. An empty list drops every
/// capability: strongest hardening, but plates that install packages at boot (PHP) need a
/// prebuilt image then.
/// </summary>
public List<string> Capabilities { get; set; } = [.. RequiredByPackageInstalls];

/// <summary>Set <c>no-new-privileges</c> on the container.</summary>
public bool NoNewPrivileges { get; set; } = true;
}

27 changes: 25 additions & 2 deletions Utils/Docker/WebSpaceRuntime.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/// <summary>
/// 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
/// <see cref="DockerWebSpaceSecurityConfig.RequiredByPackageInstalls"/>.
/// </summary>
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();
Expand Down