Untrusted statements#
Sometimes the statement to run was not written by you: most often it came from a language model asked to turn a question into Cypher, or from a user typing a query into a tool. Such a statement must be able to read and must not be able to write.
The rule is that the server refuses the write, not the driver. A driver that reads the text to
decide whether it writes has to recognise every way of writing, and it would not: this is PostgreSQL
underneath, so besides Cypher’s CREATE, SET and DELETE there are INSERT, UPDATE, TRUNCATE,
DROP, functions that write, and more. A read-only transaction refuses them all.
Three conditions#
from agensgraph import cypher
cypher.check_single_statement(statement) # 3. one statement, no more
with conn.read_only_transaction(): # 2. the server refuses every write
result = conn.execute_query(statement) # 1. as a role that cannot run host programs
Each closes a hole the others leave open.
1. A role that cannot run programs on the host#
A read-only transaction stops writes to the database, and COPY ... TO PROGRAM is not one: it runs a
command on the server’s machine and only takes rows out of the database, so there is nothing for a
read-only transaction to refuse. What stops it is not holding the privilege. Superusers hold it, and
so does any role that is a member of pg_execute_server_program.
So run untrusted statements as a role that is neither. Create one, once, with only what it needs to read the graph:
for statement in [
"CREATE ROLE reader LOGIN",
"GRANT USAGE ON SCHEMA moviekb TO reader",
"GRANT SELECT ON ALL TABLES IN SCHEMA moviekb TO reader",
]:
conn.execute(statement)
conn.commit()
read_only_transaction() checks this for you. On a connection whose role could run a program on the
host, it refuses to begin, saying what to change:
>>> conn.can_run_server_programs()
True
>>> with conn.read_only_transaction():
... pass
Traceback (most recent call last):
...
agensgraph.errors.ConfigurationError: this role may run a command on the server's host, through COPY ... TO PROGRAM, which a read-only transaction does not stop -- so a read-only transaction is not a boundary for a statement this role did not write. Connect as a role that is not a superuser and does not hold pg_execute_server_program, or pass allow_server_programs=True to accept it.
can_run_server_programs() asks the server once per connection and keeps the answer.
allow_server_programs=True accepts the risk, for a development database and nothing else.
2. A read-only transaction#
>>> reader = agensgraph.connect("host=localhost dbname=docs user=reader", autocommit=True)
>>> reader.graph("moviekb")
>>> with reader.read_only_transaction():
... reader.execute_query(
... "MATCH (m:movie) WHERE m.year > 2015 RETURN m.title ORDER BY m.title"
... ).records
[('Blade Runner 2049',), ('The Matrix Resurrections',)]
A read runs. Every write is refused by the server:
>>> with reader.read_only_transaction():
... reader.execute_query("MATCH (n) DETACH DELETE n")
Traceback (most recent call last):
...
agensgraph.errors.ReadOnlyGraphWrite: cannot write to a graph in a read-only transaction
>>> with reader.read_only_transaction():
... reader.execute_query("DROP TABLE moviekb.movie")
Traceback (most recent call last):
...
psycopg.errors.ReadOnlySqlTransaction: cannot execute DROP TABLE in a read-only transaction
>>> with reader.read_only_transaction():
... reader.execute_query("COPY (SELECT 1) TO PROGRAM 'id'")
Traceback (most recent call last):
...
psycopg.errors.InsufficientPrivilege: permission denied to COPY to or from an external program
Statement |
Refused by |
|---|---|
a Cypher write, |
the read-only transaction, |
|
the missing privilege, |
a read |
nothing: it runs |
A graph write is raised as agensgraph.errors.ReadOnlyGraphWrite, a subclass of psycopg’s
ReadOnlySqlTransaction, since the server reports it with a message that varies between releases.
read_only_transaction() always rolls back at the end, even after a read, so nothing the
statement did, such as a SET, outlives it or reaches the next user of a pooled connection. Inside an
open transaction it is a savepoint, and rolls back only itself.
conn.read_only = True makes a whole session read-only, which is psycopg’s setting; this adds the
privilege check and the rollback.
3. One statement#
A statement sent with no parameters goes over PostgreSQL’s simple protocol, which runs every statement in the text and reports only the first one’s result. A read with a write after a semicolon runs the write, and looks like the read. Inside a read-only transaction the write is still refused, but nothing else would refuse it, so check that the text is one statement before sending it:
>>> from agensgraph import cypher
>>> cypher.check_single_statement(
... "MATCH (m:movie) RETURN count(m); CREATE (:movie {title: 'smuggled'})"
... )
Traceback (most recent call last):
...
ValueError: this is more than one statement: there is a "; CREATE (:movie {title: 'smug" after the first one. Sent without parameters it would run all of them and report only the first one's result, so a statement from somewhere else is taken one at a time.
>>> cypher.check_single_statement("MATCH (m:movie {title: 'a;b'}) RETURN m;")
It reads the text with its literals blanked, so a semicolon inside a string, a dollar-quoted body or a comment separates nothing, and one at the end is not a second statement. The driver does not call it for you; it is for text you did not write.
Binding any parameter closes the same hole from the other side: it sends the statement over the
extended protocol, where the server refuses a second statement with 42601. A statement with
parameters from a model that also produced the parameters gets both protections.
A hint, not a boundary#
cypher.check_can_wrap(statement) refuses a statement that contains a write clause, naming it. It is
the check stream() uses, and it is a
reasonable thing to show a user early, before anything is sent. It reads the text, so for the reasons
at the top of this page it is not a boundary, and the driver never uses it as one.
Telling the model what went wrong#
A model that wrote a bad query does better when it reads the error. Hand it
safe_message(exc), which is the SQLSTATE and the
server’s one-line message, and never the row data PostgreSQL can put in a failure’s detail.
>>> reader.close()