postgres: the store's query runs as a read-only login, never as the admin (hq #193)

The verb wrapped the caller's text in BEGIN READ ONLY ... ROLLBACK as the superuser, so
'COMMIT; ...' left the transaction and, proven on a throwaway server, COPY TO PROGRAM ran a
shell command on the database host. The statement now runs as mesh_store_reader:
pg_read_all_data, no other grant, read-only transactions by role and session, its password
an own-secret the mesh mints. Without that password the call is refused. -q drops the
command tags that came back as rows keyed by BEGIN.
This commit is contained in:
2026-10-02 00:09:09 +02:00
parent ef44c502db
commit 160b5ad65a
5 changed files with 204 additions and 10 deletions
+80 -5
View File
@@ -25,12 +25,30 @@ export interface PgConn {
readonly port: number;
readonly user: string;
readonly password: string;
/**
* The read-only login's password, which the mesh mints for this module (`own-secrets.reader`).
* Absent when the mesh has not delivered it: then a caller's statement is refused, never run as
* the admin (novox/hq issue 193).
*/
readonly readerPassword?: string;
}
/**
* The login a caller's statement runs as (novox/hq issue 193). It may read every table and change
* nothing: `pg_read_all_data` and no other grant, and every transaction it opens is read-only by
* the server's own setting. A statement cannot climb out of a login the way it can out of a
* transaction wrapped around it as text: `COMMIT; DROP …` ended the old wrapper and ran the rest as
* the superuser, and even one read-only statement as a superuser can run a program on the server.
*/
export const READER = "mesh_store_reader";
export class PostgresClient {
constructor(private readonly conn: PgConn) {}
/** The reader is made once per process: idempotent, and repeating it re-sets a rotated password. */
private readerReady?: Promise<void>;
/**
* Build from the module's resolved environment. Reads MESH_POSTGRES_* first (the documented
* names), falling back to the MESH_PROVISION_* keys the manifest already sets on the provisioner
@@ -54,7 +72,9 @@ export class PostgresClient {
if (!host || !password) {
throw new Error("postgres host or admin password is not set — postgres's own code cannot reach the server");
}
return new PostgresClient({ host, port, user, password });
const readerPassword = env.MESH_POSTGRES_READER_PASSWORD ??
readSecretFile(env.MESH_POSTGRES_READER_PASSWORD_FILE);
return new PostgresClient({ host, port, user, password, readerPassword });
}
get host(): string {
@@ -143,11 +163,66 @@ export class PostgresClient {
return res.rows.map((r) => ({ name: String(r.datname), sizeBytes: Number(r.size) }));
}
/** Run a read-only SQL statement against a named database, for the postgres_query tool. */
async readOnlyQuery(database: string, sql: string): Promise<QueryResult> {
// The read-only guarantee is a wrapping transaction the server honours.
return this.query(`BEGIN TRANSACTION READ ONLY; ${sql}; ROLLBACK;`, database);
/**
* Make the read-only login, idempotently, with the password the mesh minted for it. Run as the
* admin, because only the admin can make a role.
*/
async ensureReader(): Promise<void> {
const password = this.conn.readerPassword;
if (!password) throw readerMissing();
const roles = await this.query("SELECT 1 FROM pg_roles WHERE rolname = " + literal(READER));
const verb = roles.rows.length === 0 ? "CREATE" : "ALTER";
// Every attribute stated, so an existing role someone widened is narrowed again on every start.
await this.query(
`${verb} ROLE ${ident(READER)} WITH LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION ` +
`NOBYPASSRLS INHERIT PASSWORD ${literal(password)} VALID UNTIL 'infinity'`,
);
await this.query(`GRANT pg_read_all_data TO ${ident(READER)}`);
await this.query(`ALTER ROLE ${ident(READER)} SET default_transaction_read_only = on`);
await this.query(`ALTER ROLE ${ident(READER)} SET statement_timeout = '60s'`);
}
/**
* Run a caller's statement against a named database as the read-only login, for the
* postgres_query tool and the store seat's `query` verb (novox/hq ADR 0159, issue 193).
*
* **Read-only by the login, not by text around the statement.** The statement is sent as it was
* given, as the reader, whose role can write nothing and whose transactions the server makes
* read-only. Never as the admin: without the reader's password the call is refused.
*/
async readOnlyQuery(database: string, sql: string): Promise<QueryResult> {
this.readerReady ??= this.ensureReader().catch((err) => {
this.readerReady = undefined; // asked again next call, not failed for the process's life
throw err;
});
await this.readerReady;
const { stdout } = await run(
"psql",
// -q: no command tags, so the output is the header and the rows and nothing else — the tags
// were what came back as rows keyed by BEGIN.
["-h", this.conn.host, "-p", String(this.conn.port), "-U", READER, "-d", database,
"-v", "ON_ERROR_STOP=1", "--no-psqlrc", "-q", "--csv", "-c", sql],
{
env: {
...process.env,
PGPASSWORD: this.conn.readerPassword,
// Read-only from the first statement, before the role's own setting is read.
PGOPTIONS: "-c default_transaction_read_only=on -c statement_timeout=60s",
},
maxBuffer: 16 << 20,
},
);
const command = /^\s*([A-Za-z]+)/.exec(sql)?.[1]?.toUpperCase() ?? "";
return { command, rows: parseCsvRows(stdout) };
}
}
function readerMissing(): Error {
return new Error(
"the read-only login's password was not delivered (own-secrets.reader, " +
"MESH_POSTGRES_READER_PASSWORD_FILE), so the statement is refused rather than run as the " +
"admin (novox/hq issue 193)",
);
}
/** Generate a URL-safe password. */