mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
225 lines
8.3 KiB
Go
225 lines
8.3 KiB
Go
package main
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"bytes"
|
||
|
|
"context"
|
||
|
|
"fmt"
|
||
|
|
"strings"
|
||
|
|
"testing"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"github.com/runbear-io/beardrive/internal/config"
|
||
|
|
"github.com/runbear-io/beardrive/internal/journal"
|
||
|
|
)
|
||
|
|
|
||
|
|
// Round 5, scoreboard row 15 / the "Op.Note reaching bdrive log's terminal
|
||
|
|
// output" gap carried since round 3.
|
||
|
|
//
|
||
|
|
// `bdrive log` is the audit surface: it is what an operator reads to find out
|
||
|
|
// what a teammate (or a compromised teammate device) did to a shared project.
|
||
|
|
// Every string it prints — Path, Note, User, UserName, Author, DeviceName —
|
||
|
|
// is arbitrary JSON off a peer's journal, written straight to a terminal with
|
||
|
|
// fmt.Fprintln and no escaping at all (cmds.go:304-310).
|
||
|
|
//
|
||
|
|
// A terminal is not a text sink. It executes what it is handed: ESC sequences
|
||
|
|
// move the cursor, repaint, clear the scrollback, set the window title, and on
|
||
|
|
// terminals with the classic OSC/DECRQSS reporting behaviours they make the
|
||
|
|
// emulator *type* a reply back onto the shell's stdin. A carriage return
|
||
|
|
// rewrites the line that was just drawn. A newline forges a whole row. So a
|
||
|
|
// peer that controls these strings controls the audit trail an operator uses
|
||
|
|
// to detect the peer.
|
||
|
|
//
|
||
|
|
// The secure behavior asserted here is the minimum that makes the output
|
||
|
|
// trustworthy, and the one every well-behaved CLI already implements: one
|
||
|
|
// journal entry is one line, and a line carries no C0 control characters. It
|
||
|
|
// says nothing about *how* (escape, strip, quote) — that is the ciso's call.
|
||
|
|
//
|
||
|
|
// Helpers are prefixed secout.
|
||
|
|
|
||
|
|
// secoutMount builds an isolated BDRIVE_HOME with one enrolled project folder
|
||
|
|
// and returns the folder.
|
||
|
|
func secoutMount(t *testing.T) string {
|
||
|
|
t.Helper()
|
||
|
|
t.Setenv("BDRIVE_HOME", t.TempDir())
|
||
|
|
folder := t.TempDir()
|
||
|
|
if _, err := config.SaveProject(folder, config.Project{
|
||
|
|
Volume: "wiki",
|
||
|
|
Remote: "https://hub.example.com/p/p-12345678", // never contacted: log is local
|
||
|
|
}); err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
if _, _, err := config.EnrollMount(folder); err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
return folder
|
||
|
|
}
|
||
|
|
|
||
|
|
// secoutPlant writes ops into the volume store as device dev's journal —
|
||
|
|
// byte for byte what `Cycle`'s pull lands there after a peer pushes them.
|
||
|
|
func secoutPlant(t *testing.T, folder, dev string, ops []journal.Op) {
|
||
|
|
t.Helper()
|
||
|
|
sess, _, err := openSession(context.Background(), folder, false)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
defer closeSession(sess)
|
||
|
|
if err := sess.Store.AppendOps(dev, ops); err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// secoutRun runs `bdrive log` against folder and returns exactly what it
|
||
|
|
// printed.
|
||
|
|
func secoutRun(t *testing.T, folder string, extra ...string) string {
|
||
|
|
t.Helper()
|
||
|
|
c := logCmd()
|
||
|
|
var out bytes.Buffer
|
||
|
|
c.SetOut(&out)
|
||
|
|
c.SetErr(&out)
|
||
|
|
c.SetArgs(append([]string{folder}, extra...))
|
||
|
|
if err := c.Execute(); err != nil {
|
||
|
|
t.Fatalf("log: %v", err)
|
||
|
|
}
|
||
|
|
return out.String()
|
||
|
|
}
|
||
|
|
|
||
|
|
// secoutOp is a plausible peer put op; callers overwrite the one field they
|
||
|
|
// are attacking.
|
||
|
|
func secoutOp(seq int64, p string) journal.Op {
|
||
|
|
return journal.Op{
|
||
|
|
Seq: seq, Lamport: seq, Time: time.Now().UTC().Add(-time.Duration(seq) * time.Minute),
|
||
|
|
Mtime: time.Now().UTC().Add(-time.Duration(seq) * time.Minute),
|
||
|
|
Device: "attacker", DeviceName: "eve-laptop", Author: "eve@evil.test",
|
||
|
|
User: "eve@evil.test", UserName: "Eve",
|
||
|
|
Kind: journal.KindPut, Path: p,
|
||
|
|
Blob: strings.Repeat("a", 64), Size: 12, Mode: 0o644,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// secoutBadRunes reports the control characters a line must never carry. '\n'
|
||
|
|
// is excluded because it is the row separator the caller splits on.
|
||
|
|
func secoutBadRunes(line string) []string {
|
||
|
|
var found []string
|
||
|
|
for _, r := range line {
|
||
|
|
if r < 0x20 && r != '\t' || r == 0x7f {
|
||
|
|
found = append(found, fmt.Sprintf("%q", r))
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return found
|
||
|
|
}
|
||
|
|
|
||
|
|
// Every hostile shape, one at a time, so a failure names which field and which
|
||
|
|
// sequence got through.
|
||
|
|
func TestSec_Output_PeerJournalStringsCannotRewriteTheTerminal(t *testing.T) {
|
||
|
|
for _, tc := range []struct {
|
||
|
|
name string
|
||
|
|
mut func(*journal.Op)
|
||
|
|
}{
|
||
|
|
{"note clears the screen and the scrollback", func(o *journal.Op) {
|
||
|
|
o.Note = "\x1b[2J\x1b[3J\x1b[H harmless note"
|
||
|
|
}},
|
||
|
|
{"note sets the window title", func(o *journal.Op) {
|
||
|
|
o.Note = "\x1b]0;bdrive — everything is fine\x07"
|
||
|
|
}},
|
||
|
|
{"note writes the system clipboard (OSC 52)", func(o *journal.Op) {
|
||
|
|
o.Note = "\x1b]52;c;cm0gLXJmIH4K\x07"
|
||
|
|
}},
|
||
|
|
{"note asks the terminal to report, which types onto the shell", func(o *journal.Op) {
|
||
|
|
o.Note = "\x1bP$q\"q\x1b\\\x1b[6n"
|
||
|
|
}},
|
||
|
|
{"note colours the row to look like the ones around it", func(o *journal.Op) {
|
||
|
|
o.Note = "\x1b[32mverified\x1b[0m"
|
||
|
|
}},
|
||
|
|
{"carriage return redraws the row as something else", func(o *journal.Op) {
|
||
|
|
// The row is printed as a delete; the CR sends the cursor home and
|
||
|
|
// the rest of the note paints a put over it.
|
||
|
|
o.Kind = journal.KindDelete
|
||
|
|
o.Note = "\r2020-01-01 00:00:00 put secrets.md Alice on alice-mac"
|
||
|
|
}},
|
||
|
|
{"newline in a note forges extra rows", func(o *journal.Op) {
|
||
|
|
o.Note = "ok]\n2020-01-01 00:00:00 put README.md Alice on alice-mac"
|
||
|
|
}},
|
||
|
|
{"newline in a path forges extra rows", func(o *journal.Op) {
|
||
|
|
o.Path = "a.md\n2020-01-01 00:00:00 delete everything.md Alice on alice-mac"
|
||
|
|
}},
|
||
|
|
{"newline in a user name forges extra rows", func(o *journal.Op) {
|
||
|
|
o.UserName = "Eve\n2020-01-01 00:00:00 put approved.md Alice on alice-mac"
|
||
|
|
}},
|
||
|
|
{"escape in the device name", func(o *journal.Op) {
|
||
|
|
o.DeviceName = "\x1b[1;31mALICE-MAC\x1b[0m"
|
||
|
|
}},
|
||
|
|
{"escape in the author fallback", func(o *journal.Op) {
|
||
|
|
o.User, o.UserName = "", ""
|
||
|
|
o.Author = "\x1b[8mhidden\x1b[28m"
|
||
|
|
}},
|
||
|
|
{"a NUL byte in a note", func(o *journal.Op) {
|
||
|
|
o.Note = "clean\x00 not clean"
|
||
|
|
}},
|
||
|
|
} {
|
||
|
|
t.Run(tc.name, func(t *testing.T) {
|
||
|
|
folder := secoutMount(t)
|
||
|
|
// One honest op alongside the hostile one: the control proves the
|
||
|
|
// formatter works and that the row count below is meaningful.
|
||
|
|
honest := secoutOp(1, "notes/honest.md")
|
||
|
|
honest.Note = "a normal note"
|
||
|
|
hostile := secoutOp(2, "notes/hostile.md")
|
||
|
|
tc.mut(&hostile)
|
||
|
|
secoutPlant(t, folder, "attacker", []journal.Op{honest, hostile})
|
||
|
|
|
||
|
|
out := secoutRun(t, folder)
|
||
|
|
lines := strings.Split(strings.TrimSuffix(out, "\n"), "\n")
|
||
|
|
if len(lines) != 2 {
|
||
|
|
t.Errorf("2 journal ops printed %d rows — a peer string forged %d of them:\n%q",
|
||
|
|
len(lines), len(lines)-2, out)
|
||
|
|
}
|
||
|
|
for i, line := range lines {
|
||
|
|
if bad := secoutBadRunes(line); len(bad) > 0 {
|
||
|
|
t.Errorf("row %d carries control characters %s the terminal will act on:\n%q",
|
||
|
|
i, strings.Join(bad, " "), line)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
})
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Length is the other way to own the screen: `bdrive log` prints at most -n
|
||
|
|
// rows (50 by default), so a peer whose single entry is 40 KB of padding
|
||
|
|
// scrolls every real entry out of the operator's scrollback without any
|
||
|
|
// escape sequence at all. Bounding the variable parts is what conflictName
|
||
|
|
// already does for exactly this reason (syncer.go).
|
||
|
|
func TestSec_Output_OneEntryCannotFillTheScreen(t *testing.T) {
|
||
|
|
folder := secoutMount(t)
|
||
|
|
honest := secoutOp(1, "notes/honest.md")
|
||
|
|
honest.Note = "a normal note"
|
||
|
|
huge := secoutOp(2, "notes/huge.md")
|
||
|
|
huge.Note = strings.Repeat("padding ", 5000)
|
||
|
|
huge.DeviceName = strings.Repeat("D", 5000)
|
||
|
|
huge.Path = "notes/" + strings.Repeat("p", 5000) + ".md"
|
||
|
|
secoutPlant(t, folder, "attacker", []journal.Op{honest, huge})
|
||
|
|
|
||
|
|
out := secoutRun(t, folder)
|
||
|
|
for i, line := range strings.Split(strings.TrimSuffix(out, "\n"), "\n") {
|
||
|
|
if len(line) > 1024 {
|
||
|
|
t.Errorf("row %d is %d bytes; one peer entry buries the rest of the log", i, len(line))
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// The same strings reach the other reader of a peer's journal: `bdrive
|
||
|
|
// restore --list` prints Author/UserName/DeviceName through printVersions
|
||
|
|
// (restore.go:197). Whatever the fix is, it belongs in one place — this test
|
||
|
|
// exists so the second surface cannot be forgotten.
|
||
|
|
func TestSec_Output_RestoreListDoesNotRenderPeerEscapes(t *testing.T) {
|
||
|
|
hostile := secoutOp(1, "notes/hostile.md")
|
||
|
|
hostile.UserName = "\x1b[2J\x1b[HEve"
|
||
|
|
hostile.DeviceName = "eve\rALICE-MAC"
|
||
|
|
|
||
|
|
var out bytes.Buffer
|
||
|
|
printVersions(&out, []journal.Op{hostile}, "")
|
||
|
|
for i, line := range strings.Split(strings.TrimSuffix(out.String(), "\n"), "\n") {
|
||
|
|
if bad := secoutBadRunes(line); len(bad) > 0 {
|
||
|
|
t.Errorf("restore --list row %d carries %s:\n%q", i, strings.Join(bad, " "), line)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|