Give the store's reader only the columns a module declares readable, never a secret, so a query cannot read the controller's signing key (hq issue 337)
mesh_store_reader held pg_read_all_data, so every caller of mesh-store.query and postgres_query could read every column the store holds: the signing key nodes trust, sealed secrets, password hashes. The reader now holds no role; each minute, on every database, its memberships are revoked and its grants brought to what the consumer's contribution lists under readable, minus any column or table whose name says it holds a secret. Before a statement runs, the reader's own session is asked which secret columns it can read and the statement is refused if any; a statement naming a known secret table is refused before it is sent.
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
package main
|
||||
|
||||
// Against a real server (MESH_POSTGRES_LIVE), novox/hq issue 337: the reconcile gives the reader exactly
|
||||
// what is declared and no secret, takes back what is no longer declared, and a grant it does not own
|
||||
// that exposes a secret is found and refuses queries there.
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func columnReadable(t *testing.T, c *Client, db, tbl, col string) bool {
|
||||
t.Helper()
|
||||
r, err := c.Query(ctx, db, "SELECT has_column_privilege("+Literal(Reader)+", "+Literal(tbl)+", "+Literal(col)+", 'SELECT')")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return cell(r.Rows[0], 0) == "t"
|
||||
}
|
||||
|
||||
// secretsReadable is the probe every database is held to: the columns the reader can read whose names
|
||||
// say they hold a secret, across every database the server holds.
|
||||
func secretsReadable(t *testing.T, c *Client) []string {
|
||||
t.Helper()
|
||||
dbs, err := c.ListDatabases(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
probe := strings.Replace(ExposureProbe, "pg_catalog.has_column_privilege(c.oid", "pg_catalog.has_column_privilege("+Literal(Reader)+", c.oid", 1)
|
||||
var out []string
|
||||
for _, db := range dbs {
|
||||
r, err := c.Query(ctx, db.Name, probe)
|
||||
if err != nil {
|
||||
t.Fatal(db.Name, err)
|
||||
}
|
||||
for _, e := range Exposed(r) {
|
||||
out = append(out, db.Name+": "+e)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestLiveTheReaderReadsWhatIsDeclaredAndNoSecret(t *testing.T) {
|
||||
c := liveClient(t)
|
||||
freshStore(t, c)
|
||||
defer dropStore(c)
|
||||
|
||||
if got := secretsReadable(t, c); len(got) == 0 {
|
||||
t.Fatal("the install before the fix was not reproduced: no secret readable")
|
||||
}
|
||||
decl := Declarations{Tables: map[string][]ReadableTable{liveStore: {
|
||||
{Schema: "public", Table: "notes"},
|
||||
{Schema: "public", Table: "vault"}, // every column: private is still refused, by its name
|
||||
{Schema: "audit", Table: "events"},
|
||||
}}}
|
||||
rep := c.ReconcileReader(ctx, decl, "")
|
||||
if rep.Problem != "" {
|
||||
t.Fatal(rep.Problem)
|
||||
}
|
||||
if got := secretsReadable(t, c); len(got) != 0 {
|
||||
t.Fatalf("after the reconcile the reader can read %v", got)
|
||||
}
|
||||
for _, want := range [][3]string{{liveStore, "notes", "body"}, {liveStore, "vault", "name"}, {liveStore, "audit.events", "what"}} {
|
||||
if !columnReadable(t, c, want[0], want[1], want[2]) {
|
||||
t.Errorf("%v is declared and not readable", want)
|
||||
}
|
||||
}
|
||||
if columnReadable(t, c, liveStore, "vault", "private") || columnReadable(t, c, "identity", "signing_key", "public") {
|
||||
t.Fatal("a secret, or an undeclared database, is readable")
|
||||
}
|
||||
r, err := c.ReadOnlyQuery(ctx, liveStore, "SELECT name FROM vault")
|
||||
if err != nil || len(r.Rows) != 1 {
|
||||
t.Fatal("a declared column cannot be read:", r, err)
|
||||
}
|
||||
if _, err := c.ReadOnlyQuery(ctx, liveStore, "SELECT * FROM vault"); err == nil {
|
||||
t.Fatal("SELECT * read past the refused column")
|
||||
}
|
||||
if _, err := c.ReadOnlyQuery(ctx, "identity", "SELECT id FROM signing_key"); err == nil {
|
||||
t.Fatal("the signing key's table answered")
|
||||
}
|
||||
// The same declaration again changes nothing.
|
||||
again := c.ReconcileReader(ctx, decl, "")
|
||||
for _, db := range again.Databases {
|
||||
if len(db.Revoked) > 0 || db.Error != "" {
|
||||
t.Fatalf("a second pass changed %s: %+v", db.Database, db)
|
||||
}
|
||||
}
|
||||
|
||||
// Narrowed: notes is no longer declared, and is taken back.
|
||||
decl.Tables[liveStore] = []ReadableTable{{Schema: "public", Table: "vault", Columns: []string{"name"}}}
|
||||
c.ReconcileReader(ctx, decl, "")
|
||||
if columnReadable(t, c, liveStore, "notes", "body") || columnReadable(t, c, liveStore, "audit.events", "what") {
|
||||
t.Fatal("a table no longer declared is still readable")
|
||||
}
|
||||
|
||||
// A grant the provider does not own exposes a secret: found, said, and queries there refused.
|
||||
if _, err := c.Query(ctx, liveStore, "GRANT SELECT ON vault TO PUBLIC"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rep = c.ReconcileReader(ctx, decl, "")
|
||||
var exposed []string
|
||||
for _, db := range rep.Databases {
|
||||
if db.Database == liveStore {
|
||||
exposed = db.Exposed
|
||||
}
|
||||
}
|
||||
if strings.Join(exposed, ",") != "public.vault.private" {
|
||||
t.Fatalf("exposure not found: %+v", rep.Databases)
|
||||
}
|
||||
if _, err := c.ReadOnlyQuery(ctx, liveStore, "SELECT name FROM vault"); err == nil || !strings.Contains(err.Error(), "public.vault.private") {
|
||||
t.Fatal("a query ran while the reader can read a secret:", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user