package remote import ( "context" "crypto/rand" "crypto/rsa" "crypto/x509" "encoding/json" "encoding/pem" "fmt" "io" "net/http" "net/http/httptest" "os" "path" "path/filepath" "strings" "sync" "testing" "time" gcs "cloud.google.com/go/storage" "google.golang.org/api/option" ) // Round 4, rows 5 and 11, attacked from inside internal/remote. // // remote.Prefixed is the single containment primitive for multi-tenancy on the // hub: every project lives at // because Prefixed glues that // string on the front of every key. localBackend is the storage under every // file:// hub and every test fixture. httpBackend is the client every // storage-blind device syncs through — the mirror image of the hub trusting a // device. // // All helpers here are prefixed secrem. // ---- a recording backend, so we can see the exact key the layer below is // asked for (that is the containment question) ---- type secremCall struct { Op string Key string Size int64 TTL time.Duration } type secremRecorder struct { mu sync.Mutex calls []secremCall list []Object // what List hands back, whatever was asked } func (b *secremRecorder) record(op, key string, size int64, ttl time.Duration) { b.mu.Lock() defer b.mu.Unlock() b.calls = append(b.calls, secremCall{Op: op, Key: key, Size: size, TTL: ttl}) } func (b *secremRecorder) keys() []string { b.mu.Lock() defer b.mu.Unlock() out := make([]string, 0, len(b.calls)) for _, c := range b.calls { out = append(out, c.Op+" "+c.Key) } return out } func (b *secremRecorder) Put(_ context.Context, key string, r io.Reader, size int64) error { io.Copy(io.Discard, r) b.record("Put", key, size, 0) return nil } func (b *secremRecorder) Get(_ context.Context, key string) (io.ReadCloser, error) { b.record("Get", key, 0, 0) return io.NopCloser(strings.NewReader("")), nil } func (b *secremRecorder) Exists(_ context.Context, key string) (bool, error) { b.record("Exists", key, 0, 0) return false, nil } func (b *secremRecorder) List(_ context.Context, prefix string) ([]Object, error) { b.record("List", prefix, 0, 0) return b.list, nil } func (b *secremRecorder) SignPut(_ context.Context, key string, size int64, ttl time.Duration) (*SignedPut, error) { b.record("SignPut", key, size, ttl) return &SignedPut{URL: "https://storage.invalid/" + key, Method: http.MethodPut, Expires: time.Now().Add(ttl)}, nil } func (b *secremRecorder) Close() error { return nil } // secremEscapes reports whether an underlying storage key, once the object // store (or the filesystem) normalizes it, still lives under ns/. func secremEscapes(ns, key string) bool { return !strings.HasPrefix(path.Clean("/"+key), "/"+ns+"/") } // A project id is a namespace, and Prefixed is the only thing enforcing it. // Every key it forwards must still land under / AFTER the storage // normalizes it — S3 and GCS both path.Join their own prefix on, and // localBackend hands the key to filepath.Join, so ".." is not an inert // character down there. A key that walks out of the namespace reads and // writes another tenant's objects on the same storage root. func TestSec_Prefixed_KeyCannotEscapeTheProjectNamespace(t *testing.T) { hostile := []string{ "../victim/blobs/deadbeef", "blobs/../../victim/journal/d.jsonl", "../victim/blobs/x", "./../victim/blobs/x", } ctx := context.Background() for _, key := range hostile { t.Run(key, func(t *testing.T) { rec := &secremRecorder{} p := Prefixed(rec, "attacker") signer, ok := p.(PutSigner) if !ok { t.Fatal("Prefixed must keep the PutSigner capability") } p.Put(ctx, key, strings.NewReader("x"), 1) p.Get(ctx, key) p.Exists(ctx, key) signer.SignPut(ctx, key, 1, time.Minute) for _, c := range rec.calls { if secremEscapes("attacker", c.Key) { t.Errorf("%s(%q) reached storage as %q — outside the attacker/ namespace", c.Op, key, c.Key) } } }) } } // The namespace has to survive the way OUT too. Prefixed filters List by // string prefix and then trims it, so a stored key that merely starts with // "/" is handed back as an in-project key — and fed straight back to // Get, it resolves somewhere else entirely. That is a round trip through the // one containment primitive with no boundary left. func TestSec_Prefixed_ListedKeysStayInsideTheNamespace(t *testing.T) { rec := &secremRecorder{list: []Object{ {Key: "attacker/blobs/aa", Size: 1}, {Key: "attacker/../victim/blobs/secret", Size: 2}, {Key: "victim/blobs/secret", Size: 3}, }} p := Prefixed(rec, "attacker") objs, err := p.List(context.Background(), "blobs/") if err != nil { t.Fatal(err) } for _, o := range objs { if o.Key != path.Clean(o.Key) || strings.HasPrefix(o.Key, "/") { t.Errorf("List handed back %q, which is not a clean relative key", o.Key) } // The real test: feed it back in, as every caller does (syncer.pull, // exportStore, RemoteSource.loadOps all Get what List named). rec.calls = nil p.Get(context.Background(), o.Key) for _, c := range rec.calls { if secremEscapes("attacker", c.Key) { t.Errorf("List returned %q; Get(%q) reached storage as %q — outside the namespace", o.Key, o.Key, c.Key) } } } } // A project id that is a prefix of another project's id must not see its // objects. This is the multi-tenancy question in its plainest form. func TestSec_Prefixed_SiblingWithAPrefixNameIsNotListed(t *testing.T) { root := t.TempDir() be, err := Open(context.Background(), "file://"+root) if err != nil { t.Fatal(err) } defer be.Close() ctx := context.Background() abc, abcd := Prefixed(be, "abc"), Prefixed(be, "abcd") if err := abc.Put(ctx, "blobs/aa", strings.NewReader("mine"), 4); err != nil { t.Fatal(err) } if err := abcd.Put(ctx, "blobs/bb", strings.NewReader("theirs"), 6); err != nil { t.Fatal(err) } objs, err := abc.List(ctx, "") if err != nil { t.Fatal(err) } for _, o := range objs { if o.Key != "blobs/aa" { t.Errorf("project abc listed %q, which belongs to abcd", o.Key) } } if ok, _ := abc.Exists(ctx, "blobs/bb"); ok { t.Error("project abc can see abcd's blob through Exists") } } // ---- localBackend: round 3 taught path() to refuse a key that climbs out of // the root. That guard is lexical, and Get/Put both follow symlinks. ---- // A symlink planted anywhere inside the storage root turns every lexically // valid key into a read or a write anywhere on the hub host. path() answers // "does this string stay under root", which is a different question from // "does this file". func TestSec_Local_SymlinkInsideTheRootIsNotAWayOut(t *testing.T) { root, outside := t.TempDir(), t.TempDir() if err := os.WriteFile(filepath.Join(outside, "secret.txt"), []byte("other tenant"), 0o600); err != nil { t.Fatal(err) } if err := os.Symlink(outside, filepath.Join(root, "escape")); err != nil { t.Skipf("symlinks unavailable: %v", err) } be, err := Open(context.Background(), "file://"+root) if err != nil { t.Fatal(err) } defer be.Close() ctx := context.Background() if rc, err := be.Get(ctx, "escape/secret.txt"); err == nil { data, _ := io.ReadAll(rc) rc.Close() t.Errorf("Get read %q from outside the storage root through a symlink", data) } if err := be.Put(ctx, "escape/planted.txt", strings.NewReader("owned"), 5); err == nil { if _, err := os.Stat(filepath.Join(outside, "planted.txt")); err == nil { t.Error("Put wrote outside the storage root through a symlink") } } } // Round 3 fixed Get/Put. List and Exists take keys from the same callers, so // they need the same answer. func TestSec_Local_ListAndExistsCannotEscapeTheStorageRoot(t *testing.T) { root, outside := t.TempDir(), t.TempDir() if err := os.WriteFile(filepath.Join(outside, "secret.txt"), []byte("no"), 0o600); err != nil { t.Fatal(err) } be, err := Open(context.Background(), "file://"+root) if err != nil { t.Fatal(err) } defer be.Close() ctx := context.Background() for _, key := range []string{"../" + filepath.Base(outside) + "/secret.txt", "/etc/hosts", "a/../../x"} { if ok, err := be.Exists(ctx, key); ok && err == nil { t.Errorf("Exists(%q) resolved outside the storage root", key) } } for _, prefix := range []string{"../", "/", "a/../../"} { objs, err := be.List(ctx, prefix) if err != nil { continue } for _, o := range objs { t.Errorf("List(%q) reported %q", prefix, o.Key) } } } // ---- httpBackend: the device as the client of a hostile hub ---- func secremHub(t *testing.T, h http.HandlerFunc) Backend { t.Helper() t.Setenv("BDRIVE_HOME", t.TempDir()) t.Setenv("BDRIVE_TOKEN", "secret-device-token") ts := httptest.NewServer(h) t.Cleanup(ts.Close) be, err := Open(context.Background(), ts.URL+"/p/p-0123abcd") if err != nil { t.Fatal(err) } t.Cleanup(func() { be.Close() }) return be } // The hub names the keys; the device believes it. Those names become local // journal paths (syncer.pull) and tar entry names (cmd/bdrive export writes // o.Key straight into the archive header), so a key with ".." in it is a // hostile hub writing to a spot on the victim's disk it was never given. // Nothing downstream re-checks the shape, so this is the choke point. func TestSec_HTTP_ListedKeysFromTheHubStayInTheKeySpace(t *testing.T) { be := secremHub(t, func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]any{"objects": []Object{ {Key: "journal/good.jsonl", Size: 1}, {Key: "../../../../../../tmp/pwned", Size: 2}, {Key: "/etc/cron.d/pwned", Size: 3}, {Key: "journal/../../.ssh/authorized_keys", Size: 4}, }}) }) objs, err := be.List(context.Background(), "journal/") if err != nil { t.Fatal(err) } for _, o := range objs { if strings.HasPrefix(o.Key, "/") || o.Key != path.Clean(o.Key) || strings.HasPrefix(o.Key, "../") { t.Errorf("hub named %q and the backend passed it through", o.Key) } } } // The device token is scoped to the hub the user configured. Every endpoint // this backend calls is the hub's own API, so a 3xx is never part of the // contract — and net/http's redirect rules only strip Authorization when the // HOSTNAME changes, ignoring scheme and port. A hub that answers with a // redirect therefore handed the credential to whatever else listens on that // name: another port on the same box, an https->http downgrade, a sibling // subdomain. // // Round 4 asserted "if we follow it, the token does not go along". Round 5 // showed the identity headers went along too (X-Bdrive-Device{,-Name,-Os}), // and that the same reasoning argues for not following at all — so the client // now REFUSES a cross-origin redirect. This test asserts that stronger // property, which subsumes the original one: nothing at all reaches the other // origin, and the caller sees an error rather than a silent hop. func TestSec_HTTP_BearerTokenIsNeverSentToAnotherOrigin(t *testing.T) { var got http.Header elsewhere := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { got = r.Header.Clone() w.Write([]byte("{}")) })) defer elsewhere.Close() var hubURL string be := secremHub(t, func(w http.ResponseWriter, r *http.Request) { hubURL = "http://" + r.Host http.Redirect(w, r, elsewhere.URL+r.URL.Path, http.StatusFound) }) rc, err := be.Get(context.Background(), "blobs/aa") if err == nil { rc.Close() t.Errorf("configured hub %s redirected to %s and the client followed it silently", hubURL, elsewhere.URL) } if got != nil { t.Errorf("configured hub %s redirected to %s and the client went there carrying %v; "+ "a 3xx is not part of the store API's contract and must be refused", hubURL, elsewhere.URL, got) if v := got.Get("Authorization"); v != "" { t.Errorf("...and the device token went along: %q", v) } } } // TLS is what keeps the device token off the wire. The backend must refuse a // certificate it cannot verify rather than sync through it. func TestSec_HTTP_UnverifiableTLSIsRefused(t *testing.T) { t.Setenv("BDRIVE_HOME", t.TempDir()) t.Setenv("BDRIVE_TOKEN", "secret-device-token") ts := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Write([]byte(`{"objects":[]}`)) })) defer ts.Close() be, err := Open(context.Background(), ts.URL+"/p/p-0123abcd") if err != nil { t.Fatal(err) } defer be.Close() if _, err := be.List(context.Background(), "journal/"); err == nil { t.Fatal("synced through an unverifiable TLS certificate") } else if !strings.Contains(err.Error(), "certificate") && !strings.Contains(err.Error(), "x509") { t.Fatalf("refused, but not for the certificate: %v", err) } } // ---- the PutSigner contract ---- // SignPut takes a size because the whole point of a presigned URL is that it // grants ONE bounded write. A backend that accepts the parameter and drops it // mints a URL that will take a body of any length — the hub quota-checked a // number the signature never enforces, and the grant is unmetered for its // whole TTL. S3 signs Content-Length (see TestS3SignPut); this asserts the // same contract for every signer, since handleStoreSign/handleUploadInit // cannot tell them apart. // // Signing is a local computation, so both arms run offline against synthetic // credentials — no bucket and no network. func TestSec_Sign_DeclaredSizeIsBoundIntoTheSignature(t *testing.T) { for name, open := range map[string]func(*testing.T) PutSigner{"s3": secremFakeS3, "gcs": secremFakeGCS} { t.Run(name, func(t *testing.T) { signer := open(t) sp, err := signer.SignPut(context.Background(), "blobs/aa", 42, 5*time.Minute) if err != nil { t.Fatalf("sign: %v", err) } if !secremBindsSize(sp, 42) { t.Errorf("%s presigned URL takes a body of ANY size — the 42 it was given is not in the signature: url=%s headers=%v", name, sp.URL, sp.Headers) } }) } } func secremFakeS3(t *testing.T) PutSigner { t.Helper() t.Setenv("AWS_ACCESS_KEY_ID", "AKIAFAKEFAKEFAKEFAKE") t.Setenv("AWS_SECRET_ACCESS_KEY", "fakefakefakefakefakefakefakefakefakefake") t.Setenv("AWS_SESSION_TOKEN", "") t.Setenv("AWS_REGION", "us-east-1") t.Setenv("AWS_PROFILE", "") t.Setenv("AWS_ENDPOINT_URL", "") be, err := Open(context.Background(), "s3://test-bucket/vol1") if err != nil { t.Fatal(err) } t.Cleanup(func() { be.Close() }) return be.(PutSigner) } // A GCS service account whose private key is generated here: V4 signing needs // a key it can sign bytes with, and nothing else. func secremFakeGCS(t *testing.T) PutSigner { t.Helper() key, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { t.Fatal(err) } der, err := x509.MarshalPKCS8PrivateKey(key) if err != nil { t.Fatal(err) } sa, err := json.Marshal(map[string]string{ "type": "service_account", "project_id": "p", "private_key_id": "k", "private_key": string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der})), "client_email": "signer@p.iam.gserviceaccount.com", "client_id": "1", "token_uri": "https://oauth2.googleapis.com/token", }) if err != nil { t.Fatal(err) } c, err := gcs.NewClient(context.Background(), option.WithCredentialsJSON(sa)) if err != nil { t.Fatal(err) } t.Cleanup(func() { c.Close() }) return &gcsBackend{client: c, bucket: c.Bucket("test-bucket"), prefix: "vol1"} } // secremBindsSize reports whether the size the caller declared is actually // carried by the signed request (a signed Content-Length, or a length // constraint in the URL's policy) rather than merely known to the server. func secremBindsSize(sp *SignedPut, size int64) bool { want := fmt.Sprintf("%d", size) for k, v := range sp.Headers { if strings.EqualFold(k, "Content-Length") && v == want { return true } } return strings.Contains(sp.URL, "content-length-range") || strings.Contains(sp.URL, "x-goog-content-length") }