mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
588 lines
22 KiB
Go
588 lines
22 KiB
Go
package main
|
|||
|
|
|
||
|
|
// Round 7 — the CLI commands no TestSec_* has ever driven. Round 6 closed
|
||
|
|
// `export`, `scope` and `status`; the coverage note names eleven that were
|
||
|
|
// still untouched: login, logout, stop, forget, share, import, resume,
|
||
|
|
// autostart, read-log, whoami, daemon.
|
||
|
|
//
|
||
|
|
// These attack four of them plus one primitive:
|
||
|
|
//
|
||
|
|
// - `bdrive forget` — the per-path tool that edits the SYNCED .bdriveignore
|
||
|
|
// and then runs a prune cycle. Round 6 proved the scope block is a
|
||
|
|
// team-wide delete lever and fixed cleanScopeDirs; forget writes into the
|
||
|
|
// same file through a different function that was not fixed.
|
||
|
|
// - `bdrive resume` — validates the FOLDER's mount id and then builds a path
|
||
|
|
// from the REGISTRY KEY, which nothing validates.
|
||
|
|
// - `bdrive login` / `bdrive whoami` / `bdrive status` — every string the hub
|
||
|
|
// hands back at sign-in reaches a terminal with a bare %s.
|
||
|
|
//
|
||
|
|
// Helpers are prefixed sec7.
|
||
|
|
|
||
|
|
import (
|
||
|
|
"encoding/json"
|
||
|
|
"fmt"
|
||
|
|
"net/http"
|
||
|
|
"net/http/httptest"
|
||
|
|
"os"
|
||
|
|
"path/filepath"
|
||
|
|
"strings"
|
||
|
|
"sync"
|
||
|
|
"testing"
|
||
|
|
|
||
|
|
"github.com/spf13/cobra"
|
||
|
|
|
||
|
|
"github.com/runbear-io/beardrive/internal/config"
|
||
|
|
"github.com/runbear-io/beardrive/internal/syncer"
|
||
|
|
)
|
||
|
|
|
||
|
|
// sec7ControlRunes reports the control characters in s that must never reach a
|
||
|
|
// terminal: C0, DEL, C1 (U+009B *is* CSI on any xterm-lineage terminal) and
|
||
|
|
// the bidi format controls that reorder a rendered row (CVE-2021-42574). This
|
||
|
|
// is the same set round 6 settled on for `bdrive log` and `bdrive status`.
|
||
|
|
func sec7ControlRunes(s string) []rune {
|
||
|
|
var out []rune
|
||
|
|
for _, r := range s {
|
||
|
|
switch {
|
||
|
|
case r == '\n', r == '\t':
|
||
|
|
// legitimate row separators in CLI output
|
||
|
|
case r < 0x20, r == 0x7f, r >= 0x80 && r <= 0x9f,
|
||
|
|
r == 0x061c, r == 0x200e, r == 0x200f,
|
||
|
|
r >= 0x202a && r <= 0x202e, r >= 0x2066 && r <= 0x2069:
|
||
|
|
out = append(out, r)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return out
|
||
|
|
}
|
||
|
|
|
||
|
|
// sec7Hostile is one hub-chosen string carrying an attack a terminal executes.
|
||
|
|
var sec7Hostile = []string{
|
||
|
|
"ada\x1b]0;pwned\x07", // rewrite the window title
|
||
|
|
"ada\x1b]52;c;cHduZWQ=\x07", // OSC 52: write the operator's clipboard
|
||
|
|
"ada\x1b[2K\rsigned in as root", // repaint the line as something else
|
||
|
|
"ada\u009b2K\rsigned in as root", // the same via C1 CSI, no ESC anywhere
|
||
|
|
"ada\u202egnp.eciovni\u202c", // bidi override: the row reads backwards
|
||
|
|
"ada\x7f\x7f\x7f\x7f", // DEL
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// bdrive forget
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
// TestSec_Forget_APathCannotInjectExtraIgnoreRules
|
||
|
|
//
|
||
|
|
// `bdrive forget <path>` turns its argument into a .bdriveignore line:
|
||
|
|
//
|
||
|
|
// rel, err := filepath.Rel(root, abs) // forget.go:102
|
||
|
|
// ...
|
||
|
|
// body += rule + "\n" // forget.go:137
|
||
|
|
// os.WriteFile(path, []byte(body), 0o644)
|
||
|
|
//
|
||
|
|
// and then runs a cycle with Prune set, which removes from the hub — for every
|
||
|
|
// teammate — everything the resulting rules now exclude.
|
||
|
|
//
|
||
|
|
// ignoreRule checks the argument for `..` and for being .bdriveignore itself.
|
||
|
|
// It does not check it for NEWLINES, and a newline is a legal byte in a unix
|
||
|
|
// path (and in an argument an agent was told to pass). One argument therefore
|
||
|
|
// writes as many rules as it likes. `*` is the whole project, so
|
||
|
|
//
|
||
|
|
// bdrive forget $'notes\n*'
|
||
|
|
//
|
||
|
|
// adds "ignore everything" to the file that syncs to every device, and the
|
||
|
|
// prune that runs in the same command then strips the project from the hub for
|
||
|
|
// the entire team. Nothing in the CLI can take the rule back out again — the
|
||
|
|
// managed-block remover only knows about the scope block, and `bdrive forget`
|
||
|
|
// only ever appends.
|
||
|
|
//
|
||
|
|
// This is verbatim the hole round 6 closed for the OTHER writer of this file
|
||
|
|
// (TestSec_CLI_ScopeRuleCannotOutliveTheScopeThatWroteIt → cleanScopeDirs).
|
||
|
|
// forget was not part of that fix.
|
||
|
|
//
|
||
|
|
// The secure behavior asserted: one argument adds at most one rule.
|
||
|
|
func TestSec_Forget_APathCannotInjectExtraIgnoreRules(t *testing.T) {
|
||
|
|
// Control: an ordinary path adds exactly one line, so the harness is
|
||
|
|
// known to reach the writer at all.
|
||
|
|
t.Run("control_ordinary_path", func(t *testing.T) {
|
||
|
|
root := t.TempDir()
|
||
|
|
got := sec7ForgetLines(t, root, "notes")
|
||
|
|
if len(got) != 1 || got[0] != "notes" {
|
||
|
|
t.Fatalf("control: forgetting %q wrote %q, want exactly [notes]", "notes", got)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
for _, arg := range []string{
|
||
|
|
"notes\n*",
|
||
|
|
"notes\n*/",
|
||
|
|
"notes\r\n!/",
|
||
|
|
"notes\n\n*",
|
||
|
|
} {
|
||
|
|
t.Run(strings.NewReplacer("\n", "\\n", "\r", "\\r").Replace(arg), func(t *testing.T) {
|
||
|
|
root := t.TempDir()
|
||
|
|
got := sec7ForgetLines(t, root, arg)
|
||
|
|
if len(got) > 1 {
|
||
|
|
t.Errorf("one `bdrive forget %q` wrote %d rules into the synced .bdriveignore: %q\n"+
|
||
|
|
"the extra rules apply to every teammate's device, and the prune cycle forget "+
|
||
|
|
"runs in the same command removes everything they exclude from the hub",
|
||
|
|
arg, len(got), got)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// sec7ForgetLines runs the two functions `bdrive forget` uses to turn an
|
||
|
|
// argument into .bdriveignore content, and returns the non-empty lines the
|
||
|
|
// file gained. An argument the door refuses returns no lines, which is the
|
||
|
|
// secure outcome.
|
||
|
|
func sec7ForgetLines(t *testing.T, root, arg string) []string {
|
||
|
|
t.Helper()
|
||
|
|
t.Chdir(root)
|
||
|
|
rule, err := ignoreRule(root, arg)
|
||
|
|
if err != nil {
|
||
|
|
return nil // refused at the door
|
||
|
|
}
|
||
|
|
if _, err := appendIgnoreRules(root, []string{rule}); err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
data, err := os.ReadFile(filepath.Join(root, syncer.IgnoreFile))
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
var out []string
|
||
|
|
for _, line := range strings.Split(string(data), "\n") {
|
||
|
|
if line = strings.TrimRight(line, "\r"); strings.TrimSpace(line) != "" {
|
||
|
|
out = append(out, line)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return out
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// bdrive resume
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
// TestSec_Resume_ARegistryKeyCannotEscapeTheBdriveHome
|
||
|
|
//
|
||
|
|
// `bdrive resume` — the command the login agent runs at every boot — walks the
|
||
|
|
// mount registry and builds a volume path out of the map KEY:
|
||
|
|
//
|
||
|
|
// for id, mi := range mounts { // resume.go:45
|
||
|
|
// if _, ok, err := config.LoadProject(mi.Path); ... // validates the FOLDER
|
||
|
|
// vdir, err := config.VolumeDir(id) // resume.go:58 — the KEY
|
||
|
|
// ...
|
||
|
|
// daemon.Start(mi.Path, vdir, ...) // creates <vdir>/daemon.log
|
||
|
|
//
|
||
|
|
// Round 4 established that a mount id is untrusted and validated it where it is
|
||
|
|
// read — but only in LoadProject (config/project.go:134). VolumeDir itself is
|
||
|
|
// still a bare filepath.Join onto $BDRIVE_HOME, and LoadMounts (config.go:113)
|
||
|
|
// unmarshals mounts.json into a map without looking at a single key. So the
|
||
|
|
// folder's id is checked and the registry's id is not, and resume uses the
|
||
|
|
// registry's.
|
||
|
|
//
|
||
|
|
// Round 6 closed exactly this shape one layer over: Store.LoadCache handed back
|
||
|
|
// whatever key was in state-<mount>.json and both delete passes joined those
|
||
|
|
// keys onto the working folder (TestSec_Store_CacheKeysCannotNameAPathOutsideTheVolume).
|
||
|
|
// mounts.json is the same kind of file — plain JSON in $BDRIVE_HOME, written by
|
||
|
|
// anything running as the user (an agent session, a dependency's install
|
||
|
|
// script, an older bdrive) — and its keys reach os.OpenFile.
|
||
|
|
//
|
||
|
|
// The secure behavior asserted: nothing `bdrive resume` writes lands outside
|
||
|
|
// $BDRIVE_HOME, whatever the registry says.
|
||
|
|
func TestSec_Resume_ARegistryKeyCannotEscapeTheBdriveHome(t *testing.T) {
|
||
|
|
home := t.TempDir()
|
||
|
|
t.Setenv("BDRIVE_HOME", home)
|
||
|
|
|
||
|
|
// A real, valid project folder — so resume gets past its own
|
||
|
|
// LoadProject(mi.Path) gate and reaches VolumeDir.
|
||
|
|
folder := t.TempDir()
|
||
|
|
if _, err := config.SaveProject(folder, config.Project{Volume: "wiki"}); err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
|
||
|
|
// The escape target: an existing directory outside $BDRIVE_HOME, named by
|
||
|
|
// the registry key alone.
|
||
|
|
outside := t.TempDir()
|
||
|
|
rel, err := filepath.Rel(filepath.Join(home, "volumes"), outside)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
if err := config.SaveMounts(map[string]config.MountInfo{
|
||
|
|
filepath.ToSlash(rel): {Path: folder, Volume: "wiki"},
|
||
|
|
}); err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
|
||
|
|
// The primitive, first: this is the line resume runs.
|
||
|
|
if vdir, err := config.VolumeDir(filepath.ToSlash(rel)); err == nil {
|
||
|
|
if r, rerr := filepath.Rel(home, vdir); rerr != nil || r == ".." ||
|
||
|
|
strings.HasPrefix(r, ".."+string(filepath.Separator)) {
|
||
|
|
t.Errorf("VolumeDir(<registry key>) = %q, outside $BDRIVE_HOME %q — "+
|
||
|
|
"LoadMounts validates no key and VolumeDir validates no id", vdir, home)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// And the reachable caller: resume must not create anything out there.
|
||
|
|
before := sec7Entries(t, outside)
|
||
|
|
out, err := seccliRun(t, resumeCmd(), []string{"--quiet"})
|
||
|
|
t.Logf("resume: err=%v out=%q", err, out)
|
||
|
|
for name := range sec7Entries(t, outside) {
|
||
|
|
if !before[name] {
|
||
|
|
t.Errorf("`bdrive resume` created %q in %q — outside $BDRIVE_HOME, at a path "+
|
||
|
|
"chosen by a mounts.json key", name, outside)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func sec7Entries(t *testing.T, dir string) map[string]bool {
|
||
|
|
t.Helper()
|
||
|
|
out := map[string]bool{}
|
||
|
|
ents, err := os.ReadDir(dir)
|
||
|
|
if err != nil {
|
||
|
|
return out
|
||
|
|
}
|
||
|
|
for _, e := range ents {
|
||
|
|
out[e.Name()] = true
|
||
|
|
}
|
||
|
|
return out
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// bdrive login / whoami / status
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
// TestSec_Login_HubChosenAccountStringsAreNotRenderedToTheTerminal
|
||
|
|
//
|
||
|
|
// Scoreboard row 21 is "every peer-controlled string that reaches a terminal".
|
||
|
|
// Round 5 closed it for `bdrive log` and `bdrive restore --list`; round 6 added
|
||
|
|
// `bdrive status`, but only for the two fields its fixture set — the project
|
||
|
|
// name and the remote URL (cmds.go:213, 215, both now safeField).
|
||
|
|
//
|
||
|
|
// The account line above them was left with a bare %s, and so was every other
|
||
|
|
// place the sign-in flow prints what the hub said:
|
||
|
|
//
|
||
|
|
// fmt.Printf("device: %s (%s) signed in as %s\n\n", ...) // cmds.go:191
|
||
|
|
// fmt.Printf("account: %s (from `bdrive login`...)\n", who) // main.go:110
|
||
|
|
// fmt.Printf("signed in as %s <%s>\n", u.Name, u.Email) // login.go:65
|
||
|
|
// fmt.Printf("logged in to %s as %s <%s>\n", ...) // login.go:198
|
||
|
|
// fmt.Printf("to finish signing in, open this link...%s\n", ...)// login.go:362
|
||
|
|
//
|
||
|
|
// `u.Name` and `u.Email` are JSON fields off /api/auth/me and off the device
|
||
|
|
// poll; `verify_url` is a string the hub composes. Scoreboard row 19 is the
|
||
|
|
// device as client of a HOSTILE hub — `bdrive login <url>` is the one command
|
||
|
|
// whose whole job is to point this device at a hub it has never seen, on a URL
|
||
|
|
// a user was given. Everything that hub answers with is rendered raw.
|
||
|
|
//
|
||
|
|
// The secure behavior asserted is the one round 5 and round 6 already chose for
|
||
|
|
// the other CLI surfaces: no control character reaches the terminal.
|
||
|
|
func TestSec_Login_HubChosenAccountStringsAreNotRenderedToTheTerminal(t *testing.T) {
|
||
|
|
// Control: an ordinary account name is printed by all three commands, so
|
||
|
|
// the assertions below are looking at output they actually produced.
|
||
|
|
t.Run("control_ordinary_name", func(t *testing.T) {
|
||
|
|
hub := sec7Hub(t, "Ada Lovelace")
|
||
|
|
sec7Session(t, hub.URL, "Ada Lovelace")
|
||
|
|
for name, cmd := range sec7AccountCmds() {
|
||
|
|
out, err := seccliRun(t, cmd(), sec7AccountArgs()[name])
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("%s: %v (%s)", name, err, out)
|
||
|
|
}
|
||
|
|
if !strings.Contains(out, "Ada Lovelace") {
|
||
|
|
t.Fatalf("control: %s did not print the account name:\n%s", name, out)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
for _, hostile := range sec7Hostile {
|
||
|
|
t.Run(sec7Label(hostile), func(t *testing.T) {
|
||
|
|
hub := sec7Hub(t, hostile)
|
||
|
|
sec7Session(t, hub.URL, hostile)
|
||
|
|
for name, cmd := range sec7AccountCmds() {
|
||
|
|
out, err := seccliRun(t, cmd(), sec7AccountArgs()[name])
|
||
|
|
if err != nil {
|
||
|
|
t.Logf("%s returned %v", name, err)
|
||
|
|
}
|
||
|
|
if bad := sec7ControlRunes(out); len(bad) > 0 {
|
||
|
|
t.Errorf("`bdrive %s` printed control characters %q that the hub chose:\n%q",
|
||
|
|
name, bad, out)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
// The sign-in flow itself: `bdrive login <url>` against a hub this device
|
||
|
|
// has never seen. Without a TTY the CLI takes the device-code path, which
|
||
|
|
// prints the hub's verify_url and then the hub's account name.
|
||
|
|
for _, hostile := range sec7Hostile {
|
||
|
|
t.Run("login/"+sec7Label(hostile), func(t *testing.T) {
|
||
|
|
hub := sec7Hub(t, hostile)
|
||
|
|
t.Setenv("BDRIVE_HOME", t.TempDir())
|
||
|
|
out, err := seccliRun(t, loginCmd(), []string{hub.URL})
|
||
|
|
if err != nil {
|
||
|
|
t.Logf("login returned %v", err)
|
||
|
|
}
|
||
|
|
if !strings.Contains(out, "finish signing in") {
|
||
|
|
t.Fatalf("control: login did not reach the device-code print:\n%s", out)
|
||
|
|
}
|
||
|
|
if bad := sec7ControlRunes(out); len(bad) > 0 {
|
||
|
|
t.Errorf("`bdrive login <hostile-hub>` printed control characters %q the hub chose:\n%q",
|
||
|
|
bad, out)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// sec7AccountCmds are the commands that render the signed-in account.
|
||
|
|
func sec7AccountCmds() map[string]func() *cobra.Command {
|
||
|
|
return map[string]func() *cobra.Command{
|
||
|
|
"whoami": whoamiCmd,
|
||
|
|
"status": statusCmd,
|
||
|
|
"login --status": loginCmd,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func sec7AccountArgs() map[string][]string {
|
||
|
|
return map[string][]string{
|
||
|
|
"whoami": nil,
|
||
|
|
"status": nil,
|
||
|
|
"login --status": {"--status"},
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// sec7Session writes the settings `bdrive login` would have written after
|
||
|
|
// signing in to hub, with the hub's chosen display name.
|
||
|
|
func sec7Session(t *testing.T, server, name string) {
|
||
|
|
t.Helper()
|
||
|
|
t.Setenv("BDRIVE_HOME", t.TempDir())
|
||
|
|
if err := config.SaveSettings(config.Settings{
|
||
|
|
Server: server, Token: "device-token", Email: "ada@example.com", Name: name,
|
||
|
|
}); err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
// status with no folder argument needs at least one mount, or it returns
|
||
|
|
// before printing the account line.
|
||
|
|
folder := t.TempDir()
|
||
|
|
if _, err := config.SaveProject(folder, config.Project{Volume: "wiki"}); err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
if _, _, err := config.EnrollMount(folder); err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// sec7Hub is a bdrive hub that answers every sign-in route with a display name
|
||
|
|
// of its choosing — the hostile-hub half of row 19.
|
||
|
|
func sec7Hub(t *testing.T, name string) *httptest.Server {
|
||
|
|
t.Helper()
|
||
|
|
user := map[string]string{"id": "u-1", "email": "ada@example.com", "name": name}
|
||
|
|
mux := http.NewServeMux()
|
||
|
|
mux.HandleFunc("/api/config", func(w http.ResponseWriter, r *http.Request) {
|
||
|
|
json.NewEncoder(w).Encode(map[string]any{
|
||
|
|
"mode": "hub",
|
||
|
|
"auth": map[string]any{"enabled": true, "cli_login": "/auth/cli"},
|
||
|
|
})
|
||
|
|
})
|
||
|
|
mux.HandleFunc("/api/auth/me", func(w http.ResponseWriter, r *http.Request) {
|
||
|
|
json.NewEncoder(w).Encode(user)
|
||
|
|
})
|
||
|
|
mux.HandleFunc("/api/auth/device/start", func(w http.ResponseWriter, r *http.Request) {
|
||
|
|
json.NewEncoder(w).Encode(map[string]any{
|
||
|
|
"code": "abcd1234",
|
||
|
|
"verify_url": "https://hub.example.com/auth/device/" + name,
|
||
|
|
"interval": 1,
|
||
|
|
})
|
||
|
|
})
|
||
|
|
mux.HandleFunc("/api/auth/device/poll", func(w http.ResponseWriter, r *http.Request) {
|
||
|
|
json.NewEncoder(w).Encode(map[string]any{"token": "device-token", "user": user})
|
||
|
|
})
|
||
|
|
srv := httptest.NewServer(mux)
|
||
|
|
t.Cleanup(srv.Close)
|
||
|
|
return srv
|
||
|
|
}
|
||
|
|
|
||
|
|
func sec7Label(s string) string {
|
||
|
|
return strings.Map(func(r rune) rune {
|
||
|
|
if len(sec7ControlRunes(string(r))) > 0 {
|
||
|
|
return '.'
|
||
|
|
}
|
||
|
|
return r
|
||
|
|
}, s)
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// bdrive share
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
// TestSec_Share_TheFolderConfigCannotRedirectTheDeviceToken
|
||
|
|
//
|
||
|
|
// Round 4's client critical was TestSec_Config_FolderConfigCannotRedirectTheDeviceToken:
|
||
|
|
// a folder's .bdrive/config.json chose where this device's hub token was sent,
|
||
|
|
// plaintext http included, because the remote URL travels with the folder (a
|
||
|
|
// zip, a clone, a colleague's copy). The fix bound the credential to
|
||
|
|
// settings.Server's ORIGIN — in remote.deviceToken (remote/http.go:69), which
|
||
|
|
// is the SYNC backend's door.
|
||
|
|
//
|
||
|
|
// `bdrive share` does not go through it. All three of its calls read the
|
||
|
|
// destination out of the same untrusted file and attach the raw token:
|
||
|
|
//
|
||
|
|
// server, projectID, err := splitHubRemote(proj.Remote) // share.go:67
|
||
|
|
// serverDo(POST, server+"/api/p/"+projectID+"/shares", settings.Token, data)
|
||
|
|
// serverDo(GET, server+"/api/p/"+projectID+"/shares", settings.Token, nil) // --list
|
||
|
|
// serverDo(DELETE, server+"/api/shares/"+token, settings.Token, nil) // --revoke
|
||
|
|
//
|
||
|
|
// splitHubRemote checks the SHAPE of the URL (https?://host/p/<id>) and
|
||
|
|
// nothing else, so any host at all satisfies it. Hand someone a folder — the
|
||
|
|
// documented way to move a project between machines — and the first
|
||
|
|
// `bdrive share` in it posts their device token, in an Authorization header,
|
||
|
|
// to a server of your choosing.
|
||
|
|
//
|
||
|
|
// The secure behavior asserted is round 4's: the credential goes to the origin
|
||
|
|
// it was issued for, and nowhere else.
|
||
|
|
func TestSec_Share_TheFolderConfigCannotRedirectTheDeviceToken(t *testing.T) {
|
||
|
|
const token = "SECRET-DEVICE-TOKEN"
|
||
|
|
|
||
|
|
// Control: when the folder's remote IS the server this device signed in
|
||
|
|
// to, the token is sent — so the assertions below are looking at a request
|
||
|
|
// the command really makes.
|
||
|
|
t.Run("control_same_origin", func(t *testing.T) {
|
||
|
|
hub := sec7Recorder(t)
|
||
|
|
folder := sec7FolderFor(t, hub.URL, hub.URL, token)
|
||
|
|
t.Chdir(folder)
|
||
|
|
if _, err := seccliRun(t, shareCmd(), []string{"notes.md"}); err != nil {
|
||
|
|
t.Fatalf("share: %v", err)
|
||
|
|
}
|
||
|
|
if !sec7SawToken(hub, token) {
|
||
|
|
t.Fatalf("control: the device token never reached its own hub; " +
|
||
|
|
"the harness is not exercising the request")
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
for _, args := range [][]string{{"notes.md"}, {"--list"}, {"--revoke", "abc123"}} {
|
||
|
|
t.Run(strings.Join(args, "_"), func(t *testing.T) {
|
||
|
|
hostile := sec7Recorder(t)
|
||
|
|
// Signed in to a different hub entirely; the folder names the
|
||
|
|
// attacker's.
|
||
|
|
folder := sec7FolderFor(t, hostile.URL, "https://hub.example.com", token)
|
||
|
|
t.Chdir(folder)
|
||
|
|
out, err := seccliRun(t, shareCmd(), args)
|
||
|
|
t.Logf("share %v: err=%v out=%q", args, err, out)
|
||
|
|
if sec7SawToken(hostile, token) {
|
||
|
|
t.Errorf("`bdrive share %v` sent this device's token to %s — an origin chosen "+
|
||
|
|
"by the folder's .bdrive/config.json, not by `bdrive login`", args, hostile.URL)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestSec_CLI_TheDeviceTokenIsNotFollowedToAnotherOrigin
|
||
|
|
//
|
||
|
|
// Round 4 closed this on the sync client: net/http strips Authorization only
|
||
|
|
// when the HOSTNAME changes, so a hub's 302 to another port, an https→http
|
||
|
|
// downgrade, or a sibling subdomain kept the bearer token (round 5 hardened it
|
||
|
|
// further to refusing the redirect outright — remote/http.go's
|
||
|
|
// refuseOffOriginRedirect, installed on that backend's http.Client).
|
||
|
|
//
|
||
|
|
// The CLI has its own client and it got neither fix:
|
||
|
|
//
|
||
|
|
// var initClient = &http.Client{Timeout: 10 * time.Second} // init.go:584
|
||
|
|
//
|
||
|
|
// No CheckRedirect at all. Every command that talks to the hub's JSON API —
|
||
|
|
// share, share --list, share --revoke, init's create/get/list project, import —
|
||
|
|
// goes through serverDo on this client, with `Authorization: Bearer <device
|
||
|
|
// token>` set. One 302 from the hub (or from anything that can answer for it)
|
||
|
|
// hands the token to a different origin.
|
||
|
|
//
|
||
|
|
// The secure behavior asserted: the token reaches the origin it was sent to,
|
||
|
|
// and no other.
|
||
|
|
func TestSec_CLI_TheDeviceTokenIsNotFollowedToAnotherOrigin(t *testing.T) {
|
||
|
|
const token = "SECRET-DEVICE-TOKEN"
|
||
|
|
elsewhere := sec7Recorder(t)
|
||
|
|
hub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
|
|
// Same hostname, different port: net/http keeps Authorization.
|
||
|
|
http.Redirect(w, r, elsewhere.URL+r.URL.Path, http.StatusFound)
|
||
|
|
}))
|
||
|
|
t.Cleanup(hub.Close)
|
||
|
|
|
||
|
|
folder := sec7FolderFor(t, hub.URL, hub.URL, token)
|
||
|
|
t.Chdir(folder)
|
||
|
|
out, err := seccliRun(t, shareCmd(), []string{"--list"})
|
||
|
|
t.Logf("share --list: err=%v out=%q", err, out)
|
||
|
|
if len(sec7Seen(elsewhere)) == 0 {
|
||
|
|
t.Fatalf("control: the redirect target was never reached; the test proves nothing")
|
||
|
|
}
|
||
|
|
if sec7SawToken(elsewhere, token) {
|
||
|
|
t.Errorf("a hub 302 carried this device's token to %s: initClient has no CheckRedirect, "+
|
||
|
|
"and net/http only strips Authorization when the HOSTNAME changes", elsewhere.URL)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// sec7Recorder is a stand-in hub that records the Authorization header of
|
||
|
|
// every request and answers each CLI call with a well-formed body.
|
||
|
|
func sec7Recorder(t *testing.T) *httptest.Server {
|
||
|
|
t.Helper()
|
||
|
|
var mu sync.Mutex
|
||
|
|
var seen []string
|
||
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
|
|
mu.Lock()
|
||
|
|
seen = append(seen, r.Header.Get("Authorization"))
|
||
|
|
mu.Unlock()
|
||
|
|
w.Header().Set("Content-Type", "application/json")
|
||
|
|
if strings.HasSuffix(r.URL.Path, "/shares") && r.Method == http.MethodGet {
|
||
|
|
fmt.Fprint(w, `{"shares":[]}`)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
fmt.Fprint(w, `{"url":"https://hub.example.com/s/abc","expires":"0001-01-01T00:00:00Z"}`)
|
||
|
|
}))
|
||
|
|
t.Cleanup(srv.Close)
|
||
|
|
sec7Recorded.Store(srv, &sec7Log{mu: &mu, seen: &seen})
|
||
|
|
return srv
|
||
|
|
}
|
||
|
|
|
||
|
|
type sec7Log struct {
|
||
|
|
mu *sync.Mutex
|
||
|
|
seen *[]string
|
||
|
|
}
|
||
|
|
|
||
|
|
var sec7Recorded sync.Map
|
||
|
|
|
||
|
|
func sec7Seen(srv *httptest.Server) []string {
|
||
|
|
v, ok := sec7Recorded.Load(srv)
|
||
|
|
if !ok {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
l := v.(*sec7Log)
|
||
|
|
l.mu.Lock()
|
||
|
|
defer l.mu.Unlock()
|
||
|
|
return append([]string(nil), *l.seen...)
|
||
|
|
}
|
||
|
|
|
||
|
|
func sec7SawToken(srv *httptest.Server, token string) bool {
|
||
|
|
for _, h := range sec7Seen(srv) {
|
||
|
|
if strings.Contains(h, token) {
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
|
||
|
|
// sec7FolderFor builds an enrolled project folder whose .bdrive/config.json
|
||
|
|
// points at remoteServer, on a device signed in to loginServer.
|
||
|
|
func sec7FolderFor(t *testing.T, remoteServer, loginServer, token string) string {
|
||
|
|
t.Helper()
|
||
|
|
t.Setenv("BDRIVE_HOME", t.TempDir())
|
||
|
|
if err := config.SaveSettings(config.Settings{
|
||
|
|
Server: loginServer, Token: token, Email: "ada@example.com", Name: "Ada",
|
||
|
|
}); err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
folder := t.TempDir()
|
||
|
|
if _, err := config.SaveProject(folder, config.Project{
|
||
|
|
Volume: "wiki", Remote: remoteServer + "/p/proj1234",
|
||
|
|
}); err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
if _, _, err := config.EnrollMount(folder); err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
if err := os.WriteFile(filepath.Join(folder, "notes.md"), []byte("hi\n"), 0o644); err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
return folder
|
||
|
|
}
|