mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
QuotaProvider grows a read half: CheckRead(org, bytes) and RecordEgress(org, bytes). CheckRead is enforced on /s/* only — a public share link is the sole unauthenticated door to stored bytes, so it is the only egress a plan can cap. The sync proxy and viewer merely RecordEgress: refusing a device mid-cycle surfaces as ErrForbidden, which the syncer reads as "access is gone — pause and touch nothing", and sync must never break over a bill. UnlimitedQuota stays the OSS default. countingWriter bills what actually reached the client rather than a size claimed before the write. bdrive init warns past 1 GiB or 20k files and says how to narrow scope; syncer.Measure sizes that through the real Filter and the one walkFolder predicate. starterIgnore gains video/archive/disk-image globs and Library/ — every version is kept forever, so a big binary committed once is paid for forever on every device. deploy: a Nearline-at-30-days lifecycle rule and the arithmetic for why it stops there. Coldline and Archive only pay off below roughly one read per month, and a first sync pulls every historical blob rather than just the current tree, so blob read rate tracks device onboarding. docs/launch-plan.md said Cloud was waitlist-only and framed Product Hunt as an OSS launch whose goal was not signups or revenue; both are stale. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
380 lines
15 KiB
Go
380 lines
15 KiB
Go
package webapp
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
|
|
"github.com/runbear-io/beardrive/internal/remote"
|
|
)
|
|
|
|
// Security tests for scoreboard rows 5 (the /store/* sync proxy) and 6
|
|
// (uploads). Every test asserts the SECURE behavior, so it goes green when
|
|
// the hole is closed and stays as a regression test.
|
|
|
|
// secstoreDo is do() with request headers — the store proxy identifies the
|
|
// calling device through X-Bdrive-Device, which do() cannot set.
|
|
func secstoreDo(t *testing.T, h http.Handler, method, url string, body []byte, hdr map[string]string) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
req := httptest.NewRequest(method, url, bytes.NewReader(body))
|
|
for k, v := range hdr {
|
|
req.Header.Set(k, v)
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, req)
|
|
return rec
|
|
}
|
|
|
|
// secstoreUnsized sends a body of unknown length (chunked transfer encoding),
|
|
// which is what any streaming client does. ContentLength is -1.
|
|
func secstoreUnsized(t *testing.T, h http.Handler, method, url string, body []byte) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
req := httptest.NewRequest(method, url, io.NopCloser(bytes.NewReader(body)))
|
|
req.ContentLength = -1
|
|
req.TransferEncoding = []string{"chunked"}
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, req)
|
|
return rec
|
|
}
|
|
|
|
// secstoreQuota is a plan-limited quota provider: the seam a managed
|
|
// deployment plugs in. It refuses writes that would exceed limit.
|
|
type secstoreQuota struct {
|
|
// Embedded so the read-side hooks (CheckRead/RecordEgress) come for
|
|
// free: this fake exercises the write path, and a widened interface
|
|
// should not need a no-op added here every time.
|
|
UnlimitedQuota
|
|
|
|
mu sync.Mutex
|
|
limit int64
|
|
used int64
|
|
}
|
|
|
|
func (q *secstoreQuota) CheckWrite(_ string, n int64) error {
|
|
q.mu.Lock()
|
|
defer q.mu.Unlock()
|
|
if q.used+n > q.limit {
|
|
return fmt.Errorf("storage quota exceeded")
|
|
}
|
|
return nil
|
|
}
|
|
func (q *secstoreQuota) CheckSeat(string, int) error { return nil }
|
|
func (q *secstoreQuota) RecordUsage(_ string, n int64) {
|
|
q.mu.Lock()
|
|
defer q.mu.Unlock()
|
|
q.used += n
|
|
}
|
|
|
|
// secstoreJournal reads a device's journal straight out of the storage root.
|
|
func secstoreJournal(t *testing.T, root, project, device string) string {
|
|
t.Helper()
|
|
b, err := os.ReadFile(filepath.Join(root, project, "journal", device+".jsonl"))
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return ""
|
|
}
|
|
t.Fatal(err)
|
|
}
|
|
return string(b)
|
|
}
|
|
|
|
// ---- Row 5: the /store/* sync proxy ----
|
|
|
|
// A device may write only its own journal. That is the repo's core
|
|
// concurrency invariant ("each device writes only its own journal") and the
|
|
// only reason no locking service is needed: no journal object ever has two
|
|
// writers. The proxy must therefore bind the journal key to the calling
|
|
// device, not merely to "someone with write permission on the project".
|
|
func TestSec_Store_ForeignDeviceJournalWrite(t *testing.T) {
|
|
srv, p, root := newHub(t, true, nil)
|
|
h := srv.Handler()
|
|
base := "/api/p/" + p.ID + "/store/"
|
|
attacker := map[string]string{"X-Bdrive-Device": "attacker-device", "X-Bdrive-Device-Name": "evil-laptop"}
|
|
|
|
// Seed a victim device's journal the way a real peer would.
|
|
f := newFakeRemoteAt(t, filepath.Join(root, p.ID))
|
|
f.put("victim-device", "salary.md", "confidential")
|
|
victimBefore := secstoreJournal(t, root, p.ID, "victim-device")
|
|
if victimBefore == "" {
|
|
t.Fatal("fixture: victim journal not seeded")
|
|
}
|
|
|
|
// Control: the attacker writing its OWN journal is legitimate and works.
|
|
own := []byte(`{"kind":"put","path":"mine.md","seq":1,"lamport":1,"device":"attacker-device"}` + "\n")
|
|
if rec := secstoreDo(t, h, "PUT", base+"object?key=journal/attacker-device.jsonl", own, attacker); rec.Code != 200 {
|
|
t.Fatalf("attacker writing its own journal: %d %s, want 200", rec.Code, rec.Body)
|
|
}
|
|
|
|
// Attack: same caller, same credential, another device's journal key.
|
|
forged := []byte(`{"kind":"delete","path":"salary.md","seq":99,"lamport":9999,"device":"victim-device","user":"alice@example.com"}` + "\n")
|
|
rec := secstoreDo(t, h, "PUT", base+"object?key=journal/victim-device.jsonl", forged, attacker)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Errorf("PUT another device's journal: %d %s, want 403", rec.Code, rec.Body)
|
|
}
|
|
if after := secstoreJournal(t, root, p.ID, "victim-device"); after != victimBefore {
|
|
t.Errorf("victim journal was rewritten by another device:\n before=%q\n after =%q", victimBefore, after)
|
|
}
|
|
|
|
// The hub's own journal (browser uploads, restores, removes) is just as
|
|
// writable, so a device can rewrite the server's own history.
|
|
srvKey := "journal/" + webDevice.ID + ".jsonl"
|
|
if rec := secstoreDo(t, h, "PUT", base+"object?key="+srvKey, forged, attacker); rec.Code != http.StatusForbidden {
|
|
t.Errorf("PUT the server's own journal: %d %s, want 403", rec.Code, rec.Body)
|
|
}
|
|
}
|
|
|
|
// Blob keys are content addresses and the whole store treats them as
|
|
// immutable: sign/init answer "already there, skip the upload" on a sha
|
|
// match, and history serves every old version straight out of blobs/<sha>.
|
|
// So the proxy must refuse a blob whose bytes do not hash to its key —
|
|
// otherwise one member permanently substitutes the content of every past and
|
|
// future version that hashes there.
|
|
func TestSec_Store_BlobContentMustMatchItsKey(t *testing.T) {
|
|
srv, p, root := newHub(t, true, nil)
|
|
h := srv.Handler()
|
|
base := "/api/p/" + p.ID + "/store/"
|
|
key := "blobs/" + shaOf("the real contract")
|
|
|
|
// Control: honest content at its own address is accepted.
|
|
if rec := do(t, h, "PUT", base+"object?key="+key, []byte("the real contract")); rec.Code != 200 {
|
|
t.Fatalf("honest blob put: %d %s", rec.Code, rec.Body)
|
|
}
|
|
// Attack: overwrite that address with different bytes.
|
|
rec := do(t, h, "PUT", base+"object?key="+key, []byte("the forged contract"))
|
|
if rec.Code == 200 {
|
|
t.Errorf("PUT blob whose content does not hash to its key: %d, want 4xx", rec.Code)
|
|
}
|
|
got, err := os.ReadFile(filepath.Join(root, p.ID, "blobs", strings.TrimPrefix(key, "blobs/")))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if string(got) != "the real contract" {
|
|
t.Errorf("blob at %s = %q; a content address now serves content that does not hash to it", key, got)
|
|
}
|
|
}
|
|
|
|
// The quota seam (CheckWrite/RecordUsage) is the only thing between a plan
|
|
// and the storage bill, and the store proxy is the highest-volume write path
|
|
// on the hub. It sizes writes from Content-Length, which the client controls:
|
|
// a chunked request declares no length at all.
|
|
func TestSec_Store_QuotaHonorsUnsizedPut(t *testing.T) {
|
|
q := &secstoreQuota{limit: 16}
|
|
srv, p, root := newHub(t, true, nil)
|
|
srv.Quota = q
|
|
h := srv.Handler()
|
|
base := "/api/p/" + p.ID + "/store/"
|
|
big := bytes.Repeat([]byte("A"), 4096)
|
|
key := "blobs/" + shaOf(string(big))
|
|
|
|
// Control: the same oversized write with a declared length is refused.
|
|
if rec := do(t, h, "PUT", base+"object?key="+key, big); rec.Code != http.StatusForbidden {
|
|
t.Fatalf("oversized sized put: %d %s, want 403", rec.Code, rec.Body)
|
|
}
|
|
// Attack: identical bytes, no declared length.
|
|
rec := secstoreUnsized(t, h, "PUT", base+"object?key="+key, big)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Errorf("oversized chunked put: %d %s, want 403", rec.Code, rec.Body)
|
|
}
|
|
if fi, err := os.Stat(filepath.Join(root, p.ID, "blobs", strings.TrimPrefix(key, "blobs/"))); err == nil {
|
|
t.Errorf("over-quota blob landed in storage anyway (%d bytes)", fi.Size())
|
|
}
|
|
if q.used != 0 {
|
|
t.Errorf("recorded usage = %d, want 0 (nothing should have been stored)", q.used)
|
|
}
|
|
}
|
|
|
|
// Key escapes TestStoreAPIKeyValidation does not cover: encoded separators,
|
|
// backslashes, doubled and absolute separators, a NUL suffix, and a list
|
|
// prefix that passes the HasPrefix check but climbs upward. None may reach
|
|
// outside <root>/<project-id>/.
|
|
func TestSec_Store_KeyEscapesRefused(t *testing.T) {
|
|
srv, p, root := newHub(t, true, nil)
|
|
other, _, err := srv.Projects.GetOrCreate("victim-project", "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Something worth stealing in the other project, plus a hub-level file.
|
|
newFakeRemoteAt(t, filepath.Join(root, other.ID)).put("devx", "secret.md", "top secret")
|
|
if err := os.WriteFile(filepath.Join(root, "hub-secret.txt"), []byte("creds"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
h := srv.Handler()
|
|
base := "/api/p/" + p.ID + "/store/"
|
|
|
|
bad := []string{
|
|
"blobs%2f..%2f..%2fhub-secret.txt",
|
|
"journal%2F..%2F..%2F" + other.ID + "%2Fjournal%2Fdevx.jsonl",
|
|
`journal\..\..\` + other.ID + `\journal\devx.jsonl`,
|
|
"/blobs/" + shaOf("x"),
|
|
"blobs//" + shaOf("x"),
|
|
"../" + other.ID + "/journal/devx.jsonl",
|
|
"blobs/" + shaOf("x") + "/../../hub-secret.txt",
|
|
"journal/devx.jsonl%00.png",
|
|
"JOURNAL/devx.jsonl",
|
|
}
|
|
for _, key := range bad {
|
|
for _, m := range []string{"GET", "PUT"} {
|
|
rec := do(t, h, m, base+"object?key="+key, []byte("x"))
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Errorf("%s %q: %d %s, want 400", m, key, rec.Code, rec.Body)
|
|
}
|
|
}
|
|
if rec := do(t, h, "POST", base+"sign", map[string]any{"key": key, "size": 1}); rec.Code != http.StatusBadRequest {
|
|
t.Errorf("sign %q: %d %s, want 400", key, rec.Code, rec.Body)
|
|
}
|
|
}
|
|
|
|
// A list prefix that HasPrefix accepts but climbs out of the project.
|
|
rec := do(t, h, "GET", base+"list?prefix=journal/../../"+other.ID+"/journal/", nil)
|
|
if rec.Code == 200 {
|
|
var list struct {
|
|
Objects []struct {
|
|
Key string `json:"key"`
|
|
} `json:"objects"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &list); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(list.Objects) != 0 {
|
|
t.Errorf("traversing list prefix leaked %+v from another project", list.Objects)
|
|
}
|
|
}
|
|
|
|
// Reading another project's blob by its sha, through this project.
|
|
if rec := do(t, h, "GET", base+"exists?key=blobs/"+shaOf("top secret"), nil); !strings.Contains(rec.Body.String(), "false") {
|
|
t.Errorf("exists on a foreign project's blob: %d %s, want false", rec.Code, rec.Body)
|
|
}
|
|
if rec := do(t, h, "GET", base+"object?key=blobs/"+shaOf("top secret"), nil); rec.Code == 200 {
|
|
t.Errorf("read a foreign project's blob by sha: %d %q", rec.Code, rec.Body)
|
|
}
|
|
}
|
|
|
|
// ---- Row 6: uploads ----
|
|
|
|
// cleanUploadPath is the only validator between a client-supplied
|
|
// destination and an op every device materializes into its working folder.
|
|
// Two directory names are never sync material: .bdrive (the mount's own
|
|
// identity — "syncing it would let one device silently repoint another",
|
|
// syncer.go:116) and .git (materializing a hook script runs code on every
|
|
// teammate's next commit). The scan side excludes both via syncer.neverSync;
|
|
// materialize re-checks only the .bdriveignore filter, so an op that names
|
|
// one is written to disk on every device that pulls it.
|
|
func TestSec_Upload_ReservedDirsRefused(t *testing.T) {
|
|
srv, p, root := newHub(t, true, nil)
|
|
h := srv.Handler()
|
|
base := "/api/p/" + p.ID + "/"
|
|
reserved := []string{
|
|
".bdrive/config.json",
|
|
".git/hooks/pre-commit",
|
|
"docs/.bdrive/config.json",
|
|
}
|
|
for _, path := range reserved {
|
|
body := "repointed"
|
|
if rec := do(t, h, "PUT", base+"upload/content?path="+path, []byte(body)); rec.Code != http.StatusBadRequest {
|
|
t.Errorf("upload/content %q: %d %s, want 400", path, rec.Code, rec.Body)
|
|
}
|
|
if rec := do(t, h, "POST", base+"upload/init", initReq(path, body)); rec.Code != http.StatusBadRequest {
|
|
t.Errorf("upload/init %q: %d %s, want 400", path, rec.Code, rec.Body)
|
|
}
|
|
if rec := do(t, h, "POST", base+"upload/commit", initReq(path, body)); rec.Code != http.StatusBadRequest {
|
|
t.Errorf("upload/commit %q: %d %s, want 400", path, rec.Code, rec.Body)
|
|
}
|
|
}
|
|
// Nothing reserved may have reached the hub's journal, where every
|
|
// device would pull and materialize it.
|
|
if j := secstoreJournal(t, root, p.ID, webDevice.ID); strings.Contains(j, ".bdrive") || strings.Contains(j, ".git") {
|
|
t.Errorf("reserved path journaled for every device to materialize: %s", j)
|
|
}
|
|
}
|
|
|
|
// The blob a direct upload targets is named by the client's sha256 and the
|
|
// key is built server-side as "blobs/"+sha, so the presigned target must
|
|
// never leave the project prefix. And the three steps carry the project in
|
|
// the URL, so a swapped project id between steps must not let project B
|
|
// claim content that only exists in project A.
|
|
func TestSec_Upload_TargetStaysInProject(t *testing.T) {
|
|
srv, a, _ := newHub(t, true, func(be remote.Backend) remote.Backend {
|
|
return &signingBackend{Backend: be}
|
|
})
|
|
b, _, err := srv.Projects.GetOrCreate("project-b", "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
h := srv.Handler()
|
|
content := "project A's private content"
|
|
|
|
// A sha that tries to climb out of blobs/ or into another project.
|
|
for _, sha := range []string{
|
|
"../../" + b.ID + "/blobs/" + shaOf(content),
|
|
"../journal/devx.jsonl",
|
|
strings.ToUpper(shaOf(content)),
|
|
} {
|
|
body := map[string]any{"path": "x.txt", "sha256": sha, "size": 1}
|
|
if rec := do(t, h, "POST", "/api/p/"+a.ID+"/upload/init", body); rec.Code != http.StatusBadRequest {
|
|
t.Errorf("init sha %q: %d %s, want 400", sha, rec.Code, rec.Body)
|
|
}
|
|
}
|
|
|
|
// Swapped project id between steps: content stored in A, committed in B.
|
|
if rec := do(t, h, "PUT", "/api/p/"+a.ID+"/upload/content?path=secret.md", []byte(content)); rec.Code != 200 {
|
|
t.Fatalf("upload into project A: %d %s", rec.Code, rec.Body)
|
|
}
|
|
rec := do(t, h, "POST", "/api/p/"+b.ID+"/upload/commit", initReq("stolen.md", content))
|
|
if rec.Code == 200 {
|
|
t.Errorf("project B committed a blob that only exists in project A: %d %s", rec.Code, rec.Body)
|
|
}
|
|
}
|
|
|
|
// upload/init and upload/commit take the byte count from the client's own
|
|
// JSON and hand it straight to the quota provider, as both the check and the
|
|
// recorded usage. In direct mode the server never sees the bytes, so commit
|
|
// is the only accounting point there is — and a client that declares 0
|
|
// stores whatever it likes for free.
|
|
func TestSec_Upload_QuotaUsesRealSize(t *testing.T) {
|
|
q := &secstoreQuota{limit: 16}
|
|
srv, p, root := newHub(t, true, func(be remote.Backend) remote.Backend {
|
|
return &signingBackend{Backend: be} // storage that presigns, like S3
|
|
})
|
|
srv.Quota = q
|
|
h := srv.Handler()
|
|
base := "/api/p/" + p.ID + "/"
|
|
big := strings.Repeat("A", 4096)
|
|
|
|
// Control: an honest declaration is refused, and so is the relayed body.
|
|
if rec := do(t, h, "POST", base+"upload/init", initReq("big.txt", big)); rec.Code != http.StatusForbidden {
|
|
t.Fatalf("honest oversized init: %d %s, want 403", rec.Code, rec.Body)
|
|
}
|
|
if rec := do(t, h, "PUT", base+"upload/content?path=big.txt", []byte(big)); rec.Code != http.StatusForbidden {
|
|
t.Fatalf("oversized upload/content: %d %s, want 403", rec.Code, rec.Body)
|
|
}
|
|
|
|
// Attack: same content, declared as 0 bytes. init hands out a presigned
|
|
// URL for it despite the quota.
|
|
lie := map[string]any{"path": "big.txt", "sha256": shaOf(big), "size": 0}
|
|
if rec := do(t, h, "POST", base+"upload/init", lie); rec.Code != http.StatusForbidden {
|
|
t.Errorf("init with an understated size: %d %s, want 403", rec.Code, rec.Body)
|
|
}
|
|
// The client PUTs straight to the object store through that URL; the
|
|
// server never sees the bytes. That is what lands in storage:
|
|
if err := os.MkdirAll(filepath.Join(root, p.ID, "blobs"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(root, p.ID, "blobs", shaOf(big)), []byte(big), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if rec := do(t, h, "POST", base+"upload/commit", lie); rec.Code != http.StatusForbidden {
|
|
t.Errorf("commit with an understated size: %d %s, want 403", rec.Code, rec.Body)
|
|
}
|
|
if j := secstoreJournal(t, root, p.ID, webDevice.ID); strings.Contains(j, "big.txt") {
|
|
t.Errorf("over-quota file joined the volume: %s", j)
|
|
}
|
|
}
|