Skip to content

fix(db): import only checked local dumps, streamed over SSH - #9

Merged
itsjeremyjohnson merged 7 commits into
mainfrom
fix/db-import-stream-local
Oct 6, 2026
Merged

itsjeremyjohnson merged 7 commits into
mainfrom
fix/db-import-stream-local

Conversation

@itsjeremyjohnson

@itsjeremyjohnson itsjeremyjohnson commented Oct 6, 2026 •

Copy link
Copy Markdown
Owner

Summary

wpgo db import used to run wp db import <file> on the server. That needed a dump stored on the server, which breaks the no-backups-on-servers rule. It also let the mysql client run anything in the file, including client commands such as \! (shell) and source.

Now wpgo db import <file|->:

  1. Takes a file on this machine or - for stdin. A path that does not exist locally is refused with a message saying server files are never imported, and host:path gets a "looks like a server path" error. The dump file name never reaches the remote command.
  2. Reads the site's DB_NAME (wp config get DB_NAME).
  3. Copies the dump into a 0600 staging file in the 0700 local backup dir while scanDump checks it. The import sends this checked copy, so a file edited after the scan cannot change what gets imported.
  4. Prints the site, database, dump and pre-restore backup path, then stops unless both --yes and --ack-destructive are set. That matches the destructive tier safety.Classify("db","import") already had and the help text already showed, though the old import never enforced it.
  5. Saves a fresh local backup through the PR fix(backup): write database dumps outside the web root #7 export path (exportToLocal) to <backup dir>/<site>/<site>_DB_pre-import_<ts>_<rand>.sql, prints it, and aborts if the backup fails or is empty.
  6. Streams the staged copy over SSH into wp db import - through a new Adapter.ExecStdin method (standard and WP Engine), which uses the existing SSHClient.ExecWithStdin. Nothing is written on the server. If the import fails, the error names the backup to restore from.

Other changes:

  • wpgo raw db import ... (and sql import) is refused. Its only use was importing a server file.
  • MCP server: internal/mcpserver has no import or restore tool (its tools are list_*, core_version, get_option, plugin activate/deactivate/update and update_option), so nothing changed there.
  • exportToLocal now takes a context so the pre-restore backup shares the import's Ctrl-C handling.

Why the scanner uses an allowlist

The mysql client recognizes long-form commands (system, source, quit, prompt and about 30 more, which differ between MySQL and MariaDB versions) where a statement starts. It recognizes backslash commands anywhere outside quotes and comments. A denylist of command names would have to track every client version. An allowlist of the statement types mysqldump, mariadb-dump and wp db export actually write excludes every long-form command at a statement start by construction, including ones added in the future. Backslash commands are refused by position rather than by name.

To be precise about what counts as data, the scanner follows the client's own lexing:

  • ', " and ` quotes with backslash escapes, so INSERT values containing system, \!, source or use are data;
  • # and -- comments, and lines starting with # or --, which the client drops whole;
  • /* */ comments;
  • /*!NNNNN */, /*M!NNNNNN */ and /*+ */, whose contents the client treats as SQL, so they are scanned as SQL;
  • DELIMITER changes.

It reads with a 64 KiB bufio buffer and keeps at most a 64 KiB statement head, so memory does not grow with dump size or line length. The benchmark shows about 480 MB/s.

Refused forms and why each is safe to refuse

Refused What the client or server would do Why mysqldump / wp db export output never contains it
A backslash outside quotes and comments (\!, \., \r, \u, \T, \P, \e, \C, \d, \g, ...), at any position including inside /*! */. Only \- is allowed. \! runs a shell command, \. runs a file, \r and \u switch database, \T and \P write or pipe output, \C changes how later bytes are read. mysqldump only writes backslashes inside quoted values, as escapes. \- is MariaDB's sandbox switch: mariadb-dump writes it on line 1, and it turns client commands off.
system, source, connect, tee, pager, edit or charset as the first word of any line, after optional whitespace, in any case, with LF or CRLF. Shell command, run a file, reconnect, write a file, pipe through a shell, run an editor, change the charset. mysqldump lines start with SQL keywords, backtick-quoted column names, ) or comments. Quoted data spanning lines is not checked because the scanner is inside the quote there.
use / USE naming any database other than the site's DB_NAME, either as a line command or as a statement (including inside /*! */). Later statements write to another database. Plain wp db export writes no USE. A --databases dump of the same database names that database, which is allowed.
Any statement whose head is not SET, INSERT, REPLACE, LOCK/UNLOCK TABLES, COMMIT, CREATE/DROP of TABLE/VIEW/TRIGGER/PROCEDURE/FUNCTION/EVENT, ALTER TABLE, CREATE/ALTER DATABASE of the site DB, or USE of the site DB. Covers quit/exit (would silently truncate the restore), prompt and every other long-form client command, as well as SELECT, GRANT, CREATE USER, DROP DATABASE, PREPARE/EXECUTE (runs SQL built from strings that cannot be checked) and CALL. These are the statement types mysqldump 8.x and mariadb-dump 10/11 write, including set autocommit=0 and commit, triggers, routines, views and events.
OUTFILE / DUMPFILE outside quotes, including in trigger bodies. Writes a file on the database server. mysqldump never writes these outside data. This matches the existing db query policy.
SET of sql_mode to anything except one quoted list without NO_BACKSLASH_ESCAPES, or a variable saved from @@sql_mode. Refused: CONCAT(...), numbers, adjacent literals, escaped letters. With NO_BACKSLASH_ESCAPES on, the client ends quotes at different places, so a \r other_db the scanner reads as quoted data would run as a command. mysqldump writes SQL_MODE='NO_AUTO_VALUE_ON_ZERO', quoted trigger modes, and @OLD_SQL_MODE / @saved_sql_mode saved from @@sql_mode.
SET naming big5, cp932, gbk, gb18030 or sjis. In these charsets byte 0x5c can be the second byte of a character, so the client and the scanner disagree about quotes. WordPress dumps use utf8/utf8mb4/latin1.
A DELIMITER that is not one token of punctuation (no letters, quotes, \, #, -, /, *), has extra arguments, or appears inside a statement. The scanner would split statements differently from the client. mysqldump writes only DELIMITER ;; and DELIMITER ; between statements.
A /*! comment nested inside another, or a statement head over 64 KiB. Cannot be checked in constant memory. Not written by mysqldump.

No --allow-unsafe-sql or similar override exists.

Remaining gaps

  • If a server's default sql_mode already includes NO_BACKSLASH_ESCAPES or its default client charset is one of the multibyte sets above, the lexing assumption fails. mysqldump output would not restore correctly on such a server anyway.
  • The staged copy needs free local disk equal to the dump size while the import runs. It is deleted afterwards. A SIGKILL can leave a .import-*.sql file in the local backup dir; it is never written on the server.
  • --dry-run does not read DB_NAME, so in dry-run any USE is refused.
  • Not run against any real site, as instructed. wp config get DB_NAME and wp db import - through the real adapters are covered only by the fake-SSH transport test and the fake restore target, not by a live wp-cli.

Testing

  • make ci (MISE_GO_VERSION=1.25.1): fmt-check, golangci-lint 0 issues, and go test -race all pass. The baseline before the change also passed, so there were no pre-existing failures.
  • dumpscan_test.go: accepts a realistic mysqldump 8 sample (conditional comments, DELIMITER ;; trigger and procedure, INSERT strings containing system, \!, source, use other_db, #, /*, DELIMITER $$), the same sample with CRLF, a mariadb-dump sample (sandbox line, view, USE, CREATE DATABASE, REPLACE, commit), and a ~1 MiB single-line INSERT. Every case also runs with one byte per Read. It refuses 48 cases covering each forbidden form with leading whitespace, CRLF, mid-line, after a string, inside /*! */ and /*+ */, after a 1 MiB line, and the line-start -- comment trick. Mutation checks: disabling each rule makes its cases fail.
  • db_import_test.go: the backup runs before the import and the imported bytes equal the dump; a failed or empty backup means no import; without confirmation only DB_NAME is read and the site, database and backup path are shown; an unsafe dump is refused before any backup; an import failure names the backup; server paths, host:path, flag-like names and non-regular files are refused before any config or SSH.
  • internal/ssh: the in-process fake SSH server receives a ~3 MB stdin stream intact, and a reader error ends remote stdin after only the bytes read.
  • raw_guard_test.go: raw db import with a server file, -, flags, sql alias or a leading global flag is refused.

🤖 Generated with Claude Code


Note

High Risk
Destructive database restore with new security-critical dump parsing; a scanner bug could block valid dumps or miss malicious input, and partial import failures can leave the DB inconsistent despite naming the backup path.

Overview
wpgo db import is rebuilt so restores never use a dump file on the server: it only accepts a local path or -, refuses host:path and missing local files, then runs a guarded pipeline before touching the live database.

The dump is staged and scanned with new scanDump / dumpValues logic that lexes like the mysql/mariadb clients and allowlists mysqldump-style SQL only (blocking client commands, cross-database USE, unsafe SET, OUTFILE, non-literal INSERT bodies, etc.). After showing site/DB/backup paths, import proceeds only with --yes and --ack-destructive, takes a local pre-import backup via the existing export path, then streams the checked copy plus a session preamble over SSH into wp db import - through new Adapter.ExecStdin / RunContext.ExecWPStdin (standard and WP Engine).

wpgo raw now blocks db import (and related paths) so server-side imports cannot bypass this flow. Docs and AGENTS.md document the scanner rules and local-only import behavior; exportToLocal accepts a context so pre-import backups share Ctrl-C handling with import.

Reviewed by Cursor Bugbot for commit d46de47. Bugbot is set up for automated code reviews on this repo. Configure here.

`wpgo db import` ran `wp db import <file>` on the server, so it needed a
dump stored on the server and ran whatever the mysql client found in it,
including client commands such as \! (shell) and source.

db import now takes a file on this machine or '-' for stdin. It copies
the dump into a private staging file next to the local backups while a
streaming scanner checks it, shows the site, DB_NAME and pre-restore
backup path, requires --yes --ack-destructive, saves a fresh local
backup (aborting if it fails or is empty), and streams the checked copy
into `wp db import -`. Nothing is written on the server.

`wpgo raw db import` is refused: it could only import a server file.
The MCP server has no import or restore tool.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@cursor

cursor Bot commented Oct 6, 2026

Copy link
Copy Markdown

Bugbot couldn't run - usage limit reached

Bugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit.

A user or team admin can review and increase usage limits in the Cursor dashboard.

(requestId: b1cf0cfc-efe8-4483-939f-06c82c864e02)

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale comment

Agentic security review found three issues in the new dump scanner: two HIGH parser mismatches with the mysql client that can skip client commands, and one MEDIUM gap in database-name restriction for INSERT/TABLE statements.

Open in Web View Automation 

Sent by Cursor Security Agent: Security Reviewer

Comment thread internal/cmd/dumpscan.go Outdated
Comment thread internal/cmd/dumpscan.go Outdated
Comment thread internal/cmd/dumpscan.go Outdated
itsjeremyjohnson and others added 2 commits October 6, 2026 13:15
Refuse the dump constructs that let the scanner, the mysql/mariadb client
and the server disagree about where quotes, comments and statements end:

- a backslash inside a backtick-quoted name (neither client nor server
  treats it as an escape)
- /*+ */ optimizer hints, inside which quotes are not read
- inside /*! */ and /*M! */: comments, delimiters, and quotes (escaped or
  not) that run past */, since a server older than the comment's version
  skips it without reading quotes
- a DELIMITER line after a /*! */ comment the client keeps in its statement
- lines starting with -- and a non-space, which mysql reads as code and the
  scanner used to drop
- := outside quotes, user variables set from anything but @@name, and
  restores from a user variable that was not saved from the same session
  variable or that any other statement mentions (trigger and routine
  bodies included, so a later trigger cannot rewrite it)
- sql_mode values outside an allowlist that excludes NO_BACKSLASH_ESCAPES,
  ANSI_QUOTES and the combination modes containing it
- client character set DEFAULT, numeric ids, and unsafe multibyte sets
- names qualified with a database other than the target, and ALTER TABLE
  other than {DISABLE|ENABLE} KEYS

Fix the session state instead of assuming it: the import stream now starts
with SET SESSION sql_mode='NO_AUTO_VALUE_ON_ZERO' and SET NAMES utf8mb4, and
wp db import gets --default-character-set=utf8mb4, so a server default
sql_mode with NO_BACKSLASH_ESCAPES cannot end quotes early.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@cursor

cursor Bot commented Oct 6, 2026

Copy link
Copy Markdown

Bugbot couldn't run - usage limit reached

Bugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit.

A user or team admin can review and increase usage limits in the Cursor dashboard.

(requestId: 4a0def46-1391-479f-bfdd-c80acdb82d5e)

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale comment

Agentic security review for the latest head: prior -- comment, backtick-escape, and top-level qualifier issues are addressed. One remaining high-severity finding is that CREATE TRIGGER/EVENT/ROUTINE bodies are accepted after the object name is checked, so the dump scanner\u2019s extra-database and statement allowlists do not apply to those bodies.

Open in Web View Automation 

Sent by Cursor Security Agent: Security Reviewer

Comment thread internal/cmd/dumpscan.go
return true, c.checkAllNames(c.tokens[name:])
case kind == "TABLE" && verb == "ALTER":
return c.checkAlterTable(name, final)
case ddlObjects[kind] && verb == "CREATE":

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Agentic Security Review
Severity: HIGH

Once checkDDL sees CREATE plus an allowed object kind, checkObjectName / checkTriggerTable marks the statement accepted. The rest of that statement is no longer run through checkHead, so qualifier, DROP DATABASE, GRANT, and PREPARE refusals never apply to trigger, event, procedure, or function bodies (and CREATE TABLE options after the name are likewise unchecked).

scanDump therefore accepts dumps that a later INSERT can fire, for example CREATE TRIGGER … FOR EACH ROW DELETE FROM other_db.wp_users, or CREATE EVENT … DO DROP DATABASE other_db. Bare OUTFILE/DUMPFILE tokens in those bodies are still caught; SQL in prepared-statement strings is not. Top-level INSERT INTO other_db… and DROP DATABASE are refused, so this is a bypass of the site-database and dump-like-SQL controls this scanner claims to enforce.

Impact: On hosts where the WordPress MySQL user can reach more than one schema (or has EVENT/FILE/routine privileges), a dump imported with --yes --ack-destructive can still change other databases or write server files after the operator has acknowledged a site-scoped restore.

Fix in Cursor Fix in Web

Reviewed by Cursor Security Reviewer for commit bf5b611. Configure here.

@cursor

cursor Bot commented Oct 6, 2026

Copy link
Copy Markdown

Bugbot couldn't run - usage limit reached

Bugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit.

A user or team admin can review and increase usage limits in the Cursor dashboard.

(requestId: ee10eb58-0cc5-446f-80df-dcd91b284495)

@cursor

cursor Bot commented Oct 6, 2026

Copy link
Copy Markdown

Bugbot couldn't run - usage limit reached

Bugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit.

A user or team admin can review and increase usage limits in the Cursor dashboard.

(requestId: eb6719ed-4ba4-44c1-8f1f-aee0f859238e)

@itsjeremyjohnson
itsjeremyjohnson merged commit 9ac16e3 into main Oct 6, 2026
4 checks passed
itsjeremyjohnson added a commit that referenced this pull request Oct 12, 2026
Main (#9) now refuses `wp db import` in checkRawArgs before the stdin
check runs, so these cases are still refused with nothing sent, under
that message.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
itsjeremyjohnson added a commit that referenced this pull request Oct 12, 2026
* fix(raw): forward piped stdin to wp and refuse "-" without input

`wpgo raw -- post update <id> -` ran the remote wp with no stdin. wp read
EOF for "-", reported Success and saved an empty post body.

raw now forwards local stdin to wp when it is piped or redirected, through
a new Adapter.ExecWithStdin backed by SSHClient.ExecWithStdin. When a "-"
argument makes wp read stdin and stdin is a terminal, /dev/null or empty,
raw refuses before connecting. db export and db dump keep "-" as stdout.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(raw): forward stdin only for "-", never to wp db, across both parses

Piped SQL reached `wpgo raw -- db query` unchecked, so INTO OUTFILE got past
the guard that refuses it in arguments. raw now refuses "-" on every db
subcommand except export and dump, and db query with no SQL or --execute.

On WP Engine an argument like "'-'" becomes "-" only after the gateway's
second parse, so raw let it through with empty stdin. Dash detection now
uses the same parse layers as checkRawArgs.

raw forwards stdin only when wp reads it for "-", and dry-run prints the
command without reading stdin.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* test(raw): expect main's db import refusal for piped db import cases

Main (#9) now refuses `wp db import` in checkRawArgs before the stdin
check runs, so these cases are still refused with nothing sent, under
that message.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant