Pwned by a Polite Note
Pwned by a Polite Note
Yesterday I opened my own blog and found a post I hadn't written.
Dear Me,
Well… this is awkward.
If you're reading this, it means I managed to publish a blog post in about three clicks — no drama, no alarms, no friction. And while that might sound like a smooth user experience, let's be honest: it's actually a red flag.
The note was friendly, and it was also a security incident. Here's what I did about it.
The shape of the problem
The note didn't say how they got in, but it didn't sound like it had been hard.
The admin panel here uses a single shared password stored as a Cloudflare Pages secret. There's a login form, a session cookie, and middleware that checks the cookie on every /admin/* route. The whole thing fits in a paragraph, which turned out to be relevant.
The blog has two write paths. The first is the /admin/* routes on the Astro site, where I handle post CRUD and image uploads. The second is a separate Cloudflare Worker at scopecreeplabs-api.workers.dev for license issuance and payment webhooks. A grep through the worker confirmed it only exposes read endpoints for blog content; every post mutation goes through /admin/*. So whoever published the note got past the login page itself, not through a code bug somewhere downstream.
What was wrong
I went through the code and wrote down what I found. None of it was exotic.
- No rate limit on the login endpoint. Passwords could be thrown at
/admin/loginas fast as the network allowed. - No login alert. If someone succeeded, I wouldn't know unless I happened to spot a strange post.
- No friction on publish. Changing status from "draft" to "published" was a radio button and a Save button, which is exactly the three clicks the note described.
- A bad session cookie threw a worker exception instead of redirecting cleanly. I found this one by accident, when rotating the password locked me out with a Cloudflare 1101.
- The session hash compare was
===, not constant-time. Probably not the vector here, but a bug class worth eliminating by default.
The fixes
Before reading any code, I rotated the password. The hash in the session cookie is derived from the password, so rotating it invalidates every existing session immediately, including whoever owned the polite note's. Then three commits.
Rate limiting backed by D1.
A new login_attempts table records every attempt. The handler counts failures in the last 15 minutes per IP and blocks the sixth one:
const cutoff = Date.now() - WINDOW_MS;
const row = await db.prepare(
`SELECT COUNT(*) as c FROM login_attempts
WHERE ip = ? AND succeeded = 0 AND attempted_at > ?`
).bind(ip, cutoff).first<{ c: number }>();
if ((row?.c ?? 0) >= MAX_FAILED_ATTEMPTS) blocked = true;
IP rate limiting is trivially bypassable by anyone with a pool of proxies. The goal isn't to make brute-force impossible, just to put the password back in the role of barrier.
Login alert email.
On every successful login, the handler fires off a Resend email with time, IP, country, and user agent. It's sent fire-and-forget so it doesn't delay the redirect.
Publish confirmation.
The admin form now intercepts submission and prompts before flipping a draft to published:
if (selected?.value === 'published' && current !== 'published') {
if (!confirm('Publish this post live on the blog right now?')) {
e.preventDefault();
}
}
Editing an already-published post still saves silently. The dialog only shows up on the actual draft-to-public transition.
Middleware that doesn't throw.
Stale cookies now hit a manually-constructed redirect with the cookie explicitly cleared on the response:
function buildLoginRedirect(opts: { clearCookie?: boolean } = {}): Response {
const headers = new Headers({ Location: '/admin/login' });
if (opts.clearCookie) {
headers.append('Set-Cookie',
`${SESSION_COOKIE}=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax; Secure`);
}
return new Response(null, { status: 302, headers });
}
The entire auth block is wrapped in try/catch, so any unexpected exception during validation falls through to the same redirect rather than becoming a 1101.
The new auth flow
Takeaways
If you run something like this, the login alert email is probably the best return on time spent. It's a small handler, and it turns "I might be compromised and not know" into something I'd notice in real time.
After that, look at the path from intent to irreversible action in your admin. Find the step that makes content public and put a deliberate pause in front of it. A confirm dialog is enough.
And if your middleware uses framework helpers to redirect, consider rebuilding the redirect by hand for the auth paths. The day you'll be glad you did is the day you rotate a secret and find your worker has been throwing 500s to every request for the last six hours.
A note to whoever left it
Thanks. The post was generous, the demonstration was harmless, and the framing was right. If you try the route again, I hope I see the email before you've finished typing.