I Found Three Vulnerabilities in My Own Plugin Before Shipping It

Every AJAX handler in my plugin had a nonce check. Every one of them also verified manage_options. I’d been deliberate about that from the start, and when I sat down to do a security pass before submitting to WordPress.org, I expected it to be a formality.

It wasn’t. I found two high-severity issues and one medium, and none of them were authorization failures. The gate was locked on every single endpoint. The problems were all downstream — in what the code did with the data after the checks had already passed.

That’s the thing I want to write down, because I don’t think I’d have articulated it before I went looking:

A capability check tells you who is asking. It tells you nothing about what they asked for.

All of this was found and fixed before the plugin was ever published. It shipped as version 1.1.2, with the fixes in the changelog, and no user was ever running the vulnerable code. I’m writing it up because the mistakes are ordinary ones, and I suspect they’re sitting in a lot of plugins right now.


1. SQL injection in the database repair module

My plugin scans for broken database tables — missing columns, collation mismatches — and offers one-click repair. Here’s what the repair did:

$sql = "ALTER TABLE `{$table_name}` ADD COLUMN `{$column}` {$expected_type}";
$wpdb->query( $sql );

All three interpolated values came from $_POST. All three had been run through sanitize_text_field().

And this is the misconception at the heart of it: sanitize_text_field() is not a security function for SQL. It strips tags, removes invalid UTF-8, collapses whitespace. It makes a string safe to display. It does absolutely nothing to make a string safe to execute as part of a query. I knew that in the abstract. I had still written this.

Worse, $expected_type wasn’t even backtick-wrapped. It was raw statement text, going straight into an ALTER TABLE. A crafted POST could alter any table in the database.

Why “it needs an admin and a nonce” wasn’t good enough

My first reaction was that this isn’t remotely exploitable. You need a valid nonce and manage_options. If an attacker already has an admin session, you have larger problems.

Two things changed my mind.

My plugin declares Network: true. On multisite, manage_options belongs to ordinary site administrators, who are not super admins. So this wasn’t “an admin can break their own site” — it was a path for a single-site admin to alter tables across an entire network. That’s privilege escalation.

Unprepared SQL carrying request data is something WordPress.org reviewers grep for directly. Even setting aside the real risk, this is a pattern that gets plugins rejected on sight, and rightly.

The fix: invert the trust

I couldn’t parameterize my way out. MySQL will not accept a prepared placeholder for a table or column name — that’s a hard limitation, not a gap in $wpdb->prepare(). Identifiers have to be interpolated.

So the answer is allowlisting, and specifically making the request supply as little as possible:

The request now sends only which detected issue to fix — an identifier, not a table name. The repair method then looks that issue up in the scanner’s known-schema registry and takes the table, the column, and the column type from the registry, discarding whatever the request sent.

The practical effect is that a repair can only ever add a column that WooCommerce, Yoast, Rank Math, Redirection, or Flamingo actually declares in a schema I control. There is no input that produces an arbitrary ALTER.

On top of that:

  • Table names are verified against SHOW TABLES and must carry this site’s prefix
  • Collation values are verified against SHOW COLLATION — what the server actually supports
  • Multisite repairs now require a super administrator
  • Validation is enforced in two places — the AJAX handler and the repair method itself — so the module is safe even if something else in the codebase calls it later

You’ll still see ALTER TABLE with interpolated identifiers in my code. That’s unavoidable. What matters is that those identifiers can no longer originate from a request.


2. Arbitrary file read through the debug log path

The plugin reads and parses debug.log. The path is a setting, because not everyone keeps it in the default location.

The setting was saved with sanitize_text_field() and then handed directly to file_get_contents().

An administrator could set that path to wp-config.php and read the contents back through the plugin’s own UI. Database credentials, auth salts, all of it, rendered neatly in an admin panel.

Same reasoning as above about why “admin-only” isn’t sufficient — on multisite, and in any situation where an admin account is lower-trust than the server itself, this is a real disclosure path. It’s also exactly the sort of thing that turns a minor compromise into a total one.

The fix:

  • Path must resolve inside wp-content
  • File must have a .log extension
  • Directory traversal sequences rejected outright
  • Validated on save and again on read — because a value can end up in the options table through a route that didn’t go through my save handler

That last point generalizes. If you validate only at the point of entry, you’re assuming your entry point is the only one. Validate again at the point of use.


3. CSV formula injection from the 404 log

This is the one I’d least like to have left in, and the one I’d never have thought of on my own.

The plugin logs 404s: request URI, user agent, referrer. You can export that log to CSV to look at it in a spreadsheet.

Every one of those three fields is attacker-controlled by anyone who can send a request to your site. No authentication. No account. Just hit a URL that doesn’t exist, with whatever user agent string you like.

They went into the CSV unescaped.

A visitor sending a user agent of:

=cmd|'/c calc'!A1

produces a cell that Excel interprets as a formula. When you — the site owner, the person who trusted the export enough to download it — open the file, it executes. The payload is the standard published proof-of-concept and it’s benign.

That’s an unauthenticated path from a stranger hitting your website to code running on the machine of whoever opens the log. The vulnerability isn’t in my plugin’s runtime at all. It’s in the artifact my plugin produces.

The fix is to neutralize any cell beginning with a character a spreadsheet treats as the start of a formula: =, +, -, @, and also tab and carriage return, which some spreadsheet software will consume before evaluating what follows.

I routed every cell — headers included — through a single escaping method rather than escaping at each call site, because the failure mode of “one export path forgot to escape” is exactly how this happens.

Worth sitting with the general shape: if your plugin logs untrusted input and then exports it, the export format is part of your attack surface. CSV is the obvious case. It’s not the only one.


4. The small stuff

Two minor issues, included because they’re the kind of thing that turns into a support ticket rather than a CVE:

Settings save could fatal on PHP 8. A scalar sanitizer receiving an array argument is a fatal error in PHP 8, where PHP 7 would have shrugged. Any field could be made to arrive as an array by crafting the POST. Now non-scalar values are skipped rather than passed through.

The flood threshold could be set to 0. Which meant the 404 flood detector would alert on every single 404 — an email bomb aimed at the site owner, triggerable by a typo. Numeric settings are now clamped to sane ranges.


What I actually took away from this

Authorization is not validation. I had done the part that’s easy to remember — check the nonce, check the capability — and treated it as the whole job. It isn’t. Those checks establish who is asking. Everything after them still has to treat the request as hostile.

sanitize_text_field() is not a security function. It’s a display sanitizer. It is not escaping for SQL, not validation for file paths, not neutralization for spreadsheets. I’d bet a lot of plugins use it as a general-purpose “make this safe” call, because the name is genuinely misleading.

Let the request choose from a list; never let it supply the value. The SQL fix wasn’t about escaping better. It was about reducing what the request was allowed to say. Sending an issue ID and looking everything else up server-side made an entire class of attack structurally impossible rather than merely filtered.

Validate at the point of use, not just the point of entry. Options can be written by migrations, by WP-CLI, by other plugins, by a developer in phpMyAdmin. Your save handler is not a chokepoint.

Think about what your plugin produces, not just what it runs. Exports, emails, generated files — those are outputs that land in software you don’t control, and untrusted data flows into them just the same.

And: audit before you’re forced to. WordPress.org’s reviewers would have caught the SQL issue. Probably the file read too. Almost certainly not the CSV one. Finding them myself meant fixing them properly instead of scrambling to satisfy a reviewer, and it meant the version that went live was the first version anyone ever ran.


Canary Site Monitor is free on WordPress.org. The submission process itself was its own ordeal, which I’ve written about separately.

Leave a Comment