Repository navigation
fix(backup): write database dumps outside the web root - #7
Conversation
`wpgo backup` (full-backup.sh) exported to "." in the WP path and
`wpgo db export <file>` ran `cd <wp_path> && wp db export '<file>'`, so
default and relative dumps landed in the publicly served docroot with
the default umask.
Both paths now write to ${WPGO_BACKUP_DIR:-$HOME/backups/wpgo} under
umask 077, with the dir chmod 0700 and the dump chmod 0600. The remote
shell resolves the target with `realpath -m` and exits 64 if it is
inside the WP path, ~/public_html or ~/www. Relative export paths
resolve under the backup dir; `db export -` still streams to stdout.
full-backup.sh reports the real absolute path in its JSON, and
`wpgo backup` now exits non-zero when the script reports an error.
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: 8df925a9-115b-492a-937e-53b8b774eaae) |
Jeremy's requirement: nothing on the server. `wpgo db export` and
`wpgo backup` now run `wp db export -` remotely and stream the dump over
SSH into ${WPGO_LOCAL_BACKUP_DIR:-$HOME/wpgo-backups}/<alias>/ on the
machine running wpgo (dirs 0700, file 0600). No file is written on the
server.
The dump goes to a temp file that is fsynced and renamed into place.
A transport error, non-zero remote exit, empty dump, or missing
mysqldump "-- Dump completed" trailer deletes the partial file and
returns an error; Ctrl-C cancels the stream the same way. Existing files
are never overwritten. wpgo prints the path, byte size and sha256
(or JSON with --json).
`db export <file>` now names a local file; `db export -` still streams
to stdout (now without buffering the whole dump in memory). The round-1
server-side behaviour (outside-webroot guard, umask 077, exit 64) and
full-backup.sh remain available only behind an explicit `--remote` flag,
which has no env var binding so it cannot be switched on implicitly.
SSHClient gains ExecStream and both adapters implement it, so dumps are
copied to disk as they arrive.
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: 3c5cbba7-73a2-4dd7-ad3f-b8ff4419853b) |
Owner rule: nothing on the server. Remove `--remote` from `db export` and `backup`, the server-write branch of wpcli.DBExport (export.go deleted), WPGO_BACKUP_DIR, and full-backup.sh (its only job was writing the dump on the server), along with their guard tests. saveDump now publishes with os.Link, which fails with EEXIST, so a file created at the destination during the stream is kept and the export errors. Default names add milliseconds and 6 random hex digits. Dump validation now requires the last non-empty line (after trimming whitespace/CR/LF) to start with "-- Dump completed", read from a bounded 4 KiB tail kept while streaming. A marker inside SQL data no longer passes. `db export -` runs the same checks (exit status, empty, trailer) and exits non-zero; README/help examples use umask 077 and note stdout cannot be rolled back. runScript returns an error on any non-zero remote exit for all script commands. Adds an ExecStream test against an in-process SSH server for stderr separation, remote exit status and writer failure. 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: ed4ee931-404e-42d6-82c6-3888d949b030) |
wpgo raw now quotes each argument, so the remote shell hands wp exactly the argv the guard checked: no expansion, redirection or command chaining. Before connecting, raw refuses: - db export / db dump unless the only file argument is '-' and every flag is a wp-cli global or an allowlisted mysqldump option (blocks --result-file, --tab and their prefixes) - export (WXR) - search-replace / db search-replace with any --export flag - db query SQL containing OUTFILE or DUMPFILE (also wpgo db query) The parser mirrors wp-cli's extract_assoc (--k=v, -x, --, @alias, mixed case) and also checks the "--flag value" reading. eval is unchanged; its help and the README say it can write server files and that backups go through wpgo db export. saveDump now returns temp-file cleanup failures, naming the leftover path, instead of discarding them. 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: d80956b6-3e04-4662-983f-c9ecaefe8adc) |
…arses Per-argument quoting broke charlie-hermes, which pre-quotes eval PHP with shlex.quote. raw again sends its args joined with spaces, byte-for-byte as before. The guard now parses that line as a POSIX shell would and refuses unquoted operators, redirections, newlines, $ and backtick expansion, and unbalanced quotes before checking the resulting argv. On WP Engine it also parses each word a second time and checks that layer, except for eval. It also normalizes the sql alias, refuses db cli/connect, --exec and --require outside eval and eval-file, mysqldump --defaults, and --tables or --exclude_tables entries that start with -, and skips runs of -- delimiters. 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: 4b7cc1cf-3b42-4dea-898c-088d3be4af45) |
raw now refuses unquoted *, ? and [ and an expansion-active ~ in both parse layers. The db subcommands that run mysql or mysqlcheck accept only a per-command flag allowlist, which blocks --tee, --pager, --init-command, --execute and --defaults* routes. db query SQL, raw or typed, is refused when it holds a mysql client command that runs or writes files. wpgo db query and wpgo db import share the raw check. 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: ad69213e-a1f8-48c9-8d84-a7b318e2c1e6) |


Incident
On 2026-10-06 we found database dumps that anyone could download from the docroot of 20 client WordPress sites. Two wpgo code paths wrote them there:
wpgo backuprunsfull-backup.shfrom the WP path throughExecWP. The script hard-codedEXPORT_DIR=".", so each dump landed in the docroot with the default umask (0644/0664).wpgo db export <file>builtcd <wp_path> && wp db export '<file>'. A relative file name, or no name at all, was written into the docroot.Behavior change
${WPGO_BACKUP_DIR:-$HOME/backups/wpgo}on the server. They run underumask 077, create the dir with mode 0700 (and chmod it 0700), and chmod the dump 0600.realpath -mand exits 64 if the target is inside the WP path (pwd -Pafter thecd),$HOME/public_html, or$HOME/www. That check catches..traversal,~/public_html/..., and a relative or in-docrootWPGO_BACKUP_DIR. All paths are shell-quoted, and paths with spaces are covered by tests.db export:<alias>_DB_<YYYYmmdd_HHMMSS>.sqlin the backup dir.~/path is allowed only outside the roots listed above.-still streams to stdout and writes no file on the server.db export refused (exit 64): refusing to write database export inside web root ....full-backup.shapplies the same guard. Its JSON reports the real absolute path, and a refusal is reported as{"status":"error", "error":"refusing backup target inside web root ..."}with exit 64.wpgo backupnow exits non-zero when the script reportsstatus: error. Before this change it printed "Backup failed" and exited 0, so a caller could not tell that the backup had failed.wpgo helpand the README examples now show the new default instead ofwpgo db export backup.sql.Possible break for users: a site whose WP path is
$HOMEitself now refuses the default backup dir, because~/backups/wpgois inside the WP path. Those sites needWPGO_BACKUP_DIRset outside the WP path on the server.Tests
internal/wpcli/export_test.goTestDBExportCommandContainsGuardasserts that the built command containsumask 077, therealpath -mresolution, the three-root check withexit 64, the 0700 mkdir, and the 0600 chmod.TestDBExportTargetsruns the built command withsh -cagainst a temporary$HOME, using a WP path that contains a space and a stubwp. Cases:~/backups/wpgo, with the file at 0600 and the dir at 0700. The test also asserts that no dump ever lands under the WP path...traversal, an absolute path in the WP path,~/public_html,~/www, a~/path, and a relativeWPGO_BACKUP_DIR.WPGO_BACKUP_DIRoverride both succeed.internal/scripts/full_backup_test.go:TestFullBackupTargetruns the embeddedfull-backup.shwith bash from a temporary WP path, using a stubwp. It checks the default dir, an override with a space, and refusals for an override inside the WP path, a relative override, and an override inside~/www. It parses the JSON and checks the absolute path and the file modes. Against the old script, all five cases fail.make build,make build-cli, andmake ci(fmt-check, golangci-lint with 0 issues,go test -race) all pass locally on Go 1.25.1.Not in this PR
db importstill resolves a relative path in the WP path, and wp-cli does not expand a literal~/. Importing a file from~/backups/wpgoneeds an absolute path.🤖 Generated with Claude Code
Note
High Risk
Changes backup/export behavior (local-only, breaking for workflows that expected server-side files) and tightens
raw/dbpass-through, which can block previously allowed wp-cli invocations.Overview
Database dumps now land only on the machine running wpgo.
db exportandbackuprun remotewp db export -, stream over SSH via newExecStreamplumbing, and write locally underWPGO_LOCAL_BACKUP_DIR(default~/wpgo-backups/<site>/) with 0700 dirs, 0600 files, temp+hard-link publish (no overwrites), SHA256 output, and mysqldump trailer validation. Failed or truncated streams drop partial files; Ctrl-C cancels in-flight exports.The embedded
full-backup.shpath is removed;backupshares the same streaming implementation.runScriptnow exits non-zero when embedded scripts fail.rawand typeddbcommands get guardrails so operators and agents cannot easily write dumps or exports on the server: shell-word parsing blocks operators/redirection/globs; wp-cli argv checks block server-sidedb export, WXRexport, search-replace--export,db cli/connect, risky mysql/mysqlcheck flags, and SQL that uses OUTFILE/DUMPFILE or mysql client file commands. WP Engine gets a second parse pass.eval/eval-filestay the intentional escape hatch. README and help document the new backup model andrawquoting.Reviewed by Cursor Bugbot for commit 4b57af9. Bugbot is set up for automated code reviews on this repo. Configure here.