The personal website for Brandon Steili. A work in progress - both the site and the human.

← Writing

Essay

Why My Personal Blog Has a Stricter Security Posture Than It Needs To

Published2026·07·09
Filed underauthentication · csp · osshp · security · self-hosting · ssrf
The osshp logo

steili.com is a personal site. Nobody is trying to breach it for a payday, and I don't kid myself that it's a high-value target. So it's a fair question why I built osshp, the platform it runs on, with a security posture that reads more like something you'd defend a payments API with than a portfolio-and-blog. The honest answer is that I don't build differently for "low stakes." A weak boundary is a weak boundary whether the thing behind it is a bank or a blog, and the discipline of building it right doesn't get cheaper later, it gets more expensive. Here's what I actually built, and what each choice cost me.

A CSP with no exceptions, and the dependency it fired

Every response from osshp carries a Content Security Policy built around a per-request nonce, not 'unsafe-inline'. In practice that means three things are simply not allowed, anywhere in the app, ever:

  • No inline style="..." attributes.
  • No inline <script> blocks, unless they carry that request's nonce.
  • No content loaded from a third-party origin that hasn't been explicitly allow-listed.

This is a stricter policy than most production web apps ship, and it's stricter on purpose. 'unsafe-inline' is the single most common CSP escape hatch, and it's common precisely because it's the path of least resistance: some library wants to set a style inline, punching a hole is faster than fixing it, and the hole stays forever. I decided up front that osshp would never take that shortcut, which meant every dependency and every line of first-party code had to actually earn its way past the policy instead of getting waved through.

That decision had a real cost partway through the build. The Photos module's lightbox originally used GLightbox, a solid, well-maintained MIT-licensed library. It worked, until the CSP started rejecting it, because GLightbox positions and animates images by writing directly to the style attribute on DOM elements. That's exactly the pattern a nonce-only CSP exists to catch. I had two options: carve a style-src-attr exception into the policy to accommodate one dependency, or replace the dependency. I replaced it. osshp now ships a small, first-party, zero-dependency vanilla-JS lightbox that carries all of its visual state through CSS classes and element properties, never inline styles. It's CSP-strict by construction, and as a side benefit it's one less third-party library to track for CVEs and breaking changes.

That's the general shape of what a strict CSP buys you: it doesn't just block attackers, it blocks your own dependencies from doing something sloppy, and it makes you find that out in week six of a build instead of the week someone actually tries to exploit it.

Passkeys, and no email or SMS anywhere in the recovery chain

Authentication is passkey-primary, built on SimpleWebAuthn. That's the easy part; passkeys are phishing-resistant by design and there's not much to argue about there. The harder part, and the part I spent more time on, is what happens when the passkey isn't available: a new device, a lost device, a browser that doesn't have the credential synced.

The layered recovery path is: password plus TOTP (via otplib) as a second factor, one-time recovery codes generated at setup, and a local CLI break-glass path for the scenario where all of the above is unavailable and you're standing at the machine that hosts the site. What's deliberately absent from every layer of that chain is email or SMS. That's not an oversight; it's the actual design decision. Email and SMS recovery are the two most commonly abused account-recovery paths on the internet, because they hand your account's security over to the weakest link in someone else's infrastructure: a mail provider's password-reset flow, or a phone carrier's SIM-swap-vulnerable support line. Building "forgot your password" on top of email feels like the obvious, friendly choice, and it's also how a large share of real account takeovers actually happen. I decided the platform's recovery chain would depend on nothing outside things the operator directly controls: a second factor they hold, codes they generated and stored themselves, or physical access to the host. That's more friction than clicking a reset link. It's also not delegating your account's security to a system you don't own.

One sanitization boundary, not a dozen escaping calls

Every post on osshp is authored in Markdown and rendered as HTML through a single pipeline: unified/remark/rehype, with rehype-sanitize as the last stop before anything reaches a browser. That's the one point in the entire codebase where untrusted content becomes trusted content, and it's the only point. There is no second code path that renders Markdown a different way, and no ad hoc string-escaping scattered through admin routes that also happens to touch user content.

This matters more than it sounds like it should for a single-admin platform, because "the admin wrote it" doesn't mean "the admin's browser is safe to trust." If a post ever gets pasted content from somewhere else, if a future contributor role gets added, if an import script brings in content from another platform, the sanitization boundary is what stands between "content got rendered" and "content got to run arbitrary script in a visitor's browser." Having exactly one well-tested choke point for that conversion is worth far more than having several less-tested ones, because a single boundary is a boundary you can actually audit and actually trust.

Default-deny, and a boot process that would rather fail than run weak

Routes in osshp are inaccessible by default; a route has to be explicitly marked public to serve unauthenticated traffic. That's the standard advice everyone gives and almost nobody actually enforces structurally, because it's easier to build "public unless protected" and remember to protect things as you go. Default-deny means a forgotten route fails closed instead of failing open.

The same philosophy shows up at boot. osshp enforces strength floors on the session secret and the encryption key at startup. If either one is too weak, the app doesn't start in a degraded, "insecure but running" mode; it fails to boot and serves 500s. That's a deliberate tradeoff: a loud, obvious failure that an operator notices immediately and has to fix, instead of a silent one that runs fine for months until it's the reason something got compromised. I'd rather have someone message me confused about why their site won't start than have someone running a production instance for a year on a secret that was never strong enough to protect anything.

Backups that fail closed too

Full-instance backups are encrypted with age, an authenticated encryption tool. Authenticated encryption means tampering with a backup file doesn't just corrupt it silently, it makes decryption fail outright: you find out immediately if a backup has been altered, rather than restoring something that's been quietly tampered with.

That wasn't the first version of this. The earlier backup encryption was a custom scheme: AES-256-CBC plus a separate HMAC for integrity. It worked, technically, but a security review turned up a real problem: the way the encryption key was being derived meant it could leak through process arguments, visible to anything that could inspect the process list at the wrong moment. Once that was found, I didn't patch around it. I replaced the whole scheme with age, a well-audited, purpose-built tool that doesn't have that class of problem to begin with. Hand-rolled crypto is a well-known trap for a reason, and the fix here wasn't cleverness, it was admitting the custom scheme should never have been the answer and swapping it for a tool built by people who do this for a living.

The SSRF boundary on the one feature that fetches URLs

The riskiest single feature in osshp, from a security standpoint, is also one of the newest: fetching an external image by URL so it can be imported and hosted locally instead of hotlinked (more on why in a different post). Any feature where the server fetches a URL that a user supplies is a textbook Server-Side Request Forgery surface, because "fetch this URL" can just as easily mean "fetch this URL that happens to point at your own internal network."

The fetcher is built to close that off completely, not partially:

  • It blocks every private, loopback, and link-local IP range, including the cloud metadata address (169.254.169.254 and its equivalents), and it does this for both IPv4 and IPv6, including every IPv4-mapped IPv6 textual form.
  • The check happens against the actual resolved IP, parsed into real address bytes, not by pattern-matching the hostname or the string form of the address. String-matching is how these filters get bypassed; parsing to bytes isn't.
  • Once a destination IP passes validation, the connection is pinned to that exact IP. This closes the DNS-rebinding window where a hostname resolves to a safe IP at validation time and a different, unsafe one at connection time.
  • Every redirect hop is re-validated from scratch, not just the first request. A URL that resolves safely but redirects somewhere internal gets caught at the redirect, not waved through because the original request looked fine.
  • The fetch itself is capped on size and time, and the response is content-sniffed and re-encoded rather than trusted and stored as-is.

That's a lot of engineering for "import a picture from a URL." It's also exactly the amount of engineering that feature actually needs, because the moment a server accepts an arbitrary URL from a form field, it's making requests on the operator's behalf from inside their own network, and every shortcut in that validation is a potential way into infrastructure the image importer was never supposed to be able to reach.

Why bother, for a personal site

None of this is because I think steili.com is a target. It's because the discipline of building a boundary correctly doesn't scale down with the stakes, and the moment you let yourself believe "it's just a personal site" is the moment sloppy shortcuts start feeling justified. osshp is also meant to be run by other people, on their own infrastructure, for their own reasons, some of which I'll never know. A security posture that's "good enough for a low-stakes blog" isn't a posture at all; it's a bet that nobody using the software ever has anything worth protecting. I'd rather not make that bet on someone else's behalf.

— end —