diff --git a/README.md b/README.md index 25b5301..73b4105 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,7 @@ wpgo setup | Variable | Description | |----------|-------------| | `WPGO_SITE` | Default target site alias | +| `WPGO_LOCAL_BACKUP_DIR` | Local dir for `db export` and `backup` dumps (default `~/wpgo-backups`) | ### Site Registry @@ -170,7 +171,9 @@ wpgo -s mysite core verify-checksums ### db -- Database operations ```bash -wpgo -s mysite db export +wpgo -s mysite db export # this machine: ~/wpgo-backups/mysite/mysite_DB_export__.sql +wpgo -s mysite db export pre-update.sql # this machine: ./pre-update.sql +(umask 077 && wpgo -s mysite db export - > dump.sql) # stdout; see below wpgo -s mysite db import dump.sql wpgo -s mysite db query "SELECT COUNT(*) FROM wp_posts" wpgo -s mysite db size @@ -179,6 +182,14 @@ wpgo -s mysite db optimize wpgo -s mysite db repair ``` +`db export` and `backup` run `wp db export -` on the server and stream the dump over SSH to the machine running wpgo. Nothing is written on the server, and wpgo has no option to write one there. The default destination is `${WPGO_LOCAL_BACKUP_DIR:-~/wpgo-backups}//_DB____.sql` (dir 0700, file 0600). wpgo writes to a temp file, fsyncs it and links it into place, then prints the path, byte size and sha256. It never replaces an existing file, including one created while the dump was streaming. If the remote export exits non-zero, the dump is empty, or its last non-empty line does not start with mysqldump's `-- Dump completed`, wpgo deletes the partial file and exits non-zero. + +`db export -` applies the same checks and exits non-zero on failure, but it cannot take back bytes already written to stdout, so check the exit status before using the output. Redirect it under `umask 077` so the dump is not world-readable: + +```bash +(umask 077 && wpgo -s mysite db export - > dump.sql) || rm -f dump.sql +``` + ### user -- User management ```bash @@ -276,18 +287,38 @@ wpgo -s mysite maintenance disable wpgo -s mysite eval "echo get_option('siteurl');" ``` +`eval` runs any PHP, so it can write files on the server. Take backups and exports with `wpgo db export`, never with `eval`. + ### raw -- Pass-through to wp-cli ```bash -wpgo -s mysite raw "wp option list" +wpgo -s mysite raw -- option list +wpgo -s mysite raw -- post meta update 7 title "'Spring cleaning tips'" ``` +Put wp-cli arguments after `--`. `raw` joins them with spaces and the server shell parses that line, so quote any argument that holds spaces or shell characters, in single quotes. Before connecting, `raw` refuses: + +- shell syntax outside single quotes: `;`, `&`, `|`, `<`, `>`, a newline, `(`, `)`, `{`, `}`, a leading `#`, `$` or a backtick (also inside double quotes), a line continuation, an unbalanced quote, the glob characters `*`, `?` and `[`, or a `~` at the start of a word or after `=` or `:`. Quote or backslash-escape them to pass them literally; +- `db export` (or `db dump`) with a file argument other than `-`, with no file argument, with a flag outside a short mysqldump allowlist (blocks `--result-file`, `--tab`, their prefixes and `--defaults`), or with a `--tables` or `--exclude_tables` entry that starts with `-`. Use `wpgo db export`; +- `export` (the WXR exporter); +- `search-replace` or `db search-replace` with any `--export` flag; +- `db query` whose SQL contains `OUTFILE` or `DUMPFILE` in any case, a mysql client command that runs or writes files (`system`, `tee`, `pager`, `source` or `edit` at the start of a statement, or `\!`, `\T`, `\P`, `\.` or `\e` anywhere, in any case), or the same in an `--execute` value; +- `db query`, `import`, `create`, `drop`, `reset`, `clean`, `check`, `optimize`, `repair`, `tables`, `size`, `columns` and `prefix` with a flag outside a short per-command allowlist. wp-cli hands extra flags to mysql or mysqlcheck, so this blocks `--tee`, `--pager`, `--init-command`, `--execute` (except on `db query`, where its SQL is checked), `--defaults` and `--defaults-*` (`--no-defaults` passes), and their prefixes. `wpgo db query` and `wpgo db import` apply the same checks; +- `db cli` and `db connect`; +- the `--exec` and `--require` globals. + +`sql` counts as `db`, and any run of `--` delimiters is skipped. On WP Engine, whose gateway parses each word a second time, `raw` checks that second layer too. + +`eval` and `eval-file` are the one exception: `raw` checks only their shell syntax, never their PHP, and allows `--exec` and `--require` with them. PHP can write files on the server, so take backups and exports with `wpgo db export`, never with `eval`. + +These checks stop agents and operators from writing dumps on the server by accident or in passing. `eval` and `eval-file` are the deliberate escape hatch. The checks are not a sandbox against an operator who sets out to get around them. + ### Shortcut Commands ```bash wpgo -s mysite health # Full site health check wpgo -s mysite status # Quick site status overview -wpgo -s mysite backup # Database backup +wpgo -s mysite backup # Database backup streamed to ~/wpgo-backups/mysite/ on this machine wpgo -s mysite backup "Pre-update snapshot" # Backup with description wpgo -s mysite update-all -y # Update core + plugins + themes wpgo -s mysite clear-cache # Full cache clear diff --git a/internal/adapter/adapter.go b/internal/adapter/adapter.go index 17ebb0a..41c636c 100644 --- a/internal/adapter/adapter.go +++ b/internal/adapter/adapter.go @@ -2,6 +2,7 @@ package adapter import ( "context" + "io" "time" "github.com/builtbyrobben/wpssh/internal/registry" @@ -22,6 +23,10 @@ type Adapter interface { // The wpCmd should be the wp-cli arguments (e.g., "plugin list --format=json"). Exec(ctx context.Context, client *internalssh.SSHClient, site *registry.Site, wpCmd string) (internalssh.ExecResult, error) + // ExecStream runs a wp-cli command like Exec but copies its stdout to + // stdout as it arrives; the returned ExecResult has an empty Stdout. + ExecStream(ctx context.Context, client *internalssh.SSHClient, site *registry.Site, wpCmd string, stdout io.Writer) (internalssh.ExecResult, error) + // Upload transfers a local file to the remote host. Upload(ctx context.Context, client *internalssh.SSHClient, site *registry.Site, localPath, remotePath string) error diff --git a/internal/adapter/standard.go b/internal/adapter/standard.go index 49de8f7..7c2c049 100644 --- a/internal/adapter/standard.go +++ b/internal/adapter/standard.go @@ -3,6 +3,7 @@ package adapter import ( "context" "fmt" + "io" "os" "strings" "time" @@ -35,6 +36,11 @@ func (a *StandardAdapter) Exec(ctx context.Context, client *internalssh.SSHClien return client.Exec(ctx, cfg, site.CanonicalHost, wpCmd) } +// ExecStream runs a pre-built remote shell command and streams its stdout. +func (a *StandardAdapter) ExecStream(ctx context.Context, client *internalssh.SSHClient, site *registry.Site, wpCmd string, stdout io.Writer) (internalssh.ExecResult, error) { + return client.ExecStream(ctx, siteToClientConfig(site), site.CanonicalHost, wpCmd, stdout) +} + // Upload streams a local file to the remote host via stdin. // Runs: cat > {remotePath} with the file content piped to stdin. func (a *StandardAdapter) Upload(ctx context.Context, client *internalssh.SSHClient, site *registry.Site, localPath, remotePath string) error { diff --git a/internal/adapter/wpengine.go b/internal/adapter/wpengine.go index a5d33f4..c77a1da 100644 --- a/internal/adapter/wpengine.go +++ b/internal/adapter/wpengine.go @@ -4,6 +4,7 @@ import ( "context" "encoding/base64" "fmt" + "io" "os" "strings" "time" @@ -38,20 +39,28 @@ func (a *WPEngineAdapter) Capabilities() AdapterCapabilities { // Callers (via wpcli.Command.Build) already include the cd + wp prefix. // WP Engine's SSH environment places sites at ~/sites/{user}/ when configured. func (a *WPEngineAdapter) Exec(ctx context.Context, client *internalssh.SSHClient, site *registry.Site, wpCmd string) (internalssh.ExecResult, error) { - cfg := wpengineClientConfig(site) + execCtx, cancel := wpengineSessionContext(ctx) + defer cancel() + return client.Exec(execCtx, wpengineClientConfig(site), site.CanonicalHost, wpCmd) +} + +// ExecStream runs a pre-built remote shell command on WP Engine and streams +// its stdout, within the same session timeout as Exec. +func (a *WPEngineAdapter) ExecStream(ctx context.Context, client *internalssh.SSHClient, site *registry.Site, wpCmd string, stdout io.Writer) (internalssh.ExecResult, error) { + execCtx, cancel := wpengineSessionContext(ctx) + defer cancel() + return client.ExecStream(execCtx, wpengineClientConfig(site), site.CanonicalHost, wpCmd, stdout) +} - // Enforce WP Engine's 10-minute session timeout. +// wpengineSessionContext enforces WP Engine's 10-minute session timeout. +func wpengineSessionContext(ctx context.Context) (context.Context, context.CancelFunc) { timeout := 10 * time.Minute if deadline, ok := ctx.Deadline(); ok { - remaining := time.Until(deadline) - if remaining < timeout { + if remaining := time.Until(deadline); remaining < timeout { timeout = remaining } } - execCtx, cancel := context.WithTimeout(ctx, timeout) - defer cancel() - - return client.Exec(execCtx, cfg, site.CanonicalHost, wpCmd) + return context.WithTimeout(ctx, timeout) } // Upload transfers a local file to WP Engine via stdin piping. diff --git a/internal/cmd/context.go b/internal/cmd/context.go index 708e6a6..9074492 100644 --- a/internal/cmd/context.go +++ b/internal/cmd/context.go @@ -113,6 +113,21 @@ func (rc *RunContext) ExecWP(ctx context.Context, site *registry.Site, wpCmd str return a.Exec(ctx, rc.SSHClient, site, wpCmd) } +// ExecWPStream executes a wp-cli command on a site and copies its stdout to +// stdout as it arrives. In dry-run mode it prints the command and writes +// nothing. +func (rc *RunContext) ExecWPStream(ctx context.Context, site *registry.Site, wpCmd string, stdout io.Writer) (internalssh.ExecResult, error) { + a := adapter.ForSite(site) + if rc.Globals.Verbose { + fmt.Fprintf(rc.Stderr, "[adapter: %s] %s\n", a.Name(), wpCmd) + } + if rc.Globals.DryRun { + fmt.Fprintf(rc.Stderr, "[dry-run] %s\n", wpCmd) + return internalssh.ExecResult{}, nil + } + return a.ExecStream(ctx, rc.SSHClient, site, wpCmd, stdout) +} + // CacheGet checks the cache for a stored result. Returns the cached data // string or empty string on miss. Handles nil cache gracefully. func (rc *RunContext) CacheGet(siteAlias, commandCacheKey string) string { diff --git a/internal/cmd/db.go b/internal/cmd/db.go index 4033c47..253ff66 100644 --- a/internal/cmd/db.go +++ b/internal/cmd/db.go @@ -3,6 +3,7 @@ package cmd import ( "context" "fmt" + "time" "github.com/builtbyrobben/wpssh/internal/cache" "github.com/builtbyrobben/wpssh/internal/wpcli" @@ -25,7 +26,7 @@ type DBCmd struct { } type DBExportCmd struct { - File string `arg:"" optional:"" help:"Export file path"` + File string `arg:"" optional:"" help:"Local file on this machine (default $WPGO_LOCAL_BACKUP_DIR//, else ~/wpgo-backups//); '-' streams to stdout. Nothing is written on the server."` } type DBImportCmd struct { File string `arg:"" help:"Import file path"` @@ -57,22 +58,28 @@ func (c *DBExportCmd) Run(g *Globals) error { return err } - builder := wpcli.New("db", "export") - if c.File != "" { - builder.Arg(c.File) + if c.File == "-" { + return exportToStdout(rc, site) } - result, err := rc.ExecWP(context.Background(), site, builder.Build(site.WPPath)) + + path := c.File + if path == "" { + if path, err = defaultDumpPath(site.Alias, "export", time.Now()); err != nil { + return err + } + } + dump, err := exportToLocal(rc, site, path) if err != nil { return err } - if result.ExitCode != 0 { - return fmt.Errorf("wp db export: %s", result.Stderr) - } - fmt.Fprint(rc.Stdout, result.Stdout) - return nil + return printDump(rc, "Exported to:", dump) } func (c *DBImportCmd) Run(g *Globals) error { + // wp-cli reads a file argument that starts with -- as a mysql flag. + if err := checkWPArgv([]string{"db", "import", c.File}); err != nil { + return err + } rc, err := NewRunContext(g) if err != nil { return err @@ -101,6 +108,9 @@ func (c *DBImportCmd) Run(g *Globals) error { } func (c *DBQueryCmd) Run(g *Globals) error { + if err := checkWPArgv([]string{"db", "query", c.SQL}); err != nil { + return err + } rc, err := NewRunContext(g) if err != nil { return err diff --git a/internal/cmd/dump.go b/internal/cmd/dump.go new file mode 100644 index 0000000..44d174d --- /dev/null +++ b/internal/cmd/dump.go @@ -0,0 +1,250 @@ +package cmd + +import ( + "bytes" + "context" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "os" + "os/signal" + "path/filepath" + "regexp" + "strings" + "syscall" + "time" + + "github.com/builtbyrobben/wpssh/internal/registry" + internalssh "github.com/builtbyrobben/wpssh/internal/ssh" + "github.com/builtbyrobben/wpssh/internal/wpcli" +) + +// dumpTrailer starts the last line of every complete mysqldump/mariadb-dump +// output; a dump whose final non-empty line does not start with it was cut off. +const dumpTrailer = "-- Dump completed" + +// dumpTailSize bounds the bytes kept to find a dump's final line. The +// mysqldump trailer line is about 40 bytes. +const dumpTailSize = 4096 + +// unsafeFilenameChars matches characters replaced in dump names. +var unsafeFilenameChars = regexp.MustCompile(`[^a-zA-Z0-9_-]`) + +// savedDump describes a database dump written on the operator machine. +type savedDump struct { + Path string `json:"path"` + Bytes int64 `json:"bytes"` + SHA256 string `json:"sha256"` +} + +// defaultDumpPath returns +// ${WPGO_LOCAL_BACKUP_DIR:-$HOME/wpgo-backups}//_DB___.sql +// on the operator machine, creating the base and alias dirs with mode 0700. +// has millisecond precision and is 6 random hex digits, so +// concurrent backups of one site get distinct names. +func defaultDumpPath(alias, desc string, now time.Time) (string, error) { + base := os.Getenv("WPGO_LOCAL_BACKUP_DIR") + if base == "" { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("local backup dir: %w", err) + } + base = filepath.Join(home, "wpgo-backups") + } + safeAlias := unsafeFilenameChars.ReplaceAllString(alias, "_") + dir := filepath.Join(base, safeAlias) + for _, d := range []string{base, dir} { + if err := os.MkdirAll(d, 0o700); err != nil { + return "", fmt.Errorf("create local backup dir: %w", err) + } + if err := os.Chmod(d, 0o700); err != nil { + return "", fmt.Errorf("chmod local backup dir: %w", err) + } + } + suffix := make([]byte, 3) + if _, err := rand.Read(suffix); err != nil { + return "", fmt.Errorf("dump name suffix: %w", err) + } + name := fmt.Sprintf("%s_DB_%s_%s%03d_%x.sql", safeAlias, unsafeFilenameChars.ReplaceAllString(desc, "_"), + now.Format("20060102_150405_"), now.Nanosecond()/int(time.Millisecond), suffix) + return filepath.Join(dir, name), nil +} + +// saveDump writes the stdout of run to path on the operator machine. +// +// It streams into a 0600 temp file next to path, fsyncs it and hard-links it +// to path, which fails if path already exists, even when another process +// created it during the stream. If run fails or verifyDump rejects the dump, +// the temp file is removed and an error is returned. An existing file at path +// is never replaced. If the temp file cannot be removed, the returned error +// names it, even when the dump was published. +func saveDump(path string, run func(io.Writer) (internalssh.ExecResult, error)) (_ savedDump, err error) { + if _, err := os.Lstat(path); err == nil { + return savedDump{}, fmt.Errorf("refusing to overwrite existing file %s", path) + } + dir := filepath.Dir(path) + tmp, err := os.CreateTemp(dir, "."+filepath.Base(path)+".partial-*") + if err != nil { + return savedDump{}, fmt.Errorf("create local dump: %w", err) + } + defer func() { + _ = tmp.Close() + rerr := os.Remove(tmp.Name()) + switch { + case rerr == nil || errors.Is(rerr, fs.ErrNotExist): + case err == nil: + err = fmt.Errorf("saved dump to %s, but could not remove temp file %s: %w", path, tmp.Name(), rerr) + default: + err = errors.Join(err, fmt.Errorf("could not remove partial dump %s: %w", tmp.Name(), rerr)) + } + }() + + hash := sha256.New() + tail := &tailWriter{} + result, err := run(io.MultiWriter(tmp, hash, tail)) + if err != nil { + return savedDump{}, fmt.Errorf("stream dump: %w", err) + } + if err := verifyDump(result, tail); err != nil { + return savedDump{}, err + } + if err := tmp.Sync(); err != nil { + return savedDump{}, fmt.Errorf("fsync local dump: %w", err) + } + if err := tmp.Close(); err != nil { + return savedDump{}, fmt.Errorf("close local dump: %w", err) + } + if err := os.Link(tmp.Name(), path); err != nil { + if errors.Is(err, fs.ErrExist) { + return savedDump{}, fmt.Errorf("refusing to overwrite existing file %s", path) + } + return savedDump{}, fmt.Errorf("publish local dump: %w", err) + } + if d, err := os.Open(dir); err == nil { + _ = d.Sync() + d.Close() + } + + abs, err := filepath.Abs(path) + if err != nil { + abs = path + } + return savedDump{Path: abs, Bytes: tail.n, SHA256: hex.EncodeToString(hash.Sum(nil))}, nil +} + +// verifyDump rejects a `wp db export -` stream that exited non-zero, was +// empty, or whose final non-empty line does not start with dumpTrailer. +func verifyDump(result internalssh.ExecResult, tail *tailWriter) error { + if result.ExitCode != 0 { + return fmt.Errorf("wp db export exited %d: %s", result.ExitCode, strings.TrimSpace(result.Stderr)) + } + if tail.n == 0 { + return errors.New("wp db export produced an empty dump") + } + if !tail.endsWithTrailer() { + return fmt.Errorf("dump is truncated: last line after %d bytes does not start with %q", tail.n, dumpTrailer) + } + return nil +} + +// tailWriter counts the bytes written and keeps the last dumpTailSize of them. +type tailWriter struct { + n int64 + buf []byte +} + +func (w *tailWriter) Write(p []byte) (int, error) { + w.n += int64(len(p)) + w.buf = append(w.buf, p...) + if excess := len(w.buf) - dumpTailSize; excess > 0 { + w.buf = w.buf[:copy(w.buf, w.buf[excess:])] + } + return len(p), nil +} + +// endsWithTrailer reports whether the final non-empty line starts with +// dumpTrailer. A final line longer than the kept tail is rejected. +func (w *tailWriter) endsWithTrailer() bool { + t := bytes.TrimRight(w.buf, " \t\r\n") + i := bytes.LastIndexByte(t, '\n') + if i < 0 && w.n > int64(len(w.buf)) { + return false + } + return bytes.HasPrefix(t[i+1:], []byte(dumpTrailer)) +} + +// exportToLocal streams `wp db export -` from site into path on the operator +// machine. Nothing is written on the server. Ctrl-C cancels the stream and +// removes the partial file. +func exportToLocal(rc *RunContext, site *registry.Site, path string) (savedDump, error) { + remoteCmd := dbExportStdout(site.WPPath) + if rc.Globals.DryRun { + fmt.Fprintf(rc.Stderr, "[dry-run] %s > %s (local)\n", remoteCmd, path) + return savedDump{Path: path}, nil + } + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + return saveDump(path, func(w io.Writer) (internalssh.ExecResult, error) { + return rc.ExecWPStream(ctx, site, remoteCmd, w) + }) +} + +// exportToStdout streams `wp db export -` from site to rc.Stdout with the +// checks of writeDump. +func exportToStdout(rc *RunContext, site *registry.Site) error { + remoteCmd := dbExportStdout(site.WPPath) + if rc.Globals.DryRun { + fmt.Fprintf(rc.Stderr, "[dry-run] %s\n", remoteCmd) + return nil + } + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + return writeDump(rc.Stdout, func(w io.Writer) (internalssh.ExecResult, error) { + return rc.ExecWPStream(ctx, site, remoteCmd, w) + }) +} + +// writeDump copies the stdout of run to w and applies verifyDump, keeping +// only a bounded tail in memory. It cannot take back bytes already written, +// so on error w holds a partial dump. +func writeDump(w io.Writer, run func(io.Writer) (internalssh.ExecResult, error)) error { + tail := &tailWriter{} + result, err := run(io.MultiWriter(w, tail)) + if err != nil { + return fmt.Errorf("stream dump: %w", err) + } + return verifyDump(result, tail) +} + +// dbExportStdout is the remote command that writes the dump to stdout and +// nothing on the server. +func dbExportStdout(wpPath string) string { + return wpcli.New("db", "export").Arg("-").Build(wpPath) +} + +// printDump reports a local dump as JSON or text. +func printDump(rc *RunContext, label string, d savedDump) error { + if rc.Globals.DryRun { + return nil + } + if rc.Globals.JSON { + out, err := json.MarshalIndent(struct { + Status string `json:"status"` + savedDump + }{"ok", d}, "", " ") + if err != nil { + return err + } + fmt.Fprintln(rc.Stdout, string(out)) + return nil + } + fmt.Fprintf(rc.Stdout, "%-14s%s\n", label, d.Path) + fmt.Fprintf(rc.Stdout, "%-14s%d bytes\n", "Size:", d.Bytes) + fmt.Fprintf(rc.Stdout, "%-14s%s\n", "SHA256:", d.SHA256) + return nil +} diff --git a/internal/cmd/dump_test.go b/internal/cmd/dump_test.go new file mode 100644 index 0000000..715d71c --- /dev/null +++ b/internal/cmd/dump_test.go @@ -0,0 +1,332 @@ +package cmd + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "errors" + "io" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "testing" + "time" + + internalssh "github.com/builtbyrobben/wpssh/internal/ssh" +) + +const completeDump = "CREATE TABLE wp_posts (ID int);\n-- Dump completed on 2026-10-06 12:00:00\n" + +// stubWPScript is a fake wp for `wp db export -`. STUB_MODE picks the outcome; +// any other export target is written as a file so a stray remote write shows up. +const stubWPScript = `#!/bin/sh +[ "$1 $2" = "db export" ] || exit 2 +if [ "$3" != "-" ]; then printf 'dump\n' > "$3"; exit 0; fi +case "$STUB_MODE" in +ok) printf '%s' "$STUB_DUMP" ;; +fail) printf 'CREATE TABLE wp_po'; echo 'mysqldump: Lost connection' >&2; exit 3 ;; +esac +` + +// remoteShells returns sh plus dash when it is installed (sh is dash on +// Ubuntu CI). +func remoteShells(t *testing.T) []string { + t.Helper() + shells := []string{"sh"} + if _, err := exec.LookPath("dash"); err == nil { + shells = append(shells, "dash") + } else { + t.Log("dash not installed; testing sh only") + } + return shells +} + +// fakeRemote sets up a temp "server" HOME with a WordPress path and stub wp, +// and returns a run func that executes the real remote export command there. +// In mode "ok" the stub prints dump. +func fakeRemote(t *testing.T, shell, mode, dump string) (home string, run func(io.Writer) (internalssh.ExecResult, error)) { + t.Helper() + home = t.TempDir() + wpPath := filepath.Join(home, "public_html", "my site") + if err := os.MkdirAll(wpPath, 0o755); err != nil { + t.Fatal(err) + } + bin := t.TempDir() + if err := os.WriteFile(filepath.Join(bin, "wp"), []byte(stubWPScript), 0o755); err != nil { + t.Fatal(err) + } + return home, func(w io.Writer) (internalssh.ExecResult, error) { + cmd := exec.Command(shell, "-c", dbExportStdout(wpPath)) + cmd.Env = []string{"HOME=" + home, "PATH=" + bin + ":" + os.Getenv("PATH"), "STUB_MODE=" + mode, "STUB_DUMP=" + dump} + var stderr bytes.Buffer + cmd.Stdout = w + cmd.Stderr = &stderr + err := cmd.Run() + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + return internalssh.ExecResult{Stderr: stderr.String(), ExitCode: exitErr.ExitCode()}, nil + } + return internalssh.ExecResult{Stderr: stderr.String()}, err + } +} + +func TestDefaultDumpPath(t *testing.T) { + now := time.Date(2026, 10, 6, 12, 0, 0, 123_000_000, time.UTC) + + t.Run("home default", func(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("WPGO_LOCAL_BACKUP_DIR", "") + got, err := defaultDumpPath("my site", "pre update", now) + if err != nil { + t.Fatal(err) + } + dir := filepath.Join(home, "wpgo-backups", "my_site") + want := regexp.MustCompile(`^my_site_DB_pre_update_20261006_120000_123_[0-9a-f]{6}\.sql$`) + if filepath.Dir(got) != dir || !want.MatchString(filepath.Base(got)) { + t.Fatalf("path = %s, want %s/%s", got, dir, want) + } + assertPerm(t, filepath.Join(home, "wpgo-backups"), 0o700) + assertPerm(t, dir, 0o700) + }) + + t.Run("same millisecond gets distinct names", func(t *testing.T) { + t.Setenv("WPGO_LOCAL_BACKUP_DIR", t.TempDir()) + a, err := defaultDumpPath("acme", "export", now) + if err != nil { + t.Fatal(err) + } + b, err := defaultDumpPath("acme", "export", now) + if err != nil { + t.Fatal(err) + } + if a == b { + t.Fatalf("two backups at the same instant both named %s", a) + } + }) + + t.Run("env override tightens existing dir", func(t *testing.T) { + base := filepath.Join(t.TempDir(), "dumps") + if err := os.MkdirAll(base, 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("WPGO_LOCAL_BACKUP_DIR", base) + got, err := defaultDumpPath("acme", "export", now) + if err != nil { + t.Fatal(err) + } + if want := filepath.Join(base, "acme"); filepath.Dir(got) != want { + t.Fatalf("path = %s, want it in %s", got, want) + } + assertPerm(t, base, 0o700) + }) +} + +// TestSaveDumpStreamsToOperator runs the default export command against a +// local stand-in for the server and checks the dump lands only locally. +func TestSaveDumpStreamsToOperator(t *testing.T) { + dumps := map[string]string{ + "lf": completeDump, + "crlf": "CREATE TABLE wp_posts (ID int);\r\n-- Dump completed on 2026-10-06 12:00:00\r\n", + "trailing blank lines": completeDump + "\n \n\t\n", + } + for _, shell := range remoteShells(t) { + for name, dump := range dumps { + t.Run(shell+"/"+name, func(t *testing.T) { + sum := sha256.Sum256([]byte(dump)) + wantSHA := hex.EncodeToString(sum[:]) + serverHome, run := fakeRemote(t, shell, "ok", dump) + path := filepath.Join(t.TempDir(), "site_DB_export.sql") + + got, err := saveDump(path, run) + if err != nil { + t.Fatal(err) + } + if got.Path != path || got.Bytes != int64(len(dump)) || got.SHA256 != wantSHA { + t.Errorf("saveDump = %+v, want path %s, %d bytes, sha %s", got, path, len(dump), wantSHA) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(data) != dump { + t.Errorf("local dump = %q, want %q", data, dump) + } + assertPerm(t, path, 0o600) + if left := listFiles(t, filepath.Dir(path)); len(left) != 1 { + t.Errorf("local dir has %v, want only the dump", left) + } + if remote := listFiles(t, serverHome); len(remote) != 0 { + t.Errorf("server has files %v, want none", remote) + } + + var out bytes.Buffer + if err := writeDump(&out, run); err != nil { + t.Fatalf("stdout export: %v", err) + } + if out.String() != dump { + t.Errorf("stdout export = %q, want %q", out.String(), dump) + } + }) + } + } +} + +// TestDumpFailures checks that file exports leave no file and stdout exports +// return an error for each way a dump can be incomplete. +func TestDumpFailures(t *testing.T) { + tests := []struct { + name string + mode string + dump string + wantErr string + }{ + {name: "remote exit", mode: "fail", wantErr: "wp db export exited 3: mysqldump: Lost connection"}, + {name: "empty", mode: "ok", dump: "", wantErr: "empty dump"}, + {name: "truncated", mode: "ok", dump: "CREATE TABLE wp_posts (ID int);\nINSERT INTO wp_po", wantErr: "dump is truncated"}, + { + name: "trailer inside data", + mode: "ok", + dump: "INSERT INTO wp_posts VALUES (1,'-- Dump completed on 2026-10-06');\nINSERT INTO wp_posts VALUES (2,'cut", + wantErr: "dump is truncated", + }, + { + // The kept tail starts at a marker in the middle of a long line. + name: "trailer inside a final line longer than the tail", + mode: "ok", + dump: "INSERT INTO wp_posts VALUES ('" + dumpTrailer + strings.Repeat("y", dumpTailSize-len(dumpTrailer)), + wantErr: "dump is truncated", + }, + } + for _, shell := range remoteShells(t) { + for _, tt := range tests { + t.Run(shell+"/"+tt.name, func(t *testing.T) { + _, run := fakeRemote(t, shell, tt.mode, tt.dump) + dir := t.TempDir() + + _, err := saveDump(filepath.Join(dir, "x.sql"), run) + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("file export err = %v, want %q", err, tt.wantErr) + } + if left := listFiles(t, dir); len(left) != 0 { + t.Errorf("failed export left %v", left) + } + + if err := writeDump(io.Discard, run); err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("stdout export err = %v, want %q", err, tt.wantErr) + } + }) + } + } + + t.Run("transport error mid-stream", func(t *testing.T) { + dir := t.TempDir() + run := func(w io.Writer) (internalssh.ExecResult, error) { + _, _ = io.WriteString(w, "CREATE TABLE wp_po") + return internalssh.ExecResult{}, errors.New("connection reset") + } + _, err := saveDump(filepath.Join(dir, "x.sql"), run) + if err == nil || !strings.Contains(err.Error(), "connection reset") { + t.Fatalf("err = %v, want connection reset", err) + } + if left := listFiles(t, dir); len(left) != 0 { + t.Errorf("failed export left %v", left) + } + if err := writeDump(io.Discard, run); err == nil || !strings.Contains(err.Error(), "connection reset") { + t.Fatalf("stdout export err = %v, want connection reset", err) + } + }) +} + +func TestSaveDumpRefusesOverwrite(t *testing.T) { + path := filepath.Join(t.TempDir(), "x.sql") + if err := os.WriteFile(path, []byte("keep"), 0o600); err != nil { + t.Fatal(err) + } + _, run := fakeRemote(t, "sh", "ok", completeDump) + if _, err := saveDump(path, run); err == nil || !strings.Contains(err.Error(), "refusing to overwrite") { + t.Fatalf("err = %v, want refusal", err) + } + if data, _ := os.ReadFile(path); string(data) != "keep" { + t.Errorf("existing file changed to %q", data) + } +} + +// TestSaveDumpKeepsFileCreatedDuringStream covers a destination that appears +// after the up-front existence check, e.g. a concurrent backup. +func TestSaveDumpKeepsFileCreatedDuringStream(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "x.sql") + _, err := saveDump(path, func(w io.Writer) (internalssh.ExecResult, error) { + half := len(completeDump) / 2 + _, _ = io.WriteString(w, completeDump[:half]) + if err := os.WriteFile(path, []byte("keep"), 0o600); err != nil { + t.Fatal(err) + } + _, _ = io.WriteString(w, completeDump[half:]) + return internalssh.ExecResult{}, nil + }) + if err == nil || !strings.Contains(err.Error(), "refusing to overwrite") { + t.Fatalf("err = %v, want refusal", err) + } + if data, _ := os.ReadFile(path); string(data) != "keep" { + t.Errorf("existing file changed to %q", data) + } + if left := listFiles(t, dir); len(left) != 1 { + t.Errorf("dir has %v, want only the pre-existing file", left) + } +} + +// TestSaveDumpReportsLeftoverPartial makes the dir read-only mid-stream so +// the failed dump's temp file cannot be removed; the error must name it. +func TestSaveDumpReportsLeftoverPartial(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("root ignores directory permissions") + } + dir := t.TempDir() + t.Cleanup(func() { _ = os.Chmod(dir, 0o700) }) + _, err := saveDump(filepath.Join(dir, "x.sql"), func(w io.Writer) (internalssh.ExecResult, error) { + _, _ = io.WriteString(w, "CREATE TABLE wp_po") + if err := os.Chmod(dir, 0o500); err != nil { + t.Fatal(err) + } + return internalssh.ExecResult{}, errors.New("connection reset") + }) + left := listFiles(t, dir) + if len(left) != 1 { + t.Fatalf("dir has %v, want the one partial dump", left) + } + for _, want := range []string{"connection reset", "could not remove partial dump " + left[0]} { + if err == nil || !strings.Contains(err.Error(), want) { + t.Errorf("err = %v, want it to contain %q", err, want) + } + } +} + +func listFiles(t *testing.T, root string) []string { + t.Helper() + var files []string + err := filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error { + if err == nil && !d.IsDir() { + files = append(files, p) + } + return err + }) + if err != nil { + t.Fatal(err) + } + return files +} + +func assertPerm(t *testing.T, p string, want os.FileMode) { + t.Helper() + info, err := os.Stat(p) + if err != nil { + t.Fatal(err) + } + if got := info.Mode().Perm(); got != want { + t.Errorf("mode of %s = %o, want %o", p, got, want) + } +} diff --git a/internal/cmd/help_cmd.go b/internal/cmd/help_cmd.go index 06ece7b..36ea0a2 100644 --- a/internal/cmd/help_cmd.go +++ b/internal/cmd/help_cmd.go @@ -138,7 +138,10 @@ func printHelpDatabase() { fmt.Println() fmt.Println("Database:") fmt.Println(" wpgo db size") - fmt.Println(" wpgo db export backup.sql") + fmt.Println(" wpgo db export # this machine: ~/wpgo-backups//_DB_export__.sql") + fmt.Println(" wpgo db export pre-update.sql # this machine: ./pre-update.sql") + fmt.Println(" (umask 077 && wpgo db export - > dump.sql) || rm -f dump.sql # stdout; non-zero exit means partial") + fmt.Println(" (dumps stream over SSH and are never written on the server; set WPGO_LOCAL_BACKUP_DIR to change the dir)") fmt.Println(" wpgo db import backup.sql -y --ack-destructive") fmt.Println() fmt.Println("Options/cache/transients:") @@ -174,7 +177,7 @@ func printHelpShortcuts() { fmt.Println("Daily operations:") fmt.Println(" wpgo status") fmt.Println(" wpgo health") - fmt.Println(" wpgo backup \"pre-deploy\"") + fmt.Println(" wpgo backup \"pre-deploy\" # this machine: ~/wpgo-backups//; prints path, size, sha256") fmt.Println(" wpgo clear-cache -y") fmt.Println(" wpgo update-all -y") } diff --git a/internal/cmd/raw.go b/internal/cmd/raw.go index c6c980f..b9559c8 100644 --- a/internal/cmd/raw.go +++ b/internal/cmd/raw.go @@ -5,6 +5,7 @@ import ( "fmt" "strings" + "github.com/builtbyrobben/wpssh/internal/adapter" "github.com/builtbyrobben/wpssh/internal/cache" "github.com/builtbyrobben/wpssh/internal/wpcli" ) @@ -12,10 +13,13 @@ import ( // Raw command — pass-through escape hatch to wp-cli. type RawCmd struct { - Args []string `arg:"" optional:"" help:"Arguments to pass to wp-cli"` + Args []string `arg:"" optional:"" help:"Arguments to pass to wp-cli. Refuses shell operators outside quotes and commands that write dumps or exports on the server."` } func (c *RawCmd) Run(g *Globals) error { + if err := checkRawArgs(c.Args, false); err != nil { + return err + } rc, err := NewRunContext(g) if err != nil { return err @@ -25,6 +29,11 @@ func (c *RawCmd) Run(g *Globals) error { if err != nil { return err } + if adapter.ForSite(site).Name() == "wpengine" { + if err := checkRawArgs(c.Args, true); err != nil { + return err + } + } // Build the raw command by passing all args through. builder := wpcli.New(c.Args...) diff --git a/internal/cmd/raw_guard.go b/internal/cmd/raw_guard.go new file mode 100644 index 0000000..226e6cb --- /dev/null +++ b/internal/cmd/raw_guard.go @@ -0,0 +1,441 @@ +package cmd + +import ( + "errors" + "fmt" + "strings" +) + +// wpGlobalFlags are wp-cli's runtime flags, accepted before or after any +// command. --exec and --require are refused outside eval and eval-file, so +// they are not listed. +var wpGlobalFlags = map[string]bool{ + "path": true, "url": true, "ssh": true, "http": true, "user": true, + "skip-plugins": true, "skip-themes": true, "skip-packages": true, + "context": true, "color": true, "debug": true, "prompt": true, "quiet": true, +} + +// rawDBExportFlags are the flags `wpgo raw -- db export -` accepts besides +// wpGlobalFlags. wp db export hands unknown flags to mysqldump, which also +// accepts unambiguous prefixes, so --result-file, --tab and their prefixes are +// kept out by allowing only these names. --tables and --exclude_tables are +// the only ones wp-cli splits into several mysqldump arguments; their entries +// are checked separately. --defaults is left out: it drops --no-defaults, so +// mysqldump would read option files on the server. +var rawDBExportFlags = map[string]bool{ + "tables": true, "exclude_tables": true, "include-tablespaces": true, + "add-drop-table": true, "skip-opt": true, "where": true, + "no-create-info": true, "create-info": true, "single-transaction": true, + "quick": true, "lock-tables": true, "skip-lock-tables": true, + "hex-blob": true, "routines": true, "triggers": true, "events": true, + "default-character-set": true, "dbuser": true, "dbpass": true, +} + +// wpValueFlags are the wp-cli global flags that take a value. +var wpValueFlags = map[string]bool{ + "path": true, "url": true, "ssh": true, "http": true, "user": true, + "require": true, "exec": true, "context": true, +} + +// dbFlagKind says whether a db subcommand flag may carry a value. +type dbFlagKind int + +const ( + dbFlagBool dbFlagKind = iota + dbFlagValue +) + +// mysqlClientFlags and mysqlcheckFlags are the flags the db subcommands that +// run mysql or mysqlcheck accept besides wpGlobalFlags and --no-defaults. +// wp-cli forwards unknown flags to the client, which also accepts unambiguous +// prefixes and option-file routes, so only these exact names pass: --tee, +// --pager, --init-command, --execute, --defaults* and every prefix of them +// are refused. +var mysqlClientFlags = map[string]dbFlagKind{ + "dbuser": dbFlagValue, "dbpass": dbFlagValue, "default-character-set": dbFlagValue, + "skip-sql-mode-compat": dbFlagBool, "skip-column-names": dbFlagBool, "column-names": dbFlagBool, + "batch": dbFlagBool, "raw": dbFlagBool, "silent": dbFlagBool, "vertical": dbFlagBool, + "table": dbFlagBool, "html": dbFlagBool, "xml": dbFlagBool, "binary-as-hex": dbFlagBool, + "show-warnings": dbFlagBool, "unbuffered": dbFlagBool, "force": dbFlagBool, "verbose": dbFlagBool, +} + +var mysqlcheckFlags = map[string]dbFlagKind{ + "dbuser": dbFlagValue, "dbpass": dbFlagValue, "default-character-set": dbFlagValue, + "auto-repair": dbFlagBool, "check-upgrade": dbFlagBool, "check-only-changed": dbFlagBool, + "extended": dbFlagBool, "fast": dbFlagBool, "medium-check": dbFlagBool, "quick": dbFlagBool, + "silent": dbFlagBool, "verbose": dbFlagBool, "force": dbFlagBool, "use-frm": dbFlagBool, +} + +// dbSubcommandFlags lists, per db subcommand other than export, dump, cli, +// connect, search and search-replace, the flags it accepts. tables, size, +// columns and prefix take only their own wp-cli flags. +var dbSubcommandFlags = map[string]map[string]dbFlagKind{ + "query": withFlags(mysqlClientFlags, map[string]dbFlagKind{"execute": dbFlagValue}), + "import": withFlags(mysqlClientFlags, map[string]dbFlagKind{"skip-optimization": dbFlagBool}), + "create": mysqlClientFlags, + "drop": withFlags(mysqlClientFlags, map[string]dbFlagKind{"yes": dbFlagBool}), + "reset": withFlags(mysqlClientFlags, map[string]dbFlagKind{"yes": dbFlagBool}), + "clean": withFlags(mysqlClientFlags, map[string]dbFlagKind{"yes": dbFlagBool}), + "check": mysqlcheckFlags, + "optimize": mysqlcheckFlags, + "repair": mysqlcheckFlags, + "tables": { + "scope": dbFlagValue, "network": dbFlagBool, "all-tables-with-prefix": dbFlagBool, + "all-tables": dbFlagBool, "format": dbFlagValue, + }, + "size": { + "size_format": dbFlagValue, "tables": dbFlagBool, "human-readable": dbFlagBool, + "format": dbFlagValue, "scope": dbFlagValue, "network": dbFlagBool, "decimals": dbFlagValue, + "all-tables": dbFlagBool, "all-tables-with-prefix": dbFlagBool, "order": dbFlagValue, + "orderby": dbFlagValue, + }, + "columns": {"format": dbFlagValue}, + "prefix": {}, +} + +func withFlags(base, extra map[string]dbFlagKind) map[string]dbFlagKind { + m := make(map[string]dbFlagKind, len(base)+len(extra)) + for k, v := range base { + m[k] = v + } + for k, v := range extra { + m[k] = v + } + return m +} + +// wpFlag is one --name[=value] or -n[=value] argument as wp-cli reads it. +// name is lowercased with any "no-" prefix removed; negated records that +// prefix and hasValue records an "=". +type wpFlag struct { + name string + value string + negated bool + hasValue bool +} + +// wpReading is one way wp-cli versions may split an argv. checkWPArgv refuses +// if any reading is refused. +type wpReading struct { + // spaceValues reads a wpValueFlags flag without "=" as taking the next + // token as its value. wp-cli has no "--flag value" form, but a user may + // intend it. + spaceValues bool + // dropDelimiters ignores every "--" token, as newer wp-cli strips them. + // Otherwise "--" ends options and a run of "--" tokens counts as one. + dropDelimiters bool +} + +var wpReadings = []wpReading{{false, false}, {true, false}, {false, true}, {true, true}} + +// parseWPArgs splits args into positionals and flags the way wp-cli's +// Configurator::extract_assoc does. A leading @alias positional is dropped, +// positionals are lowercased and the "sql" alias becomes "db". +func parseWPArgs(args []string, r wpReading) (positional []string, flags []wpFlag) { + endOfOptions, skipNext, prevDelim := false, false, false + for _, a := range args { + isDelim := a == "--" + switch { + case skipNext: + skipNext = false + case isDelim && (r.dropDelimiters || prevDelim || !endOfOptions): + endOfOptions = !r.dropDelimiters + case endOfOptions: + positional = append(positional, strings.ToLower(a)) + case strings.HasPrefix(a, "--") || (len(a) >= 2 && a[0] == '-' && isASCIILetter(a[1]) && (len(a) == 2 || a[2] == '=')): + name, value, hasValue := strings.Cut(strings.TrimLeft(a, "-"), "=") + name = strings.ToLower(name) + negated := strings.HasPrefix(name, "no-") + name = strings.TrimPrefix(name, "no-") + flags = append(flags, wpFlag{name: name, value: value, negated: negated, hasValue: hasValue}) + skipNext = r.spaceValues && !hasValue && wpValueFlags[name] + default: + positional = append(positional, strings.ToLower(a)) + } + prevDelim = isDelim + } + if len(positional) > 0 && strings.HasPrefix(positional[0], "@") { + positional = positional[1:] + } + if len(positional) > 0 && positional[0] == "sql" { + positional[0] = "db" + } + return positional, flags +} + +func isASCIILetter(b byte) bool { + return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') +} + +// checkRawArgs refuses a raw invocation unless the remote shell hands wp plain +// words and none of them writes a dump or export on the server. The transport +// joins args with spaces, unquoted, so they are checked as the remote shell +// parses that string. With nested, as for WP Engine, whose gateway parses the +// command a second time, each word is parsed again and that layer is checked +// too, except for eval and eval-file, whose PHP is left to the caller. +func checkRawArgs(args []string, nested bool) error { + line := strings.Join(args, " ") + words, err := shellWords(line) + if err != nil { + return fmt.Errorf("wpgo raw refuses %q: %w; quote it in single quotes", line, err) + } + if err := checkWPArgv(words); err != nil { + return err + } + if isDBQuery(words) { + if err := checkSQLNoFileWrite(line); err != nil { + return err + } + } + if !nested || isEvalArgv(words) { + return nil + } + var inner []string + for _, w := range words { + ws, err := shellWords(w) + if err != nil { + return fmt.Errorf("wpgo raw refuses %q on WP Engine, which parses each word again: %w", w, err) + } + inner = append(inner, ws...) + } + return checkWPArgv(inner) +} + +// checkWPArgv refuses argv, the exact words wp receives, if any wpReading of +// it writes a dump or export on the server. +func checkWPArgv(argv []string) error { + for _, r := range wpReadings { + pos, flags := parseWPArgs(argv, r) + if err := checkWPInvocation(pos, flags); err != nil { + return err + } + } + // wp db query hands its flags to the mysql client, so SQL can also + // arrive in a flag value such as --execute=. + if isDBQuery(argv) { + return checkSQLNoFileWrite(strings.Join(argv, " ")) + } + return nil +} + +func isDBQuery(argv []string) bool { + for _, r := range wpReadings { + if pos, _ := parseWPArgs(argv, r); len(pos) > 1 && pos[0] == "db" && pos[1] == "query" { + return true + } + } + return false +} + +// isEvalArgv reports whether every wpReading of argv runs eval or eval-file. +func isEvalArgv(argv []string) bool { + for _, r := range wpReadings { + if pos, _ := parseWPArgs(argv, r); len(pos) == 0 || !isEvalCommand(pos[0]) { + return false + } + } + return true +} + +func isEvalCommand(cmd string) bool { + return cmd == "eval" || cmd == "eval-file" +} + +func checkWPInvocation(pos []string, flags []wpFlag) error { + at := func(i int) string { + if i < len(pos) { + return pos[i] + } + return "" + } + if !isEvalCommand(at(0)) { + for _, f := range flags { + if f.name == "exec" || f.name == "require" { + return fmt.Errorf("wpgo raw refuses --%s outside eval and eval-file: it runs arbitrary PHP; use `wpgo eval`", f.name) + } + } + } + switch { + case at(0) == "export": + return fmt.Errorf("wpgo raw refuses `wp export`: it writes WXR files on the server; nothing may be exported to the server") + case at(0) == "db" && (at(1) == "cli" || at(1) == "connect"): + return fmt.Errorf("wpgo raw refuses `wp db %s`: the mysql client can write files on the server; use `wpgo db query`", at(1)) + case at(0) == "db" && (at(1) == "export" || at(1) == "dump"): + if len(pos) != 3 || pos[2] != "-" { + return fmt.Errorf("wpgo raw refuses `wp db %s` without the single file argument '-': it writes the dump on the server; use `wpgo db export` to save it on this machine", at(1)) + } + for _, f := range flags { + if !wpGlobalFlags[f.name] && !rawDBExportFlags[f.name] && (f.name != "defaults" || !f.negated) { + return fmt.Errorf("wpgo raw refuses `wp db %s` flag --%s: it may write a file on the server; use `wpgo db export`", at(1), f.name) + } + if f.name == "tables" || f.name == "exclude_tables" { + for _, table := range strings.Split(f.value, ",") { + // wp-cli trims each entry with PHP trim(). + if strings.HasPrefix(strings.Trim(table, " \t\n\r\x00\x0b"), "-") { + return fmt.Errorf("wpgo raw refuses `wp db %s` --%s entry %q: mysqldump would read it as an option; use `wpgo db export`", at(1), f.name, table) + } + } + } + } + case at(0) == "db" && dbSubcommandFlags[at(1)] != nil: + allowed := dbSubcommandFlags[at(1)] + for _, f := range flags { + if wpGlobalFlags[f.name] || (f.name == "defaults" && f.negated) { + continue + } + kind, ok := allowed[f.name] + if !ok || f.negated || (kind == dbFlagBool && f.hasValue) { + return fmt.Errorf("refusing `wp db %s` flag --%s: wp-cli hands it to the mysql client, which can write or run files on the server", at(1), f.name) + } + if f.name == "execute" { + if err := checkSQLClientCommands(f.value); err != nil { + return err + } + } + } + if at(1) == "query" { + for _, sql := range pos[2:] { + if err := checkSQLClientCommands(sql); err != nil { + return err + } + } + } + case at(0) == "search-replace" || (at(0) == "db" && at(1) == "search-replace"): + for _, f := range flags { + if f.name == "export" { + return fmt.Errorf("wpgo raw refuses search-replace with --export: it writes a SQL dump on the server; run search-replace without --export and take backups with `wpgo db export`") + } + } + } + return nil +} + +// shellWords splits line into the words a POSIX shell (sh, dash or bash) +// would pass to a command, handling single quotes, double quotes and +// backslash escapes. It returns an error for anything that would make the +// shell do more than pass literal words: an unquoted control operator, +// redirection, newline, comment, subshell or brace, a $ or backtick outside +// single quotes, a line continuation, an unbalanced quote, an unquoted glob +// character (*, ? or [), or an unquoted ~ at the start of a word or after = +// or :, where bash expands it. +func shellWords(line string) ([]string, error) { + var words []string + var cur strings.Builder + inWord := false + for i := 0; i < len(line); i++ { + c := line[i] + switch { + case c == ' ' || c == '\t': + if inWord { + words = append(words, cur.String()) + cur.Reset() + inWord = false + } + case c == '\'': + end := strings.IndexByte(line[i+1:], '\'') + if end < 0 { + return nil, errors.New("unbalanced single quote") + } + cur.WriteString(line[i+1 : i+1+end]) + i += end + 1 + inWord = true + case c == '"': + inWord = true + for i++; ; i++ { + if i >= len(line) { + return nil, errors.New("unbalanced double quote") + } + c = line[i] + if c == '"' { + break + } + switch c { + case '$', '`': + return nil, fmt.Errorf("%c inside double quotes expands", c) + case '\\': + if i+1 < len(line) && line[i+1] == '\n' { + return nil, errors.New("line continuation") + } + // Inside double quotes a backslash escapes only these. + if i+1 < len(line) && strings.IndexByte("$`\"\\", line[i+1]) >= 0 { + i++ + c = line[i] + } + } + cur.WriteByte(c) + } + case c == '\\': + if i+1 >= len(line) { + return nil, errors.New("trailing backslash") + } + if line[i+1] == '\n' { + return nil, errors.New("line continuation") + } + i++ + cur.WriteByte(line[i]) + inWord = true + case c == '#' && !inWord: + return nil, errors.New("unquoted # starts a comment") + case c == '*' || c == '?' || c == '[': + return nil, fmt.Errorf("unquoted %q matches server file names", c) + case c == '~' && (!inWord || line[i-1] == '=' || line[i-1] == ':'): + return nil, errors.New("unquoted ~ expands to a home directory") + case strings.IndexByte(";&|<>()$`{}\n", c) >= 0: + return nil, fmt.Errorf("unquoted %q is shell syntax", c) + default: + cur.WriteByte(c) + inWord = true + } + } + if inWord { + words = append(words, cur.String()) + } + return words, nil +} + +// checkSQLNoFileWrite refuses SQL that mentions OUTFILE or DUMPFILE in any +// case. It matches the bare word so comments or versioned comments between +// INTO and the keyword cannot hide it. +func checkSQLNoFileWrite(sql string) error { + upper := strings.ToUpper(sql) + for _, kw := range []string{"OUTFILE", "DUMPFILE"} { + if strings.Contains(upper, kw) { + return fmt.Errorf("refusing SQL containing %s: SELECT ... INTO %s writes a file on the database server; take backups with `wpgo db export`", kw, kw) + } + } + return nil +} + +// sqlClientCommands are the mysql client's long-form commands that read, +// write or run files. The client recognizes them at the start of a statement. +var sqlClientCommands = []string{"system", "tee", "pager", "source", "edit"} + +// sqlClientShortCommands are the backslash forms of the client commands that +// read, write or run files: system, tee, pager, source and edit. The client +// recognizes them anywhere in a line. +var sqlClientShortCommands = []string{`\!`, `\t`, `\p`, `\.`, `\e`} + +// checkSQLClientCommands refuses SQL that would make the mysql client run a +// shell command, read a file, or write its output to a file. It checks every +// line and every statement in a line, in any case, and also refuses a +// harmless match inside a string literal. +func checkSQLClientCommands(sql string) error { + lower := strings.ToLower(sql) + for _, short := range sqlClientShortCommands { + if strings.Contains(lower, short) { + return fmt.Errorf("refusing SQL containing the mysql client command %s: it can run or write files on the server", short) + } + } + for _, line := range strings.Split(lower, "\n") { + for _, stmt := range strings.Split(line, ";") { + stmt = strings.TrimLeft(stmt, " \t\r\v\f") + for _, cmd := range sqlClientCommands { + rest, ok := strings.CutPrefix(stmt, cmd) + if ok && (rest == "" || strings.IndexByte(" \t\r\v\f;", rest[0]) >= 0) { + return fmt.Errorf("refusing SQL with the mysql client command %q at the start of a statement: it can run or write files on the server", cmd) + } + } + } + } + return nil +} diff --git a/internal/cmd/raw_guard_test.go b/internal/cmd/raw_guard_test.go new file mode 100644 index 0000000..46ccb0a --- /dev/null +++ b/internal/cmd/raw_guard_test.go @@ -0,0 +1,395 @@ +package cmd + +import ( + "io" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "testing" +) + +// TestRawRefusesServerDumps runs each refused argv through RawCmd.Run with +// zero Globals: the refusal must come back before any site is resolved or +// connected. +func TestRawRefusesServerDumps(t *testing.T) { + tests := []struct { + name string + args []string + wantErr string + }{ + {"db export to file", []string{"db", "export", "backup.sql"}, "use `wpgo db export`"}, + {"db export default file", []string{"db", "export"}, "use `wpgo db export`"}, + {"db export empty file arg", []string{"db", "export", ""}, "use `wpgo db export`"}, + {"db dump alias", []string{"db", "dump", "backup.sql"}, "use `wpgo db export`"}, + {"db export mixed case", []string{"DB", "Export", "backup.sql"}, "use `wpgo db export`"}, + {"global flags before command", []string{"--path=/var/www", "--url=example.com", "--skip-plugins", "db", "export", "x.sql"}, "use `wpgo db export`"}, + {"flag then separate value", []string{"--path", "/var/www", "db", "export"}, "use `wpgo db export`"}, + {"site alias", []string{"@prod", "db", "export", "x.sql"}, "use `wpgo db export`"}, + {"end of options marker", []string{"--", "db", "export", "x.sql"}, "use `wpgo db export`"}, + {"repeated delimiters", []string{"--", "--", "--", "db", "export", "backup.sql"}, "use `wpgo db export`"}, + {"alias then repeated delimiters", []string{"@prod", "--", "--", "db", "export", "backup.sql"}, "use `wpgo db export`"}, + {"flags between words", []string{"db", "--quiet", "export", "--add-drop-table", "x.sql"}, "use `wpgo db export`"}, + {"quoted command word", []string{"db", "'export'", "x.sql"}, "use `wpgo db export`"}, + {"backslash in command word", []string{"db", `ex\port`, "x.sql"}, "use `wpgo db export`"}, + {"sql alias export", []string{"sql", "export", "x.sql"}, "use `wpgo db export`"}, + {"sql alias dump with site", []string{"@prod", "sql", "dump", "x.sql"}, "use `wpgo db export`"}, + {"stdout plus result-file", []string{"db", "export", "-", "--result-file=x.sql"}, "flag --result-file"}, + {"stdout plus result-file prefix", []string{"db", "export", "-", "--result=x.sql"}, "flag --result"}, + {"stdout plus tab dir", []string{"db", "export", "-", "--tab=/tmp"}, "flag --tab"}, + {"stdout plus short flag", []string{"db", "export", "-", "-r=x.sql"}, "flag --r"}, + {"stdout plus extra file", []string{"db", "export", "-", "x.sql"}, "use `wpgo db export`"}, + {"stdout plus defaults", []string{"db", "export", "-", "--defaults"}, "flag --defaults"}, + {"tables value is a flag", []string{"db", "export", "-", "--tables=--result-file=x.sql"}, "--tables entry"}, + {"tables entry is a flag", []string{"db", "export", "-", "--tables=a,--tab=/tmp"}, "--tables entry"}, + {"exclude_tables entry is a flag", []string{"db", "export", "-", "'--exclude_tables=a, -r'"}, "--exclude_tables entry"}, + {"db cli", []string{"db", "cli"}, "wp db cli"}, + {"db connect", []string{"db", "connect"}, "wp db connect"}, + {"sql cli", []string{"sql", "cli"}, "wp db cli"}, + {"sql connect", []string{"@prod", "sql", "connect"}, "wp db connect"}, + {"exec global", []string{"option", "get", "home", `--exec='file_put_contents("x.sql", 1);'`}, "--exec"}, + {"exec global spaced", []string{"--exec", "'echo 1;'", "option", "get", "home"}, "--exec"}, + {"require global", []string{"--require=/tmp/dump.php", "option", "get", "home"}, "--require"}, + {"require global spaced", []string{"plugin", "list", "--require", "/tmp/dump.php"}, "--require"}, + {"wxr export", []string{"export", "--dir=/tmp"}, "wp export"}, + {"wxr export to stdout", []string{"export", "--stdout"}, "wp export"}, + {"search-replace export file", []string{"search-replace", "a", "b", "--export=x.sql"}, "--export"}, + {"search-replace export dash", []string{"search-replace", "a", "b", "--export=-"}, "--export"}, + {"search-replace bare export", []string{"search-replace", "a", "b", "--export"}, "--export"}, + {"search-replace export uppercase", []string{"Search-Replace", "a", "b", "--EXPORT=x.sql"}, "--export"}, + {"search-replace export before command", []string{"--export=x.sql", "search-replace", "a", "b"}, "--export"}, + {"db search-replace export", []string{"db", "search-replace", "a", "b", "--export=x.sql"}, "--export"}, + {"query into outfile", []string{"db", "query", `"SELECT * FROM wp_users INTO OUTFILE '/tmp/u'"`}, "refusing SQL containing OUTFILE"}, + {"query into dumpfile lowercase", []string{"db", "query", "select 1 into dumpfile '/tmp/u'"}, "DUMPFILE"}, + {"query outfile behind comment", []string{"db", "query", `"SELECT 1 INTO/**/OutFile '/tmp/u'"`}, "refusing SQL containing OUTFILE"}, + {"query outfile in versioned comment", []string{"db", "query", "SELECT 1 INTO /*!50000OUTFILE*/ '/tmp/u'"}, "OUTFILE"}, + {"query outfile via execute flag", []string{"db", "query", "--execute=SELECT 1 INTO OUTFILE '/tmp/u'"}, "OUTFILE"}, + {"query outfile split by quotes", []string{"db", "query", `'SELECT 1 INTO OUT'"FILE '/tmp/u'"`}, "OUTFILE"}, + {"sql alias query outfile", []string{"sql", "query", `"SELECT 1 INTO OUTFILE '/tmp/u'"`}, "OUTFILE"}, + {"semicolon chain", []string{"option", "get", "home;", "wp", "db", "export", "x.sql"}, "';'"}, + {"semicolon inside one arg", []string{"option", "get", "x; wp db export y.sql"}, "';'"}, + {"and chain", []string{"option get home && wp export"}, "'&'"}, + {"pipe", []string{"post", "list", "|", "tee", "x.sql"}, "'|'"}, + {"redirect", []string{"post", "list", ">x.sql"}, "'>'"}, + {"append redirect", []string{"post", "list", ">>", "x.sql"}, "'>'"}, + {"input redirect", []string{"db", "import", "post_title . ' isn\\'t `x` & | > ;';\nreturn;", + "echo json_encode(['a' => \"b's\"]);", + } { + args := []string{"eval", shlexQuote(php)} + cmd, err := dryRunRaw(t, args) + if err != nil { + t.Fatalf("raw %q refused: %v", args, err) + } + for _, shell := range shells { + c := exec.Command(shell, "-c", cmd) + c.Env = []string{"PATH=" + bin + ":" + os.Getenv("PATH")} + out, err := c.Output() + if err != nil { + t.Fatalf("%s -c %q: %v", shell, cmd, err) + } + if got, want := string(out), "eval\x00"+php+"\x00"; got != want { + t.Errorf("%s: wp argv = %q, want %q", shell, got, want) + } + } + if left := listFiles(t, wpPath); len(left) != 0 { + t.Errorf("remote shell created %v", left) + } + } +} + +// TestRawWPEngineChecksSecondParse covers WP Engine, whose gateway parses each +// word again: words that are harmless after one parse are refused there, while +// a double-quoted eval passes on both adapters. +func TestRawWPEngineChecksSecondParse(t *testing.T) { + php := `$x = get_option('home'); echo "$x"; // isn't > x.sql` + tests := []struct { + name string + args []string + wantErr string // on wpengine; standard must pass + }{ + {"hidden export word", []string{"db", "'export x.sql'"}, "use `wpgo db export`"}, + {"hidden chain", []string{"option", "get", "'home; wp db export y.sql'"}, "';'"}, + {"hidden export flag", []string{"search-replace", "a", "b", `"--exp''ort=x.sql"`}, "--export"}, + {"hidden result-file", []string{"db", "export", "-", `"--tables=a --result-file=x.sql"`}, "flag --result-file"}, + {"hidden glob", []string{"db", "'e[x]port'", "backup.sql"}, "'['"}, + {"hidden tilde", []string{"option", "get", `'~/x'`}, "unquoted ~"}, + {"double-quoted eval", []string{"eval", shlexQuote(shlexQuote(php))}, ""}, + {"plain command", []string{"plugin", "list", "--status=active"}, ""}, + } + for _, hostType := range []string{"standard", "wpengine"} { + for _, tt := range tests { + t.Run(hostType+"/"+tt.name, func(t *testing.T) { + fakeSite(t, hostType) + cmd, err := dryRunRaw(t, tt.args) + want := "" + if hostType == "wpengine" { + want = tt.wantErr + } + if want == "" { + if err != nil || !strings.HasSuffix(cmd, " && wp "+strings.Join(tt.args, " ")) { + t.Fatalf("raw %q: cmd = %q, err = %v; want sent unchanged", tt.args, cmd, err) + } + return + } + if err == nil || !strings.Contains(err.Error(), want) || cmd != "" { + t.Fatalf("raw %q: cmd = %q, err = %v; want refusal %q before sending", tt.args, cmd, err, want) + } + }) + } + } +} diff --git a/internal/cmd/root.go b/internal/cmd/root.go index c1e5716..7f6e5a2 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -26,8 +26,8 @@ type CLI struct { Config ConfigCmdGroup `cmd:"" name:"config" help:"wp-config.php management"` Role RoleCmd `cmd:"" help:"Role management"` Maintenance MaintenanceCmd `cmd:"" help:"Maintenance mode management"` - Eval EvalCmd `cmd:"" help:"Execute arbitrary PHP"` - Raw RawCmd `cmd:"" help:"Pass-through to wp-cli"` + Eval EvalCmd `cmd:"" help:"Execute arbitrary PHP. It can write files on the server; take backups and exports with 'wpgo db export'."` + Raw RawCmd `cmd:"" help:"Pass-through to wp-cli. Refuses server-side dumps and exports, db cli, --exec/--require and shell syntax outside quotes; eval and eval-file PHP is not checked."` // Shortcut commands Health HealthCmd `cmd:"" help:"Full site health check"` diff --git a/internal/cmd/shortcuts.go b/internal/cmd/shortcuts.go index 3d3268a..172f8e6 100644 --- a/internal/cmd/shortcuts.go +++ b/internal/cmd/shortcuts.go @@ -25,37 +25,31 @@ func (c *HealthCmd) Run(g *Globals) error { }) } -// BackupCmd creates a named database backup via the embedded full-backup.sh script. +// BackupCmd creates a named database backup by streaming the dump to this +// machine. Nothing is written on the server. type BackupCmd struct { Description string `arg:"" optional:"" help:"Backup description" default:"Manual"` } func (c *BackupCmd) Run(g *Globals) error { - return runScript(g, scripts.ScriptFullBackup, []string{g.Site, c.Description}, func(output string, globals *Globals) error { - if globals.JSON { - fmt.Println(output) - return nil - } - var result struct { - Status string `json:"status"` - Filename string `json:"filename"` - Path string `json:"path"` - Size string `json:"size"` - Error string `json:"error"` - } - if err := json.Unmarshal([]byte(output), &result); err != nil { - fmt.Println(output) - return nil - } - if result.Status == "ok" { - fmt.Printf("Backup created: %s\n", result.Filename) - fmt.Printf("Path: %s\n", result.Path) - fmt.Printf("Size: %s\n", result.Size) - } else { - fmt.Printf("Backup failed: %s\n", result.Error) - } - return nil - }) + rc, err := NewRunContext(g) + if err != nil { + return err + } + defer rc.Close() + site, err := rc.ResolveSite() + if err != nil { + return err + } + path, err := defaultDumpPath(site.Alias, c.Description, time.Now()) + if err != nil { + return err + } + dump, err := exportToLocal(rc, site, path) + if err != nil { + return fmt.Errorf("backup failed: %w", err) + } + return printDump(rc, "Backup saved:", dump) } // StatusCmd shows a quick site status (from cache or live). @@ -217,6 +211,12 @@ func runScript(g *Globals, scriptName string, args []string, format func(string, // Extract JSON from output (wp-cli may prepend warnings). output := extractJSON(result.Stdout) + if result.ExitCode != 0 { + if output != "" { + fmt.Println(output) + } + return fmt.Errorf("%s exited %d: %s", scriptName, result.ExitCode, strings.TrimSpace(result.Stderr)) + } return format(output, g) } diff --git a/internal/scripts/embed.go b/internal/scripts/embed.go index dba19bc..a3e5dd6 100644 --- a/internal/scripts/embed.go +++ b/internal/scripts/embed.go @@ -32,7 +32,6 @@ func MustGetScript(name string) string { // Available script names. const ( ScriptHealthCheck = "health-check.sh" - ScriptFullBackup = "full-backup.sh" ScriptCacheClear = "cache-clear.sh" ScriptSecurityAudit = "security-audit.sh" ) diff --git a/internal/scripts/embed_test.go b/internal/scripts/embed_test.go index b9db464..f915c14 100644 --- a/internal/scripts/embed_test.go +++ b/internal/scripts/embed_test.go @@ -21,19 +21,6 @@ func TestGetScript_HealthCheck(t *testing.T) { } } -func TestGetScript_FullBackup(t *testing.T) { - s, err := GetScript(ScriptFullBackup) - if err != nil { - t.Fatalf("GetScript(%s): %v", ScriptFullBackup, err) - } - if !strings.Contains(s, "wp db export") { - t.Error("full-backup.sh should contain 'wp db export'") - } - if !strings.Contains(s, "FILENAME") { - t.Error("full-backup.sh should build a filename") - } -} - func TestGetScript_CacheClear(t *testing.T) { s, err := GetScript(ScriptCacheClear) if err != nil { @@ -89,7 +76,6 @@ func TestMustGetScript_Success(t *testing.T) { func TestAllScriptsOutputJSON(t *testing.T) { scriptNames := []string{ ScriptHealthCheck, - ScriptFullBackup, ScriptCacheClear, ScriptSecurityAudit, } diff --git a/internal/scripts/scripts/full-backup.sh b/internal/scripts/scripts/full-backup.sh deleted file mode 100644 index 15f943a..0000000 --- a/internal/scripts/scripts/full-backup.sh +++ /dev/null @@ -1,45 +0,0 @@ -#!/bin/bash -# full-backup.sh — Named database backup for wpgo. -# Executed via SSH stdin: bash -s -- -# Output: JSON with status, filename, path. - -set -e - -CLIENT="${1:-Site}" -DESC="${2:-Manual}" -DATE=$(date +%Y%m%d_%H%M%S) - -# Sanitize inputs: replace spaces/special chars with underscores. -CLIENT_SAFE=$(echo "$CLIENT" | sed 's/[^a-zA-Z0-9_-]/_/g') -DESC_SAFE=$(echo "$DESC" | sed 's/[^a-zA-Z0-9_-]/_/g') - -FILENAME="${CLIENT_SAFE}_DB_${DESC_SAFE}_${DATE}.sql" - -# Export to a temp location within the WordPress directory. -EXPORT_DIR="." -EXPORT_PATH="${EXPORT_DIR}/${FILENAME}" - -if wp db export "$EXPORT_PATH" 2>/dev/null; then - FILESIZE=$(du -h "$EXPORT_PATH" 2>/dev/null | cut -f1 || echo "unknown") - FULLPATH=$(realpath "$EXPORT_PATH" 2>/dev/null || echo "$EXPORT_PATH") - - cat <