A full security audit of fiventhree.com, and the bug it found
An external and internal audit of the whole domain, five subdomains in. Most of it held up. One header fix turned out to be silently ignored in production for a reason worth knowing about.
This domain has grown since it went live: four sites, a password manager, a media server and a handful of internal admin tools, all behind one Caddy on one VPS. Nothing had failed. That's exactly the condition under which a security review is worth doing, before something forces it.
The scope was two-sided: everything reachable from the outside (headers, TLS, DNS, open ports, whether the internal-tools gate actually holds), and everything checkable from the inside over SSH (what's actually listening, what's patched, what's exposed to Docker's own network versus the host). Not a penetration test, and I said so to myself before starting rather than after finding nothing: no fuzzing, no credential attacks against my own services, nothing that needed permission from anyone but me.
What was already solid#
A few things worth stating plainly, because a security write-up that only lists problems gives a false read on the baseline:
- Every backend service, the Postgres database included, publishes no port to the host at all. The only thing Docker exposes to the internet is Caddy, on 80 and 443. Checked with docker compose ps and cross-checked with ss -tln on the host itself, which showed nothing listening besides SSH, HTTP and HTTPS. - The client IP header going into the two rate limiters behind this proxy (the Tracker's own, and Vaultwarden's) is overwritten, not appended to. A client that supplies its own X-Forwarded-For gets it discarded before the app ever sees it. Caddy's default is to append, which would have made the rate limiter key on a value the attacker chose. - The internal-network admin tools (Proxmox, JDownloader, qBittorrent, Sonarr, Radarr, Jellyseerr, Filebrowser) all sit behind a second gate (Authelia) on top of whatever login each service has of its own, and I checked that gate is actually enforced rather than trusting the config: all seven answered an unauthenticated request with a redirect to the login portal, not their own interface. - fail2ban is active and enabled. rpcbind, flagged and removed in an earlier pass, is still gone. unattended-upgrades is running its periodic cycle. Nothing in the pending-update list touches a security-relevant package; it's mostly point releases and a few versions behind on Docker's own packages, which sit outside Ubuntu's own security-update scope and need checking on their own schedule. - No passwordless sudo, anywhere. Every root action needs an interactive password, including from the session that was already SSH'd in with a key.
The ports that weren't there#
The one result that actually stopped me was a full external port scan against the domain's IP. Alongside 22, 80 and 443, it reported FTP, SMTP, POP3, IMAP, a SIP port and two others as open.
That's a bad five seconds if you don't immediately check it against something else. So I did: the same host, checked from inside over SSH, with ss -tln and no root needed to see the listening list. Nothing there but SSH, HTTP, HTTPS and the local DNS stub. No process, no container, nothing in Docker's port mappings corresponds to an FTP or mail server anywhere on this box.
Next step was a banner grab against each of the ports the scan called open. A real FTP or SMTP server answers within about a second of the TCP handshake completing, unprompted: that's the whole point of a greeting banner. Every one of them came back silent. The handshake completes; nothing ever speaks after it.
That pattern, a completed TCP connection with no protocol behind it, reads like something answering at the network edge before traffic ever reaches the guest OS, rather than anything actually running here. I can't fully confirm the mechanism without asking the hosting provider directly, and I'm not going to publish a guess dressed up as a finding. What I can say is that the first-party evidence (what's actually listening on the machine I administer) and the external scan disagree, and the first-party evidence is the one I'd act on. Worth a periodic re-check, not a fire drill.
Adding the header that was missing#
Every subdomain already carried HSTS, X-Content-Type-Options, X-Frame-Options and a couple of others, from one shared Caddy snippet. Three of the sites (the homepage, the blog and this portfolio) had no Content-Security-Policy at all. Unlike the headers above, a CSP genuinely needs a per-site allowlist rather than one shared value, which is probably why it had been left out instead of gotten wrong: it's real work to get right, and wrong is worse than missing.
So the real work: read what each site actually loads rather than guess. All three pull Google Fonts and nothing else external, checked directly against the built HTML, not assumed. The one exception was the contact form on this portfolio, which posts straight from the browser to a third-party form handler rather than through anything this domain runs. Missing that from the allowlist would have silently broken the one interactive thing on the page, which is exactly the failure mode a CSP change deserves the most care about.
'unsafe-inline' stayed on for scripts and styles. Each site ships a small inline <script> block and a handful of inline style="" attributes, and moving those out to external files with a nonce is real scope beyond a header audit. What the policy still buys, even with that concession: nothing can load a script, a stylesheet, a font, an image or open a connection to any origin but the domain itself and Google Fonts. That's the actual path a compromised ad, widget or dependency would need, and it's closed.
The header that a rebuild wouldn't apply#
Alongside the CSP work, curl -I on the Tracker showed X-Powered-By: Next.js on every response. Free information for anyone probing the domain, easy fix: Next.js has a config flag for exactly this.
Set it, redeployed, checked again. The header was still there.
Redeployed a second time to rule out a stale build. Still there. That's the point where "did I actually change the right thing" stops being a reasonable question and the Dockerfile becomes one.
The frontend's production image is a two-stage build: compile in one stage, copy only the built output into a slim runtime stage. That second stage copied the compiled app, the public assets, node_modules, and package.json, then started the server with npm run start, which is next start, which reads the config file itself, at runtime, from its own working directory. The config file was never in the list of things copied into that directory. Every setting written into it, including the one added here, had been silently ignored in production since the file was first written, because the process reading it never had a copy to read.
An output: "standalone" build would have avoided this entirely: Next.js bakes the resolved config into the generated server at build time, so nothing needs to travel separately afterward. This build predates that choice. The fix here was one more COPY line; the more durable fix, for another day, is moving to a standalone build so a class of "the config file technically exists but the running process can't see it" bugs stops being possible at all.
Left open, on purpose#
Two things came up that I'm flagging rather than changing unilaterally, because both are access decisions, not header hygiene:
- The media server is the one internal-network service that doesn't get the second Authelia gate that every other admin tool behind this domain does. It has its own real login, which is more than some of the others can say, so this may already be the right call, but reading back through when each gate was added, this one predates the point where "always double-gate" became the stated policy for anything reached over the internal network. Worth a deliberate yes or no, not a leftover. - One of the internal tools has no DNS record pointing at it at all, which I confirmed by trying to reach it directly against the server's own IP with the right hostname: the TLS handshake fails outright, because Let's Encrypt never had a working challenge to issue it a certificate against. Not a vulnerability, if anything the opposite: it's currently unreachable for the person who's supposed to use it too.
Neither gets touched here. One changes who can reach something without a second login; the other needs a DNS record added somewhere I don't have write access to from this box.
What this wasn't#
Worth being explicit about the edges. This didn't include a real penetration test: no fuzzing, nothing aimed at Vaultwarden or Authelia's own auth logic, nothing that assumes an already-compromised foothold. It also didn't touch the home network the media server and admin tools actually run on; that's had its own recent, separate hardening pass. This was the domain's edge: what a stranger sees hitting it cold, and what a look from the inside confirms or contradicts about that.
The header work landed exactly where I expected it to: mostly confirming things already done correctly, with one real gap closed. The interesting part was the two places where checking from a second angle changed the answer: the ports that weren't real, and the header that a whole redeploy couldn't apply until the actual cause turned out to be one missing line in a Dockerfile.