mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
458 lines
15 KiB
Go
458 lines
15 KiB
Go
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)
|
||
|
|
}
|
||
|
|
}
|