feat(deploy): host hardening on the frontend server (FH-D.3)

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 <noreply@anthropic.com>
This commit is contained in:
sdarbinyan
2026-08-21 13:17:02 +04:00
parent cf17b0b6c6
commit a3808842c1
3 changed files with 111 additions and 5 deletions

View File

@@ -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

View File

@@ -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**

View File

@@ -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