The fix that survives the update
6 min read
A while back a nonprofit early-childhood center asked me to look at their public website. A WordPress site. Not a big one. A handful of pages, a contact form, a photo of the building, and the phone number parents call when they need to.
I want to write about one finding from that engagement, because the finding itself was ordinary and the part after it was not. This is a post about the after.
The finding
The theme they were running let you set a few contact details in the WordPress
Customizer. Email address, phone number, a couple of social links. Normal stuff.
Those values were then dropped straight into the site’s header and footer as
href attributes.
Straight in. No sanitize callback when the setting was registered, no escaping when the value was echoed. The theme trusted whatever was in the database and the database trusted whatever had been typed into the box.
Which means anyone with Customizer access could type something that was not a URL into the “Facebook link” field, and every page on the site would render it into a link, forever. A classic stored cross-site scripting path. Not the most glamorous bug in the world, but it lived on every page, it persisted, and the input it needed was a text box in the admin.
“But you need Customizer access.” Sure. That is one compromised password, one shared laptop, or one former volunteer whose account was never removed. At a small nonprofit, all three of those are more of a when than an if. I know this because on the same engagement I found a departed employee’s address still quietly receiving every job application the site collected. The people were gone. The plumbing had not noticed.
The obvious fix, and why I didn’t do it
The obvious fix is about four lines. Open the theme, find where the values are
echoed, wrap them in esc_url() and esc_attr(), save, done. I could have had
that patched before the coffee got cold.
The problem is what happens next Tuesday.
That theme is somebody else’s product. It gets updates. WordPress will one day show the office manager a little orange badge that says an update is available, and they will click it, because they have been told (correctly!) that keeping things updated is how you stay safe. The update replaces every file in the theme directory with the vendor’s copy.
My four lines are gone. The hole is back. Nobody knows, because nothing looks different. The site renders exactly the same with the fix as without it.
That is the trap with patching upstream code directly. The patch is real, the patch is correct, and the patch has a shelf life nobody can see. It is the same mistake as a detection keyed to a hardcoded string: it works right up until someone changes something unrelated, and then it silently stops working with no signal that it did.
So the actual requirement was not “fix the XSS”. It was “fix the XSS in a way that a non-technical office manager clicking Update cannot undo”.
Where the fix went instead
WordPress has a mechanism for exactly this: a child theme. It sits on top of the parent, overrides what it needs to, and survives parent updates because it is a separate directory the updater never touches.
The values in question flow through get_theme_mod() on their way to the page,
and WordPress runs each one through a filter named theme_mod_{$name} before it
hands it back. So the child theme’s entire security fix looked, more or less,
like this:
add_filter( 'theme_mod_contact_email', 'sanitize_email' );
add_filter( 'theme_mod_contact_phone', 'sanitize_text_field' );
add_filter( 'theme_mod_facebook_url', 'esc_url' );
add_filter( 'theme_mod_instagram_url', 'esc_url' );
That is it. The parent theme still echoes whatever it is given. It is just that
what it is given has already had its teeth pulled. A javascript: scheme
becomes an empty string. A quote that tries to break out of the attribute gets
encoded. The vendor’s files are byte-for-byte what the vendor shipped, so the
next update installs cleanly and changes nothing about the fix.
Zero upstream files modified. That number ended up on the project card for this engagement, and it is the number I am proudest of, which is a strange thing to say about a zero.
The part that became the rest of the job
Once the child theme existed, it became the place everything else went too.
The client’s real problem was never the XSS. It was that they could not change anything on their own site without calling someone. Every photo, every tuition rate, every staff bio was hardcoded in a template somewhere. Which meant that every time reality changed, the site did not, and the site slowly became a description of a school that used to exist.
So the child theme grew into a full front end where every piece of content is a Customizer field. The office updates a rate, the page updates. Staff member leaves, delete the entry, the card is gone. And an empty field renders nothing at all, rather than a placeholder, so the site degrades to less content instead of to a broken layout. No “lorem ipsum” testimonial can ever reach production, because the testimonial carousel does not render until a real one is entered.
Twenty-seven releases later, they run the site themselves. That is the deliverable.
What I took from it
Three things, mostly.
A fix has a lifetime, and you are responsible for it. Anyone can patch a bug. The question is who is going to be around when the environment moves underneath the patch. If the answer is “nobody”, the fix needs to be somewhere the environment cannot reach.
The finding is the beginning of the engagement, not the end. I could have sent a tidy PDF that said “stored XSS, severity high, recommend escaping output” and been correct. The client would have had no way to act on it. A report nobody can act on is an expensive way of saying “good luck”. I said something similar about red team reports a while back, and I think it is the same principle in a different costume.
Small clients have the same bugs as big ones, with fewer people to catch them. The XSS was not exotic. The stale email address was not exotic. What made them dangerous was that there was no one whose job it was to notice, and there was never going to be. Building the fix into the structure of the site, rather than into a process someone has to remember, was the only version that was going to hold.
The next time that theme updates, four lines in a directory the updater never touches will still be doing their job. Nobody at the school will know. That is the point.