The Patch Was Easy. The Password Reset Almost Locked Me Out.
Five months ago I wrote My Blog Told Me It Was Vulnerable. It Was Right. It was about CVE-2026-26980, a 9.4 CVSS SQL injection in Ghost's Content API, and about the mildly humiliating discovery that my only publicly exposed service was also the only one I'd never bothered to put in a compose file. I fixed the CVE, codified the stack, wrote down a standard, and ended the post with a line about how this would never happen again.
Reader, it happened again.
This time the email came from Ghost directly. Subject line "Critical Ghost security update," the kind of thing that arrives at 10am on a Tuesday and rearranges your afternoon. A batch of new advisories had landed, and I should update as soon as possible and consider resetting my authentication credentials.
Here's the thing though. This time I was ready. The compose file existed. The standard existed. I knew exactly which host, which volume, which env file. The patch itself took about four minutes.
And then I nearly locked myself out of my own blog forever, because of a config value I'd never once thought about.
How It Started
The March incident had a certain clarity to it. One CVE, one number, one very large CVSS score. Nine point four is a number that makes you act. You read "unauthenticated attackers can read arbitrary data from your database," you look at your publicly accessible blog, and you go patch it.
August was murkier. Seven advisories, published as a batch, and not one of them had a CVE ID assigned. Just GHSA identifiers and severity labels:
| Advisory | Severity | Patched in | Was I exposed? |
|---|---|---|---|
| Editor oEmbed Preview Allows Untrusted Script Execution | High (8.1) | 6.34.0 | Yes |
| Input Validation Issue in Admin iframe Could Result in Staff Account Takeover | High (7.3) | 6.34.0 | Yes |
| Blind Password Hash Disclosure in Ghost Admin API | Moderate (4.3) | 6.58.0 | Yes |
| Staff Sessions not fully Invalidated on Password Change | Moderate | 6.34.0 | Yes |
| Staff Tokens Granted Elevated Post Privileges | Moderate | 6.58.0 | Yes |
| Unauthenticated Comment Read in Private Mode | Moderate | 6.58.0 | Yes |
| Denied Extension Bypass in Theme Serving via URL Encoding | Moderate | 6.20.0 | No |
I was on 6.21.2. The highest patch floor in that list was 6.58.0, latest release was 6.59.0, so I was thirty eight minor versions behind and exposed to six of the seven. The theme serving bypass was patched in 6.20.0 and I'd scraped past it by two releases, entirely by accident. I'd like to claim that as good hygiene but it's just what happens when you patch once and then stop paying attention for five months.
I want to flag something about the psychology here, because I think it's the actually interesting part. A single 9.4 with a CVE number attached is easy to take seriously. Seven moderates and a couple of highs with no CVE IDs is much easier to file under "I'll get to it." The March vulnerability was scarier. The August batch was more likely to be ignored. Those are not the same axis, and I only noticed because I happened to be looking.
The Plan
- [x] Back up the database and content volume before touching anything
- [x] Update Ghost from 6.21.2 to 6.59.0
- [x] Rotate the MySQL credentials
- [x] Rotate every Ghost API key, password, and session
- [x] Fix the thing I found along the way that would have locked me out
- [x] Re-point the parts of my infrastructure that depended on the old keys
That fifth item is the post.
The Easy Part
I want to be honest about how boring this was, because boring is the whole payoff from March.
Back up first. The content volume is 186MB of themes and images, the database dumps to about 400KB gzipped:
# mysqldump straight out of the running container
docker exec mysql-db sh -c 'MYSQL_PWD=$MYSQL_ROOT_PASSWORD mysqldump -uroot \
--single-transaction --routines --triggers ghost' | gzip > ghost-db.sql.gz
# tar the content volume via a throwaway alpine container
docker run --rm -v ghost-content:/src:ro -v $PWD:/dst alpine \
tar czf /dst/ghost-content.tar.gz -C /src .
# then actually verify them, because an unverified backup is a wish
gzip -t ghost-db.sql.gz && tar tzf ghost-content.tar.gz > /dev/null
That last line matters more than it looks. A backup you haven't integrity-checked is a backup you're hoping about. It costs two seconds to know.
Then the upgrade, which was a one line diff:
services:
ghost:
- image: ghost:6.21.2
+ image: ghost:6.59.0
scp the compose file up, docker compose up -d, wait. Ghost ran its migrations for 107 seconds, chattering about renaming columns and adding indexes and one slightly ominous line about gift_links that turned out to be fine. Then:
[INFO] Database is in a ready state.
[INFO] Ghost booted in 109.857s
Twenty three posts intact. Site up. Admin up. That was the whole upgrade. In March this same operation was an evening project because I had to reverse engineer my own container first. This time it was a text edit and a file copy. The standard held, which is a genuinely nice feeling and also the last nice feeling in this post for a while.
I rotated the MySQL credentials the same way as last time, and I'll repeat the gotcha from the March post because it's still the thing that bites people: MYSQL_ROOT_PASSWORD and MYSQL_PASSWORD only do anything on first initialization of the data directory. Changing them in your .env does not change the password in the database. You ALTER USER inside MySQL first, then update the env file to match, then recreate. Other order, and your fresh container confidently presents a password the database has never heard of.
The Part Where I Almost Ruined My Afternoon
Ghost's advice was to update and consider resetting authentication credentials. There's an official flow for this, and it's genuinely well built. Settings, Advanced, Danger zone, "Reset all authentication." It rotates every API key, signs out every staff user, and forces a password reset. Exactly what you want after a batch of advisories that includes password hash disclosure and incomplete session invalidation.
I had my cursor over the button. And then, for no particularly noble reason, I decided to check how Ghost was configured to send email.
docker exec emir.fyi-blog sh -c 'cat /var/lib/ghost/config.production.json'
{
"mail": {
"transport": "Direct"
}
}
Direct means Ghost attempts to deliver mail itself, connecting straight to the recipient's mail server from wherever it happens to be running. Where it happens to be running, in my case, is a Docker host on a residential connection behind a Cloudflare tunnel. Gmail's opinion of unauthenticated mail arriving directly from a consumer IP block is not a warm one. That mail was going nowhere.
Now put those two facts next to each other.
"Reset all authentication" signs you out and requires a password reset. The password reset arrives by email. I have exactly one staff account. If I had clicked that button ten seconds earlier, I would have been signed out of a blog I could not sign back into, holding a reset email that was never going to be delivered.
Recoverable? Sure. I have database access, and a bcrypt hash written directly into the users table would have gotten me back in eventually. But "eventually," on a Tuesday afternoon, via manual surgery on my own auth table, is not how I wanted to spend the day. And it would have been entirely self-inflicted.
The worse realization came a minute later. Ghost 6 has Device Verification, which emails you a six digit code when you sign in from a new device. That feature had been silently useless on my install for its entire existence. I'd simply never signed in from a new enough device to find out. The blog had a broken dependency sitting quietly in the middle of its authentication flow, and the only thing keeping it invisible was that I'd never needed it.
This is the failure mode I actually want to write down. It was not a bug. Nothing was broken, crashed, or alerting. Every dashboard was green, the site served traffic all day, and Beszel was perfectly happy. It was a capability I had never exercised, which meant it had never had a chance to fail where I could see it. My monitoring watched whether Ghost was up. Nothing on earth was watching whether Ghost could send an email.
Fixing Mail First
I already run Brevo SMTP for other services in the homelab, so the fix was mostly plumbing. Ghost takes config as env vars with double underscores for nesting:
mail__transport=SMTP
mail__options__host=smtp-relay.brevo.com
mail__options__port=587
mail__options__secure=false
mail__options__auth__user=your-smtp-user
mail__options__auth__pass=your-smtp-password
mail__from=Your Blog <[email protected]>
Two notes. Set secure: false for port 587, which uses STARTTLS. true is for 465. And the from address has to be on a domain you've actually authenticated in your relay. My blog domain isn't verified in Brevo but another domain I own is, so mail goes out from there. Slightly inelegant, completely functional.
Then I verified it, in three escalating steps, because I'd just been reminded what assumptions are worth.
Step one, does the config exist. Trivially checkable and almost meaningless, but it rules out typos:
docker exec emir.fyi-blog env | grep '^mail__'
Step two, does the relay accept us. Ghost bundles nodemailer, so you can borrow it to test the handshake without sending anything:
docker exec emir.fyi-blog node -e "
const nm = require('/var/lib/ghost/current/node_modules/nodemailer');
nm.createTransport({
host: process.env.mail__options__host,
port: Number(process.env.mail__options__port),
secure: false,
auth: { user: process.env.mail__options__auth__user,
pass: process.env.mail__options__auth__pass }
}).verify()
.then(() => console.log('SMTP AUTH: OK'))
.catch(e => console.log('SMTP AUTH: FAILED', e.message));
"
SMTP AUTH: OK
Step three, does a human receive it. This is the only step that counts, and I want to be emphatic about why. I have a note in my TODO from a previous adventure with a different provider that shows 100 sent, 100 delivered, and not a single email ever arriving in the inbox. Not in spam. Not in promotions. Just gone. "The relay accepted it" and "a person read it" are separated by SPF, DKIM, DMARC, reputation scoring, and the inscrutable moods of large mail providers.
So I triggered a real Ghost password reset, through Ghost's own code path, and went and looked at an actual inbox:
curl -X POST http://localhost:2368/ghost/api/admin/authentication/password_reset \
-H 'Content-Type: application/json' \
-H 'X-Forwarded-Proto: https' \
-d '{"password_reset":[{"email":"[email protected]"}]}'
It arrived. Better still, signing back in triggered a Device Verification code, which also arrived, confirming that a feature I'd never successfully used in my life was now working.
Then I clicked the Danger zone button.
What The Reset Actually Did
Worth documenting, since the docs describe it in general terms and I had the database open anyway:
API keys rotated in the last 10 minutes: 9
Active sessions: 5 → 1
Posts: 23 (unchanged)
Staff account: active
The sign out was immediate and total. I landed on a page reading "Update your password. For security, you need to create a new password. An email has been sent to you with instructions."
That sentence is the whole post in miniature. Twenty minutes earlier, that page would have been a wall.
The Bill Comes Due For Baked-In Keys
One consequence I did see coming. I proxy a public JSON feed of my posts at /api/posts through Caddy, which injects a Ghost Content API key server side so callers don't have to:
@api_posts path /api/posts /api/posts/
handle @api_posts {
handle {
rewrite * /ghost/api/content/posts/?key={{ ghost_content_api_key }}&limit=all&{http.request.uri.query}
reverse_proxy 192.168.1.10:2368 {
header_up X-Forwarded-Proto https
}
}
}
"Rotate every API key" includes that one. The moment the reset completed, the feed started returning 401. Entirely expected, briefly satisfying to watch, and then I had to go fix it.
The fix itself was unglamorous. That key is templated into the Caddy config on my load balancer nodes, so it got the new value and Caddy got a reload. Ten minutes, no drama. If you inject an API key anywhere in your infrastructure, write down where, because "rotate all keys" is a bigger button than it looks and the reverse proxy will not tell you it's now serving a dead credential.
The part worth keeping is how I checked it afterward. Verify every branch, not just the one you were thinking about:
curl -o /dev/null -w "%{http_code}\n" "https://emir.fyi/api/posts" # 200
curl -o /dev/null -w "%{http_code}\n" "https://emir.fyi/api/posts?limit=1" # 200
curl -o /dev/null -w "%{http_code}\n" "https://emir.fyi/api/posts?filter=slug:x" # 200
My config has separate handlers for "caller passed a limit" and "caller didn't." Testing only the default path would have left the other one quietly broken with the old key, and I'd have found out from a stranger.
What I Learned
March's lesson was about deployment. I had a service with no compose file, and the CVE forced me to codify it. I ended that post pleased with myself for establishing a standard.
The standard worked. That part's real. The patch was a one line diff and a file copy, exactly as designed.
But codifying deployment only answers "how do I change this thing." It says nothing about "can I recover this thing." Those feel adjacent and they are not. Every piece of my March work was about the deploy path, and the thing that nearly bit me was on the recovery path, which I had never walked, never tested, and never thought about.
The specific shape of it is worth internalizing. A broken deploy is loud. Containers crash loop, healthchecks go red, monitoring pages you. A broken recovery path is completely silent, because by definition you aren't using it. Mine had been broken for the entire life of the install and the only way I found out was by hesitating over a button for no reason.
So the question I'm adding to my list, for every service I run: if I locked myself out of this right now, what's the path back in, and when did I last confirm that path works? For Ghost the answer was email, and the answer to the second half was "never."
The blog is on 6.59.0 with fresh everything. But the actual repair today wasn't the version bump. It was noticing that the fire escape had been welded shut since the day I moved in.
Featured Image Prompt
I used this prompt to generate the featured image.
A dimly lit server room at night, a single rack glowing with soft blue status LEDs, all indicators green and healthy. In the foreground, a heavy steel emergency exit door stands slightly ajar with warm light spilling through it, but the door frame is crudely welded shut with thick industrial weld beads along its edge. A small brass envelope icon hangs from the door handle like a key tag. Cinematic lighting, shallow depth of field, moody teal and amber color grading, photorealistic, 16:9 aspect ratio.