diff --git a/deploy/files/etc/systemd/system/left4me-server@.service.d/10-hardening.conf b/deploy/files/etc/systemd/system/left4me-server@.service.d/10-hardening.conf new file mode 100644 index 0000000..6408963 --- /dev/null +++ b/deploy/files/etc/systemd/system/left4me-server@.service.d/10-hardening.conf @@ -0,0 +1,74 @@ +# Hardening drop-in for left4me-server@.service. +# +# Source of truth: this file (in left4me/deploy/files/). ckn-bw deploys +# it to /etc/systemd/system/left4me-server@.service.d/10-hardening.conf +# via a target-side symlink into the checkout. +# +# Gameserver unit: full hardening profile. No sudo path inside; no +# sudo-incompatibility carve-outs. +[Service] +NoNewPrivileges=true +RestrictSUIDSGID=true +CapabilityBoundingSet= +AmbientCapabilities= +# srcds_linux is i386 (Source 2007 engine). Bare 'native' kills every +# 32-bit syscall and traps srcds_run in a respawn loop. +SystemCallArchitectures=native x86 +SystemCallFilter=@system-service +SystemCallFilter=~@debug @mount @raw-io @reboot @swap @cpu-emulation @obsolete @privileged +TemporaryFileSystem=/var/lib /etc /opt /home /root /srv /mnt /media +BindReadOnlyPaths=/var/lib/left4me/installation +BindReadOnlyPaths=/var/lib/left4me/overlays +# Workshop VPKs in overlays are symlinks into workshop_cache; +# without this bind they dangle inside the unit and Source +# silently fails to load the addons. +BindReadOnlyPaths=/var/lib/left4me/workshop_cache +# Steam SDK: srcds dlopen's ~/.steam/sdk32/steamclient.so for +# Steam master-server registration. Without this, SteamAPI_Init +# fails and the server falls back to LAN-only mode regardless +# of sv_lan=0 — clients then get "LAN servers are restricted +# to local clients (class C)". .steam holds symlinks into +# /var/lib/left4me/steam, so both paths need to be bound back +# through TemporaryFileSystem. +BindReadOnlyPaths=/var/lib/left4me/.steam +BindReadOnlyPaths=/var/lib/left4me/steam +BindReadOnlyPaths=/etc/left4me/host.env +BindReadOnlyPaths=/etc/ssl +BindReadOnlyPaths=/etc/ca-certificates +BindReadOnlyPaths=/etc/resolv.conf +BindReadOnlyPaths=/etc/nsswitch.conf +BindReadOnlyPaths=/etc/alternatives +BindPaths=/var/lib/left4me/runtime/%i +ProtectSystem=strict +ProtectHome=true +PrivateUsers=true +# PrivatePIDs is the test-plan amendment that closes D2.b: same-uid +# ProtectProc=invisible cannot hide gunicorn from srcds (both run as +# uid 980); a private PID namespace does. +PrivatePIDs=true +PrivateTmp=true +PrivateDevices=true +PrivateIPC=true +RestrictNamespaces=true +RestrictRealtime=true +ProtectProc=invisible +ProcSubset=pid +ProtectKernelTunables=true +ProtectKernelModules=true +ProtectKernelLogs=true +ProtectClock=true +ProtectControlGroups=true +ProtectHostname=true +LockPersonality=true +RemoveIPC=true +KeyringMode=private +UMask=0027 +RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX +# Lock srcds bindable sockets to the game port range. Hard-coded range +# because systemd directive variable substitution is uneven for +# SocketBindAllow. +SocketBindAllow=udp:27000-27999 +SocketBindAllow=tcp:27000-27999 +# W+X mprotect (text relocations in Source engine i386 .so files) is +# incompatible with the memory-deny-write-execute directive; that +# directive is therefore intentionally absent from this drop-in. diff --git a/deploy/files/etc/systemd/system/left4me-web.service.d/10-hardening.conf b/deploy/files/etc/systemd/system/left4me-web.service.d/10-hardening.conf new file mode 100644 index 0000000..92d00ab --- /dev/null +++ b/deploy/files/etc/systemd/system/left4me-web.service.d/10-hardening.conf @@ -0,0 +1,39 @@ +# Hardening drop-in for left4me-web.service. +# +# Source of truth: this file (in left4me/deploy/files/). ckn-bw deploys +# it to /etc/systemd/system/left4me-web.service.d/10-hardening.conf via a +# target-side symlink into the checkout. +# +# See docs/superpowers/specs/2026-05-15-hardening-defenses-survey.md +# and 2026-05-15-hardening-test-plan.md for the threat model and the +# verification matrix. +# +# This unit is the web app; some sudo-incompatible directives are +# intentionally absent: +# NoNewPrivileges — blocks sudo's setuid escalation +# PrivateUsers — breaks sudo's host-root mapping +# RestrictSUIDSGID — blocks setuid()/setgid() +# CapabilityBoundingSet — empty value would deny sudo's caps +# @privileged exclusion in SystemCallFilter — blocks sudo's setuid syscall +# All of those are unconditional on the gameserver unit (no sudo there). +[Service] +ProtectSystem=strict +ProtectHome=true +PrivateTmp=true +ProtectProc=invisible +ProtectKernelTunables=true +ProtectKernelModules=true +ProtectKernelLogs=true +ProtectClock=true +ProtectControlGroups=true +ProtectHostname=true +LockPersonality=true +SystemCallArchitectures=native +SystemCallFilter=@system-service +SystemCallFilter=~@debug @mount @raw-io @reboot @swap @cpu-emulation @obsolete +RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX +RestrictNamespaces=true +RestrictRealtime=true +RemoveIPC=true +KeyringMode=private +UMask=0027 diff --git a/deploy/files/usr/local/lib/systemd/system/left4me-server@.service b/deploy/files/usr/local/lib/systemd/system/left4me-server@.service index 9f64825..8e08b59 100644 --- a/deploy/files/usr/local/lib/systemd/system/left4me-server@.service +++ b/deploy/files/usr/local/lib/systemd/system/left4me-server@.service @@ -1,10 +1,11 @@ # left4me gameserver — system unit, one instance per gameserver. # -# This is the REFERENCE COPY of the deployed unit. The live source is -# the systemd/units reactor at ~/Projekte/ckn-bw/bundles/left4me/metadata.py -# (look for 'left4me-server@.service'). Hardening directives live in -# the HARDENING_SERVER constant near the top of the same file. -# This file is hand-synced; edit both together. +# This is the REFERENCE COPY of the deployed unit base body. The live +# source is the systemd/units reactor at +# ~/Projekte/ckn-bw/bundles/left4me/metadata.py (look for +# 'left4me-server@.service'). +# +# Hardening: see left4me-server@.service.d/10-hardening.conf # # Threat model: docs/superpowers/specs/2026-05-15-hardening-threat-model.md # Defenses survey: docs/superpowers/specs/2026-05-15-hardening-defenses-survey.md @@ -29,10 +30,11 @@ EnvironmentFile=/var/lib/left4me/instances/%i/instance.env # once ExecStartPre's overlay mount succeeds. WorkingDirectory=-/var/lib/left4me/runtime/%i/merged/left4dead2 # `+` prefix runs the helper as PID 1 (root, all caps, host -# namespaces) — required because the unit has NoNewPrivileges=true -# AND PrivateUsers=true; both block sudo's setuid path. nsenter into -# PID 1's mount namespace ensures the umount in ExecStopPost succeeds -# without EBUSY from the unit's own slave-mount tree. +# namespaces) — required because the hardening drop-in sets +# NoNewPrivileges and PrivateUsers; both block sudo's setuid path. +# nsenter into PID 1's mount namespace ensures the umount in +# ExecStopPost succeeds without EBUSY from the unit's own +# slave-mount tree. ExecStartPre=+/usr/bin/nsenter --mount=/proc/1/ns/mnt -- /usr/local/libexec/left4me/left4me-overlay mount %i # Run from the merged overlay, NOT installation/. srcds_run cds to its # own dirname before exec'ing srcds_linux; the binary's path determines @@ -57,72 +59,5 @@ KillSignal=SIGINT TimeoutStopSec=15s LogRateLimitIntervalSec=0 -# === Identity / privilege drop === -NoNewPrivileges=true # block setuid escalation (defense: D3) -RestrictSUIDSGID=true # block setuid()/setgid() syscalls -CapabilityBoundingSet= # drop all caps — no privilege to escalate -AmbientCapabilities= - -# === Filesystem virtualization === -# Mask /var/lib, /etc, /opt, etc. with empty tmpfs; bind back only -# what srcds needs. The DB (/var/lib/left4me/left4me.db) and web.env -# (/etc/left4me/web.env) are intentionally not bound — they don't -# exist in this unit's filesystem view (defenses: D1.a, D1.b). -TemporaryFileSystem=/var/lib /etc /opt /home /root /srv /mnt /media -BindReadOnlyPaths=/var/lib/left4me/installation -BindReadOnlyPaths=/var/lib/left4me/overlays -BindReadOnlyPaths=/etc/left4me/host.env -BindReadOnlyPaths=/etc/ssl -BindReadOnlyPaths=/etc/ca-certificates -BindReadOnlyPaths=/etc/resolv.conf -BindReadOnlyPaths=/etc/nsswitch.conf -BindReadOnlyPaths=/etc/alternatives -BindPaths=/var/lib/left4me/runtime/%i -ProtectSystem=strict # belt-and-braces with TemporaryFileSystem -ProtectHome=true - -# === Process namespacing === -PrivateUsers=true # own user namespace; cross-uid ptrace blocked (D2) -PrivatePIDs=true # own PID namespace; hides peer-srcds + gunicorn (D2.b, D5) -PrivateTmp=true -PrivateDevices=true -PrivateIPC=true -RestrictNamespaces=true # block unshare()/clone(CLONE_NEW*) - -# === /proc and /sys === -ProtectProc=invisible # foreign-uid /proc hidden (paired with PrivatePIDs for full hide) -ProcSubset=pid # /proc shows only PID dirs, no kallsyms/cpuinfo -ProtectKernelTunables=true # /proc/sys, /sys read-only -ProtectKernelModules=true # no module load/unload -ProtectKernelLogs=true # no /dev/kmsg or syslog() -ProtectClock=true # no settimeofday() -ProtectControlGroups=true # /sys/fs/cgroup read-only -ProtectHostname=true # no sethostname() -LockPersonality=true # no personality() switches - -# === Syscall filter === -# srcds_linux is i386 (Source 2007 engine). 'native x86' allows both -# x86_64 (from srcds_run + the dynamic linker) and i386 (from srcds_linux). -# Bare 'native' traps srcds_run in a respawn loop. -SystemCallArchitectures=native x86 -SystemCallFilter=@system-service -SystemCallFilter=~@debug @mount @raw-io @reboot @swap @cpu-emulation @obsolete @privileged -# ~@debug is the load-bearing block for D2.a: drops ptrace(), process_vm_readv/writev(). -# ~@privileged blocks anything requiring CAP_*, redundant with empty bounding set. -# MemoryDenyWriteExecute=true is NOT set — Source engine i386 .so files -# have text relocations that need mprotect(W+X) during dynamic-linker pass. - -# === Network === -RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX # AF_UNIX needed for journald -# Lock srcds bindable sockets to the game port range. -SocketBindAllow=udp:27000-27999 -SocketBindAllow=tcp:27000-27999 - -# === Misc hygiene === -RestrictRealtime=true # no real-time scheduling -RemoveIPC=true # clean up SysV IPC on unit stop -KeyringMode=private # private kernel keyring -UMask=0027 - [Install] WantedBy=multi-user.target diff --git a/deploy/files/usr/local/lib/systemd/system/left4me-web.service b/deploy/files/usr/local/lib/systemd/system/left4me-web.service index 6d8d013..0b38a9d 100644 --- a/deploy/files/usr/local/lib/systemd/system/left4me-web.service +++ b/deploy/files/usr/local/lib/systemd/system/left4me-web.service @@ -1,22 +1,11 @@ # left4me web application — system unit. # -# This is the REFERENCE COPY of the deployed unit. The live source is -# the systemd/units reactor at ~/Projekte/ckn-bw/bundles/left4me/metadata.py -# (look for 'left4me-web.service'). Hardening directives live in -# the HARDENING_WEB constant near the top of the same file. -# This file is hand-synced; edit both together. +# This is the REFERENCE COPY of the deployed unit base body. The live +# source is the systemd/units reactor at +# ~/Projekte/ckn-bw/bundles/left4me/metadata.py (look for +# 'left4me-web.service'). # -# Several directives that the gameserver uses are intentionally absent -# from this unit: -# NoNewPrivileges — blocks sudo's setuid escalation -# PrivateUsers — breaks sudo's host-root mapping -# RestrictSUIDSGID — blocks setuid()/setgid() -# CapabilityBoundingSet= — empty value would deny sudo's caps -# ~@privileged in SystemCallFilter — blocks sudo's setuid syscall -# The web app invokes privileged helpers (left4me-systemctl, -# left4me-overlay, left4me-script-sandbox) via sudo, so these -# directives can't be applied here. A future refactor replacing sudo -# with systemctl-managed transient units would unlock them. +# Hardening: see left4me-web.service.d/10-hardening.conf # # Threat model + defenses + tests: see docs/superpowers/specs/2026-05-15-hardening-* @@ -35,7 +24,7 @@ EnvironmentFile=/etc/left4me/host.env EnvironmentFile=/etc/left4me/web.env # Placeholder values for --workers / --threads. Live emission interpolates # from metadata.get('left4me/gunicorn_workers') and gunicorn_threads. -ExecStart=/var/lib/left4me/.venv/bin/gunicorn --workers 4 --threads 4 --bind 127.0.0.1:8000 'l4d2web.app:create_app()' +ExecStart=/var/lib/left4me/.venv/bin/gunicorn --workers 1 --threads 32 --bind 127.0.0.1:8000 'l4d2web.app:create_app()' Restart=on-failure RestartSec=3 @@ -44,42 +33,5 @@ RestartSec=3 # (server@ uses BindPaths to bind only its instance dir). ReadWritePaths=/var/lib/left4me -# === Filesystem === -ProtectSystem=strict # tightened from prior 'full'; via HARDENING_COMMON -ProtectHome=true -PrivateTmp=true - -# === /proc + kernel === -# Note: ProcSubset=pid is intentionally NOT set on the web unit. -# It hides /proc/sys/kernel/random/boot_id which journalctl reads at -# startup, and the web invokes `sudo -n left4me-journalctl` to stream -# live server logs into the UI. The server unit can keep ProcSubset=pid -# because srcds doesn't shell out to journalctl. -ProtectProc=invisible # foreign-uid /proc hidden (defense: D4) -ProtectKernelTunables=true -ProtectKernelModules=true -ProtectKernelLogs=true -ProtectClock=true -ProtectControlGroups=true -ProtectHostname=true -LockPersonality=true - -# === Syscall filter (sudo-compatible — note absence of ~@privileged) === -SystemCallArchitectures=native -SystemCallFilter=@system-service -SystemCallFilter=~@debug @mount @raw-io @reboot @swap @cpu-emulation @obsolete -# ~@debug blocks ptrace + process_vm_readv/writev (D4). -# ~@privileged intentionally omitted — sudo needs setuid(). - -# === Network === -RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX - -# === Misc hygiene === -RestrictNamespaces=true -RestrictRealtime=true -RemoveIPC=true -KeyringMode=private -UMask=0027 - [Install] WantedBy=multi-user.target diff --git a/deploy/tests/test_example_units.py b/deploy/tests/test_example_units.py index 6a14a92..e1ece09 100644 --- a/deploy/tests/test_example_units.py +++ b/deploy/tests/test_example_units.py @@ -19,6 +19,8 @@ SYSCTL_CONF = DEPLOY / "files/etc/sysctl.d/99-left4me.conf" SANDBOX_RESOLV_CONF = DEPLOY / "files/etc/left4me/sandbox-resolv.conf" HOST_ENV = DEPLOY / "templates/etc/left4me/host.env" WEB_ENV_TEMPLATE = DEPLOY / "templates/etc/left4me/web.env.template" +WEB_HARDENING_DROPIN = DEPLOY / "files/etc/systemd/system/left4me-web.service.d/10-hardening.conf" +SERVER_HARDENING_DROPIN = DEPLOY / "files/etc/systemd/system/left4me-server@.service.d/10-hardening.conf" def test_global_unit_files_exist_at_product_level_paths(): @@ -41,14 +43,14 @@ def test_web_unit_contains_required_runtime_contract(): # NoNewPrivileges must remain unset because sudo (used by the overlay, # systemctl and journalctl helpers) is setuid. assert "NoNewPrivileges=true" not in unit - # Restored now that fuse-overlayfs propagation is no longer the mechanism. - assert "PrivateTmp=true" in unit - assert "ProtectSystem=full" in unit assert "ReadWritePaths=/var/lib/left4me" in unit # Mounts now happen in PID 1's namespace via the left4me-overlay helper, # so MountFlags propagation is irrelevant — and the previous assumption # that MountFlags=shared made it work was incorrect. assert "MountFlags=" not in unit + # Hardening directives belong in the drop-in; must not appear in the base unit. + assert "PrivateTmp=" not in unit + assert "ProtectSystem=" not in unit def test_server_unit_contains_required_runtime_contract(): @@ -69,15 +71,14 @@ def test_server_unit_contains_required_runtime_contract(): assert "ExecStart=/var/lib/left4me/runtime/%i/merged/srcds_run" in unit assert "$L4D2_ARGS" in unit assert "${L4D2_ARGS}" not in unit - assert "NoNewPrivileges=true" in unit - assert "PrivateTmp=true" in unit - assert "PrivateDevices=true" in unit - assert "ProtectHome=true" in unit - assert "ProtectSystem=strict" in unit - assert "ReadOnlyPaths=/var/lib/left4me/installation /var/lib/left4me/overlays" in unit - assert "ReadWritePaths=/var/lib/left4me/runtime/%i" in unit - assert "RestrictSUIDSGID=true" in unit - assert "LockPersonality=true" in unit + # Hardening directives belong in the drop-in; must not appear in the base unit. + assert "NoNewPrivileges=" not in unit + assert "PrivateTmp=" not in unit + assert "PrivateDevices=" not in unit + assert "ProtectHome=" not in unit + assert "ProtectSystem=" not in unit + assert "RestrictSUIDSGID=" not in unit + assert "LockPersonality=" not in unit def test_server_unit_mounts_overlay_via_exec_start_pre(): @@ -232,3 +233,69 @@ def test_sandbox_resolv_conf_exists(): if first_octet == 172: second_octet = int(ns.split(".")[1]) assert not (16 <= second_octet <= 31), ns + + +def test_web_hardening_dropin_present_with_directives(): + assert WEB_HARDENING_DROPIN.is_file() + text = WEB_HARDENING_DROPIN.read_text() + assert "[Service]" in text + # COMMON + for d in ( + "ProtectProc=invisible", + "ProtectKernelTunables=true", + "ProtectKernelModules=true", + "ProtectKernelLogs=true", + "ProtectClock=true", + "ProtectControlGroups=true", + "ProtectHostname=true", + "LockPersonality=true", + "ProtectSystem=strict", + "ProtectHome=true", + "PrivateTmp=true", + "RestrictNamespaces=true", + "RestrictRealtime=true", + "RemoveIPC=true", + "KeyringMode=private", + "UMask=0027", + "RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX", + ): + assert d in text, f"missing {d!r} in web hardening drop-in" + # WEB-specific + assert "SystemCallArchitectures=native" in text + assert "SystemCallFilter=@system-service" in text + assert "SystemCallFilter=~@debug @mount @raw-io @reboot @swap @cpu-emulation @obsolete" in text + # WEB must NOT include the sudo-incompatible directives. + assert "NoNewPrivileges=" not in text + assert "PrivateUsers=" not in text + assert "RestrictSUIDSGID=" not in text + assert "CapabilityBoundingSet=" not in text + assert "~@privileged" not in text + + +def test_server_hardening_dropin_present_with_directives(): + assert SERVER_HARDENING_DROPIN.is_file() + text = SERVER_HARDENING_DROPIN.read_text() + assert "[Service]" in text + for d in ( + "NoNewPrivileges=true", + "RestrictSUIDSGID=true", + "PrivateUsers=true", + "PrivatePIDs=true", + "PrivateIPC=true", + "PrivateDevices=true", + "CapabilityBoundingSet=", + "AmbientCapabilities=", + "SystemCallArchitectures=native x86", + "ProcSubset=pid", + "TemporaryFileSystem=/var/lib /etc /opt /home /root /srv /mnt /media", + "BindReadOnlyPaths=/var/lib/left4me/installation", + "BindReadOnlyPaths=/var/lib/left4me/overlays", + "BindReadOnlyPaths=/etc/left4me/host.env", + "BindPaths=/var/lib/left4me/runtime/%i", + "SocketBindAllow=udp:27000-27999", + "SocketBindAllow=tcp:27000-27999", + ): + assert d in text, f"missing {d!r} in server hardening drop-in" + assert "SystemCallFilter=~@debug @mount @raw-io @reboot @swap @cpu-emulation @obsolete @privileged" in text + # MemoryDenyWriteExecute must remain absent (Source engine compat). + assert "MemoryDenyWriteExecute" not in text