Files

458 lines
15 KiB
Go
Raw Permalink Normal View History

package webapp
// Security tests for the hub's metadata store (scoreboard row 14).
//
// Everything the hub uses to decide "who may reach what" — accounts, orgs and
// their member roles, projects and their grants, invites, shares, devices,
// read buckets — lives behind MetaStore. Two things must hold:
//
// - attacker-supplied text (project/org names, emails, paths, device names,
// tokens) is DATA on every backend, never SQL and never a way to reach
// another tenant's rows
// - a change the registry reports as applied is actually durable, and a
// write the store refused leaves the live registry agreeing with disk
//
// Helpers are prefixed `secdb` per the harness rules; the backend matrix is
// `metaBackends` from db_conformance_test.go, reused rather than rebuilt.
import (
"os"
"path/filepath"
"testing"
"time"
)
// secdbHostile is the injection corpus: every string is chosen so that a
// backend which concatenated it into SQL would destroy or leak something.
var secdbHostile = struct {
projectName, orgName, email, sharePath, deviceID, deviceName, readPath, readActor string
}{
projectName: `'; DROP TABLE projects;--`,
orgName: `Acme'); DROP TABLE org_members;--`,
email: `bob'; DELETE FROM project_perms WHERE '1'='1`,
sharePath: `docs/100%_quarter's "report".md`,
deviceID: `d'1`,
deviceName: `lap"top'; DROP TABLE devices;--`,
readPath: `wiki/a%b_c'd.md`,
readActor: `spy'@x.io`,
}
// Hostile text must round-trip verbatim and must not disturb any other row,
// on every backend. A file backend that mangles JSON, or a SQL backend that
// string-built any statement, fails here.
func TestSec_DB_HostileStringsStayDataOnEveryBackend(t *testing.T) {
for _, be := range metaBackends(t) {
t.Run(be.name, func(t *testing.T) {
be.reset(t)
st := be.open(t)
projects, err := NewProjectDB(st.Projects())
if err != nil {
t.Fatal(err)
}
// A control row in a different org — it must survive intact.
ctl, _, err := projects.GetOrCreate("control", "o-control")
if err != nil {
t.Fatal(err)
}
if err := projects.SetPerm(ctl.ID, "keeper@x.io", PermAdmin); err != nil {
t.Fatal(err)
}
bad, created, err := projects.GetOrCreate(secdbHostile.projectName, "o-1")
if err != nil || !created {
t.Fatalf("create hostile project: created=%v err=%v", created, err)
}
if err := projects.SetPerm(bad.ID, secdbHostile.email, PermAdmin); err != nil {
t.Fatal(err)
}
orgs, err := NewOrgDB(st.Orgs())
if err != nil {
t.Fatal(err)
}
org, err := orgs.Create(secdbHostile.orgName, secdbHostile.email)
if err != nil {
t.Fatal(err)
}
inv, err := orgs.CreateInvite(org.ID, secdbHostile.email, time.Hour)
if err != nil {
t.Fatal(err)
}
shares, err := NewShareDB(st.Shares())
if err != nil {
t.Fatal(err)
}
sh, err := shares.Create(bad.ID, secdbHostile.sharePath, secdbHostile.email, 0)
if err != nil {
t.Fatal(err)
}
devices, err := NewDeviceRegistry(st.Devices())
if err != nil {
t.Fatal(err)
}
devices.Observe(DeviceInfo{
ID: secdbHostile.deviceID, Name: secdbHostile.deviceName,
OS: `mac"'`, User: secdbHostile.email, IP: "1.2.3.4",
})
reads, err := NewReadLedger(st.Reads(), 0)
if err != nil {
t.Fatal(err)
}
reads.Record(bad.ID, secdbHostile.readPath, ReadKindHuman, secdbHostile.readActor)
if err := reads.Close(); err != nil {
t.Fatal(err)
}
if err := st.Close(); err != nil {
t.Fatal(err)
}
// ---- reopen: nothing executed, nothing was lost ----
st2 := be.open(t)
defer st2.Close()
projects2, err := NewProjectDB(st2.Projects())
if err != nil {
t.Fatalf("projects table gone after hostile write: %v", err)
}
got, ok := projects2.Get(bad.ID)
if !ok || got.Name != secdbHostile.projectName {
t.Fatalf("hostile project name did not round-trip: %+v", got)
}
if got.Perms[normEmail(secdbHostile.email)] != PermAdmin {
t.Fatalf("hostile grant did not round-trip: %+v", got.Perms)
}
keep, ok := projects2.Get(ctl.ID)
if !ok || keep.Perms["keeper@x.io"] != PermAdmin {
t.Fatalf("control project/grant destroyed by the hostile write: %+v ok=%v", keep, ok)
}
orgs2, err := NewOrgDB(st2.Orgs())
if err != nil {
t.Fatalf("orgs table gone after hostile write: %v", err)
}
ro, ok := orgs2.Get(org.ID)
if !ok || ro.Name != secdbHostile.orgName {
t.Fatalf("hostile org name did not round-trip: %+v", ro)
}
if ro.Members[normEmail(secdbHostile.email)] != RoleOwner {
t.Fatalf("hostile owner email did not round-trip: %+v", ro.Members)
}
if !orgs2.ValidInvite(inv.Token) {
t.Fatal("invite lost after the hostile write")
}
shares2, err := NewShareDB(st2.Shares())
if err != nil {
t.Fatalf("shares table gone after hostile write: %v", err)
}
gs, ok := shares2.Get(sh.Token)
if !ok || gs.Path != secdbHostile.sharePath || gs.Project != bad.ID {
t.Fatalf("hostile share path did not round-trip: %+v ok=%v", gs, ok)
}
devices2, err := NewDeviceRegistry(st2.Devices())
if err != nil {
t.Fatalf("devices table gone after hostile write: %v", err)
}
gd, ok := devices2.Get(secdbHostile.deviceID)
if !ok || gd.Name != secdbHostile.deviceName {
t.Fatalf("hostile device name did not round-trip: %+v ok=%v", gd, ok)
}
reads2, err := NewReadLedger(st2.Reads(), 0)
if err != nil {
t.Fatalf("read_stats gone after hostile write: %v", err)
}
if e := reads2.Heat(bad.ID, "", time.Time{})[secdbHostile.readPath]; e.Human != 1 {
t.Fatalf("hostile read bucket did not round-trip: %+v", e)
}
// The `%`/`_` in the path must be literal, not a wildcard: a
// prefix query for another folder must not pick it up.
if e := reads2.Heat(bad.ID, "other", time.Time{}); len(e) != 0 {
t.Fatalf("prefix query matched an unrelated path: %+v", e)
}
})
}
}
// RETIRED (round 11): TestSec_DB_NULBytesDoNotTruncateRecords.
//
// It asserted that a NUL byte in a stored identifier must round-trip verbatim
// ("refused, or stored, but never silently lost" — round 5's rule, resolved in
// the "stored" direction). Postgres cannot implement that: a text column
// rejects 0x00 outright (SQLSTATE 22021), so satisfying it would mean moving
// the whole metadata layer to bytea. Until round 11 nobody had run this suite
// against Postgres, which is why the contradiction went seven rounds unseen.
//
// Round 11 resolved the same rule in the OTHER direction, at the repo boundary
// and identically on all three backends: unstorable text is REFUSED (see
// `storable` in db.go). That is what the ingest doors already enforce
// (printableOnly, hasControlChars, journal.SafePath), and it means a hub cannot
// change what it accepts by changing its database.
//
// The property this test was protecting — "a device registered as
// laptop\x00-of-eve must not come back as laptop, impersonating another
// device" — is protected more strongly by refusal, and is now asserted by
// TestSec_DB_EveryBackendAgreesWhichTextIsStorable and
// TestSec_DB_AcceptedTextIsStoredVerbatimOnEveryBackend in sec_pg_test.go.
// The two tests assert opposite decisions and cannot both be green; this is
// the one that was wrong.
// ---- registries must not hand out their live maps ------------------------
// OrgDB stores Org by value but Org.Members is a map, so Get/OrgsFor return a
// struct that still points at the registry's live membership map. Anything
// holding that "copy" can write roles straight into the registry — skipping
// the last-owner guard, skipping the MetaStore entirely (so the change never
// reaches orgs.json and is invisible to an audit), and, since handleOrgs
// ranges over it outside OrgDB's mutex, racing every concurrent AddMember /
// RemoveMember on the same org.
func TestSec_DB_OrgMemberMapDoesNotEscapeTheRegistry(t *testing.T) {
st, err := OpenFileStore(t.TempDir())
if err != nil {
t.Fatal(err)
}
defer st.Close()
orgs, err := NewOrgDB(st.Orgs())
if err != nil {
t.Fatal(err)
}
org, err := orgs.Create("Acme", "boss@x.io")
if err != nil {
t.Fatal(err)
}
snapshot, ok := orgs.Get(org.ID)
if !ok {
t.Fatal("org vanished")
}
snapshot.Members["dave@x.io"] = RoleOwner // a caller editing its own copy
if role := orgs.Role(org.ID, "dave@x.io"); role != "" {
t.Errorf("writing to the Org returned by Get made dave %q in the live registry — "+
"Get must return a defensive copy of Members", role)
}
for _, o := range orgs.OrgsFor("boss@x.io") {
o.Members["mallory@x.io"] = RoleOwner
}
if role := orgs.Role(org.ID, "mallory@x.io"); role != "" {
t.Errorf("writing to an Org returned by OrgsFor made mallory %q in the live registry", role)
}
}
// Same aliasing for per-project grants: Project.Perms is handed out live by
// ProjectDB.Get and List, so a holder can mint itself PermAdmin on a project
// without going through SetPerm (no last-admin guard, no repo write).
func TestSec_DB_ProjectPermsMapDoesNotEscapeTheRegistry(t *testing.T) {
st, err := OpenFileStore(t.TempDir())
if err != nil {
t.Fatal(err)
}
defer st.Close()
projects, err := NewProjectDB(st.Projects())
if err != nil {
t.Fatal(err)
}
p, _, err := projects.GetOrCreate("wiki", "o-1")
if err != nil {
t.Fatal(err)
}
if err := projects.SetPerm(p.ID, "boss@x.io", PermAdmin); err != nil {
t.Fatal(err)
}
snapshot, _ := projects.Get(p.ID)
snapshot.Perms["dave@x.io"] = PermAdmin
if got, _ := projects.Get(p.ID); got.Perms["dave@x.io"] != "" {
t.Errorf("writing to the Project returned by Get granted dave %q in the live registry — "+
"Get must return a defensive copy of Perms", got.Perms["dave@x.io"])
}
}
// ---- a change reported as applied must be durable ------------------------
// secdbFlakyOrgRepo is a MetaStore OrgRepo that can be told to refuse writes,
// standing in for a full disk, a read-only volume, or a Postgres blip.
type secdbFlakyOrgRepo struct {
OrgRepo
fail bool
}
func (r *secdbFlakyOrgRepo) PutOrg(o Org) error {
if r.fail {
return os.ErrPermission
}
return r.OrgRepo.PutOrg(o)
}
func (r *secdbFlakyOrgRepo) DeleteInvite(token string) error {
if r.fail {
return os.ErrPermission
}
return r.OrgRepo.DeleteInvite(token)
}
// Revoking an invite is the emergency stop for a leaked join link, and on an
// invite-only hub (the default) that link is enough for a stranger to create
// an account and join the org. OrgDB.RevokeInvite drops the invite from
// memory, throws away the store's error, and reports success — so a revoke
// that never reached the store looks revoked until the hub restarts, at which
// point the link works again.
func TestSec_DB_RevokedInviteMustNotSurviveAFailedWrite(t *testing.T) {
dir := t.TempDir()
st, err := OpenFileStore(dir)
if err != nil {
t.Fatal(err)
}
flaky := &secdbFlakyOrgRepo{OrgRepo: st.Orgs()}
orgs, err := NewOrgDB(flaky)
if err != nil {
t.Fatal(err)
}
org, err := orgs.Create("Acme", "boss@x.io")
if err != nil {
t.Fatal(err)
}
inv, err := orgs.CreateInvite(org.ID, "boss@x.io", time.Hour)
if err != nil {
t.Fatal(err)
}
flaky.fail = true
revoked := orgs.RevokeInvite(inv.Token)
flaky.fail = false
st.Close()
st2, err := OpenFileStore(dir)
if err != nil {
t.Fatal(err)
}
defer st2.Close()
orgs2, err := NewOrgDB(st2.Orgs())
if err != nil {
t.Fatal(err)
}
if revoked && orgs2.ValidInvite(inv.Token) {
t.Fatalf("RevokeInvite reported success but the invite %s is live again after a restart — "+
"the store refused the delete and the error was discarded", inv.Token)
}
}
// secdbFlakyProjectRepo is the ProjectRepo equivalent.
type secdbFlakyProjectRepo struct {
ProjectRepo
fail bool
}
func (r *secdbFlakyProjectRepo) Put(p Project) error {
if r.fail {
return os.ErrPermission
}
return r.ProjectRepo.Put(p)
}
// ProjectDB applies a grant change to its in-memory map BEFORE persisting and
// does not undo it when the store refuses (GetOrCreate, in the same file, does
// roll back — so this is an inconsistency, not a design choice). A demotion
// that failed to persist therefore reads as applied at runtime and reverts to
// the old, higher level on the next restart: the account gets its admin back
// without anyone granting it.
func TestSec_DB_FailedGrantWriteLeavesRegistryAgreeingWithDisk(t *testing.T) {
dir := t.TempDir()
st, err := OpenFileStore(dir)
if err != nil {
t.Fatal(err)
}
flaky := &secdbFlakyProjectRepo{ProjectRepo: st.Projects()}
projects, err := NewProjectDB(flaky)
if err != nil {
t.Fatal(err)
}
p, _, err := projects.GetOrCreate("wiki", "o-1")
if err != nil {
t.Fatal(err)
}
for _, e := range []string{"boss@x.io", "dave@x.io"} {
if err := projects.SetPerm(p.ID, e, PermAdmin); err != nil {
t.Fatal(err)
}
}
flaky.fail = true
demoteErr := projects.SetPerm(p.ID, "dave@x.io", PermNone)
flaky.fail = false
if demoteErr == nil {
t.Fatal("harness: the store was supposed to refuse this write")
}
live, _ := projects.Get(p.ID)
st.Close()
st2, err := OpenFileStore(dir)
if err != nil {
t.Fatal(err)
}
defer st2.Close()
projects2, err := NewProjectDB(st2.Projects())
if err != nil {
t.Fatal(err)
}
onDisk, _ := projects2.Get(p.ID)
if live.Perms["dave@x.io"] != onDisk.Perms["dave@x.io"] {
t.Fatalf("the refused demotion was applied in memory anyway: live=%q on disk=%q — "+
"dave reads as demoted until the hub restarts, then is admin again",
live.Perms["dave@x.io"], onDisk.Perms["dave@x.io"])
}
}
// ---- the file backend's on-disk footprint --------------------------------
// auth.json (password hashes, token digests) and reads.json (actor emails)
// ask writeFileAtomic for a 0700 parent, but MkdirAll is a no-op on a
// directory that already exists — and projects/orgs/shares/devices ask for
// 0755 over the SAME directory. Whichever repo writes first, or whatever
// created the data directory, decides. Assert the secrets' directory is not
// group/world readable.
func TestSec_DB_FileBackendSecretsDirectoryIsNotWorldReadable(t *testing.T) {
dir := filepath.Join(t.TempDir(), "hubdata")
st, err := OpenFileStore(dir)
if err != nil {
t.Fatal(err)
}
defer st.Close()
// The realistic order: a project is created before the first account.
projects, err := NewProjectDB(st.Projects())
if err != nil {
t.Fatal(err)
}
if _, _, err := projects.GetOrCreate("wiki", "o-1"); err != nil {
t.Fatal(err)
}
auth, err := NewBuiltinAuth(st.Accounts(), true, nil)
if err != nil {
t.Fatal(err)
}
if _, err := auth.signup("dev@x.io", "Dev", "password1"); err != nil {
t.Fatal(err)
}
fi, err := os.Stat(dir)
if err != nil {
t.Fatal(err)
}
if mode := fi.Mode().Perm(); mode&0o077 != 0 {
t.Errorf("hub data directory holding auth.json is %o, want no group/other access", mode)
}
afi, err := os.Stat(filepath.Join(dir, "auth.json"))
if err != nil {
t.Fatal(err)
}
if mode := afi.Mode().Perm(); mode&0o077 != 0 {
t.Errorf("auth.json is %o, want no group/other access", mode)
}
}