What's left to get wrong

There's no /admin on this site. No login form to try a password against, no session cookie to steal, no plugin list to check against last month's CVEs — not because a checklist said to strip them out, but because none of them were ever built in the first place.

The engine's own architecture notes put the rest of it plainly: the threat model is a static site with no server of its own. Nothing here listens on a port. The goal was never to harden a complex system against everything that could go wrong with it. The goal was to avoid needing one in the first place.

What's left to get wrong, once that's true, is narrower than a normal blog's list — what the pages carry, what the working directory holds, and the one thing that does arrive from outside.

The one thing that arrives from outside

Writing from a phone means one door does accept bytes from a stranger's own device: scripts/receive.sh, running as the forced command of an SSH key. It listens on nothing — no socket, no daemon, just a key that can run exactly this script and nothing else.

What comes in is a filename and a stream of bytes.

The filename is held to a short list of things it may not contain: no path inside it, no leading dot, no control character.

The stream is bounded before any of it lands — BLOGSH_MAX_MB cuts the read off first, so nothing gets to overflow anything downstream by simply being large.

And the markdown that follows is parsed confined: an image reference may only be a bare filename, because a path like /etc/passwd typed into ![]() would otherwise be read straight into the post and published as if it were a photo.

There's no archive format anywhere on that path, on purpose. Nothing here ever unzips what a stranger sent, which is also the reason there's nothing to zip-slip.

What the page itself can be tricked into showing

Every piece of text that came off a network — a Fediverse display name, an avatar address, the text of a reply — gets escaped twice, the same way both times: once when the build writes the HTML, again in the DOM a visitor's own browser builds live for the comments. The one exception is Mastodon's own status HTML, trusted because it was already sanitised by Mastodon before it reached here — the trust sits in that process, not in the content itself. Whatever a browser is allowed to run beyond that is named in a Content-Security-Policy delivered as a meta tag — because a plain static host may not let a site set a header at all — and a media provider's origin is admitted only on the pages that actually embed one; an archive page with nothing embedded permits nothing.

Limits, stated rather than implied

A draft's preview address carries a random token and a noindex, long enough that nobody stumbles onto it and hidden from search besides. That's obscurity, not a lock. Anyone the link reaches can read the draft — which is the entire point of being able to send someone a link — so it's worth calling that what it is rather than dressing it up as access control it was never meant to be.

env.sh holds the real credentials, stays out of git, and is written 600. The one place that quietly slips: the setup wizards leave a .bak of whatever they rewrote, at the same permissions, holding whatever tokens were live before. Worth deleting the moment a rotated token is actually confirmed rotated — a forgotten .bak is a second, unwatched copy of a secret you thought you'd retired.

And underneath both of those sits one thing this model doesn't defend against at all: the machine actually running the build. A compromised laptop or a compromised server has already won, the same way it would against any tool invoked from that machine's own shell — there's no scenario here where the build defends against its own operator, and no attempt to pretend otherwise.

None of this reads like a checklist bolted on after the fact. It reads like what's left over once a login, a plugin list and an upload endpoint that takes anything were never on the list to begin with.