Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,16 @@ Put wp-cli arguments after `--`. `raw` joins them with spaces and the server she

`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`.

When an argument is `-`, the file argument wp-cli reads from stdin, `raw` forwards piped or redirected stdin to wp. It forwards nothing otherwise:

```bash
wpgo -s mysite raw -- post update 7 - < content.html
```

`raw` refuses a `-` argument, before connecting, when stdin is a terminal, `/dev/null` or empty: wp would read nothing and write empty content. On WP Engine an argument that the gateway's second parse turns into `-`, such as `"'-'"`, counts too. `db export -` and `db dump -` are exempt, since there `-` means stdout. `--dry-run` prints the command without reading stdin.

`raw` never sends stdin to `wp db`: it refuses `-` on any other `db` subcommand, such as `db query -` or `db import -`, and `db query` with no SQL argument or `--execute`. SQL from stdin would skip the checks above, so pass it as an argument or use `wpgo db import`.

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
Expand Down
126 changes: 121 additions & 5 deletions internal/cmd/raw.go
Original file line number Diff line number Diff line change
@@ -1,22 +1,32 @@
package cmd

import (
"bufio"
"context"
"errors"
"fmt"
"io"
"os"
"slices"
"strings"

"github.com/builtbyrobben/wpssh/internal/adapter"
"github.com/builtbyrobben/wpssh/internal/cache"
internalssh "github.com/builtbyrobben/wpssh/internal/ssh"
"github.com/builtbyrobben/wpssh/internal/wpcli"
)

// Raw command — pass-through escape hatch to wp-cli.

type RawCmd struct {
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."`
Args []string `arg:"" optional:"" help:"Arguments to pass to wp-cli. Forwards piped or redirected stdin to wp only for a \"-\" argument, and refuses that \"-\" when stdin is a terminal or empty or the command is wp db. Refuses shell operators outside quotes and commands that write dumps or exports on the server."`
}

func (c *RawCmd) Run(g *Globals) error {
return c.runWithIO(g, os.Stdin)
}

func (c *RawCmd) runWithIO(g *Globals, stdin io.Reader) error {
if err := checkRawArgs(c.Args, false); err != nil {
return err
}
Expand All @@ -29,18 +39,35 @@ func (c *RawCmd) Run(g *Globals) error {
if err != nil {
return err
}
if adapter.ForSite(site).Name() == "wpengine" {
nested := adapter.ForSite(site).Name() == "wpengine"
if nested {
if err := checkRawArgs(c.Args, true); err != nil {
return err
}
}
readsStdin, err := checkRawStdin(rawWordLayers(c.Args, nested))
if err != nil {
return err
}

// Build the raw command by passing all args through.
builder := wpcli.New(c.Args...)
cmd := builder.Build(site.WPPath)
result, err := rc.ExecWP(context.Background(), site, cmd)
if err != nil {
return err
var result internalssh.ExecResult
if readsStdin && !g.DryRun {
input, err := rawInput(stdin)
if err != nil {
return err
}
result, err = rc.ExecWPStdin(context.Background(), site, cmd, input)
if err != nil {
return err
}
} else {
result, err = rc.ExecWP(context.Background(), site, cmd)
if err != nil {
return err
}
}
if result.ExitCode != 0 {
return fmt.Errorf("wp %s: %s", strings.Join(c.Args, " "), result.Stderr)
Expand All @@ -53,3 +80,92 @@ func (c *RawCmd) Run(g *Globals) error {
fmt.Fprint(rc.Stdout, result.Stdout)
return nil
}

// rawWordLayers returns the argvs wp may receive for args: the words the
// remote shell passes and, with nested, as for WP Engine, the words the
// gateway's second parse makes of them. A word that fails the second parse
// is kept as it is; checkRawArgs has already refused it unless it is eval PHP.
func rawWordLayers(args []string, nested bool) [][]string {
words, _ := shellWords(strings.Join(args, " "))
layers := [][]string{words}
if !nested {
return layers
}
var inner []string
for _, w := range words {
ws, err := shellWords(w)
if err != nil {
ws = []string{w}
}
inner = append(inner, ws...)
}
return append(layers, inner)
}

// dbStdoutSubcommands are the wp db subcommands that read "-" as stdout.
var dbStdoutSubcommands = map[string]bool{"export": true, "dump": true}

// checkRawStdin reports whether any layer of argv holds a "-" word that wp
// reads from stdin; db export and dump write to stdout for "-" instead. It
// refuses every wp db command that would read SQL from stdin, as a "-" file
// or as db query without SQL, because raw checks SQL only in arguments.
func checkRawStdin(layers [][]string) (readsStdin bool, err error) {
for _, words := range layers {
dash := slices.Contains(words, "-")
for _, r := range wpReadings {
pos, flags := parseWPArgs(words, r)
if len(pos) < 2 || pos[0] != "db" {
continue
}
if dbStdoutSubcommands[pos[1]] {
dash = false
continue
}
if pos[1] == "query" && len(pos) == 2 && !slices.ContainsFunc(flags, func(f wpFlag) bool { return f.name == "execute" }) {
return false, errors.New("wpgo raw refuses `wp db query` without SQL: wp would read SQL from stdin, which raw neither checks nor forwards; pass the SQL as an argument")
}
}
if !dash {
continue
}
for _, r := range wpReadings {
if pos, _ := parseWPArgs(words, r); len(pos) > 0 && pos[0] == "db" {
return false, errors.New(`wpgo raw refuses a "-" argument on wp db: SQL read from stdin would skip the checks raw applies to SQL arguments; pass the SQL as an argument or use ` + "`wpgo db import`")
}
}
readsStdin = true
}
return readsStdin, nil
}

// rawInput returns local stdin for wp to read for a "-" argument. It refuses
// a terminal or empty stdin: wp would read EOF and write empty content, such
// as an empty post body.
func rawInput(stdin io.Reader) (io.Reader, error) {
if !isPiped(stdin) {
return nil, errors.New(`wpgo raw refuses a "-" argument when stdin is a terminal: wp would read no input and write empty content; pipe or redirect the input, e.g. wpgo raw -- post update 7 - < content.html`)
}
buffered := bufio.NewReader(stdin)
if _, err := buffered.Peek(1); err != nil {
if err == io.EOF {
return nil, errors.New(`wpgo raw refuses a "-" argument with empty stdin: wp would write empty content`)
}
return nil, fmt.Errorf("read stdin: %w", err)
}
return buffered, nil
}

// isPiped reports whether r is input to forward: a pipe, a file or another
// reader, rather than nil, a terminal or another character device such as
// /dev/null.
func isPiped(r io.Reader) bool {
if r == nil {
return false
}
f, ok := r.(*os.File)
if !ok {
return true
}
fi, err := f.Stat()
return err == nil && fi.Mode()&os.ModeCharDevice == 0
}
Loading
Loading