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

Merged
mesh-admin merged 1 commits from fix/193-the-store-reads-as-a-reader into main 2026-10-01 22:29:57 +00:00
Contributor

The hole (hq issue 193). mesh-store.query and postgres_query sent BEGIN TRANSACTION READ ONLY; <caller's text>; ROLLBACK; as the superuser. A throwaway pgvector:pg17 server with no network, running the code on main, showed:

  • COMMIT; COPY (select 1) TO PROGRAM 'touch …' created the file on the database host, as postgres;
  • COMMIT; DROP TABLE t executed outside the read-only transaction. Only the wrapper's trailing ROLLBACK undid it; a caller's own trailing COMMIT would pre-empt that, which was not tried.

Every caller allowed to invoke the verb could do this, including the console, which may invoke everything.

The fix.

  • The statement runs as mesh_store_reader. The role has pg_read_all_data and nothing else, and every attribute is stated each time it is made (NOSUPERUSER, NOCREATEROLE, …). Its transactions are read-only by the role's own setting and by PGOPTIONS, with a 60-second statement timeout.
  • Its password is a new own-secret, reader, which the mesh mints per node. It is mounted into the runtime container at /run/secrets/reader.
  • Without that password, the call is refused. It never falls back to the admin.
  • The statement is sent as given. -q drops the command tags that came back as rows keyed by BEGIN.

Proof on the same throwaway server, with the fix. select returns rows keyed by column. Each of these is refused:

  • COMMIT; DROP
  • SET default_transaction_read_only = off; DELETE
  • SET transaction_read_only = off; DELETE: past read-only, but stopped by permission
  • SET ROLE postgres, RESET SESSION AUTHORIZATION
  • COPY … TO PROGRAM: no file created
  • CREATE TABLE, ALTER ROLE … SUPERUSER, pg_read_file

show is_superuser returns off.

Tests. test/reader.test.ts (4 tests, fake psql) checks:

  • the statement runs as the reader, sent as given, read-only;
  • the role is made as the admin, once per process;
  • the call is refused without the password;
  • the password is read from the delivered file.

Added build/test scripts to postgres's package.json, in gitea's shape.

Rollout. After merge and build, the next push mints reader on the store's node and recreates the postgres runtime container. The database server container is not touched. Verify with mesh-store.query → show is_superuser = off.

Not in this PR. mssql has the same wrapper (modules/mssql/client.ts readOnlyQuery) and the same class of hole. It is recorded on issue 193 for its own change.

**The hole (hq issue 193).** `mesh-store.query` and `postgres_query` sent `BEGIN TRANSACTION READ ONLY; <caller's text>; ROLLBACK;` as the superuser. A throwaway pgvector:pg17 server with no network, running the code on main, showed: - `COMMIT; COPY (select 1) TO PROGRAM 'touch …'` **created the file on the database host**, as `postgres`; - `COMMIT; DROP TABLE t` executed outside the read-only transaction. Only the wrapper's trailing ROLLBACK undid it; a caller's own trailing COMMIT would pre-empt that, which was not tried. Every caller allowed to invoke the verb could do this, including the console, which may invoke everything. **The fix.** - The statement runs as `mesh_store_reader`. The role has `pg_read_all_data` and nothing else, and every attribute is stated each time it is made (NOSUPERUSER, NOCREATEROLE, …). Its transactions are read-only by the role's own setting and by `PGOPTIONS`, with a 60-second statement timeout. - Its password is a new own-secret, `reader`, which the mesh mints per node. It is mounted into the runtime container at `/run/secrets/reader`. - Without that password, the call is refused. It never falls back to the admin. - The statement is sent as given. `-q` drops the command tags that came back as rows keyed by `BEGIN`. **Proof on the same throwaway server, with the fix.** `select` returns rows keyed by column. Each of these is refused: - `COMMIT; DROP` - `SET default_transaction_read_only = off; DELETE` - `SET transaction_read_only = off; DELETE`: past read-only, but stopped by permission - `SET ROLE postgres`, `RESET SESSION AUTHORIZATION` - `COPY … TO PROGRAM`: no file created - `CREATE TABLE`, `ALTER ROLE … SUPERUSER`, `pg_read_file` `show is_superuser` returns `off`. **Tests.** `test/reader.test.ts` (4 tests, fake psql) checks: - the statement runs as the reader, sent as given, read-only; - the role is made as the admin, once per process; - the call is refused without the password; - the password is read from the delivered file. Added `build`/`test` scripts to postgres's package.json, in gitea's shape. **Rollout.** After merge and build, the next push mints `reader` on the store's node and recreates the postgres runtime container. The database server container is not touched. Verify with `mesh-store.query` → `show is_superuser` = `off`. **Not in this PR.** `mssql` has the same wrapper (`modules/mssql/client.ts` `readOnlyQuery`) and the same class of hole. It is recorded on issue 193 for its own change.
mesh-admin added 1 commit 2026-10-01 22:09:25 +00:00
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.
mesh-admin merged commit da7355dce0 into main 2026-10-01 22:29:57 +00:00
mesh-admin deleted branch fix/193-the-store-reads-as-a-reader 2026-10-01 22:29:58 +00:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: novox/mesh-catalog#209