Files

267 lines
9.6 KiB
Go
Raw Permalink Normal View History

package main
import (
"io"
"os"
"path/filepath"
"strings"
"testing"
"github.com/runbear-io/beardrive/internal/config"
"github.com/runbear-io/beardrive/internal/syncer"
)
// Round 6 — "15 of 22 CLI commands have no security test driving them".
// These attack `export` (the archive command whose default output path is
// chosen by a value that travels with the folder), `scope`/`init --only` (the
// managed .bdriveignore block round 4 proved is a team-wide delete lever), and
// `status` (a command that prints hub-chosen strings straight to a terminal —
// the class round 5 closed for `bdrive log` and only for `bdrive log`).
//
// Helpers are prefixed seccli.
// seccliMount builds an isolated BDRIVE_HOME with one enrolled project folder
// whose .bdrive/config.json carries the given volume name, and a file://
// remote so commands that need a backend get a real one with no hub.
//
// The volume name matters: init writes it from the project NAME the hub
// returned (init.go:218, `Volume: p.Name`), and a project name is chosen by
// whoever created the project — any member of your org — and passes only
// through trimName, which strips \n \r \t and nothing else. So `..`, ESC and
// every other byte survive the hub round trip into this file. The same file
// also simply travels with the folder (a zip, a clone, a colleague's copy),
// which is how rounds 4 and 5 reached Project.ID and Project.Remote.
func seccliMount(t *testing.T, volume string) string {
t.Helper()
t.Setenv("BDRIVE_HOME", t.TempDir())
folder := t.TempDir()
store := t.TempDir()
if _, err := config.SaveProject(folder, config.Project{
Volume: volume,
Remote: "file://" + store,
}); err != nil {
t.Fatal(err)
}
if _, _, err := config.EnrollMount(folder); err != nil {
t.Fatal(err)
}
return folder
}
// seccliRun runs one cobra command with args, capturing everything it writes
// to the real stdout as well as to the command's own writers — `status` and
// `init` use fmt.Printf, not cmd.OutOrStdout().
func seccliRun(t *testing.T, cmd interface {
SetArgs([]string)
Execute() error
}, args []string) (string, error) {
t.Helper()
r, w, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
old := os.Stdout
os.Stdout = w
cmd.SetArgs(args)
runErr := cmd.Execute()
os.Stdout = old
w.Close()
out, _ := io.ReadAll(r)
r.Close()
return string(out), runErr
}
// TestSec_CLI_ExportOutputPathCannotEscapeTheWorkingDirectory
//
// `bdrive export` with no -o builds its destination as
//
// out = fmt.Sprintf("%s-export-%s.tar.gz", proj.Volume, ...) // migrate.go:74
// f, err := os.Create(out)
//
// proj.Volume is read verbatim out of the folder's .bdrive/config.json — the
// file rounds 4 and 5 already established is untrusted input (Project.ID is
// now regex-validated because it was joined onto $BDRIVE_HOME; Project.Remote
// is now origin-bound because it chose where the device token was sent).
// Volume was left out of that sweep, and it is the one field that reaches
// os.Create.
//
// It is not only a file that travels with the folder: init writes it from the
// project name the hub hands back, and project names are not path-validated
// anywhere on the hub (trimName strips only \n \r \t). So an org member who
// names a project `../../../../tmp/pwned` chooses, on every teammate's
// machine, where that teammate's `bdrive export` writes a multi-megabyte file
// — outside the project, outside the working directory, with no prompt and no
// mention of the path until after os.Create has already truncated whatever
// was there.
//
// The secure behavior asserted: a default output path stays in the directory
// the command was run in. -o is the flag for writing anywhere else, and it is
// the user typing it.
func TestSec_CLI_ExportOutputPathCannotEscapeTheWorkingDirectory(t *testing.T) {
// Control: an ordinary project name writes an archive in the cwd, so the
// harness is known to reach os.Create at all.
t.Run("control_ordinary_name", func(t *testing.T) {
folder := seccliMount(t, "wiki")
cwd := t.TempDir()
t.Chdir(cwd)
if _, err := seccliRun(t, exportCmd(), []string{folder}); err != nil {
t.Fatalf("export: %v", err)
}
if n := len(seccliArchives(t, cwd)); n != 1 {
t.Fatalf("control wrote %d archives in the working directory, want 1", n)
}
})
t.Run("traversing_name", func(t *testing.T) {
folder := seccliMount(t, "../../pwned")
outside := t.TempDir()
cwd := filepath.Join(outside, "a", "b")
if err := os.MkdirAll(cwd, 0o755); err != nil {
t.Fatal(err)
}
t.Chdir(cwd)
out, err := seccliRun(t, exportCmd(), []string{folder})
if err != nil {
t.Logf("export returned %v (output: %s)", err, out)
}
if escaped := seccliArchives(t, outside); len(escaped) > 0 {
t.Errorf("export wrote %v — outside the working directory, at a path chosen by "+
"the project name in .bdrive/config.json", escaped)
}
})
}
// seccliArchives lists the .tar.gz files directly in dir.
func seccliArchives(t *testing.T, dir string) []string {
t.Helper()
ents, err := os.ReadDir(dir)
if err != nil {
t.Fatal(err)
}
var out []string
for _, e := range ents {
if strings.HasSuffix(e.Name(), ".tar.gz") {
out = append(out, filepath.Join(dir, e.Name()))
}
}
return out
}
// TestSec_CLI_ScopeRuleCannotOutliveTheScopeThatWroteIt
//
// `bdrive init --only` and `bdrive scope add` both write a managed block into
// .bdriveignore, which SYNCS: its rules apply to every teammate's device.
// cleanScopeDirs (scopefile.go:97) validates each name for `..` and for being
// empty, and then scopeLines renders it as `"!/" + d + "/"` — one line, one
// name, assumed.
//
// A newline in a name is not checked, and a newline is a legal byte in a unix
// directory name. `docs\n# end bdrive scope\n*` renders as three lines, the
// second of which is the block's own END MARKER: the managed block terminates
// early and the injected `*/` rule lands OUTSIDE it. writeScopeDirs removes
// the block by finding its markers, so removing the scope — `bdrive scope rm`,
// `bdrive init --only` with nothing, widening back to the whole folder —
// keeps the injected rule forever. `*/` ignores every directory in the
// project, for the whole team, and no bdrive command can take it out again.
//
// The secure behavior asserted: whatever a scope writes, removing the scope
// puts .bdriveignore back the way it was.
func TestSec_CLI_ScopeRuleCannotOutliveTheScopeThatWroteIt(t *testing.T) {
for _, name := range []string{
"docs\n# end bdrive scope\n*",
"docs\n*",
"docs\n!/",
} {
t.Run(strings.ReplaceAll(name, "\n", "\\n"), func(t *testing.T) {
folder := t.TempDir()
ignorePath := filepath.Join(folder, syncer.IgnoreFile)
before := "node_modules/\n*.log\n"
if err := os.WriteFile(ignorePath, []byte(before), 0o644); err != nil {
t.Fatal(err)
}
dirs, err := cleanScopeDirs([]string{name})
if err != nil {
return // refused at the door: nothing to assert
}
if err := writeScopeDirs(folder, dirs); err != nil {
t.Fatal(err)
}
// Now widen back to the whole folder, the way `bdrive scope rm` of
// the last folder does.
if err := writeScopeDirs(folder, nil); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(ignorePath)
if err != nil {
t.Fatal(err)
}
if got := string(data); got != before {
t.Errorf("removing the scope left rules behind in the synced .bdriveignore\n"+
"before:\n%q\nafter:\n%q", before, got)
}
})
}
}
// TestSec_CLI_StatusDoesNotRenderHubChosenStringsToTheTerminal
//
// Round 5 closed this class for `bdrive log` and `bdrive restore --list`
// (scoreboard row 21: "every peer-controlled string that reaches a terminal"),
// with one safeField where those rows are assembled. `bdrive status` prints
// two strings from the same trust level with a bare %s:
//
// fmt.Printf(" project: %s (%s)\n", mi.Volume, id) // cmds.go:209
// fmt.Printf(" remote: %s\n", mi.Remote) // cmds.go:211
//
// Both come from .bdrive/config.json, and Volume comes originally from the
// hub's project name — chosen by any member of your org, passed through a
// trimName that strips \n \r \t and leaves ESC, OSC and DEL intact. So a
// project name is a terminal escape sequence on every teammate's `bdrive
// status`: repaint the line, clear the scrollback, set the window title, or
// on terminals with the classic OSC/DECRQSS reporting behaviours make the
// emulator type a reply back onto the shell's stdin.
//
// The secure behavior asserted is the one round 5 already chose for the other
// two surfaces: no C0 control character or DEL reaches the terminal.
func TestSec_CLI_StatusDoesNotRenderHubChosenStringsToTheTerminal(t *testing.T) {
// Control: an ordinary name is printed, so the assertion is looking at
// output the command actually produced.
folder := seccliMount(t, "wiki")
out, err := seccliRun(t, statusCmd(), []string{folder})
if err != nil {
t.Fatalf("status: %v", err)
}
if !strings.Contains(out, "wiki") {
t.Fatalf("control: status did not print the project name\n%s", out)
}
for _, name := range []string{
"wiki\x1b[2K\r project: totally-fine (p-00000000)",
"wiki\x1b]0;pwned\x07",
"wiki\x1b]52;c;cHduZWQ=\x07",
"wiki\x7f\x7f\x7f\x7f",
} {
t.Run(strings.Map(func(r rune) rune {
if r < 0x20 || r == 0x7f {
return '.'
}
return r
}, name), func(t *testing.T) {
folder := seccliMount(t, name)
out, err := seccliRun(t, statusCmd(), []string{folder})
if err != nil {
t.Fatalf("status: %v", err)
}
for _, line := range strings.Split(out, "\n") {
for _, r := range line {
if r < 0x20 || r == 0x7f {
t.Errorf("status printed control character %q inside a line: %q", r, line)
}
}
}
})
}
}