Repository navigation
fix(db): import only checked local dumps, streamed over SSH - #9
Conversation
`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>
Bugbot couldn't run - usage limit reachedBugbot 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) |
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>
Bugbot couldn't run - usage limit reachedBugbot 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) |
There was a problem hiding this comment.
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.Sent by Cursor Security Agent: Security Reviewer
| return true, c.checkAllNames(c.tokens[name:]) | ||
| case kind == "TABLE" && verb == "ALTER": | ||
| return c.checkAlterTable(name, final) | ||
| case ddlObjects[kind] && verb == "CREATE": |
There was a problem hiding this comment.
🔒 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.
Reviewed by Cursor Security Reviewer for commit bf5b611. Configure here.
Bugbot couldn't run - usage limit reachedBugbot 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) |
Bugbot couldn't run - usage limit reachedBugbot 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) |
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>
* 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>




Summary
wpgo db importused to runwp 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) andsource.Now
wpgo db import <file|->:-for stdin. A path that does not exist locally is refused with a message saying server files are never imported, andhost:pathgets a "looks like a server path" error. The dump file name never reaches the remote command.DB_NAME(wp config get DB_NAME).scanDumpchecks it. The import sends this checked copy, so a file edited after the scan cannot change what gets imported.--yesand--ack-destructiveare set. That matches the destructive tiersafety.Classify("db","import")already had and the help text already showed, though the old import never enforced it.exportToLocal) to<backup dir>/<site>/<site>_DB_pre-import_<ts>_<rand>.sql, prints it, and aborts if the backup fails or is empty.wp db import -through a newAdapter.ExecStdinmethod (standard and WP Engine), which uses the existingSSHClient.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 ...(andsql import) is refused. Its only use was importing a server file.internal/mcpserverhas no import or restore tool (its tools are list_*, core_version, get_option, plugin activate/deactivate/update and update_option), so nothing changed there.exportToLocalnow 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,promptand 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 andwp db exportactually 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 containingsystem,\!,sourceoruseare 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;DELIMITERchanges.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
wp db exportoutput never contains it\!,\.,\r,\u,\T,\P,\e,\C,\d,\g, ...), at any position including inside/*! */. Only\-is allowed.\!runs a shell command,\.runs a file,\rand\uswitch database,\Tand\Pwrite or pipe output,\Cchanges how later bytes are read.\-is MariaDB's sandbox switch: mariadb-dump writes it on line 1, and it turns client commands off.system,source,connect,tee,pager,editorcharsetas the first word of any line, after optional whitespace, in any case, with LF or CRLF.)or comments. Quoted data spanning lines is not checked because the scanner is inside the quote there.use/USEnaming any database other than the site'sDB_NAME, either as a line command or as a statement (including inside/*! */).wp db exportwrites noUSE. A--databasesdump of the same database names that database, which is allowed.SET,INSERT,REPLACE,LOCK/UNLOCK TABLES,COMMIT,CREATE/DROPof TABLE/VIEW/TRIGGER/PROCEDURE/FUNCTION/EVENT,ALTER TABLE,CREATE/ALTER DATABASEof the site DB, orUSEof the site DB.quit/exit(would silently truncate the restore),promptand every other long-form client command, as well asSELECT,GRANT,CREATE USER,DROP DATABASE,PREPARE/EXECUTE(runs SQL built from strings that cannot be checked) andCALL.set autocommit=0andcommit, triggers, routines, views and events.OUTFILE/DUMPFILEoutside quotes, including in trigger bodies.db querypolicy.SETofsql_modeto anything except one quoted list withoutNO_BACKSLASH_ESCAPES, or a variable saved from@@sql_mode. Refused:CONCAT(...), numbers, adjacent literals, escaped letters.\r other_dbthe scanner reads as quoted data would run as a command.SQL_MODE='NO_AUTO_VALUE_ON_ZERO', quoted trigger modes, and@OLD_SQL_MODE/@saved_sql_modesaved from@@sql_mode.SETnamingbig5,cp932,gbk,gb18030orsjis.DELIMITERthat is not one token of punctuation (no letters, quotes,\,#,-,/,*), has extra arguments, or appears inside a statement.DELIMITER ;;andDELIMITER ;between statements./*!comment nested inside another, or a statement head over 64 KiB.No
--allow-unsafe-sqlor similar override exists.Remaining gaps
sql_modealready 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..import-*.sqlfile in the local backup dir; it is never written on the server.--dry-rundoes not readDB_NAME, so in dry-run anyUSEis refused.wp config get DB_NAMEandwp 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-lint0 issues, andgo test -raceall 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 containingsystem,\!,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 importwith a server file,-, flags,sqlalias 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 importis rebuilt so restores never use a dump file on the server: it only accepts a local path or-, refuseshost:pathand missing local files, then runs a guarded pipeline before touching the live database.The dump is staged and scanned with new
scanDump/dumpValueslogic that lexes like the mysql/mariadb clients and allowlists mysqldump-style SQL only (blocking client commands, cross-databaseUSE, unsafeSET,OUTFILE, non-literalINSERTbodies, etc.). After showing site/DB/backup paths, import proceeds only with--yesand--ack-destructive, takes a local pre-import backup via the existing export path, then streams the checked copy plus a session preamble over SSH intowp db import -through newAdapter.ExecStdin/RunContext.ExecWPStdin(standard and WP Engine).wpgo rawnow blocksdb import(and related paths) so server-side imports cannot bypass this flow. Docs andAGENTS.mddocument the scanner rules and local-only import behavior;exportToLocalaccepts acontextso 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.