Skip to content

fix(backup): write database dumps outside the web root - #7

Merged
itsjeremyjohnson merged 6 commits into
mainfrom
fix/backup-outside-webroot
Oct 6, 2026
Merged

itsjeremyjohnson merged 6 commits into
mainfrom
fix/backup-outside-webroot

Conversation

@itsjeremyjohnson

@itsjeremyjohnson itsjeremyjohnson commented Oct 6, 2026 •

Copy link
Copy Markdown
Owner

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 backup runs full-backup.sh from the WP path through ExecWP. The script hard-coded EXPORT_DIR=".", so each dump landed in the docroot with the default umask (0644/0664).
  • wpgo db export <file> built cd <wp_path> && wp db export '<file>'. A relative file name, or no name at all, was written into the docroot.

Behavior change

  • Both paths now write to ${WPGO_BACKUP_DIR:-$HOME/backups/wpgo} on the server. They run under umask 077, create the dir with mode 0700 (and chmod it 0700), and chmod the dump 0600.
  • The remote shell resolves the final target with realpath -m and exits 64 if the target is inside the WP path (pwd -P after the cd), $HOME/public_html, or $HOME/www. That check catches .. traversal, ~/public_html/..., and a relative or in-docroot WPGO_BACKUP_DIR. All paths are shell-quoted, and paths with spaces are covered by tests.
  • db export:
    • With no file, it writes <alias>_DB_<YYYYmmdd_HHMMSS>.sql in the backup dir.
    • A relative file resolves under the backup dir, not the WP path.
    • An absolute or ~/ path is allowed only outside the roots listed above.
    • - still streams to stdout and writes no file on the server.
    • A refusal returns db export refused (exit 64): refusing to write database export inside web root ....
  • full-backup.sh applies 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 backup now exits non-zero when the script reports status: error. Before this change it printed "Backup failed" and exited 0, so a caller could not tell that the backup had failed.
  • wpgo help and the README examples now show the new default instead of wpgo db export backup.sql.

Possible break for users: a site whose WP path is $HOME itself now refuses the default backup dir, because ~/backups/wpgo is inside the WP path. Those sites need WPGO_BACKUP_DIR set outside the WP path on the server.

Tests

  • internal/wpcli/export_test.go
    • TestDBExportCommandContainsGuard asserts that the built command contains umask 077, the realpath -m resolution, the three-root check with exit 64, the 0700 mkdir, and the 0600 chmod.
    • TestDBExportTargets runs the built command with sh -c against a temporary $HOME, using a WP path that contains a space and a stub wp. Cases:
      • the default name and relative names (including one with a space) land in ~/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.
      • these targets are refused with exit 64 and no file is written: a .. traversal, an absolute path in the WP path, ~/public_html, ~/www, a ~/ path, and a relative WPGO_BACKUP_DIR.
      • an absolute path outside the web root and a WPGO_BACKUP_DIR override both succeed.
  • internal/scripts/full_backup_test.go: TestFullBackupTarget runs the embedded full-backup.sh with bash from a temporary WP path, using a stub wp. 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, and make ci (fmt-check, golangci-lint with 0 issues, go test -race) all pass locally on Go 1.25.1.

Not in this PR

  • db import still resolves a relative path in the WP path, and wp-cli does not expand a literal ~/. Importing a file from ~/backups/wpgo needs an absolute path.
  • Skill and docs text outside this repo that teaches relative dump paths needs the same update.

🤖 Generated with Claude Code


Note

High Risk
Changes backup/export behavior (local-only, breaking for workflows that expected server-side files) and tightens raw/db pass-through, which can block previously allowed wp-cli invocations.

Overview
Database dumps now land only on the machine running wpgo. db export and backup run remote wp db export -, stream over SSH via new ExecStream plumbing, and write locally under WPGO_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.sh path is removed; backup shares the same streaming implementation. runScript now exits non-zero when embedded scripts fail.

raw and typed db commands 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-side db export, WXR export, 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-file stay the intentional escape hatch. README and help document the new backup model and raw quoting.

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

`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>
@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: 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>
@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: 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>
@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: 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>
@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: 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>
@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: 4b7cc1cf-3b42-4dea-898c-088d3be4af45)

@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 of the current head found one high-severity gap in the new wpgo raw dump/export guard. Other modules did not surface additional reportable issues.

Open in Web View Automation 

Sent by Cursor Security Agent: Security Reviewer

Comment thread internal/cmd/raw_guard.go
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>
@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: ad69213e-a1f8-48c9-8d84-a7b318e2c1e6)

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