Taking a leak... because fixing one clearly just broke my admin.
What this is about. One morning we found that my CMS's page list handed every draft and scheduled post to anyone who asked. We fixed it. That evening I couldn't see my own drafts any more.
Why. A security fix can be correct and still break things, because something else was quietly relying on the hole. That's worth knowing before you close one.
The leak
The API behind my sites has an endpoint that lists pages. Long ago, "pages" meant the public ones, so the endpoint simply returned all of them. Over time, pages came to include blog drafts and posts scheduled for next week, and nobody went back to the endpoint. Anyone with the URL could read my unpublished posts, full text included.
The fix was small. Visitors now get only what's live, and editors still get everything. A test checks both cases, and it went out to every site the same day.
The evening
A few hours later I was in the admin, wanting to move some publish dates, and wrote to my AI:
There are 29 posts on idunworks and they all show "Published". maybe that pill is wrong?
The status pill wasn't wrong. Every post on the list really was published, because the list no longer had any drafts in it. And without the drafts, I couldn't change their dates.
One missing line
The admin page that lists posts is rendered on the server. It asks the API for pages with a plain fetch that goes straight to the backend.
When my browser talks to the API, a proxy in between adds my login to every request. A server-side fetch skips that proxy, so it has to add the login itself. This one never did:
const response = await fetch(url, { headers: { accept: 'application/json' } });
So the API saw an anonymous visitor and gave it the live posts only, which was correct. Before the morning's fix, an anonymous visitor got everything, so the missing login made no difference. The leak had been doing the admin's job.
The fix is three lines: read the login cookie, send it along.
const headers: Record<string, string> = { accept: 'application/json' };
const token = cookies.get(AUTH_COOKIE);
if (token) headers.Authorization = `Bearer ${token}`;
The same file was copied into all six of my sites, so it was six fixes. One site also keeps that list in a short cache, so the cache now keeps separate copies for editors and visitors. Otherwise a visitor could be handed the editors' list, drafts and all, which would be the original leak coming back the other way.
Then the AI went looking for the same pattern elsewhere and found it in the content overview, another admin page that had also been relying on the leak. Fixed as well.
What happened, how we fixed it, and how else it could be done
The leak: the page list returned drafts to anyone. Visitors now get live pages only.
The side effect: server-side admin pages never sent the login, so they lost the drafts too. Each one now sends the token.
Other ways to do it:
Option | Better at | Costs |
|---|---|---|
Add the token in each page (what we did) | Small, obvious, quick to ship | Easy to forget in the next page you write |
One shared fetch helper that always adds it | One place to get right | Every page has to use the helper, which takes discipline |
SvelteKit's | Nobody can forget it | It's global, so check it can never attach a login to calls going anywhere else |
A test that logs in as an admin and expects drafts | Catches this exact break | Needs a test login and data, which takes some setting up |
I'll probably end up with the hook plus the test. For the evening, three lines were the right size.
Before you close a hole
Search for everything that calls the endpoint you're tightening, servers included.
For each caller, ask who it is: a person, or the server acting for one?
Test as a visitor and as yourself, after the fix.
When a word in your code starts meaning more ("pages" now including drafts), check every place that used the old meaning.
My drafts are back, publish dates and all. The ones that are already out are on the Ship Log.