mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
304 lines
12 KiB
Go
304 lines
12 KiB
Go
package webapp
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"encoding/json"
|
||
|
|
"net/http"
|
||
|
|
"net/http/httptest"
|
||
|
|
"path/filepath"
|
||
|
|
"strings"
|
||
|
|
"testing"
|
||
|
|
"time"
|
||
|
|
)
|
||
|
|
|
||
|
|
// Scoreboard row 11, the hub half. Round 2 audited exactly one field of an
|
||
|
|
// incoming journal op — Blob — and left the rest of the record entering the
|
||
|
|
// model unchecked. These tests take the remaining fields to the surfaces the
|
||
|
|
// hub builds out of a journal: the viewer snapshot (RemoteSource.Files/Open)
|
||
|
|
// and the History feed.
|
||
|
|
//
|
||
|
|
// Helpers are prefixed secjrn* so they cannot collide with another agent's
|
||
|
|
// file in this package.
|
||
|
|
|
||
|
|
// secjrnHub is permHub plus a device registry — the piece production always
|
||
|
|
// has (cmd/bdrive/web.go wires one) and the permission fixture omits, so the
|
||
|
|
// journal→registry join is reachable.
|
||
|
|
func secjrnHub(t *testing.T) (http.Handler, *Server, map[string]*http.Cookie, Project) {
|
||
|
|
t.Helper()
|
||
|
|
h, srv, c, p := permHub(t)
|
||
|
|
reg, err := OpenDeviceRegistry(filepath.Join(t.TempDir(), "devices.json"))
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
srv.Devices = reg
|
||
|
|
return h, srv, c, p
|
||
|
|
}
|
||
|
|
|
||
|
|
// secjrnSync makes one store request as a real syncing device would: with the
|
||
|
|
// X-Bdrive-Device headers that populate the hub's device registry.
|
||
|
|
func secjrnSync(t *testing.T, h http.Handler, project, dev, name, os string, c *http.Cookie) *httptest.ResponseRecorder {
|
||
|
|
t.Helper()
|
||
|
|
// A device becomes known by pushing its own journal — a blob put says
|
||
|
|
// nothing about who a device is and no longer claims an id.
|
||
|
|
req := httptest.NewRequest("PUT",
|
||
|
|
"/api/p/"+project+"/store/object?key=journal/"+dev+".jsonl", nil)
|
||
|
|
req.Header.Set("X-Bdrive-Device", dev)
|
||
|
|
req.Header.Set("X-Bdrive-Device-Name", name)
|
||
|
|
req.Header.Set("X-Bdrive-Os", os)
|
||
|
|
req.AddCookie(c)
|
||
|
|
rec := httptest.NewRecorder()
|
||
|
|
h.ServeHTTP(rec, req)
|
||
|
|
if rec.Code != 200 {
|
||
|
|
t.Fatalf("device %s sync: %d %s", dev, rec.Code, rec.Body)
|
||
|
|
}
|
||
|
|
return rec
|
||
|
|
}
|
||
|
|
|
||
|
|
// secjrnPushJournal writes a whole journal (one op per element) for device dev
|
||
|
|
// into project id, through the public store API. The device header matches the
|
||
|
|
// key, so the round-1 own-journal rule is satisfied — this is a legitimate
|
||
|
|
// push whose CONTENT is hostile.
|
||
|
|
func secjrnPushJournal(t *testing.T, h http.Handler, id, dev string, ops []map[string]any, c *http.Cookie) *httptest.ResponseRecorder {
|
||
|
|
t.Helper()
|
||
|
|
var b strings.Builder
|
||
|
|
for _, op := range ops {
|
||
|
|
line, err := json.Marshal(op)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
b.Write(line)
|
||
|
|
b.WriteByte('\n')
|
||
|
|
}
|
||
|
|
req := httptest.NewRequest("PUT",
|
||
|
|
"/api/p/"+id+"/store/object?key=journal/"+dev+".jsonl",
|
||
|
|
strings.NewReader(b.String()))
|
||
|
|
req.Header.Set("X-Bdrive-Device", dev)
|
||
|
|
req.AddCookie(c)
|
||
|
|
rec := httptest.NewRecorder()
|
||
|
|
h.ServeHTTP(rec, req)
|
||
|
|
return rec
|
||
|
|
}
|
||
|
|
|
||
|
|
// secjrnOp builds a put op with every field a device sets, so a test can
|
||
|
|
// override just the one it is attacking.
|
||
|
|
func secjrnOp(seq int64, path, blob string, size int) map[string]any {
|
||
|
|
return map[string]any{
|
||
|
|
"seq": seq, "lamport": seq,
|
||
|
|
"time": time.Now().UTC().Format(time.RFC3339Nano),
|
||
|
|
"device": "alice-desk", "device_name": "Alice Desktop",
|
||
|
|
"kind": "put", "path": path, "blob": blob, "size": size,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
type secjrnHistory struct {
|
||
|
|
Entries []HistoryEntry `json:"entries"`
|
||
|
|
}
|
||
|
|
|
||
|
|
func secjrnFetchHistory(t *testing.T, h http.Handler, id string, c *http.Cookie) secjrnHistory {
|
||
|
|
t.Helper()
|
||
|
|
rec := doAs(t, h, "GET", "/api/p/"+id+"/history", nil, c)
|
||
|
|
if rec.Code != 200 {
|
||
|
|
t.Fatalf("history: %d %s", rec.Code, rec.Body)
|
||
|
|
}
|
||
|
|
var out secjrnHistory
|
||
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
return out
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---- Op.Device: the registry join History performs ----
|
||
|
|
|
||
|
|
// handleHistory resolves every op's Device field against the hub-wide device
|
||
|
|
// registry (s.Devices.Get(op.Device)) and reports the row's name and OS. The
|
||
|
|
// Device field inside an op is arbitrary JSON a client pushed — it is not the
|
||
|
|
// X-Bdrive-Device header the round-1 fix bound the journal KEY to, and nothing
|
||
|
|
// checks that the caller owns the id it names.
|
||
|
|
//
|
||
|
|
// Round 2 closed exactly this shape on the read-heat side: reads.go now runs
|
||
|
|
// devices.go's ownsDevice before a client-reported device id becomes an actor
|
||
|
|
// (TestSec_Heat_ByDeviceLeaksForeignDeviceMetadata). History has the same join
|
||
|
|
// and no such check, so a member of org A learns the machine name and OS of a
|
||
|
|
// device belonging to org B — and confirms which device ids exist on the hub —
|
||
|
|
// by naming it in an op in their own project.
|
||
|
|
func TestSec_Journal_HistoryDeviceFieldLeaksForeignDeviceMetadata(t *testing.T) {
|
||
|
|
h, _, c, alice := secjrnHub(t)
|
||
|
|
|
||
|
|
// dave is in a different org entirely. He syncs his own project from his
|
||
|
|
// own laptop; that registers his device row, owned by him.
|
||
|
|
dave := secpathNewProject(t, h, "dave-notes", c["dave"])
|
||
|
|
const (
|
||
|
|
daveDev = "dave-laptop-7f3a"
|
||
|
|
daveName = "Dave's Personal MacBook"
|
||
|
|
daveOS = "darwin 26.1 (nixos-hybrid)"
|
||
|
|
)
|
||
|
|
secjrnSync(t, h, dave.ID, daveDev, daveName, daveOS, c["dave"])
|
||
|
|
|
||
|
|
// alice registers her own device the same way — the control.
|
||
|
|
const aliceDev = "alice-desk"
|
||
|
|
secjrnSync(t, h, alice.ID, aliceDev, "Alice Desktop", "linux 6.9", c["alice"])
|
||
|
|
blob := secpathStoreBlob(t, h, alice.ID, "notes body", c["alice"])
|
||
|
|
|
||
|
|
// alice pushes her own journal, to her own project, under her own device
|
||
|
|
// key — every authorization check passes. One op simply CLAIMS to come
|
||
|
|
// from dave's device.
|
||
|
|
honest := secjrnOp(1, "mine.md", blob, len("notes body"))
|
||
|
|
forged := secjrnOp(2, "probe.md", blob, len("notes body"))
|
||
|
|
forged["device"] = daveDev
|
||
|
|
forged["device_name"] = "whatever"
|
||
|
|
if rec := secjrnPushJournal(t, h, alice.ID, aliceDev, []map[string]any{honest, forged}, c["alice"]); rec.Code != 200 {
|
||
|
|
t.Fatalf("push own journal: %d %s", rec.Code, rec.Body)
|
||
|
|
}
|
||
|
|
|
||
|
|
hist := secjrnFetchHistory(t, h, alice.ID, c["alice"])
|
||
|
|
byPath := map[string]HistoryEntry{}
|
||
|
|
for _, e := range hist.Entries {
|
||
|
|
byPath[e.Path] = e
|
||
|
|
}
|
||
|
|
// Control: the honest op resolves against the registry, proving the join
|
||
|
|
// is live and the rest of this test is measuring the same code path.
|
||
|
|
if got := byPath["mine.md"].Device.Name; got != "Alice Desktop" {
|
||
|
|
t.Fatalf("control: history did not join alice's own device row (name %q)", got)
|
||
|
|
}
|
||
|
|
got := byPath["probe.md"].Device
|
||
|
|
if got.Name == daveName || got.OS == daveOS {
|
||
|
|
t.Errorf("history reported another org's device metadata: name=%q os=%q — an op's Device field is client-asserted and is not checked against the caller",
|
||
|
|
got.Name, got.OS)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// The same join is an existence oracle even when the registry row carries no
|
||
|
|
// name: whatever History echoes back for a device id the caller does not own
|
||
|
|
// must come from the op, never from the hub's registry. Asserting the shape
|
||
|
|
// separately keeps a fix that only blanks Name (and leaves OS, or leaks
|
||
|
|
// "exists") from looking green.
|
||
|
|
func TestSec_Journal_HistoryDeviceFieldIsNotAnExistenceOracle(t *testing.T) {
|
||
|
|
h, _, c, alice := secjrnHub(t)
|
||
|
|
|
||
|
|
dave := secpathNewProject(t, h, "dave-notes-2", c["dave"])
|
||
|
|
const daveDev = "dave-laptop-aa11"
|
||
|
|
secjrnSync(t, h, dave.ID, daveDev, "Dave Box", "openbsd 7.5", c["dave"])
|
||
|
|
|
||
|
|
const aliceDev = "alice-desk"
|
||
|
|
secjrnSync(t, h, alice.ID, aliceDev, "Alice Desktop", "linux 6.9", c["alice"])
|
||
|
|
blob := secpathStoreBlob(t, h, alice.ID, "body", c["alice"])
|
||
|
|
|
||
|
|
// Two ops naming device ids alice does not own: one that exists on the
|
||
|
|
// hub (dave's) and one that does not. If the answers differ, the response
|
||
|
|
// is an oracle for which devices exist across every org on the hub.
|
||
|
|
real := secjrnOp(1, "real.md", blob, 4)
|
||
|
|
real["device"], real["device_name"] = daveDev, "claimed"
|
||
|
|
fake := secjrnOp(2, "fake.md", blob, 4)
|
||
|
|
fake["device"], fake["device_name"] = "no-such-device-9999", "claimed"
|
||
|
|
if rec := secjrnPushJournal(t, h, alice.ID, aliceDev, []map[string]any{real, fake}, c["alice"]); rec.Code != 200 {
|
||
|
|
t.Fatalf("push: %d %s", rec.Code, rec.Body)
|
||
|
|
}
|
||
|
|
|
||
|
|
hist := secjrnFetchHistory(t, h, alice.ID, c["alice"])
|
||
|
|
byPath := map[string]HistoryEntry{}
|
||
|
|
for _, e := range hist.Entries {
|
||
|
|
byPath[e.Path] = e
|
||
|
|
}
|
||
|
|
if byPath["real.md"].Device != byPath["fake.md"].Device {
|
||
|
|
t.Errorf("an existing foreign device id (%+v) reads back differently from a nonexistent one (%+v): History tells any member which device ids exist hub-wide",
|
||
|
|
byPath["real.md"].Device, byPath["fake.md"].Device)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---- Op.Path: what the hub does with an unchecked path ----
|
||
|
|
|
||
|
|
// The viewer snapshot is keyed by op.Path, and the hub never validates it. A
|
||
|
|
// path is not a storage key here (content comes from blobs/<sha>), so the
|
||
|
|
// exposure is narrower than Blob's — but a hostile path must still not steer
|
||
|
|
// the hub's own writes. cleanUploadPath already refuses one on the way IN;
|
||
|
|
// this pins that a path that only exists because it arrived through a journal
|
||
|
|
// cannot be laundered back out through restore or remove.
|
||
|
|
func TestSec_Journal_HostilePathCannotBeLaunderedThroughRestoreOrRemove(t *testing.T) {
|
||
|
|
h, srv, c, alice := secjrnHub(t)
|
||
|
|
const body = "hostile body"
|
||
|
|
blob := secpathStoreBlob(t, h, alice.ID, body, c["alice"])
|
||
|
|
|
||
|
|
hostile := []string{
|
||
|
|
"../../../etc/bdrive-owned",
|
||
|
|
"/etc/bdrive-owned",
|
||
|
|
".bdrive/config.json",
|
||
|
|
".git/hooks/pre-commit",
|
||
|
|
"docs/../.bdrive/config.json",
|
||
|
|
}
|
||
|
|
ops := make([]map[string]any, 0, len(hostile))
|
||
|
|
for i, p := range hostile {
|
||
|
|
op := secjrnOp(int64(i+1), p, blob, len(body))
|
||
|
|
ops = append(ops, op)
|
||
|
|
}
|
||
|
|
|
||
|
|
// SETUP CHANGED IN ROUND 7, ASSERTIONS UNTOUCHED. The hub's /store/* door
|
||
|
|
// used to accept these paths (its comment above said "the hub never
|
||
|
|
// validates it"), and round 7 gave that door the same journal.SafePath
|
||
|
|
// rule /upload/commit and the device already apply — so the push below is
|
||
|
|
// now refused, which is strictly stronger and is asserted as a control.
|
||
|
|
// The subject of this test is the way OUT, not the way in, so the hostile
|
||
|
|
// journal is planted directly in storage, behind the door, exactly as
|
||
|
|
// several other tests plant objects. Everything below this block is
|
||
|
|
// unchanged and still fails if restore or remove stop refusing.
|
||
|
|
if rec := secjrnPushJournal(t, h, alice.ID, "alice-desk", ops, c["alice"]); rec.Code == 200 {
|
||
|
|
t.Errorf("the /store/* journal door accepted hostile paths %v — round 7's one-rule parity is gone", hostile)
|
||
|
|
}
|
||
|
|
var planted strings.Builder
|
||
|
|
for _, op := range ops {
|
||
|
|
line, err := json.Marshal(op)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
planted.Write(line)
|
||
|
|
planted.WriteByte('\n')
|
||
|
|
}
|
||
|
|
_, v, err := srv.projectVolume(alice.ID)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
if err := v.source.(*RemoteSource).Backend.Put(t.Context(), "journal/alice-desk.jsonl",
|
||
|
|
strings.NewReader(planted.String()), int64(planted.Len())); err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
|
||
|
|
for _, p := range hostile {
|
||
|
|
rec := doAs(t, h, "POST", "/api/p/"+alice.ID+"/restore",
|
||
|
|
map[string]string{"path": p, "sha": blob}, c["alice"])
|
||
|
|
if rec.Code == 200 {
|
||
|
|
t.Errorf("restore re-journaled hostile path %q", p)
|
||
|
|
}
|
||
|
|
rec = doAs(t, h, "POST", "/api/p/"+alice.ID+"/remove",
|
||
|
|
map[string]string{"path": p}, c["alice"])
|
||
|
|
if rec.Code == 200 {
|
||
|
|
t.Errorf("remove journaled a delete for hostile path %q", p)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---- Op.Size: the length the hub promises for content it did not measure ----
|
||
|
|
|
||
|
|
// FileInfo.Size comes straight off the journal, and serveBlob sets
|
||
|
|
// Content-Length from it before streaming the blob's real bytes. Round 2 fixed
|
||
|
|
// the UPLOAD path to size from storage; the journal field never was. A
|
||
|
|
// Content-Length that disagrees with the body is a protocol violation the hub
|
||
|
|
// commits on behalf of any member who can push a journal.
|
||
|
|
func TestSec_Journal_SizeFieldCannotForgeContentLength(t *testing.T) {
|
||
|
|
h, _, c, alice := secjrnHub(t)
|
||
|
|
const body = "twenty-three bytes here"
|
||
|
|
blob := secpathStoreBlob(t, h, alice.ID, body, c["alice"])
|
||
|
|
|
||
|
|
op := secjrnOp(1, "lie.txt", blob, len(body))
|
||
|
|
op["size"] = 999999
|
||
|
|
if rec := secjrnPushJournal(t, h, alice.ID, "alice-desk", []map[string]any{op}, c["alice"]); rec.Code != 200 {
|
||
|
|
t.Fatalf("push: %d %s", rec.Code, rec.Body)
|
||
|
|
}
|
||
|
|
|
||
|
|
rec := doAs(t, h, "GET", "/api/p/"+alice.ID+"/file?path=lie.txt", nil, c["alice"])
|
||
|
|
if rec.Code != 200 {
|
||
|
|
t.Fatalf("file: %d %s", rec.Code, rec.Body)
|
||
|
|
}
|
||
|
|
if cl := rec.Header().Get("Content-Length"); cl != "" && cl != "23" {
|
||
|
|
t.Errorf("Content-Length %s does not match the %d bytes served — the journal's Size field is echoed as a wire promise the hub cannot keep",
|
||
|
|
cl, rec.Body.Len())
|
||
|
|
}
|
||
|
|
}
|