Three parts ago I made an argument I still believe, and then spent the 23rd of September undoing half of it.
The argument was that the editor needs no authentication because it does not exist in production. No route, no bundle, nothing deployed to attack. I still think that’s right, and the test that greps dist/ still passes.
Here’s what it doesn’t cover.
While astro dev is running on my laptop, localhost:4321 is a real server accepting real requests. Any page, on any site, in the browser I already have open, can send requests to it. It doesn’t need to find my editor. It doesn’t need to get past a login. It just needs me to be running the dev server and visiting something else in another tab.
“It isn’t deployed” answers the stranger on the internet. It says nothing about the tab.
How bad it actually was
Bad enough that writing this makes me wince a little.
POST api/publish read no body at all. It didn’t need one. Calling it was the whole operation.
And api/post and api/upload were what the spec calls CORS-simple, which means a browser will send them cross-origin without asking permission first. No preflight, no chance for the server to say no. A plain HTML form on any page could have posted to them.
Nothing checked Origin. Nothing checked Host. There was no secret to check.
So the chain was: you visit a page, that page submits a form to localhost, and the other end of that form is git add, git commit, git push. To the live site. Under my name.
That is a genuinely stupid way to get owned, and the thing that makes it stupid is that every individual decision was reasonable. Dev-only? Correct. No auth on a local tool? Defensible. A publish endpoint that just publishes? Convenient. It’s the combination that’s a hole, and I never looked at the combination because I was looking at the pieces.
Four checks, one chokepoint
Every API call already went through one function, route(), which made this much easier than it deserved to be.
if (!input.secret) return { ok: false, reason: 'secret' };
if (!isLoopbackHost(input.host)) return { ok: false, reason: 'host' };
if (input.origin !== null) {
if (new URL(input.origin).host !== input.host) return { ok: false, reason: 'origin' };
}
if (input.token === null) return { ok: false, reason: 'token' };
A secret exists, the Host is loopback, the Origin if present matches the Host, and the token matches, compared with timingSafeEqual after a length check.
The token is the interesting one. It’s generated per run of the dev server and lives in process.env.RUWAIZ_EDITOR_TOKEN. The page gets it as a meta tag, which a cross-origin document cannot read, and every call sends it back on an x-editor-token header.
That header is doing two jobs. It proves the caller is the editor. And because it’s a custom header, it drags every request into a preflight, which an attacker’s page cannot satisfy. The requests stop being CORS-simple, which means the browser stops sending them cross-origin before my server is even involved.
The content-type check moved to after the guard, deliberately, so it strips CORS-simple status on its own rather than depending on the guard to do it.
And the integrity test grew a word: the same scan that greps the built JavaScript for Tiptap and the editor app now greps it for editor-token too. Dev-only was the whole argument, so dev-only is the thing worth a test.
The other direction
That’s requests coming in. The same day dealt with requests going out, which I’d never once thought of as a security surface.
The link tooltips on this site are built by scraping the pages I link to. Title, description, favicon, at build time. Two problems with that, and I’d shipped both.
The first is that the scraped values went straight into an HTML string rendered with allowHTML: true, and the scraper’s regexes exclude quotes but not angle brackets. So any site I linked to could put markup into every page of mine that linked to it. Not a hypothetical I had to construct. That’s just what the code did.
The fix sounds trivial and isn’t quite: decode the entities, then escape. Escaping last means markup is inert whether it arrived raw or entity-encoded, and it means a title that genuinely contains & renders as & instead of showing the entity to the reader.
Getting that order wrong is how you end up with Free Mobile & Online Games on a page, which is exactly what happened before it was fixed, and the only thing that caught it was opening the built HTML and reading it. The unit suite, the build, the integrity suite and the scope check were all green. Codex’s build couldn’t have caught it either, because its sandbox has no network, so it produces no external tooltips at all.
The second problem was worse. The fetcher validated the scheme and nothing else, then followed redirects with no host filter onto an unbounded body. Which means a link in one of my posts could point at 127.0.0.1, or anything on my home network, or 169.254.169.254, and my build machine would go and fetch it.
The gate that wasn’t a gate
The fix for that is where the day earns its place in this series.
The plan I wrote had one gate: resolve the hostname through a custom lookup, check what it resolves to, refuse anything internal. Reasonable. It would also have done nothing at all for the three targets that matter most, because node skips a custom lookup when the host is already an IP literal. Point it at http://127.0.0.1/ and the lookup you carefully installed is simply never called.
So there are two gates. IP literals get classified before connecting, and DNS results get classified at connect time, which also closes the rebinding case where a name resolves to something harmless and then to something else.
Three more details from the same fix, none of which I’d have thought of unprompted. Sockets are unpooled, because a keep-alive connection reused from an earlier request would skip the gate entirely. Redirects are revalidated at every hop rather than just the first. And the body is capped at 512 KB, because “follow this link and read what comes back” is otherwise an invitation.
ogFetcher shares the same function, so the build-time scraper got fixed by the same change.
Where it landed
Four checks on the way in, two gates on the way out, and a test that fails if the token ever reaches dist/.
None of it makes the editor more pleasant to write in. That’s the odd thing about the 23rd. A whole day of work and the tool does exactly what it did before, just without a path from a page you happened to visit to a commit on your own site.
I’m not going to pretend I found this by being careful. I found it by asking what would happen if someone tried, which is a question I’d somehow not asked in the four days since deciding the answer was nobody can.
Next part is the opposite kind of work entirely, and the first that only matters because I’m the one typing.