The finding
The first version of Sentinel grew the way internal tools grow: a route at a time, over a long stretch, each one useful. Nobody ever sat down and asked what the whole surface looked like. So I did.
169 routes, none of which required authentication. And one of them was genuinely critical: a configuration endpoint that returned the full contents of every config file the app loaded — live API tokens and passwords for the hypervisor cluster, the SIEM, and the smart lighting bridge — as JSON, to anyone who requested it.
It was a home lab on a private network, not an internet-facing service. That lowers the real-world impact and changes nothing about the class of bug. The same pattern in a corporate internal tool is a full credential compromise on the day someone gets a foothold on the network — which is the day it matters most.
The review turned up more:
- An example config file, tracked in git, containing a real working password — the classic way a secret outlives every rotation you remember to do.
- A generic HTTP action type in the automations engine — an SSRF primitive wearing a feature's clothing.
- A config still pointing at a host that had moved during a network migration months earlier, silently falling back to example values instead of failing.
- Five overlapping, partially dead implementations of the same lighting feature, and duplicate event handlers firing twice.
The rebuild
I built v2 as a fresh, hardened fork rather than patching v1, because the shape of the problem was architectural. Every decision below is a direct answer to something the review found.
One gate, not 169 decorators
Authentication is a single global request hook that denies by default, with a small explicit allowlist for the login page and static assets. Per-route decorators are exactly how v1 ended up with 169 unprotected routes — anything that requires you to remember to add protection will eventually be forgotten. Adding a new route to v2 makes it protected automatically; you have to take deliberate action to expose one.
Behind that: password hashing, a signed session, and an in-memory brute-force lockout with exponential backoff after repeated failures.
Secrets separated by structure
Example configs are tracked and contain placeholders only. Real configs live in a directory that is gitignored as a whole, not file by file. A startup check validates that the tracked examples still contain nothing but placeholders, and the app fails loudly when real config is missing rather than silently falling back to examples — which is precisely the behaviour that hid the stale host for months.
SSRF closed by construction
The automations engine in v2 has a fixed, enumerated set of action types. There is no generic HTTP request action at all. This is deliberately not an allowlist of permitted destinations — an allowlist is a thing you can misconfigure, and a thing that grows. The capability simply does not exist in the system.
Authentication for the one client that has no human
A headless voice device calls three endpoints and cannot hold a browser session. Those three, and only those three, accept a shared secret header instead, compared in constant time. Verified by testing all three cases: no token rejected, wrong token rejected, correct token accepted.
TLS, eventually — and for a reason I did not expect
Plain HTTP on the LAN was a known, accepted residual risk from the initial build. What forced the fix was not a threat model, it was a feature: browsers only expose microphone and camera APIs in a secure context, so a push-to-talk feature simply could not work over HTTP. Diagnosed by inspecting the live page rather than guessing — the secure-context flag was false and the media device API was undefined.
I added a TLS listener alongside the existing one rather than replacing it, so nothing already pointed at the old port broke. The private key was generated on the server and never left it.
What I got wrong during the work
While testing a VPN peer generator, I verified the write path correctly and then made a mess of my own cleanup. To remove a test block I had appended to a live configuration file, I trimmed it by line count — and miscounted by one, deleting a legitimate line belonging to a real, unrelated peer.
The running interface was unaffected because it was decoupled from the file on disk, so nothing broke immediately. The on-disk config had a latent bug that would have surfaced at the next reboot. I caught it, stopped, explained the exact state and risk before touching anything else, and then fixed the single line and verified it.
A revert is a change. When undoing an edit by line count, recompute the delta from what was actually written instead of estimating it — and treat textual reverts of live configuration with the same care as the original edit, not less.
The second thing I got wrong was structural: I built the hardened version alongside the original rather than replacing it. That is the right call for keeping a working system available during a rebuild, and it is also how a known-vulnerable service quietly keeps running long after you have written its replacement. Deciding what happens to the old version is part of the migration, not an afterthought.
What it proves
I can audit an application I am personally invested in and report the findings honestly; tell the difference between a bug and an architecture that produces bugs; choose designs that make the insecure state unreachable rather than merely discouraged; and describe my own mistakes precisely enough that someone else can avoid them.