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:
@@ -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
|
installs a root-owned, argument-validating API-domain helper. The deploy user may
|
||||||
run that helper and reload nginx, but cannot replace the helper.
|
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:
|
Verify before continuing:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -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
|
- [ ] **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.
|
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
|
- [x] **FH-D.3 — Host hardening we lack** · M · **done 2026-08-21**
|
||||||
fail2ban jail, sshd hardening drop-in, sysctl hardening, scoped sudoers per deploy role. Add to `scripts/deploy/server-setup.sh`.
|
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.
|
||||||
*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.
|
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) |
|
| 2 — Contracts | 14 | — | 0 | 1 rejected (FH-2.12) |
|
||||||
| 3 — Proof | 2 | — | 3 | see note below |
|
| 3 — Proof | 2 | — | 3 | see note below |
|
||||||
| 4 — Identity | 2 | 3 | 3 | OAuth apps, which FH-0.1 gates |
|
| 4 — Identity | 2 | 3 | 3 | OAuth apps, which FH-0.1 gates |
|
||||||
| Ops | 0 | — | 3 | — |
|
| Ops | 1 | — | 2 | — |
|
||||||
| Process | 4 | — | 2 | — |
|
| Process | 4 | — | 2 | — |
|
||||||
| **Total** | **24** | **3** | **15** | 1 rejected |
|
| **Total** | **25** | **3** | **14** | 1 rejected |
|
||||||
|
|
||||||
**Landed 2026-08-21**
|
**Landed 2026-08-21**
|
||||||
|
|
||||||
|
|||||||
@@ -128,6 +128,90 @@ ufw allow 80/tcp >/dev/null
|
|||||||
ufw allow 443/tcp >/dev/null
|
ufw allow 443/tcp >/dev/null
|
||||||
ufw --force enable >/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"
|
echo "==> nginx config test"
|
||||||
nginx -t
|
nginx -t
|
||||||
systemctl enable --now nginx
|
systemctl enable --now nginx
|
||||||
|
|||||||
Reference in New Issue
Block a user