Security · tested, not assumed

Every control, attacked

The controls in front of and inside my homelab, tested by attacking them from outside. That testing caught four controls that were not doing their job; all four are fixed and re-tested. Every number on this page comes from a recorded, dated run.

  • CrowdSec
  • OWASP CRS
  • Wazuh
  • nftables
  • Trivy
  • gitleaks
4
weaknesses caught by my own testing, all fixed
8 / 8
attack payloads blocked by the WAF, 403
7.0-10.1 s
from ban decision to firewall drop
0
secrets in 245 of my commits
The edge

Ten requests, one edge

In one line: four layers stand between the internet and any app, and each was tested from outside.

Everything public sits behind one reverse proxy VM. A request has to clear four layers before it reaches an app: a geo filter in nftables covering TCP 443, UDP 443 (HTTP/3) and TCP 80; a CrowdSec ban list enforced in both the firewall and the proxy; a WAF running CrowdSec virtual patches and the OWASP Core Rule Set in blocking mode; and TLS termination with credentials redacted before anything is written to a log. Since 10-31 the proxy also adds baseline security headers to every public app that sent none of its own (nosniff, referrer and permissions policies, frame protection) and strips the server banners that named the software behind it.

I sent ten requests through the edge's WAF engine, using the same API the proxy calls, six times each. Every attack payload was blocked with a 403 before it could reach an app, with the same result in all six runs. The table has every request, verdict, score and rule. Watch one go through the layers on the home page.

edge · recorded run captured 10-30
All ten requests, as recorded. Rule IDs are OWASP CRS unless named.
RequestWAFCRS scoreRules that fired
normal page allowed, 200 0 none
/.env blocked, 403 5 930130, Virtual patch: .env access
/.git/config blocked, 403 5 930130, Virtual patch: .git/config access
SQL injection blocked, 403 10 942100
XSS blocked, 403 40 941100, 941110, 941160, 941390
path traversal blocked, 403 65 930100, 930110, 930120, 932160
Log4Shell blocked, 403 5 944150
command injection blocked, 403 30 930120, 932160, 932230
wp-login probe allowed, 200 0 none
scanner user agent blocked, 403 5 913100

Method: Each request sent to the edge's CrowdSec AppSec engine through the same API the reverse proxy's bouncer calls, with an RFC 5737 documentation IP as the client. 3 rounds through the alert and ban pipeline + 3 rounds diffing per-rule counters, after the Core Rule Set was moved in-band. All test alerts and bans deleted afterwards. Caddy 2.11.7, CrowdSec 1.8.1, 210 WAF rules loaded.

The one request that is allowed asks for a WordPress login page. By itself that is not an attack payload; scanning for it is, and CrowdSec's wordpress-scan scenario catches that pattern from the proxy logs. Four blocked requests from one source earn a 24-hour ban, and the firewall starts dropping that source 7.0 to 10.1 seconds after the decision (three measured runs).

How the WAF got to blocking: the Core Rule Set ran in detection-only mode first, scoring real traffic without blocking it. It flagged none of about 5,300 real requests, so on 10-30 it moved to blocking. Detect, tune, then enforce is the order I would want at work too.

The tuning was not finished. Three days after the switch, the media app on my phone crashed every time I changed video quality. Its crash log showed an unhandled 403: the app stops the old stream with an HTTP DELETE, and the Core Rule Set rejects DELETE, PUT and PATCH by default. The same rule was blocking recipe edits and request changes from outside the house. My 48-hour false-positive check had only counted blocks in the logs, and nobody had edited or deleted anything from outside in that window, so it found nothing. The fix exempts that one method rule for those three methods; every injection rule still inspects them, which the re-test confirmed (an SQL injection sent as a DELETE is still blocked). The lesson: a false-positive check has to send each app's write calls itself, not wait for real traffic to find them.

Found by attacking it

Four weaknesses my own testing caught

Each of these looked fine in the configuration and broke the first time I attacked it from the outside. All four are fixed and re-tested. For each: what was wrong, how it showed up, the fix, and the re-test.

fail2ban · edge

The ban that never was

fail2ban logged bans and enforced none of them.

Wrong
The edge VM is nftables-only. fail2ban's default action calls iptables, exited with code 127, and logged "Ban" anyway. No ban on that VM had ever been enforced.
Found
A family member got "banned" while streaming. They were never actually blocked, and neither was anyone else.
Fix
nftables action for TCP and UDP; the jail counts only 401 and 403, not the 404s a media client produces after an upgrade.
Re-test
A phone on cellular sent five bad logins: banned in 10 s, then 0 requests reached the proxy. (10-30, one run.)

host firewall · every guest

IPv6 walked around the admin allowlist

The IPv4 allowlist was right. IPv6 skipped it.

Wrong
Admin ports were pinned to three hosts over IPv4. The generator put the same ports in the IPv6 LAN rule, so any phone or TV could reach SSH and the hypervisor UI over link-local IPv6.
Found
A firewall review that tested IPv6, not only IPv4.
Fix
IPv6 gets service ports only. Rolled out host by host, each with a 10-minute auto-revert armed.
Re-test
From a non-admin container, hypervisor SSH and web UI over IPv4 and IPv6: closed in 3 of 3 runs. Controls (a service port it needs, admin SSH) stayed open. (10-30)

reverse proxy · logs

The proxy was logging the keys

Credentials sat in world-readable logs. Now they are hashed or dropped before anything is written.

Wrong
Media clients send API keys in query strings and headers. The access log kept them verbatim in world-readable files: 1,136 media-server keys, 141 auth tokens and 4,468 admin API keys.
Found
A secrets audit that read the logs, not just the configs.
Fix
Redact at the logger (query values hashed, credential headers dropped), tighten permissions, and mask 25,888 old values in place without breaking the three tools that parse those logs.
Re-test
3 requests from outside the network with fake credentials: each logged once, 0 fake values in any log, key stored as an 8-character hash. (10-30)

geo filter · edge

HTTP/3 walked past the geo filter

The country filter covered TCP only. HTTP/3 runs over UDP.

Wrong
The rule matched TCP 443. The proxy also speaks HTTP/3 over UDP 443, so a client outside the allowed countries could still connect.
Found
Reading which ports the proxy listens on, then testing each one.
Fix
Filter TCP and UDP 443 plus TCP 80. Closing port 80 meant moving certificates to the DNS-01 challenge first.
Re-test
A phone on cellular, cut out of the allow list: TCP 443, UDP 443 and TCP 80 all dropped at the firewall. (10-30, one run.)
Inside the perimeter

Assume the edge fails

In one line: if something gets past the edge, every host still denies by default, is hardened, and reports to a SIEM.

Every guest runs its own default-deny nftables table, loaded by its own service, so the hypervisor is not the only thing between a service and the network. Admin ports are reachable from three hosts. It ran in audit mode for 46 hours before enforcing, and the enforcing rollout fired zero auto-reverts.

All eight hosts are hardened against the CIS benchmarks in two tiers: SSH key-only for root, account lockout, audit rules, password length over composition rules (NIST SP 800-63B). Wazuh's configuration assessment scored every host 40 to 43 before and 51 to 55 after.

Wazuh collects from all eight hosts. On 10-31 I planted a file in the systemd unit directory of every one of them; all eight raised an alert within 2 seconds. On the hypervisor I planted more on 10-30: touching sudoers raised a level 10 alert, running a binary from /tmp a level 12, and an EICAR test file a level 12 VirusTotal hit (66 of 68 engines). The tuning goes the other way too: a monitoring agent's daily updater runs a script from /tmp, so one rule matches only that exact command shape. The same shape with any other script name still fires level 12.

Supply chain

What runs here, and what I publish

Trivy scans every container image daily. Most critical CVEs in public images need the upstream project to rebuild, so a weekly reminder became noise. The alert is now keyed to the exact image and CVE pairs: fewer CVEs stay quiet, a new one alerts at once. Updates and automatic rollbacks are on the homelab page.

I scanned every commit of my four public repositories with gitleaks 8.28.0: 9,194 commits, 245 of them mine. Findings in my commits: 0. The other 24 findings are test fixtures and saved recipe pages in the forked projects' own history. This site has its own gate: a build fails if a private word or an inline script reaches the output.

Seeing it

Alerts I can live with

In one line: every control above reports to one place, and only what needs me reaches me.

A read-only collector pulls from CrowdSec, the WAF, Wazuh and the image scanner every five minutes. What it finds is shown in levels, the way plant control rooms do it (ISA-101): a phone widget that answers "is anything wrong", a dashboard with one-line verdicts and a short "needs attention" list, the SIEM's own dashboard for level 10 and up, then the raw events. Each item carries a signed link to acknowledge it, undo that, or ban the source. An acknowledged item still counts in the totals; it just stops turning the tiles red. Test events get acknowledged only after the audit trail proves where they came from.

alert routing as of 11-02
Where each kind of event goes. "Spoken" means my voice assistant says it on the PC speaker if I am at the desk, otherwise on my phone.
EventGoes to
Blocked attacks, low-volume probesDashboard only
SIEM high-severity alertDashboard + spoken, also overnight
Service down, or fixed by self-healDashboard + spoken, daytime
Daily outside-in geo check failsSpoken
A data feed goes staleDashboard + Discord

That routing came from getting it wrong. On 10-27 at 00:51 the assistant woke me through Do Not Disturb to report a single probe hit. Over the next three days: low-hit probes became visual only, speech got a quiet window overnight unless the alert is loud, it speaks where I am (a presence heartbeat from the PC decides desk or phone), and the phone checks Do Not Disturb live instead of trusting a sensor that could be minutes stale.

The edge also gets tested from outside every day, not only when I think of it. A scheduled job asks Globalping, a public network of probes, to load the site from probes inside the allowed region (the control) and from probes outside it. Outside probes must time out at the firewall; inside probes must get an answer; any outside answer is spoken as a failure. First run, 11-02: 3 of 3 inside probes answered, 8 of 8 outside probes in eight countries timed out, on HTTPS and on port 80. The probe network cannot test HTTP/3, so that part still rests on the 10-30 phone test.

Mapped

Control by control

CIS Controls v8 safeguards and the ATT&CK techniques each control is there for. "Tested" means a recorded run with a date, not a configuration review.
ControlCIS v8ATT&CKTested
Geo filter, TCP + UDP4.4T1595daily probes since 11-02; phone 10-30
CrowdSec + bouncers13.3, 13.8T1595.002, T119010-30, ban timing x3
WAF: virtual patches + OWASP CRS13.10T119010-30, 10 requests x6; write methods 11-02
fail2ban13.8T111010-30, phone on cellular
Per-guest firewall, IPv4 + IPv64.4, 13.4T1021.00410-30, x3
VPN egress, default drop13.4none10-30, 6 tunnel kills
CIS hardening4.1T1110, T1548.00310-22 to 10-25, Wazuh SCA
Wazuh SIEM8.2, 8.9, 13.1T1548.003, T110510-30, planted events
Log redaction + permissions3.3T1552.00110-30, x3 from outside
Trivy + weekly updater7.4, 7.5T1190daily scans; rollback forced 10-22
Secrets scan + site gate16.12T1552.00110-30, every commit
Backups, restore drills11.5T1485, T148610-22 and 10-30, timed restores

what it demonstrates

WAF tuning (OWASP CRS, detect then enforce), intrusion prevention with CrowdSec, host firewalls in nftables for IPv4 and IPv6, SIEM rules written and tested with planted events (Wazuh), CIS hardening, container vulnerability management (Trivy), secrets scanning (gitleaks), and controls mapped to CIS v8 and MITRE ATT&CK.

ethan@justice: ~