From a3808842c122a71be5741b081a07d2bc0b1d1b29 Mon Sep 17 00:00:00 2001 From: sdarbinyan Date: Fri, 21 Aug 2026 13:17:02 +0400 Subject: [PATCH] feat(deploy): host hardening on the frontend server (FH-D.3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit server-setup.sh configured ufw and stopped there, which leaves SSH open to unlimited password guessing and the kernel on defaults that are wrong for an internet-facing host. Adds three drop-in files, so a re-run replaces its own config and never edits a distro file in place: /etc/ssh/sshd_config.d/10-marketplaces-hardening.conf password and keyboard-interactive auth off, root key-only, no agent/X11 forwarding, MaxAuthTries 3, 30s login grace /etc/fail2ban/jail.d/marketplaces.local sshd, nginx-http-auth, nginx-bad-request; 5 in 10m, 1h ban /etc/sysctl.d/99-marketplaces-hardening.conf no redirects or source routing, rp_filter, SYN cookies, forwarding off, restricted kernel pointers and dmesg Both accounts on the host are key-only by construction - the deploy user is created with no password at all - so disabling password auth cannot lock anyone out. It only closes guessing against a credential nobody intended to exist. The sshd block runs `sshd -t` first and removes its own drop-in if the test fails. A bad sshd config that takes effect on a remote box is how people lock themselves out permanently. DEPLOYMENT.md §3.2 documents all three plus the post-provision checks. Not copied from the reference implementation: its hardcoded server IP. Kept as-is because ours is already better: add-domain.sh pre-checks the DNS A record and runs nginx -t before and after. Co-Authored-By: Claude Opus 5 --- docs/DEPLOYMENT.md | 22 +++++++++ docs/FORK-HARVEST-TODO.md | 10 ++-- scripts/deploy/server-setup.sh | 84 ++++++++++++++++++++++++++++++++++ 3 files changed, 111 insertions(+), 5 deletions(-) diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index bdb04b8..58f4f7c 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -64,6 +64,28 @@ password, writes the catch-all nginx config, opens 80/443/OpenSSH in ufw, and installs a root-owned, argument-validating API-domain helper. The deploy user may run that helper and reload nginx, but cannot replace the helper. +It also applies host hardening (added 2026-08-21, FH-D.3) — three drop-in files, +so a re-run replaces its own config and never edits a distro file in place: + +| File | Effect | +|---|---| +| `/etc/ssh/sshd_config.d/10-marketplaces-hardening.conf` | Password and keyboard-interactive auth off, root key-only, no agent/X11 forwarding, `MaxAuthTries 3`, 30 s login grace | +| `/etc/fail2ban/jail.d/marketplaces.local` | `sshd`, `nginx-http-auth`, `nginx-bad-request` jails — 5 failures in 10 min, 1 h ban | +| `/etc/sysctl.d/99-marketplaces-hardening.conf` | No redirects or source routing, reverse-path filtering, SYN cookies, forwarding off, restricted kernel pointers and dmesg | + +Both accounts on this host are key-only by construction, so disabling password +auth cannot lock anyone out — it only closes unlimited guessing against a +credential nobody intended to exist. The script runs `sshd -t` before reloading +and removes its own drop-in if the test fails, because a bad sshd config taking +effect on a remote box is how people lock themselves out permanently. + +Confirm after provisioning: + +```bash +sudo fail2ban-client status sshd +sudo sshd -T | grep -E 'passwordauthentication|permitrootlogin|maxauthtries' +``` + Verify before continuing: ```bash diff --git a/docs/FORK-HARVEST-TODO.md b/docs/FORK-HARVEST-TODO.md index 9677ce8..b77fcd5 100644 --- a/docs/FORK-HARVEST-TODO.md +++ b/docs/FORK-HARVEST-TODO.md @@ -207,9 +207,9 @@ The **client half and the contract are done** (2026-08-21). What remains is back - [ ] **FH-D.2 — Database unreachable from the internet, structurally** · S · Lane B/D Data network `internal: true`; API bound to loopback only; `no-new-privileges` on every service. Makes it a property of the topology rather than a firewall promise. -- [ ] **FH-D.3 — Host hardening we lack** · M - fail2ban jail, sshd hardening drop-in, sysctl hardening, scoped sudoers per deploy role. Add to `scripts/deploy/server-setup.sh`. - *Keep ours where ours is better:* `add-domain.sh` already pre-checks the DNS A record and runs `nginx -t` before and after; `server-setup.sh` already configures ufw. Do **not** copy their hardcoded server IP. +- [x] **FH-D.3 — Host hardening we lack** · M · **done 2026-08-21** + Three drop-in files in `scripts/deploy/server-setup.sh`, documented in [DEPLOYMENT.md](DEPLOYMENT.md) §3.2: sshd hardening (password and keyboard-interactive auth off, root key-only, `MaxAuthTries 3`, 30 s grace, no forwarding), fail2ban (`sshd`, `nginx-http-auth`, `nginx-bad-request`; 5 failures in 10 min, 1 h ban), and sysctl (no redirects or source routing, rp_filter, SYN cookies, forwarding off, restricted kernel pointers and dmesg). The script runs `sshd -t` before reloading and removes its own drop-in if the test fails — a bad sshd config taking effect remotely is how people lock themselves out permanently. + Scoped sudoers was already in place. *Kept ours where ours is better:* `add-domain.sh` pre-checks the DNS A record and runs `nginx -t` before and after; ufw was already configured. Their hardcoded server IP deliberately not copied. --- @@ -248,9 +248,9 @@ The **client half and the contract are done** (2026-08-21). What remains is back | 2 — Contracts | 14 | — | 0 | 1 rejected (FH-2.12) | | 3 — Proof | 2 | — | 3 | see note below | | 4 — Identity | 2 | 3 | 3 | OAuth apps, which FH-0.1 gates | -| Ops | 0 | — | 3 | — | +| Ops | 1 | — | 2 | — | | Process | 4 | — | 2 | — | -| **Total** | **24** | **3** | **15** | 1 rejected | +| **Total** | **25** | **3** | **14** | 1 rejected | **Landed 2026-08-21** diff --git a/scripts/deploy/server-setup.sh b/scripts/deploy/server-setup.sh index 546c445..0da597e 100755 --- a/scripts/deploy/server-setup.sh +++ b/scripts/deploy/server-setup.sh @@ -128,6 +128,90 @@ ufw allow 80/tcp >/dev/null ufw allow 443/tcp >/dev/null ufw --force enable >/dev/null +# FH-D.3. ufw alone leaves SSH open to unlimited password guessing and leaves +# the kernel on defaults that are wrong for an internet-facing host. All three +# blocks below are drop-in files, so a re-run overwrites its own config and +# never edits a distro file in place. + +echo "==> sshd hardening" +cat > /etc/ssh/sshd_config.d/10-marketplaces-hardening.conf <<'SSHD' +# Both accounts on this host are key-only by construction (the deploy user is +# created with no password at all), so password auth can only ever succeed for +# a credential nobody intended to exist. +PasswordAuthentication no +KbdInteractiveAuthentication no +PermitEmptyPasswords no +PermitRootLogin prohibit-password +X11Forwarding no +AllowAgentForwarding no +MaxAuthTries 3 +LoginGraceTime 30 +ClientAliveInterval 300 +ClientAliveCountMax 2 +SSHD +# Validate before reloading: a bad sshd config that takes effect on a remote +# box is how people lock themselves out permanently. +if sshd -t; then + systemctl reload ssh 2>/dev/null || systemctl reload sshd +else + echo "sshd config test FAILED - removing the drop-in and leaving sshd as it was" >&2 + rm -f /etc/ssh/sshd_config.d/10-marketplaces-hardening.conf + exit 1 +fi + +echo "==> fail2ban" +apt-get install -y -qq fail2ban +cat > /etc/fail2ban/jail.d/marketplaces.local <<'F2B' +[DEFAULT] +backend = systemd +findtime = 10m +bantime = 1h +maxretry = 5 + +[sshd] +enabled = true + +[nginx-http-auth] +enabled = true + +[nginx-bad-request] +enabled = true +F2B +systemctl enable --now fail2ban +systemctl restart fail2ban + +echo "==> kernel hardening" +cat > /etc/sysctl.d/99-marketplaces-hardening.conf <<'SYSCTL' +# Ignore ICMP redirects and source routing: this host has one gateway and +# nothing upstream should be rewriting its routing table. +net.ipv4.conf.all.accept_redirects = 0 +net.ipv4.conf.default.accept_redirects = 0 +net.ipv6.conf.all.accept_redirects = 0 +net.ipv6.conf.default.accept_redirects = 0 +net.ipv4.conf.all.send_redirects = 0 +net.ipv4.conf.default.send_redirects = 0 +net.ipv4.conf.all.accept_source_route = 0 +net.ipv6.conf.all.accept_source_route = 0 + +# Reverse-path filtering and martian logging. +net.ipv4.conf.all.rp_filter = 1 +net.ipv4.conf.default.rp_filter = 1 +net.ipv4.conf.all.log_martians = 1 + +# SYN flood resistance. +net.ipv4.tcp_syncookies = 1 +net.ipv4.tcp_max_syn_backlog = 2048 +net.ipv4.tcp_synack_retries = 2 + +# No IP forwarding: this is a web server, not a router. +net.ipv4.ip_forward = 0 + +# Restrict kernel pointer and dmesg exposure to unprivileged users. +kernel.kptr_restrict = 2 +kernel.dmesg_restrict = 1 +SYSCTL +sysctl --quiet --system + echo "==> nginx config test" nginx -t systemctl enable --now nginx